diff --git a/pyxform/aliases.py b/pyxform/aliases.py index ff440b2a..140f43e3 100644 --- a/pyxform/aliases.py +++ b/pyxform/aliases.py @@ -90,7 +90,7 @@ "requiredmsg": ("bind", "jr:requiredMsg"), "required_message": ("bind", "jr:requiredMsg"), "body": "control", - constants.ENTITIES_SAVETO: ("bind", "entities:saveto"), + constants.ENTITIES_SAVETO: ("bind", constants.ENTITIES_SAVETO_NS), } entities_header = {constants.LIST_NAME_U: "dataset"} diff --git a/pyxform/builder.py b/pyxform/builder.py index a9064bfc..e802195d 100644 --- a/pyxform/builder.py +++ b/pyxform/builder.py @@ -4,6 +4,7 @@ import os from collections import defaultdict +from collections.abc import Mapping from typing import Any from pyxform import constants as const @@ -65,7 +66,7 @@ def set_sections(self, sections): the name of the section and the value is a dict that can be used to create a whole survey. """ - if not isinstance(sections, dict): + if not isinstance(sections, Mapping): raise PyXFormError("""Invalid value for `sections`.""") self._sections = sections @@ -79,7 +80,7 @@ def create_survey_element_from_dict( :param d: data to use for constructing SurveyElements. """ - if "add_none_option" in d: + if d.get("add_none_option", None) is not None: self._add_none_option = d["add_none_option"] if d[const.TYPE] in SECTION_CLASSES: @@ -266,7 +267,7 @@ def _name_and_label_substitutions(question_template, column_headers): # if the label in column_headers has multiple languages setup a # dictionary by language to do substitutions. info_by_lang = None - if isinstance(column_headers[const.LABEL], dict): + if isinstance(column_headers[const.LABEL], Mapping): info_by_lang = { lang: { const.NAME: column_headers[const.NAME], @@ -279,10 +280,10 @@ def _name_and_label_substitutions(question_template, column_headers): for key in result: if isinstance(result[key], str): result[key] %= column_headers - elif isinstance(result[key], dict): + elif isinstance(result[key], Mapping): result[key] = result[key].copy() for key2 in result[key]: - if info_by_lang and isinstance(column_headers[const.LABEL], dict): + if info_by_lang and isinstance(column_headers[const.LABEL], Mapping): result[key][key2] %= info_by_lang.get(key2, column_headers) else: result[key][key2] %= column_headers diff --git a/pyxform/constants.py b/pyxform/constants.py index bcdb3f14..01e687d0 100644 --- a/pyxform/constants.py +++ b/pyxform/constants.py @@ -14,6 +14,7 @@ TITLE = "title" NAME = "name" ENTITIES_SAVETO = "save_to" +ENTITIES_SAVETO_NS = "entities:saveto" ID_STRING = "id_string" SMS_KEYWORD = "sms_keyword" SMS_FIELD = "sms_field" diff --git a/pyxform/entities/entities_parsing.py b/pyxform/entities/entities_parsing.py index f2d4c076..6dfd39c7 100644 --- a/pyxform/entities/entities_parsing.py +++ b/pyxform/entities/entities_parsing.py @@ -1,18 +1,35 @@ -from collections.abc import Sequence -from typing import Any +from collections import defaultdict +from collections.abc import Iterable +from typing import Any, NamedTuple from pyxform import constants as const from pyxform.elements import action from pyxform.errors import ErrorCode, PyXFormError from pyxform.parsing.expression import is_xml_tag +from pyxform.question_type_dictionary import get_meta_group from pyxform.validators.pyxform.pyxform_reference import parse_pyxform_references EC = const.EntityColumns -def get_entity_declaration( - entities_sheet: Sequence[dict], -) -> dict[str, Any]: +class ContainerNode(NamedTuple): + name: str + type: str + + +class ReferenceSource(NamedTuple): + path: tuple[ContainerNode, ...] + row: int + type: str + + def get_scope_boundary(self) -> tuple[ContainerNode, ...]: + for i in range(len(self.path) - 1, -1, -1): + if self.path[i].type in {const.REPEAT, const.SURVEY}: + return self.path[: i + 1] + return () + + +def get_entity_declaration(row: dict) -> dict[str, Any]: """ Transform the entities sheet data into a spec for creating an EntityDeclaration. @@ -30,23 +47,14 @@ def get_entity_declaration( 1 1 1 include conditions for create and update (user's responsibility to ensure they're exclusive) - :param entities_sheet: XLSForm entities sheet data. + :param row: A row from the XLSForm entities sheet data. """ - if len(entities_sheet) > 1: - raise PyXFormError( - "Currently, you can only declare a single entity per form. " - "Please make sure your entities sheet only declares one entity." - ) - - entity_row = entities_sheet[0] - - validate_entities_columns(row=entity_row) - dataset_name = get_validated_dataset_name(entity_row) - entity_id = entity_row.get(EC.ENTITY_ID, None) - create_if = entity_row.get(EC.CREATE_IF, None) - update_if = entity_row.get(EC.UPDATE_IF, None) - label = entity_row.get(EC.LABEL, None) - repeat = get_validated_repeat_name(entity_row) + validate_entities_columns(row=row) + dataset_name = get_validated_dataset_name(row) + entity_id = row.get(EC.ENTITY_ID, None) + create_if = row.get(EC.CREATE_IF, None) + update_if = row.get(EC.UPDATE_IF, None) + label = row.get(EC.LABEL, None) if not entity_id and update_if: raise PyXFormError( @@ -69,7 +77,6 @@ def get_entity_declaration( entity = { const.NAME: const.ENTITY, const.TYPE: const.ENTITY, - EC.REPEAT.value: repeat, const.CHILDREN: [ { const.NAME: "dataset", @@ -104,11 +111,6 @@ def get_entity_declaration( first_load["value"] = "uuid()" id_attr["actions"].append(first_load) - if repeat: - new_repeat = action.ActionLibrary.setvalue_new_repeat.value.to_dict() - new_repeat["value"] = "uuid()" - id_attr["actions"].append(new_repeat) - # Update mode if entity_id: update_attr = {const.NAME: "update", const.TYPE: "attribute", "value": "1"} @@ -199,34 +201,16 @@ def get_validated_dataset_name(entity): return dataset -def get_validated_repeat_name(entity) -> str | None: - if EC.REPEAT.value not in entity: - return None - - value = entity[EC.REPEAT] - try: - match = parse_pyxform_references(value=value, match_limit=1, match_full=True) - except PyXFormError as e: - e.context.update(sheet="entities", column="repeat", row=2) - raise - else: - if not match or match[0].last_saved: - raise PyXFormError(ErrorCode.ENTITY_001.value.format(value=value)) - else: - return match[0].name - - def validate_entity_saveto( row: dict, row_number: int, - stack: Sequence[dict[str, Any]], - entity_declaration: dict[str, Any] | None = None, -): - save_to = row.get(const.BIND, {}).get("entities:saveto", "") + entity_declarations: dict[str, dict[str, Any]] | None = None, +) -> bool: + save_to = row.get(const.BIND, {}).get(const.ENTITIES_SAVETO_NS, "") if not save_to: - return + return False - if not entity_declaration: + if not entity_declarations: raise PyXFormError( "To save entity properties using the save_to column, you must add an entities sheet and declare an entity." ) @@ -236,34 +220,6 @@ def validate_entity_saveto( f"{const.ROW_FORMAT_STRING % row_number} Groups and repeats can't be saved as entity properties." ) - entity_repeat = entity_declaration.get(EC.REPEAT, None) - in_repeat = False - located = False - for i in reversed(stack): - if not i["control_name"] or not i["control_type"]: - break - elif i["control_type"] == const.REPEAT: - # Error: saveto in nested repeat inside entity repeat. - if in_repeat: - raise PyXFormError( - ErrorCode.ENTITY_005.value.format(row=row_number, value=save_to) - ) - elif i["control_name"] == entity_repeat: - located = True - in_repeat = True - - # Error: saveto not in entity repeat - if entity_repeat and not located: - raise PyXFormError( - ErrorCode.ENTITY_006.value.format(row=row_number, value=save_to) - ) - - # Error: saveto in repeat but no entity repeat declared - if in_repeat and not entity_repeat: - raise PyXFormError( - ErrorCode.ENTITY_007.value.format(row=row_number, value=save_to) - ) - # Error: naming rules if save_to.lower() in {const.NAME, const.LABEL}: raise PyXFormError( @@ -277,13 +233,15 @@ def validate_entity_saveto( sheet=const.SURVEY, row=row_number, column=const.ENTITIES_SAVETO ) ) - elif not is_xml_tag(save_to): + elif not is_xml_tag(save_to.split("#")[-1]): raise PyXFormError( ErrorCode.NAMES_008.value.format( sheet=const.SURVEY, row=row_number, column=const.ENTITIES_SAVETO ) ) + return True + def validate_entities_columns(row: dict): extra = {k: None for k in row if k not in EC.value_list()} @@ -298,54 +256,356 @@ def validate_entities_columns(row: dict): raise PyXFormError(msg) -def validate_entity_repeat_target( - entity_declaration: dict[str, Any] | None, - stack: Sequence[dict[str, Any]] | None = None, -) -> bool: +def get_entity_declarations( + entities_sheet: Iterable[dict], +) -> dict[str, dict[str, Any]]: + """ + Collect all entity declarations from the entities sheet. """ - Check if the entity repeat target exists, is a repeat, and is a name match. + entities = {} + for row in entities_sheet: + entity = get_entity_declaration(row=row) + dataset_name = next( + c["value"] for c in entity["children"] if c.get("name", "") == "dataset" + ) + if dataset_name in entities: + raise PyXFormError(f"Duplicate entity :( {dataset_name}") + entities[dataset_name] = entity + + return entities - Raises an error if the control type or name is None (such as for the Survey), or if - the control type is not a repeat. - :param entity_declaration: - :param stack: The control stack from workbook_to_json. - :return: +def get_entity_variable_references( + entities_sheet: Iterable[dict], +) -> dict[str, dict[str, list[str]]]: """ - # Ignore: entity already processed. - if not entity_declaration: - return False + Parse variable references in the entities sheet columns. + """ + entity_references = defaultdict(lambda: defaultdict(list)) + for row in entities_sheet: + for column_name, value in row.items(): + if column_name == EC.DATASET: + continue + references = parse_pyxform_references(value=value) + if references: + for ref in references: + entity_references[ref.name][row[EC.DATASET]].append(column_name) + return entity_references + + +def get_entity_references_by_question( + stack: list[dict[str, Any]], + row: dict[str, Any], + row_number: int, + question_name: str, + entity_declarations: dict[str, dict[str, Any]], + entity_variable_references: dict[str, dict[str, list[str]]], + entity_references_by_question: defaultdict[str, list[ReferenceSource]], +) -> None: + """ + For each question store the saveto or variable references that link it to an entity. + """ + # TODO: pre-calculate the current_path externally - maybe as part of the stack? It only + # changes when the container opens/closes so may be unnecessarily re-calculated and + # this block is recursing up the stack each time so may be non-trivial impact + if len(stack) > 1: + container_path = ( + ContainerNode(name=const.SURVEY, type=const.SURVEY), + *( + ContainerNode( + name=container.get("control_name"), type=container.get("control_type") + ) + for container in stack[1:] + ), + ) + else: + container_path = (ContainerNode(name=const.SURVEY, type=const.SURVEY),) + + # Collect references for later reconciliation, because otherwise the first + # referent found will determine the scope but there may be deeper refs. + saveto = row.get(const.BIND, {}).get(const.ENTITIES_SAVETO_NS, "") + if saveto: + validate_entity_saveto( + row=row, + row_number=row_number, + entity_declarations=entity_declarations, + ) + if "#" in saveto: + saveto = saveto.split("#") + dataset_name = saveto[0] + row[const.BIND][const.ENTITIES_SAVETO_NS] = saveto[-1] + else: + dataset_name = next(iter(entity_declarations.keys())) + entity_references_by_question[dataset_name].append( + ReferenceSource(path=container_path, row=row_number, type="saveto") + ) - entity_repeat = entity_declaration.get(EC.REPEAT, None) + if entity_variable_references and question_name in entity_variable_references: + for dataset_name in entity_variable_references[question_name]: + entity_references_by_question[dataset_name].append( + ReferenceSource(path=container_path, row=row_number, type="variable") + ) - # Ignore: no repeat declared for the entity. - if not entity_repeat: - return False - # Error: repeat not found while processing survey sheet. - if not stack: - raise PyXFormError(ErrorCode.ENTITY_002.value.format(value=entity_repeat)) +def get_container_scopes( + entity_references_by_question: defaultdict[str, list[ReferenceSource]], +) -> defaultdict[tuple[ContainerNode, ...], dict[str, list[ReferenceSource]]]: + """ + Find/validate the deepest scope among the entity_references. + """ + scope_paths = defaultdict(dict) - control_name = stack[-1]["control_name"] - control_type = stack[-1]["control_type"] + for dataset_name, entity_references in entity_references_by_question.items(): + scope_path = (ContainerNode(name=const.SURVEY, type=const.SURVEY),) - # Ignore: current control is not the target. - if control_name and control_name != entity_repeat: - return False + for s in entity_references: + deepest_scope = s.get_scope_boundary() - # Error: target is not a repeat. - if control_type and control_type != const.REPEAT: - raise PyXFormError(ErrorCode.ENTITY_003.value.format(value=entity_repeat)) - - # Error: repeat is in nested repeat. - located = False - for i in reversed(stack): - if not i["control_name"] or not i["control_type"]: - break - elif i["control_type"] == const.REPEAT: - if located: - raise PyXFormError(ErrorCode.ENTITY_004.value.format(value=entity_repeat)) - elif i["control_name"] == entity_repeat: - located = True - - return entity_repeat == control_name + if len(deepest_scope) == len(scope_path) and deepest_scope != scope_path: + raise PyXFormError( + f"Scope Breach for '{dataset_name}': subscriber trying to switch scope at same level" + ) + elif len(deepest_scope) > len(scope_path): + scope_path = deepest_scope + + scope_paths[scope_path][dataset_name] = entity_references + + return scope_paths + + +def get_allocation_requests( + scope_path: tuple[ContainerNode, ...], + entity_references: dict[str, list[ReferenceSource]], +): + """ + Assign/validate the preferred path for each entity declaration. + """ + allocation_requests = [] + for dataset_name, references in entity_references.items(): + if not references: + continue + + # Determine the repeat lineage of the first subscriber to use as a baseline + requested_ref = max(references, key=lambda s: len(s.path)) + + # Prioritise saveto since they must be in the nearest container. + save_tos = tuple(s for s in references if s.type == "saveto") + if save_tos: + lca_saveto = max(save_tos, key=lambda s: len(s.path)) + requested_ref = min((requested_ref, lca_saveto), key=lambda s: len(s.path)) + + # Check saveto entity_refs match the scope_path + for ref in references: + ref_scope_stack = ref.get_scope_boundary() + + if ( + ref.type == "saveto" + and ref_scope_stack + and scope_path + and ref_scope_stack != scope_path + ): + raise PyXFormError( + f"Scope Breach for '{dataset_name}': saveto references " + f"(rows {requested_ref.row}, {ref.row}) " + "exist across inconsistent repeat boundaries." + ) + + allocation_requests.append( + { + "dataset_name": dataset_name, + "requested_path": requested_ref.path, + "minimum_depth": len(scope_path), + "rows": [s.row for s in references], + "has_savetos": int(bool(save_tos)), + } + ) + + return allocation_requests + + +def allocate_entities_to_paths( + scope_path: tuple[ContainerNode, ...], requests: list[dict[str, Any]] +) -> dict[tuple[ContainerNode, ...], str]: + """ + Assign the requested allocations to available allowed container nodes if possible. + """ + allocations = {} + + # Prioritise save_to references but otherwise try to put deepest allocation first. + requests.sort( + key=lambda x: (x["has_savetos"], len(x["requested_path"])), reverse=True + ) + + for item in requests: + current_path = item["requested_path"] + placed = False + + # Attempt to place as low as possible, but try going up to the highest allowed. + while len(current_path) >= item["minimum_depth"]: + if current_path not in allocations: + allocations[current_path] = item["dataset_name"] + placed = True + break + current_path = current_path[:-1] + + if not placed: + rows = ", ".join(str(r) for r in item["rows"]) + raise PyXFormError( + f"Conflict in scope '{scope_path[-1]}': Referent '{item['dataset_name']}' " + f"(rows {rows}) has no available allowed container slots." + ) + + return allocations + + +def allocate_entities_to_containers( + entity_references_by_question: defaultdict[str, list[ReferenceSource]], +) -> dict[tuple[ContainerNode, ...], str]: + """ + Get the paths into which the entities will be placed. + """ + all_allocations = {} + scope_paths = get_container_scopes( + entity_references_by_question=entity_references_by_question + ) + for scope_path, entity_references in scope_paths.items(): + requests = get_allocation_requests( + scope_path=scope_path, entity_references=entity_references + ) + allocations = allocate_entities_to_paths(scope_path=scope_path, requests=requests) + all_allocations.update(allocations) + + return all_allocations + + +def inject_entities_into_json( + node: dict[str, Any], + allocations: dict[tuple[ContainerNode, ...], str], + entity_declarations: dict[str, dict[str, Any]], + current_path: tuple[ContainerNode, ...], + search_prefixes: set[tuple[ContainerNode, ...]], + entities_allocated: set[str] | None = None, + has_repeat_ancestor: bool = False, +) -> dict[str, Any]: + """ + Recursively traverse the json_dict to inject entity declarations. + """ + if entities_allocated is None: + entities_allocated = set() + + dataset_name = allocations.get(current_path, None) + if dataset_name and dataset_name not in entities_allocated: + entity_decl = entity_declarations.get(dataset_name, None) + # TODO: seems unlikely but perhaps there should be an error on `entity_decl is None` + if entity_decl: + if has_repeat_ancestor: + id_attr = next( + iter(c for c in entity_decl[const.CHILDREN] if c[const.NAME] == "id"), + None, + ) + # TODO: could do with a more explicit way of signaling that repeat is allowed + # TODO: should there be an error if the id attr is not found? could that ever happen + if id_attr and id_attr["actions"]: + new_repeat = action.ActionLibrary.setvalue_new_repeat.value.to_dict() + new_repeat["value"] = "uuid()" + if new_repeat not in id_attr["actions"]: + id_attr["actions"].append(new_repeat) + + if const.CHILDREN not in node: + node[const.CHILDREN] = [] + + node[const.CHILDREN].append(get_meta_group(children=[entity_decl])) + entities_allocated.add(dataset_name) + + for child in node.get(const.CHILDREN, []): + child_name = child.get(const.NAME) + child_type = child.get(const.TYPE) + if child_name and child_type in {const.GROUP, const.REPEAT}: + child_path = (*current_path, ContainerNode(name=child_name, type=child_type)) + if not has_repeat_ancestor and child_type == const.REPEAT: + has_repeat_ancestor = True + if child_path in search_prefixes: + inject_entities_into_json( + node=child, + allocations=allocations, + entity_declarations=entity_declarations, + current_path=child_path, + search_prefixes=search_prefixes, + entities_allocated=entities_allocated, + has_repeat_ancestor=has_repeat_ancestor, + ) + + return node + + +def get_search_prefixes( + allocations: dict[tuple[ContainerNode, ...], str], +) -> set[tuple[ContainerNode, ...]]: + """ + Get all the relevant path prefixes to help reduce the path search space. + + :param allocations: The entity path allocations. + :return: path prefixes like (a, b, c) -> ((a,), (a, b), (a, b, c)) + """ + active = set() + for path in allocations.keys(): + # Add every prefix of the path to the set + for i in range(1, len(path) + 1): + active.add(path[:i]) + return active + + +def apply_entities_declarations( + entity_declarations: dict[str, dict[str, Any]], + entity_references_by_question: defaultdict[str, list[ReferenceSource]], + json_dict: dict[str, Any], + meta_children: list[dict[str, Any]], +) -> None: + """ + Traverse the json_dict tree and add meta/entity blocks where appropriate. + + Processing phases: + 1. for each question collect references in get_entity_references_by_question + 2. calculate entity container assignments in allocate_entities_to_containers + 3. apply those meta/entity declarations in inject_entities_into_json + + :param entity_declarations: Entity definition data to be passed to EntityDeclaration, + structured as `{dataset_name: entity_declaration}`. + :param entity_references_by_question: For each entity, details of where and how they + are referred to, structured as `{dataset_name: list[ReferenceSource]}`. + :param json_dict: The output dict structure to be emitted from `workbook_to_json`. + :param meta_children: Details of the nodes to be added to the (parent) meta block. + :return: The json_dict is modified in-place + """ + has_allocations = False + has_repeats = False + if entity_references_by_question: + allocations = allocate_entities_to_containers( + entity_references_by_question=entity_references_by_question + ) + if allocations: + has_allocations = True + has_repeats = any( + p.type == const.REPEAT for i in allocations.keys() for p in i + ) + has_repeat_ancestor = json_dict.get(const.TYPE) == const.REPEAT + json_dict = inject_entities_into_json( + node=json_dict, + allocations=allocations, + entity_declarations=entity_declarations, + current_path=(ContainerNode(name=const.SURVEY, type=const.SURVEY),), + search_prefixes=get_search_prefixes(allocations=allocations), + has_repeat_ancestor=has_repeat_ancestor, + ) + + if len(entity_declarations) > 1 or has_repeats: + json_dict[const.ENTITY_VERSION] = const.EntityVersion.v2025_1_0 + else: + json_dict[const.ENTITY_VERSION] = const.EntityVersion.v2024_1_0 + if not has_allocations: + if len(entity_declarations) > 1: + # TODO: raise error if not already caught / handled elsewhere + pass + else: + # TODO: could this func chain deal with the no-reference case as well? + meta_children.append(next(iter(entity_declarations.values()))) diff --git a/pyxform/entities/entity_declaration.py b/pyxform/entities/entity_declaration.py index df53637e..8f01c184 100644 --- a/pyxform/entities/entity_declaration.py +++ b/pyxform/entities/entity_declaration.py @@ -6,7 +6,10 @@ from pyxform.utils import combine_lists EC = const.EntityColumns -ENTITY_EXTRA_FIELDS = (const.CHILDREN,) +ENTITY_EXTRA_FIELDS = ( + const.TYPE, + const.CHILDREN, +) ENTITY_FIELDS = (*SURVEY_ELEMENT_FIELDS, *ENTITY_EXTRA_FIELDS) CHILD_TYPES = {"label": Label, "attribute": Attribute} @@ -24,7 +27,7 @@ class EntityDeclaration(Section): def get_slot_names() -> tuple[str, ...]: return ENTITY_FIELDS - def __init__(self, name: str, type: str, **kwargs): + def __init__(self, name: str, type: str = const.ENTITY, **kwargs): super().__init__(name=name, type=type, **kwargs) self.children: list[Label | Attribute] = [] diff --git a/pyxform/errors.py b/pyxform/errors.py index e03aaeeb..aae37430 100644 --- a/pyxform/errors.py +++ b/pyxform/errors.py @@ -72,13 +72,6 @@ class ErrorCode(Enum): "The entity repeat target was not found in the 'survey' sheet." ), ) - ENTITY_003 = Detail( - name="Entities - invalid entity repeat: target is not a repeat", - msg=( - "[row : 2] On the 'entities' sheet, the 'repeat' value '{value}' is invalid. " - "The entity repeat target is not a repeat." - ), - ) ENTITY_004 = Detail( name="Entities - invalid entity repeat: target is in a repeat", msg=( @@ -102,14 +95,6 @@ class ErrorCode(Enum): "repeat." ), ) - ENTITY_007 = Detail( - name="Entities - invalid entity repeat save_to: question in repeat but no entity repeat defined", - msg=( - "[row : {row}] On the 'survey' sheet, the 'save_to' value '{value}' is invalid. " - "The entity property populated with 'save_to' must be inside a repeat that is " - "declared in the 'repeat' column of the 'entities' sheet." - ), - ) HEADER_001: Detail = Detail( name="Headers - invalid missing header row", msg=( diff --git a/pyxform/survey.py b/pyxform/survey.py index 8b3aaf71..3d89911e 100644 --- a/pyxform/survey.py +++ b/pyxform/survey.py @@ -183,7 +183,7 @@ class Survey(Section): def get_slot_names() -> tuple[str, ...]: return SURVEY_FIELDS - def __init__(self, **kwargs): + def __init__(self, name: str, type: str = constants.SURVEY, **kwargs): # Internals self._created: datetime.now = datetime.now() self._translations: recursive_dict = recursive_dict() @@ -234,8 +234,7 @@ def __init__(self, **kwargs): list_name: Itemset(name=list_name, choices=values) for list_name, values in choices.items() } - kwargs[constants.TYPE] = constants.SURVEY - super().__init__(fields=SURVEY_EXTRA_FIELDS, **kwargs) + super().__init__(name=name, type=type, fields=SURVEY_EXTRA_FIELDS, **kwargs) def to_json_dict(self, delete_keys: Iterable[str] | None = None) -> dict: to_delete = (k for k in self.get_slot_names() if k.startswith("_")) @@ -997,12 +996,6 @@ def _to_pretty_xml(self) -> str: """Get the XForm with human readable formatting.""" return f"""\n{self.xml().toprettyxml(indent=" ")}""" - def __repr__(self): - return self.__unicode__() - - def __unicode__(self): - return f"" - def _setup_xpath_dictionary(self): if self._xpath: return diff --git a/pyxform/survey_element.py b/pyxform/survey_element.py index 8076961e..668dba60 100644 --- a/pyxform/survey_element.py +++ b/pyxform/survey_element.py @@ -93,7 +93,10 @@ def __setattr__(self, key, value): super().__setattr__(key, value) def __repr__(self): - return f"""{super().__repr__()}(name="{self.name}")""" + type_info = "" + if hasattr(self, "type"): + type_info = f", type={self.type}" + return f"""{super().__repr__()}(name="{self.name}{type_info}")""" def __init__( self, diff --git a/pyxform/xls2json.py b/pyxform/xls2json.py index 2d3b57c6..3a013a83 100644 --- a/pyxform/xls2json.py +++ b/pyxform/xls2json.py @@ -5,7 +5,7 @@ import os import re import sys -from collections import Counter +from collections import Counter, defaultdict from typing import IO, Any from pyxform import aliases, constants @@ -16,9 +16,10 @@ ) from pyxform.elements import action as action_module from pyxform.entities.entities_parsing import ( - get_entity_declaration, - validate_entity_repeat_target, - validate_entity_saveto, + apply_entities_declarations, + get_entity_declarations, + get_entity_references_by_question, + get_entity_variable_references, ) from pyxform.errors import ErrorCode, PyXFormError from pyxform.parsing.expression import is_xml_tag @@ -402,7 +403,8 @@ def workbook_to_json( json_dict[constants.CHOICES] = choices # ########## Entities sheet ########### - entity_declaration = None + entity_declarations = None + entity_variable_references = None if workbook_dict.entities: entities_sheet = dealias_and_group_headers( sheet_name=constants.ENTITIES, @@ -411,7 +413,10 @@ def workbook_to_json( header_aliases=aliases.entities_header, header_columns={i.value for i in constants.EntityColumns.value_list()}, ) - entity_declaration = get_entity_declaration(entities_sheet=entities_sheet.data) + entity_declarations = get_entity_declarations(entities_sheet=entities_sheet.data) + entity_variable_references = get_entity_variable_references( + entities_sheet=entities_sheet.data + ) else: similar = find_sheet_misspellings(key=constants.ENTITIES, keys=sheet_names) if similar is not None: @@ -485,6 +490,7 @@ def workbook_to_json( element_names = Counter() trigger_references: list[tuple[str, int]] = [] repeat_names = set() + entity_references_by_question = defaultdict(list) # row by row, validate questions, throwing errors and adding warnings where needed. for row_number, row in enumerate(survey_sheet.data, start=2): @@ -743,17 +749,6 @@ def workbook_to_json( if end_control_parse: parse_dict = end_control_parse.groupdict() if parse_dict.get("end") and "type" in parse_dict: - if validate_entity_repeat_target( - entity_declaration=entity_declaration, - stack=stack, - ): - parent_children_array.append( - get_meta_group(children=[entity_declaration]) - ) - json_dict[constants.ENTITY_VERSION] = ( - constants.EntityVersion.v2025_1_0 - ) - entity_declaration = None control_type = aliases.control[parse_dict["type"]] if prev_control_type != control_type or len(stack) == 1: raise PyXFormError( @@ -792,11 +787,15 @@ def workbook_to_json( seen_names_lower=child_names_lower, warnings=warnings, ) - validate_entity_saveto( + + get_entity_references_by_question( + stack=stack, row=row, row_number=row_number, - stack=stack, - entity_declaration=entity_declaration, + question_name=question_name, + entity_declarations=entity_declarations, + entity_variable_references=entity_variable_references, + entity_references_by_question=entity_references_by_question, ) # Try to parse question as begin control statement @@ -927,10 +926,6 @@ def workbook_to_json( "row_number": row_number, } ) - validate_entity_repeat_target( - stack=stack, - entity_declaration=entity_declaration, - ) continue # Assuming a question is anything not processed above as a loop/repeat/group. @@ -1432,15 +1427,27 @@ def workbook_to_json( } ) - if entity_declaration: - validate_entity_repeat_target(entity_declaration=entity_declaration) - json_dict[constants.ENTITY_VERSION] = constants.EntityVersion.v2024_1_0 - meta_children.append(entity_declaration) + if entity_declarations: + apply_entities_declarations( + entity_declarations=entity_declarations, + entity_references_by_question=entity_references_by_question, + json_dict=json_dict, + meta_children=meta_children, + ) if len(meta_children) > 0: - meta_element = get_meta_group(children=meta_children) - survey_children_array = stack[0]["parent_children"] - survey_children_array.append(meta_element) + existing_meta = next( + ( + e + for e in json_dict[constants.CHILDREN] + if e[constants.NAME] == "meta" and e[constants.TYPE] == constants.GROUP + ), + None, + ) + if existing_meta: + existing_meta[constants.CHILDREN].extend(meta_children) + else: + json_dict[constants.CHILDREN].append(get_meta_group(children=meta_children)) validate_pyxform_references_in_workbook( workbook_dict=workbook_dict, diff --git a/tests/entities/test_create_repeat.py b/tests/entities/test_create_repeat.py index ef9e4436..759f4c5b 100644 --- a/tests/entities/test_create_repeat.py +++ b/tests/entities/test_create_repeat.py @@ -1,5 +1,4 @@ from pyxform import constants as co -from pyxform.errors import ErrorCode from tests.pyxform_test_case import PyxformTestCase from tests.xpath_helpers.entities import xpe @@ -18,8 +17,8 @@ def test_basic_usage__ok(self): | | end_repeat | | | | | entities | - | | list_name | label | repeat | - | | e1 | ${q1} | ${r1} | + | | list_name | label | + | | e1 | ${q1} | """ self.assertPyxformXform( md=md, @@ -66,8 +65,8 @@ def test_minimal_fields__ok(self): | | end_repeat | | | | entities | - | | list_name | label | repeat | - | | e1 | ${q1} | ${r1} | + | | list_name | label | + | | e1 | ${q1} | """ self.assertPyxformXform( md=md, @@ -93,8 +92,8 @@ def test_create_if__ok(self): | | end_repeat | | | | | entities | - | | list_name | label | repeat | create_if | - | | e1 | ${q1} | ${r1} | ${q1} = '' | + | | list_name | label | create_if | + | | e1 | ${q1} | ${q1} = '' | """ self.assertPyxformXform( md=md, @@ -127,8 +126,8 @@ def test_other_controls_before__ok(self): | | end_repeat | | | | entities | - | | list_name | label | repeat | - | | e1 | ${q3} | ${r2} | + | | list_name | label | + | | e1 | ${q3} | """ self.assertPyxformXform(md=md, warnings_count=0) @@ -148,8 +147,8 @@ def test_other_controls_after__ok(self): | | end_repeat | | | | entities | - | | list_name | label | repeat | - | | e1 | ${q1} | ${r1} | + | | list_name | label | + | | e1 | ${q1} | """ self.assertPyxformXform(md=md, warnings_count=0) @@ -175,8 +174,8 @@ def test_other_controls_before_and_after__ok(self): | | end_repeat | | | | entities | - | | list_name | label | repeat | - | | e1 | ${q3} | ${r2} | + | | list_name | label | + | | e1 | ${q3} | """ self.assertPyxformXform(md=md, warnings_count=0) @@ -191,8 +190,8 @@ def test_question_without_saveto_in_entity_repeat__ok(self): | | end_repeat | | | | | entities | - | | list_name | label | repeat | - | | e1 | ${q1} | ${r1} | + | | list_name | label | + | | e1 | ${q1} | """ self.assertPyxformXform( md=md, @@ -244,8 +243,8 @@ def test_repeat_without_saveto_in_entity_repeat__ok(self): | | end_repeat | | | | | entities | - | | list_name | label | repeat | - | | e1 | ${q1} | ${r1} | + | | list_name | label | + | | e1 | ${q1} | """ self.assertPyxformXform( md=md, @@ -282,8 +281,8 @@ def test_saveto_question_in_nested_group__ok(self): | | end_repeat | | | | | entities | - | | list_name | label | repeat | - | | e1 | ${q1} | ${r1} | + | | list_name | label | + | | e1 | ${q1} | """ self.assertPyxformXform( md=md, @@ -291,21 +290,20 @@ def test_saveto_question_in_nested_group__ok(self): xml__xpath_match=[ xpe.model_instance_meta( "e1", - "/x:r1", + "/x:r1[@jr:template]/x:g1", repeat=True, - template=True, create=True, label=True, ), xpe.model_instance_meta( "e1", - "/x:r1", + "/x:r1[not(@jr:template)]/x:g1", repeat=True, create=True, label=True, ), xpe.model_bind_question_saveto("/r1/g1/q1", "q1e"), - xpe.model_bind_meta_label(" ../../../g1/q1 ", "/r1"), + xpe.model_bind_meta_label(" ../../../q1 ", "/r1/g1"), ], ) @@ -321,8 +319,8 @@ def test_entity_repeat_in_group__ok(self): | | end_group | | | | | entities | - | | list_name | label | repeat | - | | e1 | ${q1} | ${r1} | + | | list_name | label | + | | e1 | ${q1} | """ self.assertPyxformXform( md=md, @@ -348,226 +346,228 @@ def test_entity_repeat_in_group__ok(self): ], ) - def test_entity_repeat_is_not_a_single_reference__error(self): - """Should raise an error if the entity repeat is not a reference.""" - md = """ - | survey | - | | type | name | label | save_to | - | | begin_repeat | r1 | R1 | | - | | text | q1 | Q1 | q1e | - | | end_repeat | | | | - - | entities | - | | list_name | label | repeat | - | | e1 | ${{q1}} | {case} | - """ - # Looks like a single reference but fails to parse. - cases_pyref = ("${.a}", "${a }", "${ }") - for case in cases_pyref: - with self.subTest(msg=case): - self.assertPyxformXform( - md=md.format(case=case), - errored=True, - error__contains=[ - ErrorCode.PYREF_001.value.format( - sheet="entities", column="repeat", row=2, value=case - ) - ], - ) - # Doesn't parse, or isn't a single reference. - cases = (".", "r1", "${r1}a", "${r1}${r2}", "${last-saved#r1}", "${}") - for case in cases: - with self.subTest(msg=case): - self.assertPyxformXform( - md=md.format(case=case), - errored=True, - error__contains=[ErrorCode.ENTITY_001.value.format(value=case)], - ) - - def test_entity_repeat_not_found__error(self): - """Should raise an error if the entity repeat was not found in the survey sheet.""" + def test_somewhat_ambiguous_repeat_nesting_references(self): md = """ | survey | | | type | name | label | | | begin_repeat | r1 | R1 | + | | begin_group | g1 | G1 | | | text | q1 | Q1 | + | | begin_repeat | r2 | R2 | + | | begin_group | g2 | G2 | + | | text | q2 | Q2 | + | | begin_group | g3 | G3 | + | | text | q3 | Q3 | + | | end_group | | | + | | end_group | | | + | | end_repeat | | | + | | end_group | | | | | end_repeat | | | | entities | - | | list_name | label | repeat | - | | e1 | ${q1} | ${r2} | - """ - self.assertPyxformXform( - md=md, - errored=True, - error__contains=[ErrorCode.ENTITY_002.value.format(value="r2")], - ) - - def test_entity_repeat_is_a_group__error(self): - """Should raise an error if the entity repeat is not a repeat.""" - md = """ - | survey | - | | type | name | label | save_to | - | | begin_group | g1 | G1 | | - | | text | q1 | Q1 | q1e | - | | end_group | | | | - - | entities | - | | list_name | label | repeat | - | | e1 | ${q1} | ${g1} | - """ - self.assertPyxformXform( - md=md, - errored=True, - error__contains=[ErrorCode.ENTITY_003.value.format(value="g1")], - ) - - def test_entity_repeat_is_a_loop__error(self): - """Should raise an error if the entity repeat is not a repeat.""" - md = """ - | survey | - | | type | name | label | save_to | - | | begin_loop over c1 | l1 | L1 | | - | | text | q1 | Q1 | q1e | - | | end_loop | | | | - - | choices | - | | list_name | name | label | - | | c1 | o1 | l1 | - - | entities | - | | list_name | label | repeat | - | | e1 | ${q1} | ${l1} | + | | list_name | label | + | | e1 | concat(${q1}, " ", ${q2}, " ", ${q3}) | """ self.assertPyxformXform( md=md, - errored=True, - error__contains=[ErrorCode.ENTITY_003.value.format(value="l1")], + xml__xpath_match=[ + """ + /h:html/h:head/x:model/x:instance/x:test_name/x:r1[@jr:template=''] + /x:g1/x:r2[@jr:template='']/x:g2/x:g3/x:meta/x:entity[@dataset='e1'] + """, + ], ) - def test_entity_repeat_in_repeat__error(self): - """Should raise an error if the entity repeat is inside a repeat.""" + def test_somewhat_ambiguous_repeat_nesting_references_with_saveto(self): md = """ | survey | - | | type | name | label | - | | begin_repeat | r1 | R1 | - | | begin_repeat | r2 | R2 | - | | text | q1 | Q1 | - | | end_repeat | | | - | | end_repeat | | | + | | type | name | label | save_to | + | | begin_repeat | r1 | R1 | | + | | begin_group | g1 | G1 | | + | | text | q1 | Q1 | | + | | begin_repeat | r2 | R2 | | + | | begin_group | g2 | G2 | | + | | text | q2 | Q2 | | + | | begin_group | g3 | G3 | | + | | text | q3 | Q3 | | + | | text | q4 | Q4 | p1 | + | | end_group | | | | + | | end_group | | | | + | | end_repeat | | | | + | | end_group | | | | + | | end_repeat | | | | | entities | - | | list_name | label | repeat | - | | e1 | ${q1} | ${r2} | + | | list_name | label | + | | e1 | concat(${q1}, " ", ${q2}, " ", ${q3}) | """ self.assertPyxformXform( md=md, - errored=True, - error__contains=[ErrorCode.ENTITY_004.value.format(value="r2")], + xml__xpath_match=[ + """ + /h:html/h:head/x:model/x:instance/x:test_name/x:r1[@jr:template=''] + /x:g1/x:r2[@jr:template='']/x:g2/x:g3/x:meta/x:entity[@dataset='e1'] + """, + ], ) - def test_saveto_question_not_in_entity_repeat_no_entity_repeat__error( + def test_somewhat_ambiguous_repeat_nesting_references_with_saveto_and_competing_lists( self, ): - """Should raise an error if a save_to question is not in the entity repeat.""" md = """ | survey | | | type | name | label | save_to | | | begin_repeat | r1 | R1 | | - | | text | q1 | Q1 | q1e | + | | begin_group | g1 | G1 | | + | | text | q1 | Q1 | | + | | begin_repeat | r2 | R2 | | + | | begin_group | g2 | G2 | | + | | text | q2 | Q2 | e1#p1 | + | | begin_group | g3 | G3 | | + | | text | q3 | Q3 | | + | | text | q4 | Q4 | e2#p1 | + | | end_group | | | | + | | end_group | | | | + | | end_repeat | | | | + | | end_group | | | | | | end_repeat | | | | | entities | - | | list_name | label | repeat | - | | e1 | ${q1} | ${r2} | + | | list_name | label | + | | e1 | concat(${q1}, " ", ${q2}, " ", ${q3}) | + | | e2 | concat(${q1}, " ", ${q2}, " ", ${q3}) | """ self.assertPyxformXform( md=md, - errored=True, - error__contains=[ErrorCode.ENTITY_006.value.format(row=3, value="q1e")], + xml__xpath_match=[ + """ + /h:html/h:head/x:model/x:instance/x:test_name/x:r1[@jr:template=''] + /x:g1/x:r2[@jr:template='']/x:g2/x:meta/x:entity[@dataset='e1'] + """, + """ + /h:html/h:head/x:model/x:instance/x:test_name/x:r1[@jr:template=''] + /x:g1/x:r2[@jr:template='']/x:g2/x:g3/x:meta/x:entity[@dataset='e2'] + """, + ], ) - def test_saveto_question_not_in_entity_repeat_in_survey__error(self): - """Should raise an error if a save_to question is not in the entity repeat.""" + def test_somewhat_ambiguous_repeat_nesting_references_with_saveto_and_many_competing_lists( + self, + ): md = """ | survey | - | | type | name | label | save_to | - | | text | q1 | Q1 | q1e | + | | type | name | label | save_to | meta gets what | | begin_repeat | r1 | R1 | | - | | text | q2 | Q2 | | + | | begin_group | g1 | G1 | | + | | text | q1 | Q1 | | + | | begin_repeat | r2 | R2 | | e4 (spare slot in R2 scope) + | | begin_group | g2 | G2 | | e1 (pinned by saveto) + | | text | q2 | Q2 | e1#p1 | + | | begin_group | g3 | G3 | | e3 (another spare slot in R2 scope) + | | begin_group | g4 | G4 | | e2 (pinned to container by saveto) + | | text | q3 | Q3 | | + | | text | q4 | Q4 | e2#p1 | + | | end_group | | | | + | | end_group | | | | + | | end_group | | | | + | | end_repeat | | | | + | | end_group | | | | | | end_repeat | | | | | entities | - | | list_name | label | repeat | - | | e1 | ${q1} | ${r1} | + | | list_name | label | create_if | + | | e1 | concat(${q1}, " ", ${q2}, " ", ${q3}) | ${q1} = '' | + | | e2 | concat(${q1}, " ", ${q2}, " ", ${q3}) | | + | | e3 | concat(${q1}, " ", ${q2}, " ", ${q3}) | | + | | e4 | concat(${q1}, " ", ${q2}, " ", ${q3}) | | """ self.assertPyxformXform( md=md, - errored=True, - error__contains=[ErrorCode.ENTITY_006.value.format(row=2, value="q1e")], + xml__xpath_match=[ + """ + /h:html/h:head/x:model/x:instance/x:test_name/x:r1[@jr:template=''] + /x:g1/x:r2[@jr:template='']/x:meta/x:entity[@dataset='e4'] + """, + """ + /h:html/h:head/x:model/x:instance/x:test_name/x:r1[@jr:template=''] + /x:g1/x:r2[@jr:template='']/x:g2/x:meta/x:entity[@dataset='e1'] + """, + """ + /h:html/h:head/x:model/x:instance/x:test_name/x:r1[@jr:template=''] + /x:g1/x:r2[@jr:template='']/x:g2/x:g3/x:meta/x:entity[@dataset='e3'] + """, + """ + /h:html/h:head/x:model/x:instance/x:test_name/x:r1[@jr:template=''] + /x:g1/x:r2[@jr:template='']/x:g2/x:g3/x:g4/x:meta/x:entity[@dataset='e2'] + """, + ], ) - def test_saveto_question_not_in_entity_repeat_in_group__error(self): - """Should raise an error if a save_to question is not in the entity repeat.""" + def test_bad_sibling_repeats_savetos(self): md = """ | survey | | | type | name | label | save_to | - | | begin_group | g1 | G1 | | - | | text | q1 | Q1 | q1e | - | | end_group | | | | | | begin_repeat | r1 | R1 | | - | | text | q2 | Q2 | | + | | text | q1 | Q1 | e1#p1 | + | | end_repeat | | | | + | | begin_repeat | r2 | R2 | | + | | text | q2 | Q2 | e1#p2 | | | end_repeat | | | | | entities | - | | list_name | label | repeat | - | | e1 | ${q1} | ${r1} | + | | list_name | label | + | | e1 | concat(${q1}, " ", ${q2}) | """ self.assertPyxformXform( md=md, errored=True, - error__contains=[ErrorCode.ENTITY_006.value.format(row=3, value="q1e")], + error__contains=[ + "Scope Breach for 'e1': subscriber trying to switch scope at same level" + ], ) - def test_saveto_question_not_in_entity_repeat_in_repeat__error(self): - """Should raise an error if a save_to question is not in the entity repeat.""" + def test_bad_sibling_repeats_saveto(self): md = """ | survey | | | type | name | label | save_to | | | begin_repeat | r1 | R1 | | - | | text | q1 | Q1 | q1e | + | | text | q1 | Q1 | e1#p1 | | | end_repeat | | | | | | begin_repeat | r2 | R2 | | | | text | q2 | Q2 | | | | end_repeat | | | | | entities | - | | list_name | label | repeat | - | | e1 | ${q1} | ${r2} | + | | list_name | label | + | | e1 | concat(${q1}, " ", ${q2}) | """ self.assertPyxformXform( md=md, errored=True, - error__contains=[ErrorCode.ENTITY_006.value.format(row=3, value="q1e")], + error__contains=[ + "Scope Breach for 'e1': subscriber trying to switch scope at same level" + ], ) - def test_saveto_question_in_nested_repeat__error(self): - """Should raise an error if a save_to question is in a repeat inside the entity repeat.""" + def test_bad_sibling_repeats(self): md = """ | survey | | | type | name | label | save_to | | | begin_repeat | r1 | R1 | | - | | begin_repeat | r2 | R2 | | - | | text | q1 | Q1 | q1e | + | | text | q1 | Q1 | | | | end_repeat | | | | + | | begin_repeat | r2 | R2 | | + | | text | q2 | Q2 | | | | end_repeat | | | | | entities | - | | list_name | label | repeat | - | | e1 | ${q1} | ${r1} | + | | list_name | label | + | | e1 | concat(${q1}, " ", ${q2}) | """ self.assertPyxformXform( md=md, errored=True, - error__contains=[ErrorCode.ENTITY_005.value.format(row=4, value="q1e")], + error__contains=[ + "Scope Breach for 'e1': subscriber trying to switch scope at same level" + ], ) diff --git a/tests/entities/test_create_survey.py b/tests/entities/test_create_survey.py index 50dbf395..ded4a899 100644 --- a/tests/entities/test_create_survey.py +++ b/tests/entities/test_create_survey.py @@ -45,22 +45,22 @@ def test_create_repeat__minimal_fields__ok(self): """ self.assertPyxformXform(md=md, warnings_count=0) - def test_multiple_dataset_rows_in_entities_sheet__errors(self): + def test_multiple_dataset_rows_in_entities_sheet__ok(self): self.assertPyxformXform( - name="data", md=""" - | survey | | | | - | | type | name | label | - | | text | a | A | - | entities | | | | - | | dataset | | | - | | trees | | | - | | shovels | | | + | survey | + | | type | name | label | save_to | + | | begin group | g1 | G1 | | + | | text | q1 | Q1 | e1#a1 | + | | end group | g1 | | | + | | text | q2 | Q2 | e2#a1 | + | | + | entities | + | | dataset | label | + | | e1 | ${q1} | + | | e2 | ${q1} | """, - errored=True, - error__contains=[ - "Currently, you can only declare a single entity per form. Please make sure your entities sheet only declares one entity." - ], + warnings_count=0, ) def test_dataset_with_reserved_prefix__errors(self): @@ -371,25 +371,6 @@ def test_saveto_on_group__errors(self): ], ) - def test_saveto_in_repeat__errors(self): - """Should find that an error is raised if a save_to is in an undeclared entity repeat.""" - md = """ - | survey | - | | type | name | label | save_to | - | | begin_repeat | r1 | R1 | | - | | text | q1 | Q1 | q1e | - | | end_repeat | r1 | | | - - | entities | - | | dataset | label | - | | trees | ${q1} | - """ - self.assertPyxformXform( - md=md, - errored=True, - error__contains=[ErrorCode.ENTITY_007.value.format(row=3, value="q1e")], - ) - def test_saveto_in_group__works(self): self.assertPyxformXform( name="data", diff --git a/tests/entities/test_update_repeat.py b/tests/entities/test_update_repeat.py index d81b37f3..df587ec7 100644 --- a/tests/entities/test_update_repeat.py +++ b/tests/entities/test_update_repeat.py @@ -25,8 +25,8 @@ def test_basic_usage__ok(self): | | csv-external | e1 | | | | entities | - | | list_name | label | repeat | entity_id | - | | e1 | ${q1} | ${r1} | ${q1} | + | | list_name | label | entity_id | + | | e1 | ${q1} | ${q1} | """ self.assertPyxformXform( md=md, @@ -74,8 +74,8 @@ def test_minimal_fields__ok(self): | | csv-external | e1 | | | entities | - | | list_name | repeat | entity_id | - | | e1 | ${r1} | ${q1} | + | | list_name | entity_id | + | | e1 | ${q1} | """ self.assertPyxformXform( md=md, @@ -96,8 +96,8 @@ def test_update_if__ok(self): | | csv-external | e1 | | | | entities | - | | list_name | label | repeat | entity_id | update_if | - | | e1 | ${q1} | ${r1} | ${q1} | ${q1} = '' | + | | list_name | label | entity_id | update_if | + | | e1 | ${q1} | ${q1} | ${q1} = '' | """ self.assertPyxformXform( md=md, @@ -118,8 +118,8 @@ def test_all_fields__ok(self): | | csv-external | e1 | | | | entities | - | | list_name | label | repeat | entity_id | create_if | update_if | - | | e1 | ${q1} | ${r1} | ${q1} | ${q1} = '' | ${q1} = '' | + | | list_name | label | entity_id | create_if | update_if | + | | e1 | ${q1} | ${q1} | ${q1} = '' | ${q1} = '' | """ self.assertPyxformXform( md=md,