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 59fb2e3d..15270e24 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}'." ), ) SURVEY_003: Detail = Detail( 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 9e3577f7..781b0769 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,)) @@ -1410,7 +1400,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 593372c1..1f71c682 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__forbidden(self): - """ - Test choices without labels for static selects. Validate will fail. - """ + def test_choices_without_labels__for_static_selects__warning_and_error(self): + """Should warn (and Validate error) if a label is missing in the choices sheet.""" self.assertPyxformXform( md=""" | survey | | | | @@ -75,15 +72,17 @@ def test_choices_without_labels__for_static_selects__forbidden(self): ] """, ], + warnings__contains=[ + ErrorCode.LABEL_001.value.format(row=2), + ErrorCode.LABEL_001.value.format(row=3), + ], odk_validate_error__contains=[ "