diff --git a/pyxform/constants.py b/pyxform/constants.py index 12fa9e69..bcdb3f14 100644 --- a/pyxform/constants.py +++ b/pyxform/constants.py @@ -151,7 +151,6 @@ class EntityColumns(StrEnum): EXTERNAL_CHOICES_ITEMSET_REF_VALUE_GEOJSON = "id" ROW_FORMAT_STRING: str = "[row : %s]" -XML_IDENTIFIER_ERROR_MESSAGE = "must begin with a letter, colon, or underscore. Other characters can include numbers, dashes, and periods." _MSG_SUPPRESS_SPELLING = ( " If you do not mean to include a sheet, to suppress this message, " "prefix the sheet name with an underscore. For example 'setting' " diff --git a/pyxform/entities/entities_parsing.py b/pyxform/entities/entities_parsing.py index ccd7486d..f2d4c076 100644 --- a/pyxform/entities/entities_parsing.py +++ b/pyxform/entities/entities_parsing.py @@ -3,64 +3,11 @@ from pyxform import constants as const from pyxform.elements import action -from pyxform.errors import Detail, PyXFormError +from pyxform.errors import ErrorCode, PyXFormError from pyxform.parsing.expression import is_xml_tag from pyxform.validators.pyxform.pyxform_reference import parse_pyxform_references EC = const.EntityColumns -ENTITY001 = Detail( - name="Invalid entity repeat reference", - msg=( - "[row : 2] On the 'entities' sheet, the 'repeat' value '{value}' is invalid. " - "The 'repeat' column, if specified, must contain only a single reference variable " - "(like '${{q1}}'), and the reference variable must contain a valid name." - ), -) -ENTITY002 = Detail( - name="Invalid entity repeat: target not found", - msg=( - "[row : 2] On the 'entities' sheet, the 'repeat' value '{value}' is invalid. " - "The entity repeat target was not found in the 'survey' sheet." - ), -) -ENTITY003 = Detail( - name="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." - ), -) -ENTITY004 = Detail( - name="Invalid entity repeat: target is in a repeat", - msg=( - "[row : 2] On the 'entities' sheet, the 'repeat' value '{value}' is invalid. " - "The entity repeat target is inside a repeat." - ), -) -ENTITY005 = Detail( - name="Invalid entity repeat save_to: question in nested repeat", - msg=( - "[row : {row}] On the 'survey' sheet, the 'save_to' value '{value}' is invalid. " - "The entity property populated with 'save_to' must not be inside of a nested " - "repeat within the entity repeat." - ), -) -ENTITY006 = Detail( - name="Invalid entity repeat save_to: question not in entity repeat", - msg=( - "[row : {row}] On the 'survey' sheet, the 'save_to' value '{value}' is invalid. " - "The entity property populated with 'save_to' must be inside of the entity " - "repeat." - ), -) -ENTITY007 = Detail( - name="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." - ), -) def get_entity_declaration( @@ -232,20 +179,21 @@ def get_validated_dataset_name(entity): if dataset.startswith(const.ENTITIES_RESERVED_PREFIX): raise PyXFormError( - f"Invalid entity list name: '{dataset}' starts with reserved prefix {const.ENTITIES_RESERVED_PREFIX}." + ErrorCode.NAMES_010.value.format( + sheet=const.ENTITIES, row=2, column=EC.DATASET.value + ) ) - - if "." in dataset: + elif "." in dataset: raise PyXFormError( - f"Invalid entity list name: '{dataset}'. Names may not include periods." + ErrorCode.NAMES_011.value.format( + sheet=const.ENTITIES, row=2, column=EC.DATASET.value + ) ) - - if not is_xml_tag(dataset): - if isinstance(dataset, bytes): - dataset = dataset.decode("utf-8") - + elif not is_xml_tag(dataset): raise PyXFormError( - f"Invalid entity list name: '{dataset}'. Names must begin with a letter, colon, or underscore. Other characters can include numbers or dashes." + ErrorCode.NAMES_008.value.format( + sheet=const.ENTITIES, row=2, column=EC.DATASET.value + ) ) return dataset @@ -263,7 +211,7 @@ def get_validated_repeat_name(entity) -> str | None: raise else: if not match or match[0].last_saved: - raise PyXFormError(ENTITY001.format(value=value)) + raise PyXFormError(ErrorCode.ENTITY_001.value.format(value=value)) else: return match[0].name @@ -297,37 +245,43 @@ def validate_entity_saveto( elif i["control_type"] == const.REPEAT: # Error: saveto in nested repeat inside entity repeat. if in_repeat: - raise PyXFormError(ENTITY005.format(row=row_number, value=save_to)) + 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(ENTITY006.format(row=row_number, value=save_to)) + 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(ENTITY007.format(row=row_number, value=save_to)) - - error_start = f"{const.ROW_FORMAT_STRING % row_number} Invalid save_to name:" - - if save_to.lower() == const.NAME or save_to.lower() == const.LABEL: raise PyXFormError( - f"{error_start} the entity property name '{save_to}' is reserved." + ErrorCode.ENTITY_007.value.format(row=row_number, value=save_to) ) - if save_to.startswith(const.ENTITIES_RESERVED_PREFIX): + # Error: naming rules + if save_to.lower() in {const.NAME, const.LABEL}: raise PyXFormError( - f"{error_start} the entity property name '{save_to}' starts with reserved prefix {const.ENTITIES_RESERVED_PREFIX}." + ErrorCode.NAMES_011.value.format( + sheet=const.SURVEY, row=row_number, column=const.ENTITIES_SAVETO + ) ) - - if not is_xml_tag(save_to): - if isinstance(save_to, bytes): - save_to = save_to.decode("utf-8") - + elif save_to.startswith(const.ENTITIES_RESERVED_PREFIX): + raise PyXFormError( + ErrorCode.NAMES_010.value.format( + sheet=const.SURVEY, row=row_number, column=const.ENTITIES_SAVETO + ) + ) + elif not is_xml_tag(save_to): raise PyXFormError( - f"{error_start} '{save_to}'. Entity property names {const.XML_IDENTIFIER_ERROR_MESSAGE}" + ErrorCode.NAMES_008.value.format( + sheet=const.SURVEY, row=row_number, column=const.ENTITIES_SAVETO + ) ) @@ -370,7 +324,7 @@ def validate_entity_repeat_target( # Error: repeat not found while processing survey sheet. if not stack: - raise PyXFormError(ENTITY002.format(value=entity_repeat)) + raise PyXFormError(ErrorCode.ENTITY_002.value.format(value=entity_repeat)) control_name = stack[-1]["control_name"] control_type = stack[-1]["control_type"] @@ -381,7 +335,7 @@ def validate_entity_repeat_target( # Error: target is not a repeat. if control_type and control_type != const.REPEAT: - raise PyXFormError(ENTITY003.format(value=entity_repeat)) + raise PyXFormError(ErrorCode.ENTITY_003.value.format(value=entity_repeat)) # Error: repeat is in nested repeat. located = False @@ -390,7 +344,7 @@ def validate_entity_repeat_target( break elif i["control_type"] == const.REPEAT: if located: - raise PyXFormError(ENTITY004.format(value=entity_repeat)) + raise PyXFormError(ErrorCode.ENTITY_004.value.format(value=entity_repeat)) elif i["control_name"] == entity_repeat: located = True diff --git a/pyxform/errors.py b/pyxform/errors.py index bc6b3eba..ef2147f9 100644 --- a/pyxform/errors.py +++ b/pyxform/errors.py @@ -41,6 +41,203 @@ def format(self, **kwargs): class ErrorCode(Enum): + ENTITY_001 = Detail( + name="Invalid entity repeat reference", + msg=( + "[row : 2] On the 'entities' sheet, the 'repeat' value '{value}' is invalid. " + "The 'repeat' column, if specified, must contain only a single reference variable " + "(like '${{q1}}'), and the reference variable must contain a valid name." + ), + ) + ENTITY_002 = Detail( + name="Invalid entity repeat: target not found", + msg=( + "[row : 2] On the 'entities' sheet, the 'repeat' value '{value}' is invalid. " + "The entity repeat target was not found in the 'survey' sheet." + ), + ) + ENTITY_003 = Detail( + name="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="Invalid entity repeat: target is in a repeat", + msg=( + "[row : 2] On the 'entities' sheet, the 'repeat' value '{value}' is invalid. " + "The entity repeat target is inside a repeat." + ), + ) + ENTITY_005 = Detail( + name="Invalid entity repeat save_to: question in nested repeat", + msg=( + "[row : {row}] On the 'survey' sheet, the 'save_to' value '{value}' is invalid. " + "The entity property populated with 'save_to' must not be inside of a nested " + "repeat within the entity repeat." + ), + ) + ENTITY_006 = Detail( + name="Invalid entity repeat save_to: question not in entity repeat", + msg=( + "[row : {row}] On the 'survey' sheet, the 'save_to' value '{value}' is invalid. " + "The entity property populated with 'save_to' must be inside of the entity " + "repeat." + ), + ) + ENTITY_007 = Detail( + name="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="Invalid missing header row.", + msg=( + "Invalid headers provided for sheet: '{sheet_name}'. For XLSForms, this may be due " + "a missing header row, in which case add a header row as per the reference template " + "https://xlsform.org/en/ref-table/. For internal API usage, may be due to a missing " + "mapping for '{header}', in which case ensure that the full set of headers appear " + "within the first 100 rows, or specify the header row in '{sheet_name}_header'." + ), + ) + HEADER_002: Detail = Detail( + name="Invalid duplicate header.", + msg=( + "Invalid headers provided for sheet: '{sheet_name}'. Headers that are different " + "names for the same column were found: '{other}', '{header}'. Rename or remove one " + "of these columns." + ), + ) + HEADER_003: Detail = Detail( + name="Invalid missing required header.", + msg=( + "Invalid headers provided for sheet: '{sheet_name}'. One or more required column " + "headers were not found: {missing}. " + "Learn more: https://xlsform.org/en/#setting-up-your-worksheets" + ), + ) + HEADER_004: Detail = Detail( + name="Invalid choices header.", + msg=( + "[row : 1] On the 'choices' sheet, the '{column}' value is invalid. " + "Column headers must not be empty and must not contain spaces. " + "Learn more: https://xlsform.org/en/#setting-up-your-worksheets" + ), + ) + INTERNAL_001: Detail = Detail( + name="Internal error: Incorrectly Processed Question Trigger Data", + msg=( + "Internal error: " + "PyXForm expected processed trigger data as a tuple, but received a " + "type '{type}' with value '{value}'." + ), + ) + LABEL_001: Detail = Detail( + name="Invalid missing label in the choices sheet", + msg=( + "[row : {row}] On the 'choices' sheet, the 'label' value is invalid. " + "Choices should have a label. " + "Learn more: https://xlsform.org/en/#setting-up-your-worksheets" + ), + ) + NAMES_001: Detail = Detail( + name="Invalid duplicate name in same context", + msg=( + "[row : {row}] On the 'survey' sheet, the 'name' value '{value}' is invalid. " + "Questions, groups, and repeats must be unique within their nearest parent group " + "or repeat, or the survey if not inside a group or repeat." + ), + ) + NAMES_002: Detail = Detail( + name="Invalid duplicate name in context (case-insensitive)", + msg=( + "[row : {row}] On the 'survey' sheet, the 'name' value '{value}' is problematic. " + "The name is a case-insensitive match to another name. Questions, groups, and " + "repeats should be unique within the nearest parent group or repeat, or the survey " + "if not inside a group or repeat. Some data processing tools are not " + "case-sensitive, so the current names may make analysis difficult." + ), + ) + NAMES_003: Detail = Detail( + name="Invalid repeat name same as survey", + msg=( + "[row : {row}] On the 'survey' sheet, the 'name' value '{value}' is invalid. " + "Repeat names must not be the same as the survey root (which defaults to 'data')." + ), + ) + NAMES_004: Detail = Detail( + name="Invalid duplicate repeat name in the survey", + msg=( + "[row : {row}] On the 'survey' sheet, the 'name' value '{value}' is invalid. " + "Repeat names must unique anywhere in the survey, at all levels of group or " + "repeat nesting." + ), + ) + NAMES_005: Detail = Detail( + name="Invalid duplicate meta name in the survey", + msg=( + "[row : {row}] On the 'survey' sheet, the 'name' value 'meta' is invalid. " + "The name 'meta' is reserved for form metadata." + ), + ) + NAMES_006: Detail = Detail( + name="Invalid missing name in the choices sheet", + msg=( + "[row : {row}] On the 'choices' sheet, the 'name' value is invalid. " + "Choices must have a name. " + "Learn more: https://xlsform.org/en/#setting-up-your-worksheets" + ), + ) + NAMES_007: Detail = Detail( + name="Invalid duplicate name in the choices sheet", + msg=( + "[row : {row}] On the 'choices' sheet, the 'name' value is invalid. " + "Choice names must be unique for each choice list. " + "If this is intentional, use the setting 'allow_choice_duplicates'. " + "Learn more: https://xlsform.org/#choice-names." + ), + ) + NAMES_008: Detail = Detail( + name="Invalid character(s) in name (XML identifier).", + msg=( + "[row : {row}] On the '{sheet}' sheet, the '{column}' value is invalid. " + "Names must begin with a letter or underscore. After the first character, " + "names may contain letters, digits, underscores, hyphens, or periods." + ), + ) + NAMES_009: Detail = Detail( + name="Invalid character(s) in name (XML identifier)(no sheet context).", + msg=( + "The '{name}' value is invalid. " + "Names must begin with a letter or underscore. After the first character, " + "names may contain letters, digits, underscores, hyphens, or periods." + ), + ) + NAMES_010: Detail = Detail( + name="Invalid character(s) in entity-related name (XML identifier)(underscores).", + msg=( + "[row : {row}] On the '{sheet}' sheet, the '{column}' value is invalid. " + "Names used here must not begin with two underscores." + ), + ) + NAMES_011: Detail = Detail( + name="Invalid character(s) in entity-related name (XML identifier)(period).", + msg=( + "[row : {row}] On the '{sheet}' sheet, the '{column}' value is invalid. " + "Names used here must not contain a period." + ), + ) + NAMES_012: Detail = Detail( + name="Invalid character(s) in entity-related name (XML identifier)(reserved words).", + msg=( + "[row : {row}] On the '{sheet}' sheet, the '{column}' value is invalid. " + "Names used here must not be 'name' or 'label' (case-insensitive)." + ), + ) PYREF_001: Detail = Detail( name="PyXForm Reference Parsing Failed", msg=( @@ -71,12 +268,18 @@ class ErrorCode(Enum): "'{q}' appears more than once." ), ) - INTERNAL_001: Detail = Detail( - name="Internal error: Incorrectly Processed Question Trigger Data", + SURVEY_001 = Detail( + name="Survey Sheet Unmatched Group/Repeat/Loop End", msg=( - "Internal error: " - "PyXForm expected processed trigger data as a tuple, but received a " - "type '{type}' with value '{value}'." + "[row : {row}] Unmatched 'end_{type}'. " + "No matching 'begin_{type}' was found for the name '{name}'." + ), + ) + SURVEY_002 = Detail( + name="Survey Sheet Unmatched Group/Repeat/Loop Begin", + msg=( + "[row : {row}] Unmatched 'begin_{type}'. " + "No matching 'end_{type}' was found for the name '{name}'." ), ) diff --git a/pyxform/parsing/sheet_headers.py b/pyxform/parsing/sheet_headers.py index aa23b1d3..8343071a 100644 --- a/pyxform/parsing/sheet_headers.py +++ b/pyxform/parsing/sheet_headers.py @@ -4,29 +4,12 @@ from typing import Any from pyxform import constants -from pyxform.errors import PyXFormError +from pyxform.errors import ErrorCode, PyXFormError from pyxform.parsing.expression import maybe_strip from pyxform.xls2json_backends import RE_WHITESPACE SMART_QUOTES = {"\u2018": "'", "\u2019": "'", "\u201c": '"', "\u201d": '"'} RE_SMART_QUOTES = re.compile(r"|".join(re.escape(old) for old in SMART_QUOTES)) -INVALID_HEADER = ( - "Invalid headers provided for sheet: '{sheet_name}'. For XLSForms, this may be due " - "a missing header row, in which case add a header row as per the reference template " - "https://xlsform.org/en/ref-table/. For internal API usage, may be due to a missing " - "mapping for '{header}', in which case ensure that the full set of headers appear " - "within the first 100 rows, or specify the header row in '{sheet_name}_header'." -) -INVALID_DUPLICATE = ( - "Invalid headers provided for sheet: '{sheet_name}'. Headers that are different " - "names for the same column were found: '{other}', '{header}'. Rename or remove one " - "of these columns." -) -INVALID_MISSING_REQUIRED = ( - "Invalid headers provided for sheet: '{sheet_name}'. One or more required column " - "headers were not found: {missing}. " - "Learn more: https://xlsform.org/en/#setting-up-your-worksheets" -) def clean_text_values( @@ -196,7 +179,7 @@ def process_row( tokens = header_key.get(header, None) if not tokens: raise PyXFormError( - INVALID_HEADER.format(sheet_name=sheet_name, header=header) + ErrorCode.HEADER_001.value.format(sheet_name=sheet_name, header=header) ) elif len(tokens) == 1: out_row[tokens[0]] = val @@ -268,7 +251,7 @@ def dealias_and_group_headers( other_header = tokens_key.get(tokens) if other_header and new_header != header: raise PyXFormError( - INVALID_DUPLICATE.format( + ErrorCode.HEADER_002.value.format( sheet_name=sheet_name, other=other_header, header=header, @@ -293,7 +276,7 @@ def dealias_and_group_headers( missing = {h for h in headers_required if h not in {h[0] for h in tokens_key}} if missing: raise PyXFormError( - INVALID_MISSING_REQUIRED.format( + ErrorCode.HEADER_003.value.format( sheet_name=sheet_name, missing=", ".join(f"'{h}'" for h in missing) ) ) diff --git a/pyxform/survey_element.py b/pyxform/survey_element.py index 24671589..8076961e 100644 --- a/pyxform/survey_element.py +++ b/pyxform/survey_element.py @@ -3,7 +3,6 @@ """ import json -import re import warnings from collections.abc import Callable, Generator, Iterable, Mapping from itertools import chain @@ -11,10 +10,9 @@ from pyxform import aliases as alias from pyxform import constants as const -from pyxform.errors import PyXFormError +from pyxform.errors import ErrorCode, PyXFormError from pyxform.parsing.expression import is_xml_tag from pyxform.utils import ( - INVALID_XFORM_TAG_REGEXP, DetachableElement, node, print_pyobj_to_json, @@ -143,10 +141,7 @@ def name_for_xpath(self) -> str: def validate(self): if not is_xml_tag(self.name): - invalid_char = re.search(INVALID_XFORM_TAG_REGEXP, self.name) - raise PyXFormError( - f"The name '{self.name}' contains an invalid character '{invalid_char.group(0)}'. Names {const.XML_IDENTIFIER_ERROR_MESSAGE}" - ) + raise PyXFormError(ErrorCode.NAMES_009.value.format(name=const.NAME)) def iter_descendants( self, @@ -519,7 +514,7 @@ def xml_label_and_hint(self, survey: "Survey") -> list["DetachableElement"]: and "big-image" in self.media ): raise PyXFormError( - "To use big-image, you must also specify an image for the survey element named {self.name}." + f"To use big-image, you must also specify an image for the survey element named {self.name}." ) return result diff --git a/pyxform/utils.py b/pyxform/utils.py index 310f59d6..6b16f870 100644 --- a/pyxform/utils.py +++ b/pyxform/utils.py @@ -5,7 +5,6 @@ import copy import csv import json -import re import sys from collections.abc import Generator, Iterable from functools import lru_cache @@ -22,7 +21,6 @@ from pyxform.parsing.expression import parse_expression from pyxform.xls2json_backends import DefinitionData -INVALID_XFORM_TAG_REGEXP = re.compile(r"[^a-zA-Z:_][^a-zA-Z:_0-9\-.]*") LAST_SAVED_INSTANCE_NAME = "__last-saved" NODE_TYPE_TEXT = {Node.TEXT_NODE, Node.CDATA_SECTION_NODE} SPACE_TRANS_TABLE = str.maketrans({" ": "_"}) diff --git a/pyxform/validators/pyxform/choices.py b/pyxform/validators/pyxform/choices.py index 3e2ab55c..61c29495 100644 --- a/pyxform/validators/pyxform/choices.py +++ b/pyxform/validators/pyxform/choices.py @@ -1,27 +1,5 @@ from pyxform import constants -from pyxform.errors import PyXFormError - -INVALID_NAME = ( - "[row : {row}] On the 'choices' sheet, the 'name' value is invalid. " - "Choices must have a name. " - "Learn more: https://xlsform.org/en/#setting-up-your-worksheets" -) -INVALID_LABEL = ( - "[row : {row}] On the 'choices' sheet, the 'label' value is invalid. " - "Choices should have a label. " - "Learn more: https://xlsform.org/en/#setting-up-your-worksheets" -) -INVALID_HEADER = ( - "[row : 1] On the 'choices' sheet, the '{column}' value is invalid. " - "Column headers must not be empty and must not contain spaces. " - "Learn more: https://xlsform.org/en/#setting-up-your-worksheets" -) -INVALID_DUPLICATE = ( - "[row : {row}] On the 'choices' sheet, the 'name' value is invalid. " - "Choice names must be unique for each choice list. " - "If this is intentional, use the setting 'allow_choice_duplicates'. " - "Learn more: https://xlsform.org/#choice-names." -) +from pyxform.errors import ErrorCode, PyXFormError def validate_headers( @@ -31,7 +9,7 @@ def check(): for header in headers: header = header[0] if header != constants.LIST_NAME_S and (" " in header or header == ""): - warnings.append(INVALID_HEADER.format(column=header)) + warnings.append(ErrorCode.HEADER_004.value.format(column=header)) yield header return tuple(check()) @@ -44,14 +22,16 @@ def validate_choice_list( duplicate_errors = [] for option in options: if constants.NAME not in option: - raise PyXFormError(INVALID_NAME.format(row=option["__row"])) + raise PyXFormError(ErrorCode.NAMES_006.value.format(row=option["__row"])) elif constants.LABEL not in option: - warnings.append(INVALID_LABEL.format(row=option["__row"])) + warnings.append(ErrorCode.LABEL_001.value.format(row=option["__row"])) if not allow_duplicates: name = option[constants.NAME] if name in seen_options: - duplicate_errors.append(INVALID_DUPLICATE.format(row=option["__row"])) + duplicate_errors.append( + ErrorCode.NAMES_007.value.format(row=option["__row"]) + ) else: seen_options.add(name) diff --git a/pyxform/validators/pyxform/select_from_file.py b/pyxform/validators/pyxform/select_from_file.py index 33ace1cc..1009a88b 100644 --- a/pyxform/validators/pyxform/select_from_file.py +++ b/pyxform/validators/pyxform/select_from_file.py @@ -1,28 +1,10 @@ -import re from pathlib import Path from pyxform import aliases +from pyxform import constants as co from pyxform.constants import EXTERNAL_INSTANCE_EXTENSIONS, ROW_FORMAT_STRING -from pyxform.errors import PyXFormError - -VALUE_OR_LABEL_TEST_REGEX = re.compile(r"^[a-zA-Z_][a-zA-Z0-9\-_\.]*$") - - -def value_or_label_format_msg(name: str, row_number: int) -> str: - return ( - ROW_FORMAT_STRING % str(row_number) - + f" Parameter '{name}' has a value which is not valid." - + " Values must begin with a letter or underscore. Subsequent " - + "characters can include letters, numbers, dashes, underscores, and periods." - ) - - -def value_or_label_test(value: str) -> bool: - query = VALUE_OR_LABEL_TEST_REGEX.search(value) - if query is None: - return False - else: - return query.group(0) == value +from pyxform.errors import ErrorCode, PyXFormError +from pyxform.parsing.expression import is_xml_tag def value_or_label_check(name: str, value: str, row_number: int) -> None: @@ -40,9 +22,12 @@ def value_or_label_check(name: str, value: str, row_number: int) -> None: :param value: The parameter value to validate. :param row_number: The survey sheet row number. """ - if not value_or_label_test(value=value): - msg = value_or_label_format_msg(name=name, row_number=row_number) - raise PyXFormError(msg) + if not is_xml_tag(value=value): + raise PyXFormError( + ErrorCode.NAMES_008.value.format( + sheet=co.SURVEY, row=row_number, column=f"{co.PARAMETERS} ({name})" + ) + ) def validate_list_name_extension( diff --git a/pyxform/validators/pyxform/settings.py b/pyxform/validators/pyxform/settings.py new file mode 100644 index 00000000..2007f6eb --- /dev/null +++ b/pyxform/validators/pyxform/settings.py @@ -0,0 +1,21 @@ +from pyxform import constants as co +from pyxform.errors import ErrorCode, PyXFormError +from pyxform.parsing.expression import is_xml_tag + + +def validate_name(name: str | None, from_sheet: bool = True): + """ + The name must be a valid XML Name since it is used for the primary instance element. + + :param name: The value to check. + :param from_sheet: If True, the value is from the settings sheet (rather than the + file name or form_name API usage), so the sheet name should be included in the + error (if any). + """ + if name is not None and not is_xml_tag(value=name): + if from_sheet: + raise PyXFormError( + ErrorCode.NAMES_008.value.format(sheet=co.SETTINGS, row=1, column=co.NAME) + ) + else: + raise PyXFormError(ErrorCode.NAMES_009.value.format(name="form_name")) diff --git a/pyxform/validators/pyxform/unique_names.py b/pyxform/validators/pyxform/unique_names.py index 566ec50c..728c7cda 100644 --- a/pyxform/validators/pyxform/unique_names.py +++ b/pyxform/validators/pyxform/unique_names.py @@ -1,46 +1,5 @@ from pyxform import constants as const -from pyxform.errors import Detail, PyXFormError - -NAMES001 = Detail( - name="Invalid duplicate name in same context", - msg=( - "[row : {row}] On the 'survey' sheet, the 'name' value '{value}' is invalid. " - "Questions, groups, and repeats must be unique within their nearest parent group " - "or repeat, or the survey if not inside a group or repeat." - ), -) -NAMES002 = Detail( - name="Invalid duplicate name in context (case-insensitive)", - msg=( - "[row : {row}] On the 'survey' sheet, the 'name' value '{value}' is problematic. " - "The name is a case-insensitive match to another name. Questions, groups, and " - "repeats should be unique within the nearest parent group or repeat, or the survey " - "if not inside a group or repeat. Some data processing tools are not " - "case-sensitive, so the current names may make analysis difficult." - ), -) -NAMES003 = Detail( - name="Invalid repeat name same as survey", - msg=( - "[row : {row}] On the 'survey' sheet, the 'name' value '{value}' is invalid. " - "Repeat names must not be the same as the survey root (which defaults to 'data')." - ), -) -NAMES004 = Detail( - name="Invalid duplicate repeat name in the survey", - msg=( - "[row : {row}] On the 'survey' sheet, the 'name' value '{value}' is invalid. " - "Repeat names must unique anywhere in the survey, at all levels of group or " - "repeat nesting." - ), -) -NAMES005 = Detail( - name="Invalid duplicate meta name in the survey", - msg=( - "[row : {row}] On the 'survey' sheet, the 'name' value 'meta' is invalid. " - "The name 'meta' is reserved for form metadata." - ), -) +from pyxform.errors import ErrorCode, PyXFormError def validate_question_group_repeat_name( @@ -73,15 +32,17 @@ def validate_question_group_repeat_name( if name in seen_names: if name == const.META: - raise PyXFormError(NAMES005.format(row=row_number)) + raise PyXFormError(ErrorCode.NAMES_005.value.format(row=row_number)) else: - raise PyXFormError(NAMES001.format(row=row_number, value=name)) + raise PyXFormError( + ErrorCode.NAMES_001.value.format(row=row_number, value=name) + ) seen_names.add(name) question_name_lower = name.lower() if question_name_lower in seen_names_lower: # No case-insensitive warning for 'meta' since it's not an exported data table. - warnings.append(NAMES002.format(row=row_number, value=name)) + warnings.append(ErrorCode.NAMES_002.value.format(row=row_number, value=name)) seen_names_lower.add(question_name_lower) @@ -107,7 +68,11 @@ def validate_repeat_name( """ if control_type == const.REPEAT: if name == instance_element_name: - raise PyXFormError(NAMES003.format(row=row_number, value=name)) + raise PyXFormError( + ErrorCode.NAMES_003.value.format(row=row_number, value=name) + ) elif name in seen_names: - raise PyXFormError(NAMES004.format(row=row_number, value=name)) + raise PyXFormError( + ErrorCode.NAMES_004.value.format(row=row_number, value=name) + ) seen_names.add(name) diff --git a/pyxform/xls2json.py b/pyxform/xls2json.py index 2bac009f..1130f620 100644 --- a/pyxform/xls2json.py +++ b/pyxform/xls2json.py @@ -13,7 +13,6 @@ _MSG_SUPPRESS_SPELLING, EXTERNAL_INSTANCE_EXTENSIONS, ROW_FORMAT_STRING, - XML_IDENTIFIER_ERROR_MESSAGE, ) from pyxform.elements import action as action_module from pyxform.entities.entities_parsing import ( @@ -21,7 +20,7 @@ validate_entity_repeat_target, validate_entity_saveto, ) -from pyxform.errors import Detail, PyXFormError +from pyxform.errors import ErrorCode, PyXFormError from pyxform.parsing.expression import is_xml_tag from pyxform.parsing.sheet_headers import dealias_and_group_headers from pyxform.question_type_dictionary import get_meta_group @@ -32,6 +31,7 @@ ) from pyxform.validators.pyxform import parameters_generic, select_from_file, unique_names from pyxform.validators.pyxform import question_types as qt +from pyxform.validators.pyxform import settings as validate_settings from pyxform.validators.pyxform.android_package_name import validate_android_package_name from pyxform.validators.pyxform.choices import validate_and_clean_choices from pyxform.validators.pyxform.pyxform_reference import ( @@ -60,20 +60,6 @@ RE_OSM = re.compile( r"(?P(" + "|".join(aliases.osm) + r")) (?P\S+)" ) -SURVEY_001 = Detail( - name="Survey Sheet Unmatched Group/Repeat/Loop End", - msg=( - "[row : {row}] Unmatched 'end_{type}'. " - "No matching 'begin_{type}' was found for the name '{name}'." - ), -) -SURVEY_002 = Detail( - name="Survey Sheet Unmatched Group/Repeat/Loop Begin", - msg=( - "[row : {row}] Unmatched 'begin_{type}'. " - "No matching 'end_{type}' was found for the name '{name}'." - ), -) def dealias_types(dict_array): @@ -278,6 +264,7 @@ def workbook_to_json( raise PyXFormError(msg) # Make sure the passed in vars are unicode + validate_settings.validate_name(name=form_name, from_sheet=False) form_name = str(coalesce(form_name, constants.DEFAULT_FORM_NAME)) default_language = str(coalesce(default_language, constants.DEFAULT_LANGUAGE_VALUE)) @@ -321,6 +308,7 @@ def workbook_to_json( header_columns=set(Survey.get_slot_names()), ) settings = settings_sheet.data[0] + validate_settings.validate_name(name=settings.get(constants.NAME, None)) else: similar = find_sheet_misspellings(key=constants.SETTINGS, keys=sheet_names) if similar is not None: @@ -769,7 +757,7 @@ def workbook_to_json( control_type = aliases.control[parse_dict["type"]] if prev_control_type != control_type or len(stack) == 1: raise PyXFormError( - SURVEY_001.format( + ErrorCode.SURVEY_001.value.format( row=row_number, type=control_type, name=row.get(constants.NAME), @@ -791,7 +779,9 @@ def workbook_to_json( question_name = str(row[constants.NAME]) if not is_xml_tag(question_name): raise PyXFormError( - f"{ROW_FORMAT_STRING % row_number} Invalid question name '{question_name}'. Names {XML_IDENTIFIER_ERROR_MESSAGE}" + ErrorCode.NAMES_008.value.format( + sheet=constants.SURVEY, row=row_number, column=constants.NAME + ) ) element_names.update((question_name,)) @@ -1397,7 +1387,7 @@ def workbook_to_json( if len(stack) > 1: raise PyXFormError( - SURVEY_002.format( + ErrorCode.SURVEY_002.value.format( row=stack[-1]["row_number"], type=stack[-1]["control_type"], name=stack[-1]["control_name"], diff --git a/tests/entities/test_create_repeat.py b/tests/entities/test_create_repeat.py index f05d00de..ef9e4436 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.entities import entities_parsing as ep from pyxform.errors import ErrorCode from tests.pyxform_test_case import PyxformTestCase @@ -382,7 +381,7 @@ def test_entity_repeat_is_not_a_single_reference__error(self): self.assertPyxformXform( md=md.format(case=case), errored=True, - error__contains=[ep.ENTITY001.format(value=case)], + error__contains=[ErrorCode.ENTITY_001.value.format(value=case)], ) def test_entity_repeat_not_found__error(self): @@ -399,7 +398,9 @@ def test_entity_repeat_not_found__error(self): | | e1 | ${q1} | ${r2} | """ self.assertPyxformXform( - md=md, errored=True, error__contains=[ep.ENTITY002.format(value="r2")] + md=md, + errored=True, + error__contains=[ErrorCode.ENTITY_002.value.format(value="r2")], ) def test_entity_repeat_is_a_group__error(self): @@ -416,7 +417,9 @@ def test_entity_repeat_is_a_group__error(self): | | e1 | ${q1} | ${g1} | """ self.assertPyxformXform( - md=md, errored=True, error__contains=[ep.ENTITY003.format(value="g1")] + md=md, + errored=True, + error__contains=[ErrorCode.ENTITY_003.value.format(value="g1")], ) def test_entity_repeat_is_a_loop__error(self): @@ -437,7 +440,9 @@ def test_entity_repeat_is_a_loop__error(self): | | e1 | ${q1} | ${l1} | """ self.assertPyxformXform( - md=md, errored=True, error__contains=[ep.ENTITY003.format(value="l1")] + md=md, + errored=True, + error__contains=[ErrorCode.ENTITY_003.value.format(value="l1")], ) def test_entity_repeat_in_repeat__error(self): @@ -456,7 +461,9 @@ def test_entity_repeat_in_repeat__error(self): | | e1 | ${q1} | ${r2} | """ self.assertPyxformXform( - md=md, errored=True, error__contains=[ep.ENTITY004.format(value="r2")] + md=md, + errored=True, + error__contains=[ErrorCode.ENTITY_004.value.format(value="r2")], ) def test_saveto_question_not_in_entity_repeat_no_entity_repeat__error( @@ -475,7 +482,9 @@ def test_saveto_question_not_in_entity_repeat_no_entity_repeat__error( | | e1 | ${q1} | ${r2} | """ self.assertPyxformXform( - md=md, errored=True, error__contains=[ep.ENTITY006.format(row=3, value="q1e")] + md=md, + errored=True, + error__contains=[ErrorCode.ENTITY_006.value.format(row=3, value="q1e")], ) def test_saveto_question_not_in_entity_repeat_in_survey__error(self): @@ -493,7 +502,9 @@ def test_saveto_question_not_in_entity_repeat_in_survey__error(self): | | e1 | ${q1} | ${r1} | """ self.assertPyxformXform( - md=md, errored=True, error__contains=[ep.ENTITY006.format(row=2, value="q1e")] + md=md, + errored=True, + error__contains=[ErrorCode.ENTITY_006.value.format(row=2, value="q1e")], ) def test_saveto_question_not_in_entity_repeat_in_group__error(self): @@ -513,7 +524,9 @@ def test_saveto_question_not_in_entity_repeat_in_group__error(self): | | e1 | ${q1} | ${r1} | """ self.assertPyxformXform( - md=md, errored=True, error__contains=[ep.ENTITY006.format(row=3, value="q1e")] + md=md, + errored=True, + error__contains=[ErrorCode.ENTITY_006.value.format(row=3, value="q1e")], ) def test_saveto_question_not_in_entity_repeat_in_repeat__error(self): @@ -533,7 +546,9 @@ def test_saveto_question_not_in_entity_repeat_in_repeat__error(self): | | e1 | ${q1} | ${r2} | """ self.assertPyxformXform( - md=md, errored=True, error__contains=[ep.ENTITY006.format(row=3, value="q1e")] + md=md, + errored=True, + error__contains=[ErrorCode.ENTITY_006.value.format(row=3, value="q1e")], ) def test_saveto_question_in_nested_repeat__error(self): @@ -552,5 +567,7 @@ def test_saveto_question_in_nested_repeat__error(self): | | e1 | ${q1} | ${r1} | """ self.assertPyxformXform( - md=md, errored=True, error__contains=[ep.ENTITY005.format(row=4, value="q1e")] + md=md, + errored=True, + error__contains=[ErrorCode.ENTITY_005.value.format(row=4, value="q1e")], ) diff --git a/tests/entities/test_create_survey.py b/tests/entities/test_create_survey.py index 58e11ec4..50dbf395 100644 --- a/tests/entities/test_create_survey.py +++ b/tests/entities/test_create_survey.py @@ -1,5 +1,5 @@ from pyxform import constants as co -from pyxform.entities import entities_parsing as ep +from pyxform.errors import ErrorCode from tests.pyxform_test_case import PyxformTestCase from tests.xpath_helpers.entities import xpe @@ -76,7 +76,9 @@ def test_dataset_with_reserved_prefix__errors(self): """, errored=True, error__contains=[ - "Invalid entity list name: '__sweet' starts with reserved prefix __." + ErrorCode.NAMES_010.value.format( + sheet=co.ENTITIES, row=2, column=co.EntityColumns.DATASET.value + ) ], ) @@ -93,7 +95,9 @@ def test_dataset_with_invalid_xml_name__errors(self): """, errored=True, error__contains=[ - "Invalid entity list name: '$sweet'. Names must begin with a letter, colon, or underscore. Other characters can include numbers or dashes." + ErrorCode.NAMES_008.value.format( + sheet=co.ENTITIES, row=2, column=co.EntityColumns.DATASET.value + ) ], ) @@ -110,7 +114,9 @@ def test_dataset_with_period_in_name__errors(self): """, errored=True, error__contains=[ - "Invalid entity list name: 's.w.eet'. Names may not include periods." + ErrorCode.NAMES_011.value.format( + sheet=co.ENTITIES, row=2, column=co.EntityColumns.DATASET.value + ) ], ) @@ -246,7 +252,9 @@ def test_name_in_saveto_column__errors(self): """, errored=True, error__contains=[ - "[row : 2] Invalid save_to name: the entity property name 'name' is reserved." + ErrorCode.NAMES_011.value.format( + sheet=co.SURVEY, row=2, column=co.ENTITIES_SAVETO + ) ], ) @@ -263,7 +271,9 @@ def test_naMe_in_saveto_column__errors(self): """, errored=True, error__contains=[ - "[row : 2] Invalid save_to name: the entity property name 'naMe' is reserved." + ErrorCode.NAMES_011.value.format( + sheet=co.SURVEY, row=2, column=co.ENTITIES_SAVETO + ) ], ) @@ -280,7 +290,9 @@ def test_label_in_saveto_column__errors(self): """, errored=True, error__contains=[ - "[row : 2] Invalid save_to name: the entity property name 'label' is reserved." + ErrorCode.NAMES_011.value.format( + sheet=co.SURVEY, row=2, column=co.ENTITIES_SAVETO + ) ], ) @@ -297,7 +309,9 @@ def test_lAbEl_in_saveto_column__errors(self): """, errored=True, error__contains=[ - "[row : 2] Invalid save_to name: the entity property name 'lAbEl' is reserved." + ErrorCode.NAMES_011.value.format( + sheet=co.SURVEY, row=2, column=co.ENTITIES_SAVETO + ) ], ) @@ -314,7 +328,9 @@ def test_system_prefix_in_saveto_column__errors(self): """, errored=True, error__contains=[ - "[row : 2] Invalid save_to name: the entity property name '__a' starts with reserved prefix __." + ErrorCode.NAMES_010.value.format( + sheet=co.SURVEY, row=2, column=co.ENTITIES_SAVETO + ) ], ) @@ -331,7 +347,9 @@ def test_invalid_xml_identifier_in_saveto_column__errors(self): """, errored=True, error__contains=[ - "[row : 2] Invalid save_to name: '$a'. Entity property names must begin with a letter, colon, or underscore. Other characters can include numbers, dashes, and periods." + ErrorCode.NAMES_008.value.format( + sheet=co.SURVEY, row=2, column=co.ENTITIES_SAVETO + ) ], ) @@ -369,7 +387,7 @@ def test_saveto_in_repeat__errors(self): self.assertPyxformXform( md=md, errored=True, - error__contains=[ep.ENTITY007.format(row=3, value="q1e")], + error__contains=[ErrorCode.ENTITY_007.value.format(row=3, value="q1e")], ) def test_saveto_in_group__works(self): diff --git a/tests/pyxform_test_case.py b/tests/pyxform_test_case.py index e257fa0c..ea3ed09f 100644 --- a/tests/pyxform_test_case.py +++ b/tests/pyxform_test_case.py @@ -312,13 +312,36 @@ def get_xpath_matcher_context(): ) problem_test_specs = ( - (error__contains, "errors", errors, self.assertContains), - (error__not_contains, "errors", errors, self.assertNotContains), - (warnings__contains, "warnings", warnings, self.assertContains), - (warnings__not_contains, "warnings", warnings, self.assertNotContains), + ("error__contains", error__contains, "errors", errors, self.assertContains), + ( + "error__not_contains", + error__not_contains, + "errors", + errors, + self.assertNotContains, + ), + ( + "warnings__contains", + warnings__contains, + "warnings", + warnings, + self.assertContains, + ), + ( + "warnings__not_contains", + warnings__not_contains, + "warnings", + warnings, + self.assertNotContains, + ), ) - for test_spec, prefix, test_obj, test_func in problem_test_specs: + for param_name, test_spec, prefix, test_obj, test_func in problem_test_specs: if test_spec is not None: + if isinstance(test_spec, str): + raise PyxformTestError( + f"The parameter '{param_name}' is a string but should be an " + f"iterable of strings." + ) test_str = "\n".join(test_obj) for i in test_spec: test_func(content=test_str, text=i, msg_prefix=prefix) diff --git a/tests/test_builder.py b/tests/test_builder.py index c568f50c..de0e069d 100644 --- a/tests/test_builder.py +++ b/tests/test_builder.py @@ -22,8 +22,14 @@ class BuilderTests(TestCase): maxDiff = None def test_unknown_question_type(self): - with self.assertRaises(PyXFormError): + with self.assertRaises(PyXFormError) as err: utils.build_survey("unknown_question_type.xls") + self.assertEqual( + ErrorCode.HEADER_002.value.format( + sheet_name="survey", other="bind:relevant", header="relevance" + ), + err.exception.args[0], + ) def setUp(self): self.this_directory = os.path.dirname(__file__) diff --git a/tests/test_choices_sheet.py b/tests/test_choices_sheet.py index 7fa09ef0..f9dac041 100644 --- a/tests/test_choices_sheet.py +++ b/tests/test_choices_sheet.py @@ -1,5 +1,4 @@ from pyxform.errors import ErrorCode -from pyxform.validators.pyxform import choices as vc from tests.pyxform_test_case import PyxformTestCase from tests.xpath_helpers.choices import xpc @@ -47,10 +46,8 @@ def test_numeric_choice_names__for_dynamic_selects__allowed(self): ], ) - def test_choices_without_labels__for_static_selects__allowed(self): - """ - Test choices without labels for static selects. Validate will NOT fail. - """ + def test_choices_without_labels__for_static_selects__warning(self): + """Should show a warning if a label is missing in the choices sheet.""" self.assertPyxformXform( md=""" | survey | | | | @@ -75,13 +72,14 @@ def test_choices_without_labels__for_static_selects__allowed(self): ] """, ], + warnings__contains=[ + ErrorCode.LABEL_001.value.format(row=2), + ErrorCode.LABEL_001.value.format(row=3), + ], ) - def test_choices_without_labels__for_dynamic_selects__allowed_by_pyxform(self): - """ - Test choices without labels for dynamic selects. Validate will fail. - """ - # TODO: validate doesn't fail + def test_choices_without_labels__for_dynamic_selects__warning(self): + """Should show a warning if a label is missing in the choices sheet.""" self.assertPyxformXform( md=""" | survey | | | | | @@ -106,6 +104,10 @@ def test_choices_without_labels__for_dynamic_selects__allowed_by_pyxform(self): ] """, ], + warnings__contains=[ + ErrorCode.LABEL_001.value.format(row=2), + ErrorCode.LABEL_001.value.format(row=3), + ], ) def test_choices_extra_columns_output_order_matches_xlsform(self): @@ -171,7 +173,7 @@ def test_duplicate_choices_without_setting(self): | | list | b | option c | """, errored=True, - error__contains=[vc.INVALID_DUPLICATE.format(row=4)], + error__contains=[ErrorCode.NAMES_007.value.format(row=4)], ) def test_multiple_duplicate_choices_without_setting(self): @@ -189,8 +191,8 @@ def test_multiple_duplicate_choices_without_setting(self): """, errored=True, error__contains=[ - vc.INVALID_DUPLICATE.format(row=3), - vc.INVALID_DUPLICATE.format(row=5), + ErrorCode.NAMES_007.value.format(row=3), + ErrorCode.NAMES_007.value.format(row=5), ], ) @@ -210,7 +212,7 @@ def test_duplicate_choices_with_setting_not_set_to_yes(self): | | Duplicates | Bob | """, errored=True, - error__contains=[vc.INVALID_DUPLICATE.format(row=4)], + error__contains=[ErrorCode.NAMES_007.value.format(row=4)], ) def test_duplicate_choices_with_allow_choice_duplicates_setting(self): @@ -553,3 +555,36 @@ def test_reference_in_extra_columns__between_columns_of_interest(self): """, ], ) + + def test_missing_name__error(self): + """Should raise an error if a name is missing in the choices sheet.""" + md = """ + | survey | + | | type | name | label | + | | select_one c1 | q1 | Q1 | + + | choices | + | | list_name | name | label | + | | c1 | | N1 | + """ + self.assertPyxformXform( + md=md, + errored=True, + error__contains=[ErrorCode.NAMES_006.value.format(row=2)], + ) + + def test_name_not_validated_as_xml_name(self): + """Should not raise an error if a name has invalid XML name characters.""" + md = """ + | survey | + | | type | name | label | + | | select_one c1 | q1 | Q1 | + + | choices | + | | list_name | name | label | + | | c1 | .n | N1 | + """ + self.assertPyxformXform( + md=md, + xml__xpath_match=[xpc.model_instance_choices_label("c1", ((".n", "N1"),))], + ) diff --git a/tests/test_external_instances.py b/tests/test_external_instances.py index f20c2b21..f9bb5abf 100644 --- a/tests/test_external_instances.py +++ b/tests/test_external_instances.py @@ -6,7 +6,7 @@ from textwrap import dedent -from pyxform.validators.pyxform import unique_names +from pyxform.errors import ErrorCode from tests.pyxform_test_case import PyxformTestCase, PyxformTestError from tests.xpath_helpers.choices import xpc @@ -50,7 +50,7 @@ def test_cannot__use_same_external_xml_id_in_same_section(self): self.assertPyxformXform( md=md, errored=True, - error__contains=[unique_names.NAMES001.format(row=3, value="mydata")], + error__contains=[ErrorCode.NAMES_001.value.format(row=3, value="mydata")], ) def test_can__use_unique_external_xml_in_same_section(self): diff --git a/tests/test_external_instances_for_selects.py b/tests/test_external_instances_for_selects.py index f66cdc48..c559bd37 100644 --- a/tests/test_external_instances_for_selects.py +++ b/tests/test_external_instances_for_selects.py @@ -8,9 +8,8 @@ from dataclasses import dataclass, field from pyxform import aliases -from pyxform.constants import EXTERNAL_INSTANCE_EXTENSIONS -from pyxform.errors import PyXFormError -from pyxform.validators.pyxform import select_from_file +from pyxform import constants as co +from pyxform.errors import ErrorCode, PyXFormError from pyxform.xls2json_backends import md_table_to_workbook from pyxform.xls2xform import get_xml_path, xls2xform_convert @@ -225,7 +224,7 @@ def test_param_value_and_label_validation(self): "label=lbl.()", ) for q_type in q_types: - for file_ext in EXTERNAL_INSTANCE_EXTENSIONS: + for file_ext in co.EXTERNAL_INSTANCE_EXTENSIONS: for param in good_params: with self.subTest(msg=f"{q_type}, {file_ext}, {param}"): self.assertPyxformXform( @@ -236,13 +235,16 @@ def test_param_value_and_label_validation(self): name = "value" if "label" in param: name = "label" - msg = select_from_file.value_or_label_format_msg( - name=name, row_number=2 - ) self.assertPyxformXform( md=md.format(q=q_type, e=file_ext, p=param), errored=True, - error__contains=[msg], + error__contains=[ + ErrorCode.NAMES_008.value.format( + sheet=co.SURVEY, + row=2, + column=f"{co.PARAMETERS} ({name})", + ) + ], ) def test_param_value_case_preserved(self): diff --git a/tests/test_fields.py b/tests/test_fields.py index 96d995a0..d904807d 100644 --- a/tests/test_fields.py +++ b/tests/test_fields.py @@ -2,8 +2,8 @@ Test duplicate survey question field name. """ +from pyxform import constants as co from pyxform.errors import ErrorCode -from pyxform.validators.pyxform import unique_names from tests.pyxform_test_case import PyxformTestCase @@ -173,7 +173,7 @@ def test_names__question_same_as_question_in_same_context_in_survey__error(self) self.assertPyxformXform( md=md, errored=True, - error__contains=[unique_names.NAMES001.format(row=3, value="q1")], + error__contains=[ErrorCode.NAMES_001.value.format(row=3, value="q1")], ) def test_names__question_same_as_group_in_same_context_in_survey__error(self): @@ -189,7 +189,7 @@ def test_names__question_same_as_group_in_same_context_in_survey__error(self): self.assertPyxformXform( md=md, errored=True, - error__contains=[unique_names.NAMES001.format(row=3, value="q1")], + error__contains=[ErrorCode.NAMES_001.value.format(row=3, value="q1")], ) def test_names__question_same_as_repeat_in_same_context_in_survey__error(self): @@ -205,7 +205,7 @@ def test_names__question_same_as_repeat_in_same_context_in_survey__error(self): self.assertPyxformXform( md=md, errored=True, - error__contains=[unique_names.NAMES001.format(row=3, value="q1")], + error__contains=[ErrorCode.NAMES_001.value.format(row=3, value="q1")], ) def test_names__question_same_as_question_in_same_context_in_group__error(self): @@ -221,7 +221,7 @@ def test_names__question_same_as_question_in_same_context_in_group__error(self): self.assertPyxformXform( md=md, errored=True, - error__contains=[unique_names.NAMES001.format(row=4, value="q1")], + error__contains=[ErrorCode.NAMES_001.value.format(row=4, value="q1")], ) def test_names__question_same_as_group_in_same_context_in_group__error(self): @@ -239,7 +239,7 @@ def test_names__question_same_as_group_in_same_context_in_group__error(self): self.assertPyxformXform( md=md, errored=True, - error__contains=[unique_names.NAMES001.format(row=4, value="q1")], + error__contains=[ErrorCode.NAMES_001.value.format(row=4, value="q1")], ) def test_names__question_same_as_repeat_in_same_context_in_group__error(self): @@ -257,7 +257,7 @@ def test_names__question_same_as_repeat_in_same_context_in_group__error(self): self.assertPyxformXform( md=md, errored=True, - error__contains=[unique_names.NAMES001.format(row=4, value="q1")], + error__contains=[ErrorCode.NAMES_001.value.format(row=4, value="q1")], ) def test_names__question_same_as_question_in_same_context_in_repeat__error(self): @@ -273,7 +273,7 @@ def test_names__question_same_as_question_in_same_context_in_repeat__error(self) self.assertPyxformXform( md=md, errored=True, - error__contains=[unique_names.NAMES001.format(row=4, value="q1")], + error__contains=[ErrorCode.NAMES_001.value.format(row=4, value="q1")], ) def test_names__question_same_as_group_in_same_context_in_repeat__error(self): @@ -291,7 +291,7 @@ def test_names__question_same_as_group_in_same_context_in_repeat__error(self): self.assertPyxformXform( md=md, errored=True, - error__contains=[unique_names.NAMES001.format(row=4, value="q1")], + error__contains=[ErrorCode.NAMES_001.value.format(row=4, value="q1")], ) def test_names__question_same_as_repeat_in_same_context_in_repeat__error(self): @@ -309,7 +309,7 @@ def test_names__question_same_as_repeat_in_same_context_in_repeat__error(self): self.assertPyxformXform( md=md, errored=True, - error__contains=[unique_names.NAMES001.format(row=4, value="q1")], + error__contains=[ErrorCode.NAMES_001.value.format(row=4, value="q1")], ) def test_names__question_same_as_question_in_same_context_in_survey__case_insensitive_warning( @@ -323,7 +323,8 @@ def test_names__question_same_as_question_in_same_context_in_survey__case_insens | | text | Q1 | Q2 | """ self.assertPyxformXform( - md=md, warnings__contains=[unique_names.NAMES002.format(row=3, value="Q1")] + md=md, + warnings__contains=[ErrorCode.NAMES_002.value.format(row=3, value="Q1")], ) def test_names__question_same_as_group_in_same_context_in_survey__case_insensitive_warning( @@ -339,7 +340,8 @@ def test_names__question_same_as_group_in_same_context_in_survey__case_insensiti | | end group | | | """ self.assertPyxformXform( - md=md, warnings__contains=[unique_names.NAMES002.format(row=3, value="Q1")] + md=md, + warnings__contains=[ErrorCode.NAMES_002.value.format(row=3, value="Q1")], ) def test_names__question_same_as_repeat_in_same_context_in_survey__case_insensitive_warning( @@ -355,7 +357,8 @@ def test_names__question_same_as_repeat_in_same_context_in_survey__case_insensit | | end repeat | | | """ self.assertPyxformXform( - md=md, warnings__contains=[unique_names.NAMES002.format(row=3, value="Q1")] + md=md, + warnings__contains=[ErrorCode.NAMES_002.value.format(row=3, value="Q1")], ) def test_names__question_same_as_question_in_same_context_in_group__case_insensitive_warning( @@ -371,7 +374,8 @@ def test_names__question_same_as_question_in_same_context_in_group__case_insensi | | end group | | | """ self.assertPyxformXform( - md=md, warnings__contains=[unique_names.NAMES002.format(row=4, value="Q1")] + md=md, + warnings__contains=[ErrorCode.NAMES_002.value.format(row=4, value="Q1")], ) def test_names__question_same_as_group_in_same_context_in_group__case_insensitive_warning( @@ -389,7 +393,8 @@ def test_names__question_same_as_group_in_same_context_in_group__case_insensitiv | | end group | | | """ self.assertPyxformXform( - md=md, warnings__contains=[unique_names.NAMES002.format(row=4, value="Q1")] + md=md, + warnings__contains=[ErrorCode.NAMES_002.value.format(row=4, value="Q1")], ) def test_names__question_same_as_repeat_in_same_context_in_group__case_insensitive_warning( @@ -407,7 +412,8 @@ def test_names__question_same_as_repeat_in_same_context_in_group__case_insensiti | | end group | | | """ self.assertPyxformXform( - md=md, warnings__contains=[unique_names.NAMES002.format(row=4, value="Q1")] + md=md, + warnings__contains=[ErrorCode.NAMES_002.value.format(row=4, value="Q1")], ) def test_names__question_same_as_question_in_same_context_in_repeat__case_insensitive_warning( @@ -423,7 +429,8 @@ def test_names__question_same_as_question_in_same_context_in_repeat__case_insens | | end repeat | | | """ self.assertPyxformXform( - md=md, warnings__contains=[unique_names.NAMES002.format(row=4, value="Q1")] + md=md, + warnings__contains=[ErrorCode.NAMES_002.value.format(row=4, value="Q1")], ) def test_names__question_same_as_group_in_same_context_in_repeat__case_insensitive_warning( @@ -441,7 +448,8 @@ def test_names__question_same_as_group_in_same_context_in_repeat__case_insensiti | | end repeat | | | """ self.assertPyxformXform( - md=md, warnings__contains=[unique_names.NAMES002.format(row=4, value="Q1")] + md=md, + warnings__contains=[ErrorCode.NAMES_002.value.format(row=4, value="Q1")], ) def test_names__question_same_as_repeat_in_same_context_in_repeat__case_insensitive_warning( @@ -459,7 +467,8 @@ def test_names__question_same_as_repeat_in_same_context_in_repeat__case_insensit | | end repeat | | | """ self.assertPyxformXform( - md=md, warnings__contains=[unique_names.NAMES002.format(row=4, value="Q1")] + md=md, + warnings__contains=[ErrorCode.NAMES_002.value.format(row=4, value="Q1")], ) def test_reference_name_not_found__target_after_source__error(self): @@ -569,7 +578,9 @@ def test_reference_in_ignored_columns__not_validated__name__error(self): self.assertPyxformXform( md=md, errored=True, - error__contains=["[row : 3] Invalid question name '${q1x}'."], + error__contains=[ + ErrorCode.NAMES_008.value.format(sheet=co.SURVEY, row=3, column=co.NAME) + ], ) def test_reference_in_ignored_columns__not_validated__name_alias__error(self): @@ -584,7 +595,9 @@ def test_reference_in_ignored_columns__not_validated__name_alias__error(self): self.assertPyxformXform( md=md, errored=True, - error__contains=["[row : 3] Invalid question name '${q1x}'."], + error__contains=[ + ErrorCode.NAMES_008.value.format(sheet=co.SURVEY, row=3, column=co.NAME) + ], ) def test_reference_in_aliased_column(self): diff --git a/tests/test_group.py b/tests/test_group.py index afec55f2..3c3673ff 100644 --- a/tests/test_group.py +++ b/tests/test_group.py @@ -5,8 +5,7 @@ from unittest import TestCase from pyxform.builder import create_survey_element_from_dict -from pyxform.validators.pyxform import unique_names -from pyxform.xls2json import SURVEY_001, SURVEY_002 +from pyxform.errors import ErrorCode from pyxform.xls2xform import convert from tests.pyxform_test_case import PyxformTestCase @@ -269,7 +268,7 @@ def test_names__group_same_as_group_in_same_context_in_survey__error(self): self.assertPyxformXform( md=md, errored=True, - error__contains=[unique_names.NAMES001.format(row=5, value="g1")], + error__contains=[ErrorCode.NAMES_001.value.format(row=5, value="g1")], ) def test_names__group_same_as_repeat_in_same_context_in_survey__error(self): @@ -287,7 +286,7 @@ def test_names__group_same_as_repeat_in_same_context_in_survey__error(self): self.assertPyxformXform( md=md, errored=True, - error__contains=[unique_names.NAMES001.format(row=5, value="g1")], + error__contains=[ErrorCode.NAMES_001.value.format(row=5, value="g1")], ) def test_names__group_same_as_group_in_same_context_in_group__error(self): @@ -307,7 +306,7 @@ def test_names__group_same_as_group_in_same_context_in_group__error(self): self.assertPyxformXform( md=md, errored=True, - error__contains=[unique_names.NAMES001.format(row=6, value="g2")], + error__contains=[ErrorCode.NAMES_001.value.format(row=6, value="g2")], ) def test_names__group_same_as_repeat_in_same_context_in_group__error(self): @@ -327,7 +326,7 @@ def test_names__group_same_as_repeat_in_same_context_in_group__error(self): self.assertPyxformXform( md=md, errored=True, - error__contains=[unique_names.NAMES001.format(row=6, value="g2")], + error__contains=[ErrorCode.NAMES_001.value.format(row=6, value="g2")], ) def test_names__group_same_as_group_in_same_context_in_repeat__error(self): @@ -347,7 +346,7 @@ def test_names__group_same_as_group_in_same_context_in_repeat__error(self): self.assertPyxformXform( md=md, errored=True, - error__contains=[unique_names.NAMES001.format(row=6, value="g2")], + error__contains=[ErrorCode.NAMES_001.value.format(row=6, value="g2")], ) def test_names__group_same_as_repeat_in_same_context_in_repeat__error(self): @@ -367,7 +366,7 @@ def test_names__group_same_as_repeat_in_same_context_in_repeat__error(self): self.assertPyxformXform( md=md, errored=True, - error__contains=[unique_names.NAMES001.format(row=6, value="g2")], + error__contains=[ErrorCode.NAMES_001.value.format(row=6, value="g2")], ) def test_names__group_same_as_group_in_same_context_in_survey__case_insensitive_warning( @@ -385,7 +384,8 @@ def test_names__group_same_as_group_in_same_context_in_survey__case_insensitive_ | | end group | | | """ self.assertPyxformXform( - md=md, warnings__contains=[unique_names.NAMES002.format(row=5, value="G1")] + md=md, + warnings__contains=[ErrorCode.NAMES_002.value.format(row=5, value="G1")], ) def test_names__group_same_as_repeat_in_same_context_in_survey__case_insensitive_warning( @@ -403,7 +403,8 @@ def test_names__group_same_as_repeat_in_same_context_in_survey__case_insensitive | | end group | | | """ self.assertPyxformXform( - md=md, warnings__contains=[unique_names.NAMES002.format(row=5, value="G1")] + md=md, + warnings__contains=[ErrorCode.NAMES_002.value.format(row=5, value="G1")], ) def test_names__group_same_as_group_in_same_context_in_group__case_insensitive_warning( @@ -423,7 +424,8 @@ def test_names__group_same_as_group_in_same_context_in_group__case_insensitive_w | | end group | | | """ self.assertPyxformXform( - md=md, warnings__contains=[unique_names.NAMES002.format(row=6, value="G2")] + md=md, + warnings__contains=[ErrorCode.NAMES_002.value.format(row=6, value="G2")], ) def test_names__group_same_as_repeat_in_same_context_in_group__case_insensitive_warning( @@ -443,7 +445,8 @@ def test_names__group_same_as_repeat_in_same_context_in_group__case_insensitive_ | | end group | | | """ self.assertPyxformXform( - md=md, warnings__contains=[unique_names.NAMES002.format(row=6, value="G2")] + md=md, + warnings__contains=[ErrorCode.NAMES_002.value.format(row=6, value="G2")], ) def test_names__group_same_as_group_in_same_context_in_repeat__case_insensitive_warning( @@ -463,7 +466,8 @@ def test_names__group_same_as_group_in_same_context_in_repeat__case_insensitive_ | | end repeat | | | """ self.assertPyxformXform( - md=md, warnings__contains=[unique_names.NAMES002.format(row=6, value="G2")] + md=md, + warnings__contains=[ErrorCode.NAMES_002.value.format(row=6, value="G2")], ) def test_names__group_same_as_repeat_in_same_context_in_repeat__case_insensitive_warning( @@ -483,7 +487,8 @@ def test_names__group_same_as_repeat_in_same_context_in_repeat__case_insensitive | | end repeat | | | """ self.assertPyxformXform( - md=md, warnings__contains=[unique_names.NAMES002.format(row=6, value="G2")] + md=md, + warnings__contains=[ErrorCode.NAMES_002.value.format(row=6, value="G2")], ) def test_group__no_end_error__no_name(self): @@ -511,7 +516,9 @@ def test_group__no_end_error(self): self.assertPyxformXform( md=md, errored=True, - error__contains=[SURVEY_002.format(row=2, type="group", name="g1")], + error__contains=[ + ErrorCode.SURVEY_002.value.format(row=2, type="group", name="g1") + ], ) def test_group__no_end_error__different_end_type(self): @@ -526,7 +533,7 @@ def test_group__no_end_error__different_end_type(self): self.assertPyxformXform( md=md, errored=True, - error__contains=[SURVEY_001.format(row=4, type="repeat")], + error__contains=[ErrorCode.SURVEY_001.value.format(row=4, type="repeat")], ) def test_group__no_end_error__with_another_closed_group(self): @@ -542,7 +549,9 @@ def test_group__no_end_error__with_another_closed_group(self): self.assertPyxformXform( md=md, errored=True, - error__contains=[SURVEY_002.format(row=2, type="group", name="g1")], + error__contains=[ + ErrorCode.SURVEY_002.value.format(row=2, type="group", name="g1") + ], ) def test_group__no_begin_error(self): @@ -556,7 +565,7 @@ def test_group__no_begin_error(self): self.assertPyxformXform( md=md, errored=True, - error__contains=[SURVEY_001.format(row=3, type="group")], + error__contains=[ErrorCode.SURVEY_001.value.format(row=3, type="group")], ) def test_group__no_begin_error__with_name(self): @@ -570,7 +579,9 @@ def test_group__no_begin_error__with_name(self): self.assertPyxformXform( md=md, errored=True, - error__contains=[SURVEY_001.format(row=3, type="group", name="g1")], + error__contains=[ + ErrorCode.SURVEY_001.value.format(row=3, type="group", name="g1") + ], ) def test_group__no_begin_error__with_another_closed_group(self): @@ -587,7 +598,7 @@ def test_group__no_begin_error__with_another_closed_group(self): md=md, errored=True, error__contains=[ - SURVEY_001.format( + ErrorCode.SURVEY_001.value.format( row=5, type="group", ) @@ -607,7 +618,7 @@ def test_group__no_begin_error__with_another_closed_repeat(self): self.assertPyxformXform( md=md, errored=True, - error__contains=[SURVEY_001.format(row=4, type="group")], + error__contains=[ErrorCode.SURVEY_001.value.format(row=4, type="group")], ) def test_empty_group__no_question__error(self): diff --git a/tests/test_loop.py b/tests/test_loop.py index 9dd0e8ba..8d9e429b 100644 --- a/tests/test_loop.py +++ b/tests/test_loop.py @@ -4,7 +4,7 @@ from unittest import TestCase -from pyxform.validators.pyxform import unique_names +from pyxform.errors import ErrorCode from pyxform.xls2xform import convert from tests.pyxform_test_case import PyxformTestCase @@ -193,7 +193,7 @@ def test_loop__repeats_error(self): md=md, errored=True, # Not caught by xls2json since loops are currently generated in builder.py - error__contains=[unique_names.NAMES004.format(row=None, value="r1")], + error__contains=[ErrorCode.NAMES_004.value.format(row=None, value="r1")], ) def test_loop__references_error(self): diff --git a/tests/test_metadata.py b/tests/test_metadata.py index 7ff645a6..25c025eb 100644 --- a/tests/test_metadata.py +++ b/tests/test_metadata.py @@ -2,7 +2,7 @@ Test language warnings. """ -from pyxform.validators.pyxform import unique_names +from pyxform.errors import ErrorCode from tests.pyxform_test_case import PyxformTestCase @@ -115,7 +115,7 @@ def test_names__question_named_meta__in_survey__error(self): | | text | meta | Q1 | """ self.assertPyxformXform( - md=md, errored=True, error__contains=[unique_names.NAMES005.format(row=2)] + md=md, errored=True, error__contains=[ErrorCode.NAMES_005.value.format(row=2)] ) def test_names__group_named_meta__in_survey__error(self): @@ -128,7 +128,7 @@ def test_names__group_named_meta__in_survey__error(self): | | end group | | | """ self.assertPyxformXform( - md=md, errored=True, error__contains=[unique_names.NAMES005.format(row=2)] + md=md, errored=True, error__contains=[ErrorCode.NAMES_005.value.format(row=2)] ) def test_names__repeat_named_meta__in_survey__error(self): @@ -141,7 +141,7 @@ def test_names__repeat_named_meta__in_survey__error(self): | | end repeat | | | """ self.assertPyxformXform( - md=md, errored=True, error__contains=[unique_names.NAMES005.format(row=2)] + md=md, errored=True, error__contains=[ErrorCode.NAMES_005.value.format(row=2)] ) def test_names__question_named_meta__in_group__error(self): @@ -154,7 +154,7 @@ def test_names__question_named_meta__in_group__error(self): | | end group | | | """ self.assertPyxformXform( - md=md, errored=True, error__contains=[unique_names.NAMES005.format(row=3)] + md=md, errored=True, error__contains=[ErrorCode.NAMES_005.value.format(row=3)] ) def test_names__group_named_meta__in_group__error(self): @@ -169,7 +169,7 @@ def test_names__group_named_meta__in_group__error(self): | | end group | | | """ self.assertPyxformXform( - md=md, errored=True, error__contains=[unique_names.NAMES005.format(row=3)] + md=md, errored=True, error__contains=[ErrorCode.NAMES_005.value.format(row=3)] ) def test_names__repeat_named_meta__in_group__error(self): @@ -184,7 +184,7 @@ def test_names__repeat_named_meta__in_group__error(self): | | end group | | | """ self.assertPyxformXform( - md=md, errored=True, error__contains=[unique_names.NAMES005.format(row=3)] + md=md, errored=True, error__contains=[ErrorCode.NAMES_005.value.format(row=3)] ) def test_names__question_named_meta__in_repeat__error(self): @@ -197,7 +197,7 @@ def test_names__question_named_meta__in_repeat__error(self): | | end group | | | """ self.assertPyxformXform( - md=md, errored=True, error__contains=[unique_names.NAMES005.format(row=3)] + md=md, errored=True, error__contains=[ErrorCode.NAMES_005.value.format(row=3)] ) def test_names__group_named_meta__in_repeat__error(self): @@ -212,7 +212,7 @@ def test_names__group_named_meta__in_repeat__error(self): | | end repeat | | | """ self.assertPyxformXform( - md=md, errored=True, error__contains=[unique_names.NAMES005.format(row=3)] + md=md, errored=True, error__contains=[ErrorCode.NAMES_005.value.format(row=3)] ) def test_names__repeat_named_meta__in_repeat__error(self): @@ -227,5 +227,5 @@ def test_names__repeat_named_meta__in_repeat__error(self): | | end repeat | | | """ self.assertPyxformXform( - md=md, errored=True, error__contains=[unique_names.NAMES005.format(row=3)] + md=md, errored=True, error__contains=[ErrorCode.NAMES_005.value.format(row=3)] ) diff --git a/tests/test_pyxform_test_case.py b/tests/test_pyxform_test_case.py index bb9788d2..a12e2730 100644 --- a/tests/test_pyxform_test_case.py +++ b/tests/test_pyxform_test_case.py @@ -1,7 +1,7 @@ import unittest from dataclasses import dataclass -from tests.pyxform_test_case import PyxformTestCase +from tests.pyxform_test_case import PyxformTestCase, PyxformTestError @dataclass(slots=True) @@ -334,3 +334,61 @@ def test_xml__n_xpath_n_match_fail__xpath_match(self): xml__xpath_match=[*self.suite2_xpaths, self.s1c1.xpath], run_odk_validate=False, ) + + +class TestPyxformTestCaseErrors(PyxformTestCase): + def test_error__contains__wrong_type__error(self): + """Should raise an error if error__contains is the wrong type.""" + md = """ + | survey | + | | type | name | label | + | | text | q1 | Q1 | + """ + with self.assertRaises(PyxformTestError) as err: + self.assertPyxformXform(md=md, error__contains="A string.") + self.assertEqual( + "The parameter 'error__contains' is a string but should be an iterable of strings.", + err.exception.args[0], + ) + + def test_error__not_contains__wrong_type__error(self): + """Should raise an error if error__not_contains is the wrong type.""" + md = """ + | survey | + | | type | name | label | + | | text | q1 | Q1 | + """ + with self.assertRaises(PyxformTestError) as err: + self.assertPyxformXform(md=md, error__not_contains="A string.") + self.assertEqual( + "The parameter 'error__not_contains' is a string but should be an iterable of strings.", + err.exception.args[0], + ) + + def test_warnings__contains__wrong_type__error(self): + """Should raise an error if warnings__contains is the wrong type.""" + md = """ + | survey | + | | type | name | label | + | | text | q1 | Q1 | + """ + with self.assertRaises(PyxformTestError) as err: + self.assertPyxformXform(md=md, warnings__contains="A string.") + self.assertEqual( + "The parameter 'warnings__contains' is a string but should be an iterable of strings.", + err.exception.args[0], + ) + + def test_warnings__not_contains__wrong_type__error(self): + """Should raise an error if warnings__not_contains is the wrong type.""" + md = """ + | survey | + | | type | name | label | + | | text | q1 | Q1 | + """ + with self.assertRaises(PyxformTestError) as err: + self.assertPyxformXform(md=md, warnings__not_contains="A string.") + self.assertEqual( + "The parameter 'warnings__not_contains' is a string but should be an iterable of strings.", + err.exception.args[0], + ) diff --git a/tests/test_repeat.py b/tests/test_repeat.py index ab131a22..4c41f609 100644 --- a/tests/test_repeat.py +++ b/tests/test_repeat.py @@ -7,7 +7,7 @@ from unittest import skip from psutil import Process -from pyxform.validators.pyxform import unique_names +from pyxform.errors import ErrorCode from pyxform.xls2json_backends import SupportedFileTypes from pyxform.xls2xform import convert @@ -1237,7 +1237,7 @@ def test_names__repeat_same_as_repeat_in_same_context_in_survey__error(self): self.assertPyxformXform( md=md, errored=True, - error__contains=[unique_names.NAMES001.format(row=5, value="r1")], + error__contains=[ErrorCode.NAMES_001.value.format(row=5, value="r1")], ) def test_names__repeat_same_as_repeat_in_same_context_in_group__error(self): @@ -1257,7 +1257,7 @@ def test_names__repeat_same_as_repeat_in_same_context_in_group__error(self): self.assertPyxformXform( md=md, errored=True, - error__contains=[unique_names.NAMES001.format(row=6, value="r1")], + error__contains=[ErrorCode.NAMES_001.value.format(row=6, value="r1")], ) def test_names__repeat_same_as_repeat_in_same_context_in_repeat__error(self): @@ -1277,7 +1277,7 @@ def test_names__repeat_same_as_repeat_in_same_context_in_repeat__error(self): self.assertPyxformXform( md=md, errored=True, - error__contains=[unique_names.NAMES001.format(row=6, value="r2")], + error__contains=[ErrorCode.NAMES_001.value.format(row=6, value="r2")], ) def test_names__repeat_same_as_repeat_in_same_context_in_survey__case_insensitive_warning( @@ -1295,7 +1295,8 @@ def test_names__repeat_same_as_repeat_in_same_context_in_survey__case_insensitiv | | end repeat | | | """ self.assertPyxformXform( - md=md, warnings__contains=[unique_names.NAMES002.format(row=5, value="R1")] + md=md, + warnings__contains=[ErrorCode.NAMES_002.value.format(row=5, value="R1")], ) def test_names__repeat_same_as_repeat_in_same_context_in_group__case_insensitive_warning( @@ -1315,7 +1316,8 @@ def test_names__repeat_same_as_repeat_in_same_context_in_group__case_insensitive | | end group | | | """ self.assertPyxformXform( - md=md, warnings__contains=[unique_names.NAMES002.format(row=6, value="R1")] + md=md, + warnings__contains=[ErrorCode.NAMES_002.value.format(row=6, value="R1")], ) def test_names__repeat_same_as_repeat_in_same_context_in_repeat__case_insensitive_warning( @@ -1335,7 +1337,8 @@ def test_names__repeat_same_as_repeat_in_same_context_in_repeat__case_insensitiv | | end repeat | | | """ self.assertPyxformXform( - md=md, warnings__contains=[unique_names.NAMES002.format(row=6, value="R2")] + md=md, + warnings__contains=[ErrorCode.NAMES_002.value.format(row=6, value="R2")], ) def test_names__repeat_same_as_survey_root__error(self): @@ -1351,7 +1354,7 @@ def test_names__repeat_same_as_survey_root__error(self): md=md, name="data", errored=True, - error__contains=[unique_names.NAMES003.format(row=2, value="data")], + error__contains=[ErrorCode.NAMES_003.value.format(row=2, value="data")], ) def test_names__repeat_same_as_repeat_in_different_context_in_group__error(self): @@ -1371,7 +1374,7 @@ def test_names__repeat_same_as_repeat_in_different_context_in_group__error(self) self.assertPyxformXform( md=md, errored=True, - error__contains=[unique_names.NAMES004.format(row=7, value="r1")], + error__contains=[ErrorCode.NAMES_004.value.format(row=7, value="r1")], ) def test_names__repeat_same_as_repeat_in_different_context_in_repeat__error(self): @@ -1391,7 +1394,7 @@ def test_names__repeat_same_as_repeat_in_different_context_in_repeat__error(self self.assertPyxformXform( md=md, errored=True, - error__contains=[unique_names.NAMES004.format(row=7, value="r2")], + error__contains=[ErrorCode.NAMES_004.value.format(row=7, value="r2")], ) def test_empty_repeat__no_question__ok(self): @@ -1587,7 +1590,7 @@ def test_expression__generated_element_same_name__error(self): self.assertPyxformXform( md=md, errored=True, - error__contains=[unique_names.NAMES001.format(value="r1_count")], + error__contains=[ErrorCode.NAMES_001.value.format(value="r1_count")], ) def test_expression__generated_element_different_name__ok(self): @@ -1643,7 +1646,7 @@ def test_manual_xpath__generated_element_same_name__error(self): self.assertPyxformXform( md=md, errored=True, - error__contains=[unique_names.NAMES001.format(value="r1_count")], + error__contains=[ErrorCode.NAMES_001.value.format(value="r1_count")], ) def test_manual_xpath__generated_element_different_name__ok(self): @@ -1694,7 +1697,7 @@ def test_constant_integer__generated_element_same_name__error(self): self.assertPyxformXform( md=md, errored=True, - error__contains=[unique_names.NAMES001.format(value="r1_count")], + error__contains=[ErrorCode.NAMES_001.value.format(value="r1_count")], ) def test_constant_integer__generated_element_different_name__ok(self): diff --git a/tests/test_settings.py b/tests/test_settings.py index d7b2ed28..c19e982e 100644 --- a/tests/test_settings.py +++ b/tests/test_settings.py @@ -1,3 +1,4 @@ +from pyxform import constants as co from pyxform.errors import ErrorCode from tests.pyxform_test_case import PyxformTestCase @@ -43,6 +44,65 @@ def test_form_id(self): xml__xpath_match=[xps.form_id("my_form")], ) + def test_name__from_sheet__valid_characters(self): + """Should allow a custom name with valid characters.""" + md = """ + | settings | + | | name | + | | master-form_v2.1 | + + | survey | + | | type | name | label | + | | text | q1 | hello | + """ + self.assertPyxformXform(md=md, xml__xpath_match=[xps.name("master-form_v2.1")]) + + def test_name__from_sheet__invalid_characters__error(self): + """Should raise an error if the form_name is not a valid name.""" + md = """ + | settings | + | | name | + | | bad@filename | + + | survey | + | | type | name | label | + | | text | q1 | hello | + """ + self.assertPyxformXform( + md=md, + errored=True, + error__contains=[ + ErrorCode.NAMES_008.value.format(sheet=co.SETTINGS, row=1, column=co.NAME) + ], + ) + + def test_name__from_file__valid_characters(self): + """Should allow a custom form_name with valid characters.""" + md = """ + | survey | + | | type | name | label | + | | text | q1 | hello | + """ + self.assertPyxformXform( + md=md, + name="master-form_v2.1", + xml__xpath_match=[xps.name("master-form_v2.1")], + ) + + def test_name__from_file__invalid_characters__error(self): + """Should raise an error if the form_name is not a valid name.""" + md = """ + | survey | + | | type | name | label | + | | text | q1 | hello | + """ + self.assertPyxformXform( + md=md, + name="bad@filename", + errored=True, + error__contains=[ErrorCode.NAMES_009.value.format(name="form_name")], + ) + def test_clean_text_values__yes(self): """Should find clean_text_values=yes (default) collapses survey sheet whitespace.""" md = """ diff --git a/tests/test_sheet_columns.py b/tests/test_sheet_columns.py index 5711b73d..8f6d7ff8 100644 --- a/tests/test_sheet_columns.py +++ b/tests/test_sheet_columns.py @@ -7,17 +7,13 @@ from unittest import skip from pyxform import constants -from pyxform.errors import PyXFormError +from pyxform.errors import ErrorCode, PyXFormError from pyxform.parsing.sheet_headers import ( - INVALID_DUPLICATE, - INVALID_HEADER, - INVALID_MISSING_REQUIRED, dealias_and_group_headers, process_header, process_row, to_snake_case, ) -from pyxform.validators.pyxform import choices as vc from pyxform.xls2xform import convert from tests.pyxform_test_case import PyxformTestCase @@ -203,7 +199,7 @@ def test_missing_survey_headers(self): """, errored=True, error__contains=[ - INVALID_MISSING_REQUIRED.format(sheet_name="survey", missing="'type'") + ErrorCode.HEADER_003.value.format(sheet_name="survey", missing="'type'") ], ) @@ -262,7 +258,7 @@ def test_invalid_choices_sheet_fails(self): ), errored=True, error__contains=[ - INVALID_MISSING_REQUIRED.format(sheet_name="choices", missing="'name'") + ErrorCode.HEADER_003.value.format(sheet_name="choices", missing="'name'") ], ) @@ -284,21 +280,6 @@ def test_missing_list_name(self): error__contains=["choices", "name", "list_name"], ) - def test_clear_filename_error_message(self): - """Test clear filename error message""" - error_message = "The name 'bad@filename' contains an invalid character '@'. Names must begin with a letter, colon, or underscore. Other characters can include numbers, dashes, and periods." - self.assertPyxformXform( - name="bad@filename", - ss_structure=self._simple_choice_ss( - [ - {"list_name": "l1", "name": "c1", "label": "choice 1"}, - {"list_name": "l1", "name": "c2", "label": "choice 2"}, - ] - ), - errored=True, - error__contains=[error_message], - ) - def test_missing_choice_headers(self): self.assertPyxformXform( md=""" @@ -311,7 +292,7 @@ def test_missing_choice_headers(self): """, errored=True, error__contains=[ - INVALID_MISSING_REQUIRED.format(sheet_name="choices", missing="'name'") + ErrorCode.HEADER_003.value.format(sheet_name="choices", missing="'name'") ], ) @@ -357,7 +338,7 @@ def test_conflicting_aliased_values_raises_error(self): """, errored=True, error__contains=[ - INVALID_DUPLICATE.format( + ErrorCode.HEADER_002.value.format( sheet_name="survey", other="name", header="value" ) ], @@ -375,7 +356,9 @@ def test_duplicate_header_raises_error(self): """, errored=True, error__contains=[ - INVALID_DUPLICATE.format(sheet_name="survey", other="name", header="name") + ErrorCode.HEADER_002.value.format( + sheet_name="survey", other="name", header="name" + ) ], ) @@ -604,7 +587,7 @@ def test_choice_filter_columns_not_normalised(self): ] """, ), - warnings__contains=(vc.INVALID_HEADER.format(column="e f"),), + warnings__contains=(ErrorCode.HEADER_004.value.format(column="e f"),), ) def test_dealias_and_group_headers__use_double_colon_modes(self): @@ -737,7 +720,7 @@ def test_process_row__bad_header_info__unit(self): row_number=2, ) self.assertEqual( - INVALID_HEADER.format(sheet_name="survey", header="e"), + ErrorCode.HEADER_001.value.format(sheet_name="survey", header="e"), err.exception.args[0], ) @@ -752,7 +735,7 @@ def test_process_row__bad_header_info__dict(self): with self.assertRaises(PyXFormError) as err: convert(xlsform={"survey": survey_data}) self.assertEqual( - INVALID_HEADER.format(sheet_name="survey", header="e"), + ErrorCode.HEADER_001.value.format(sheet_name="survey", header="e"), err.exception.args[0], ) @@ -771,7 +754,9 @@ def test_process_row__bad_header_info__markdown(self): self.assertPyxformXform( md=md, errored=True, - error__contains=INVALID_HEADER.format(sheet_name="survey", header="None"), + error__contains=[ + ErrorCode.HEADER_001.value.format(sheet_name="survey", header="unknown"), + ], ) def test_process_row__bad_header_info__happy_path(self): diff --git a/tests/test_survey_element.py b/tests/test_survey_element.py index 6eb88570..41754411 100644 --- a/tests/test_survey_element.py +++ b/tests/test_survey_element.py @@ -1,6 +1,8 @@ import warnings from unittest import TestCase +from pyxform import constants as co +from pyxform.errors import ErrorCode, PyXFormError from pyxform.survey_element import SurveyElement @@ -65,3 +67,11 @@ def test_get_call_patterns_equivalent_to_base_dict(self): _ = elem.foo with self.assertRaises(AttributeError): _ = elem["foo"] + + def test_validate__invalid_name__error(self): + """Should raise an error if the 'name' is not a valid XML Name.""" + with self.assertRaises(PyXFormError) as err: + SurveyElement(name=".q", label="Q1").validate() + self.assertEqual( + ErrorCode.NAMES_009.value.format(name=co.NAME), err.exception.args[0] + ) diff --git a/tests/test_typed_calculates.py b/tests/test_typed_calculates.py index a2b2bafe..9daef906 100644 --- a/tests/test_typed_calculates.py +++ b/tests/test_typed_calculates.py @@ -135,7 +135,7 @@ def test_row_without_label_or_calculation_throws_error(self): | | integer | a | | """, errored=True, - error__contains="The survey element named 'a' has no label or hint.", + error__contains=["The survey element named 'a' has no label or hint."], ) def test_calculate_without_calculation_without_default(self): @@ -147,7 +147,7 @@ def test_calculate_without_calculation_without_default(self): | | calculate | a | | | | """, errored=True, - error__contains="Missing calculation", + error__contains=["Missing calculation"], ) def test_calculate_without_calculation_with_default_without_dynamic_default(self): @@ -159,7 +159,7 @@ def test_calculate_without_calculation_with_default_without_dynamic_default(self | | calculate | a | | | foo | """, errored=True, - error__contains="Missing calculation", + error__contains=["Missing calculation"], ) def test_calculate_without_calculation_with_dynamic_default(self): diff --git a/tests/test_xlsform_spec.py b/tests/test_xlsform_spec.py index 891c591b..aa1746c3 100644 --- a/tests/test_xlsform_spec.py +++ b/tests/test_xlsform_spec.py @@ -1,4 +1,4 @@ -from pyxform.validators.pyxform import choices as vc +from pyxform.errors import ErrorCode from tests.pyxform_test_case import PyxformTestCase @@ -63,10 +63,10 @@ def test_warnings__count(self): ) self.maxDiff = 2000 expected = [ - vc.INVALID_LABEL.format(row=4), - vc.INVALID_LABEL.format(row=5), - vc.INVALID_LABEL.format(row=6), - vc.INVALID_LABEL.format(row=7), + ErrorCode.LABEL_001.value.format(row=4), + ErrorCode.LABEL_001.value.format(row=5), + ErrorCode.LABEL_001.value.format(row=6), + ErrorCode.LABEL_001.value.format(row=7), "[row : 9] Repeat has no label: {'name': 'repeat_test', 'type': 'begin repeat'}", "[row : 10] Group has no label: {'name': 'group_test', 'type': 'begin group'}", "[row : 17] Group has no label: {'name': 'name', 'type': 'begin group'}", diff --git a/tests/xform_test_case/test_bugs.py b/tests/xform_test_case/test_bugs.py index 79751588..83f326c9 100644 --- a/tests/xform_test_case/test_bugs.py +++ b/tests/xform_test_case/test_bugs.py @@ -7,10 +7,9 @@ from unittest import TestCase import pyxform -from pyxform.errors import PyXFormError +from pyxform.errors import ErrorCode, PyXFormError from pyxform.utils import has_external_choices from pyxform.validators.odk_validate import ODKValidateError, check_xform -from pyxform.validators.pyxform import choices as vc from pyxform.xls2json import SurveyReader from pyxform.xls2json_backends import DefinitionData, get_xlsform, xlsx_to_dict from pyxform.xls2xform import convert @@ -78,7 +77,7 @@ def test_conversion(self): observed = [ w for w in warnings - if w == vc.INVALID_HEADER.format(column="header with spaces") + if w == ErrorCode.HEADER_004.value.format(column="header with spaces") ] self.assertEqual(1, len(observed), warnings) diff --git a/tests/xpath_helpers/settings.py b/tests/xpath_helpers/settings.py index 36e9fc0d..ed177886 100644 --- a/tests/xpath_helpers/settings.py +++ b/tests/xpath_helpers/settings.py @@ -17,6 +17,16 @@ def form_id(value: str) -> str: /h:html/h:head/x:model/x:instance/x:test_name/@id[.='{value}'] """ + @staticmethod + def name(value: str) -> str: + """The primary instance root node name is set to this value.""" + return f""" + /h:html/h:head/x:model/x:instance/*[ + namespace-uri()='http://www.w3.org/2002/xforms' + and local-name()='{value}' + ] + """ + @staticmethod def language_is_default(lang: str) -> str: """The language translation has itext and is marked as the default."""