From 4c602fc99f60ba73f0e88d2c9ca7c5272e051ed8 Mon Sep 17 00:00:00 2001 From: lindsay stevens Date: Tue, 16 Dec 2025 19:39:35 +1100 Subject: [PATCH 01/33] chg: move NAMES001 error into ErrorCode enum - organising error messages --- pyxform/errors.py | 8 ++++++++ pyxform/validators/pyxform/unique_names.py | 14 ++++---------- tests/test_external_instances.py | 4 ++-- tests/test_fields.py | 18 +++++++++--------- tests/test_group.py | 13 +++++++------ tests/test_repeat.py | 13 +++++++------ 6 files changed, 37 insertions(+), 33 deletions(-) diff --git a/pyxform/errors.py b/pyxform/errors.py index bc6b3eba..6fd755d4 100644 --- a/pyxform/errors.py +++ b/pyxform/errors.py @@ -41,6 +41,14 @@ def format(self, **kwargs): class ErrorCode(Enum): + 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." + ), + ) PYREF_001: Detail = Detail( name="PyXForm Reference Parsing Failed", msg=( diff --git a/pyxform/validators/pyxform/unique_names.py b/pyxform/validators/pyxform/unique_names.py index 566ec50c..c441d6b3 100644 --- a/pyxform/validators/pyxform/unique_names.py +++ b/pyxform/validators/pyxform/unique_names.py @@ -1,14 +1,6 @@ from pyxform import constants as const -from pyxform.errors import Detail, PyXFormError +from pyxform.errors import Detail, ErrorCode, 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=( @@ -75,7 +67,9 @@ def validate_question_group_repeat_name( if name == const.META: raise PyXFormError(NAMES005.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() 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_fields.py b/tests/test_fields.py index 96d995a0..acc5ffe2 100644 --- a/tests/test_fields.py +++ b/tests/test_fields.py @@ -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( diff --git a/tests/test_group.py b/tests/test_group.py index afec55f2..aa09cb03 100644 --- a/tests/test_group.py +++ b/tests/test_group.py @@ -5,6 +5,7 @@ from unittest import TestCase from pyxform.builder import create_survey_element_from_dict +from pyxform.errors import ErrorCode from pyxform.validators.pyxform import unique_names from pyxform.xls2json import SURVEY_001, SURVEY_002 from pyxform.xls2xform import convert @@ -269,7 +270,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 +288,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 +308,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 +328,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 +348,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 +368,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( diff --git a/tests/test_repeat.py b/tests/test_repeat.py index ab131a22..fd3475f8 100644 --- a/tests/test_repeat.py +++ b/tests/test_repeat.py @@ -7,6 +7,7 @@ from unittest import skip from psutil import Process +from pyxform.errors import ErrorCode from pyxform.validators.pyxform import unique_names from pyxform.xls2json_backends import SupportedFileTypes from pyxform.xls2xform import convert @@ -1237,7 +1238,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 +1258,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 +1278,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( @@ -1587,7 +1588,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 +1644,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 +1695,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): From 357086d9098dc0ab8fd23ff2f9f6d493836e3a32 Mon Sep 17 00:00:00 2001 From: lindsay stevens Date: Tue, 16 Dec 2025 19:44:27 +1100 Subject: [PATCH 02/33] chg: move NAMES002 error into ErrorCode enum - organising error messages --- pyxform/errors.py | 10 ++++++++ pyxform/validators/pyxform/unique_names.py | 12 +--------- tests/test_fields.py | 28 ++++++++++++++-------- tests/test_group.py | 19 +++++++++------ tests/test_repeat.py | 9 ++++--- 5 files changed, 47 insertions(+), 31 deletions(-) diff --git a/pyxform/errors.py b/pyxform/errors.py index 6fd755d4..d5912e46 100644 --- a/pyxform/errors.py +++ b/pyxform/errors.py @@ -49,6 +49,16 @@ class ErrorCode(Enum): "or repeat, or the survey if not inside a group or repeat." ), ) + NAMES_002 = 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." + ), + ) PYREF_001: Detail = Detail( name="PyXForm Reference Parsing Failed", msg=( diff --git a/pyxform/validators/pyxform/unique_names.py b/pyxform/validators/pyxform/unique_names.py index c441d6b3..eee56a63 100644 --- a/pyxform/validators/pyxform/unique_names.py +++ b/pyxform/validators/pyxform/unique_names.py @@ -1,16 +1,6 @@ from pyxform import constants as const from pyxform.errors import Detail, ErrorCode, PyXFormError -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=( @@ -75,7 +65,7 @@ def validate_question_group_repeat_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) diff --git a/tests/test_fields.py b/tests/test_fields.py index acc5ffe2..f99ed87e 100644 --- a/tests/test_fields.py +++ b/tests/test_fields.py @@ -3,7 +3,6 @@ """ from pyxform.errors import ErrorCode -from pyxform.validators.pyxform import unique_names from tests.pyxform_test_case import PyxformTestCase @@ -323,7 +322,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 +339,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 +356,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 +373,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 +392,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 +411,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 +428,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 +447,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 +466,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): diff --git a/tests/test_group.py b/tests/test_group.py index aa09cb03..60164184 100644 --- a/tests/test_group.py +++ b/tests/test_group.py @@ -6,7 +6,6 @@ from pyxform.builder import create_survey_element_from_dict from pyxform.errors import ErrorCode -from pyxform.validators.pyxform import unique_names from pyxform.xls2json import SURVEY_001, SURVEY_002 from pyxform.xls2xform import convert @@ -386,7 +385,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( @@ -404,7 +404,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( @@ -424,7 +425,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( @@ -444,7 +446,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( @@ -464,7 +467,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( @@ -484,7 +488,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): diff --git a/tests/test_repeat.py b/tests/test_repeat.py index fd3475f8..015ef50d 100644 --- a/tests/test_repeat.py +++ b/tests/test_repeat.py @@ -1296,7 +1296,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( @@ -1316,7 +1317,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( @@ -1336,7 +1338,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): From a4decd26d2fcbc99cca782209dc23e962ffc6727 Mon Sep 17 00:00:00 2001 From: lindsay stevens Date: Tue, 16 Dec 2025 19:47:28 +1100 Subject: [PATCH 03/33] chg: move NAMES003 error into ErrorCode enum - organising error messages --- pyxform/errors.py | 7 +++++++ pyxform/validators/pyxform/unique_names.py | 11 +++-------- tests/test_repeat.py | 2 +- 3 files changed, 11 insertions(+), 9 deletions(-) diff --git a/pyxform/errors.py b/pyxform/errors.py index d5912e46..1483cbdc 100644 --- a/pyxform/errors.py +++ b/pyxform/errors.py @@ -59,6 +59,13 @@ class ErrorCode(Enum): "case-sensitive, so the current names may make analysis difficult." ), ) + NAMES_003 = 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')." + ), + ) PYREF_001: Detail = Detail( name="PyXForm Reference Parsing Failed", msg=( diff --git a/pyxform/validators/pyxform/unique_names.py b/pyxform/validators/pyxform/unique_names.py index eee56a63..458baa09 100644 --- a/pyxform/validators/pyxform/unique_names.py +++ b/pyxform/validators/pyxform/unique_names.py @@ -1,13 +1,6 @@ from pyxform import constants as const from pyxform.errors import Detail, ErrorCode, PyXFormError -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=( @@ -91,7 +84,9 @@ 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)) seen_names.add(name) diff --git a/tests/test_repeat.py b/tests/test_repeat.py index 015ef50d..4b73ed1e 100644 --- a/tests/test_repeat.py +++ b/tests/test_repeat.py @@ -1355,7 +1355,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): From c155796b28dd94f4a31c81f7f0bc0080bd897f7a Mon Sep 17 00:00:00 2001 From: lindsay stevens Date: Tue, 16 Dec 2025 19:55:39 +1100 Subject: [PATCH 04/33] chg: move NAMES004 error into ErrorCode enum - organising error messages --- pyxform/errors.py | 8 ++++++++ pyxform/validators/pyxform/unique_names.py | 12 +++--------- tests/test_loop.py | 4 ++-- tests/test_repeat.py | 5 ++--- 4 files changed, 15 insertions(+), 14 deletions(-) diff --git a/pyxform/errors.py b/pyxform/errors.py index 1483cbdc..9809c33a 100644 --- a/pyxform/errors.py +++ b/pyxform/errors.py @@ -66,6 +66,14 @@ class ErrorCode(Enum): "Repeat names must not be the same as the survey root (which defaults to 'data')." ), ) + NAMES_004 = 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." + ), + ) PYREF_001: Detail = Detail( name="PyXForm Reference Parsing Failed", msg=( diff --git a/pyxform/validators/pyxform/unique_names.py b/pyxform/validators/pyxform/unique_names.py index 458baa09..261433d9 100644 --- a/pyxform/validators/pyxform/unique_names.py +++ b/pyxform/validators/pyxform/unique_names.py @@ -1,14 +1,6 @@ from pyxform import constants as const from pyxform.errors import Detail, ErrorCode, PyXFormError -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=( @@ -88,5 +80,7 @@ def validate_repeat_name( 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/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_repeat.py b/tests/test_repeat.py index 4b73ed1e..4c41f609 100644 --- a/tests/test_repeat.py +++ b/tests/test_repeat.py @@ -8,7 +8,6 @@ from psutil import Process from pyxform.errors import ErrorCode -from pyxform.validators.pyxform import unique_names from pyxform.xls2json_backends import SupportedFileTypes from pyxform.xls2xform import convert @@ -1375,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): @@ -1395,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): From b3cb8f4bab570eb8a3d8b56ab1eaa187cb96c36d Mon Sep 17 00:00:00 2001 From: lindsay stevens Date: Tue, 16 Dec 2025 20:04:58 +1100 Subject: [PATCH 05/33] chg: move NAMES005 error into ErrorCode enum - organising error messages --- pyxform/errors.py | 7 +++++++ pyxform/validators/pyxform/unique_names.py | 12 ++---------- tests/test_metadata.py | 20 ++++++++++---------- 3 files changed, 19 insertions(+), 20 deletions(-) diff --git a/pyxform/errors.py b/pyxform/errors.py index 9809c33a..8cca6848 100644 --- a/pyxform/errors.py +++ b/pyxform/errors.py @@ -74,6 +74,13 @@ class ErrorCode(Enum): "repeat nesting." ), ) + NAMES_005 = 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." + ), + ) PYREF_001: Detail = Detail( name="PyXForm Reference Parsing Failed", msg=( diff --git a/pyxform/validators/pyxform/unique_names.py b/pyxform/validators/pyxform/unique_names.py index 261433d9..728c7cda 100644 --- a/pyxform/validators/pyxform/unique_names.py +++ b/pyxform/validators/pyxform/unique_names.py @@ -1,13 +1,5 @@ from pyxform import constants as const -from pyxform.errors import Detail, ErrorCode, PyXFormError - -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( @@ -40,7 +32,7 @@ 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( ErrorCode.NAMES_001.value.format(row=row_number, value=name) 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)] ) From 23c95efb0e596ea84f8941d3a4b563c15da76a72 Mon Sep 17 00:00:00 2001 From: lindsay stevens Date: Tue, 16 Dec 2025 20:27:05 +1100 Subject: [PATCH 06/33] chg: move choices.INVALID_NAME error into ErrorCode enum - organising error messages - add test to verify error raised, since no other test seems to check for this error case or message. --- pyxform/errors.py | 16 ++++++++++++---- pyxform/validators/pyxform/choices.py | 9 ++------- tests/test_choices_sheet.py | 17 +++++++++++++++++ 3 files changed, 31 insertions(+), 11 deletions(-) diff --git a/pyxform/errors.py b/pyxform/errors.py index 8cca6848..ac72e87f 100644 --- a/pyxform/errors.py +++ b/pyxform/errors.py @@ -49,7 +49,7 @@ class ErrorCode(Enum): "or repeat, or the survey if not inside a group or repeat." ), ) - NAMES_002 = Detail( + 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. " @@ -59,14 +59,14 @@ class ErrorCode(Enum): "case-sensitive, so the current names may make analysis difficult." ), ) - NAMES_003 = Detail( + 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( + 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. " @@ -74,13 +74,21 @@ class ErrorCode(Enum): "repeat nesting." ), ) - NAMES_005 = Detail( + 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" + ), + ) PYREF_001: Detail = Detail( name="PyXForm Reference Parsing Failed", msg=( diff --git a/pyxform/validators/pyxform/choices.py b/pyxform/validators/pyxform/choices.py index 3e2ab55c..06fb2b92 100644 --- a/pyxform/validators/pyxform/choices.py +++ b/pyxform/validators/pyxform/choices.py @@ -1,11 +1,6 @@ from pyxform import constants -from pyxform.errors import PyXFormError +from pyxform.errors import ErrorCode, 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. " @@ -44,7 +39,7 @@ 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"])) diff --git a/tests/test_choices_sheet.py b/tests/test_choices_sheet.py index 7fa09ef0..bea00729 100644 --- a/tests/test_choices_sheet.py +++ b/tests/test_choices_sheet.py @@ -553,3 +553,20 @@ def test_reference_in_extra_columns__between_columns_of_interest(self): """, ], ) + + def test_missing_name__error(self): + """Should raise an error if 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)], + ) From d45c46016d75beb31774649dae7f57f7bfeb7fd7 Mon Sep 17 00:00:00 2001 From: lindsay stevens Date: Tue, 16 Dec 2025 21:12:27 +1100 Subject: [PATCH 07/33] chg: move choices.INVALID_LABEL error into ErrorCode enum - organising error messages --- pyxform/errors.py | 8 ++++++++ pyxform/validators/pyxform/choices.py | 7 +------ tests/test_choices_sheet.py | 23 +++++++++++++---------- tests/test_xlsform_spec.py | 10 +++++----- 4 files changed, 27 insertions(+), 21 deletions(-) diff --git a/pyxform/errors.py b/pyxform/errors.py index ac72e87f..a342697e 100644 --- a/pyxform/errors.py +++ b/pyxform/errors.py @@ -89,6 +89,14 @@ class ErrorCode(Enum): "Learn more: https://xlsform.org/en/#setting-up-your-worksheets" ), ) + 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" + ), + ) PYREF_001: Detail = Detail( name="PyXForm Reference Parsing Failed", msg=( diff --git a/pyxform/validators/pyxform/choices.py b/pyxform/validators/pyxform/choices.py index 06fb2b92..82979ca1 100644 --- a/pyxform/validators/pyxform/choices.py +++ b/pyxform/validators/pyxform/choices.py @@ -1,11 +1,6 @@ from pyxform import constants from pyxform.errors import ErrorCode, PyXFormError -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. " @@ -41,7 +36,7 @@ def validate_choice_list( if constants.NAME not in option: 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] diff --git a/tests/test_choices_sheet.py b/tests/test_choices_sheet.py index bea00729..fe81df3d 100644 --- a/tests/test_choices_sheet.py +++ b/tests/test_choices_sheet.py @@ -47,10 +47,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 +73,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 +105,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): @@ -555,7 +558,7 @@ def test_reference_in_extra_columns__between_columns_of_interest(self): ) def test_missing_name__error(self): - """Should raise an error if name is missing in the choices sheet.""" + """Should raise an error if a name is missing in the choices sheet.""" md = """ | survey | | | type | name | label | 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'}", From 90363d9430941d12780073403acce411700c3d2a Mon Sep 17 00:00:00 2001 From: lindsay stevens Date: Tue, 16 Dec 2025 21:31:42 +1100 Subject: [PATCH 08/33] chg: move choices.INVALID_DUPLICATE error into ErrorCode enum - organising error messages --- pyxform/errors.py | 9 +++++++++ pyxform/validators/pyxform/choices.py | 10 +++------- tests/test_choices_sheet.py | 9 ++++----- 3 files changed, 16 insertions(+), 12 deletions(-) diff --git a/pyxform/errors.py b/pyxform/errors.py index a342697e..07233b3c 100644 --- a/pyxform/errors.py +++ b/pyxform/errors.py @@ -89,6 +89,15 @@ class ErrorCode(Enum): "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." + ), + ) LABEL_001: Detail = Detail( name="Invalid missing label in the choices sheet", msg=( diff --git a/pyxform/validators/pyxform/choices.py b/pyxform/validators/pyxform/choices.py index 82979ca1..cb104120 100644 --- a/pyxform/validators/pyxform/choices.py +++ b/pyxform/validators/pyxform/choices.py @@ -6,12 +6,6 @@ "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." -) def validate_headers( @@ -41,7 +35,9 @@ def validate_choice_list( 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/tests/test_choices_sheet.py b/tests/test_choices_sheet.py index fe81df3d..01ebe2da 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 @@ -174,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): @@ -192,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), ], ) @@ -213,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): From 4ca4dadef8d807ee5def70d778698d1c3ecf0a86 Mon Sep 17 00:00:00 2001 From: lindsay stevens Date: Tue, 16 Dec 2025 21:51:46 +1100 Subject: [PATCH 09/33] chg: move sheet_headers.INVALID_HEADER error into ErrorCode enum - organising error messages --- pyxform/errors.py | 10 ++++++++++ pyxform/parsing/sheet_headers.py | 11 ++--------- tests/test_sheet_columns.py | 11 ++++++----- 3 files changed, 18 insertions(+), 14 deletions(-) diff --git a/pyxform/errors.py b/pyxform/errors.py index 07233b3c..786aff6c 100644 --- a/pyxform/errors.py +++ b/pyxform/errors.py @@ -41,6 +41,16 @@ def format(self, **kwargs): class ErrorCode(Enum): + 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'." + ), + ) NAMES_001: Detail = Detail( name="Invalid duplicate name in same context", msg=( diff --git a/pyxform/parsing/sheet_headers.py b/pyxform/parsing/sheet_headers.py index aa23b1d3..705dd621 100644 --- a/pyxform/parsing/sheet_headers.py +++ b/pyxform/parsing/sheet_headers.py @@ -4,19 +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 " @@ -196,7 +189,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 diff --git a/tests/test_sheet_columns.py b/tests/test_sheet_columns.py index 5711b73d..74df9dda 100644 --- a/tests/test_sheet_columns.py +++ b/tests/test_sheet_columns.py @@ -7,10 +7,9 @@ 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, @@ -737,7 +736,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 +751,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 +770,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): From dc532540f928ae341b3ee1d2bf435fe2f0976840 Mon Sep 17 00:00:00 2001 From: lindsay stevens Date: Tue, 16 Dec 2025 22:21:56 +1100 Subject: [PATCH 10/33] fix: tests providing single strings to error__contains - the modified tests pass anyway but would have asserted that each letter in the parameter was in the error e.g. ["M", "i", "s", "s"] - added type check and tests for error/warning contains/not-contains --- tests/pyxform_test_case.py | 33 +++++++++++++++--- tests/test_pyxform_test_case.py | 60 ++++++++++++++++++++++++++++++++- tests/test_typed_calculates.py | 6 ++-- 3 files changed, 90 insertions(+), 9 deletions(-) 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_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_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): From 86fbfb67684469865c864598e9a769812293ba6a Mon Sep 17 00:00:00 2001 From: lindsay stevens Date: Tue, 16 Dec 2025 22:30:55 +1100 Subject: [PATCH 11/33] chg: move sheet_headers.INVALID_DUPLICATE error into ErrorCode enum - organising error messages --- pyxform/errors.py | 8 ++++++++ pyxform/parsing/sheet_headers.py | 7 +------ tests/test_builder.py | 8 +++++++- tests/test_sheet_columns.py | 7 ++++--- 4 files changed, 20 insertions(+), 10 deletions(-) diff --git a/pyxform/errors.py b/pyxform/errors.py index 786aff6c..d8871561 100644 --- a/pyxform/errors.py +++ b/pyxform/errors.py @@ -51,6 +51,14 @@ class ErrorCode(Enum): "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." + ), + ) NAMES_001: Detail = Detail( name="Invalid duplicate name in same context", msg=( diff --git a/pyxform/parsing/sheet_headers.py b/pyxform/parsing/sheet_headers.py index 705dd621..78b17673 100644 --- a/pyxform/parsing/sheet_headers.py +++ b/pyxform/parsing/sheet_headers.py @@ -10,11 +10,6 @@ SMART_QUOTES = {"\u2018": "'", "\u2019": "'", "\u201c": '"', "\u201d": '"'} RE_SMART_QUOTES = re.compile(r"|".join(re.escape(old) for old in SMART_QUOTES)) -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}. " @@ -261,7 +256,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, 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_sheet_columns.py b/tests/test_sheet_columns.py index 74df9dda..8d751693 100644 --- a/tests/test_sheet_columns.py +++ b/tests/test_sheet_columns.py @@ -9,7 +9,6 @@ from pyxform import constants from pyxform.errors import ErrorCode, PyXFormError from pyxform.parsing.sheet_headers import ( - INVALID_DUPLICATE, INVALID_MISSING_REQUIRED, dealias_and_group_headers, process_header, @@ -356,7 +355,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" ) ], @@ -374,7 +373,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" + ) ], ) From 48136233cf0a134bb323b1f0ebfab82b77fb46b4 Mon Sep 17 00:00:00 2001 From: lindsay stevens Date: Tue, 16 Dec 2025 22:34:45 +1100 Subject: [PATCH 12/33] chg: move sheet_headers.INVALID_MISSING_REQUIRED error into ErrorCode - organising error messages --- pyxform/errors.py | 8 ++++++++ pyxform/parsing/sheet_headers.py | 7 +------ tests/test_sheet_columns.py | 7 +++---- 3 files changed, 12 insertions(+), 10 deletions(-) diff --git a/pyxform/errors.py b/pyxform/errors.py index d8871561..68883044 100644 --- a/pyxform/errors.py +++ b/pyxform/errors.py @@ -59,6 +59,14 @@ class ErrorCode(Enum): "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" + ), + ) NAMES_001: Detail = Detail( name="Invalid duplicate name in same context", msg=( diff --git a/pyxform/parsing/sheet_headers.py b/pyxform/parsing/sheet_headers.py index 78b17673..8343071a 100644 --- a/pyxform/parsing/sheet_headers.py +++ b/pyxform/parsing/sheet_headers.py @@ -10,11 +10,6 @@ SMART_QUOTES = {"\u2018": "'", "\u2019": "'", "\u201c": '"', "\u201d": '"'} RE_SMART_QUOTES = re.compile(r"|".join(re.escape(old) for old in SMART_QUOTES)) -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( @@ -281,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/tests/test_sheet_columns.py b/tests/test_sheet_columns.py index 8d751693..054bbba0 100644 --- a/tests/test_sheet_columns.py +++ b/tests/test_sheet_columns.py @@ -9,7 +9,6 @@ from pyxform import constants from pyxform.errors import ErrorCode, PyXFormError from pyxform.parsing.sheet_headers import ( - INVALID_MISSING_REQUIRED, dealias_and_group_headers, process_header, process_row, @@ -201,7 +200,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'") ], ) @@ -260,7 +259,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'") ], ) @@ -309,7 +308,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'") ], ) From 2af3a1acec55c8ea4a86a9c61201ff1bd37d4d5a Mon Sep 17 00:00:00 2001 From: lindsay stevens Date: Tue, 16 Dec 2025 22:41:13 +1100 Subject: [PATCH 13/33] chg: move choices.INVALID_HEADER error into ErrorCode enum - organising error messages --- pyxform/errors.py | 8 ++++++++ pyxform/validators/pyxform/choices.py | 8 +------- tests/test_sheet_columns.py | 3 +-- tests/xform_test_case/test_bugs.py | 5 ++--- 4 files changed, 12 insertions(+), 12 deletions(-) diff --git a/pyxform/errors.py b/pyxform/errors.py index 68883044..78603e46 100644 --- a/pyxform/errors.py +++ b/pyxform/errors.py @@ -67,6 +67,14 @@ class ErrorCode(Enum): "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" + ), + ) NAMES_001: Detail = Detail( name="Invalid duplicate name in same context", msg=( diff --git a/pyxform/validators/pyxform/choices.py b/pyxform/validators/pyxform/choices.py index cb104120..61c29495 100644 --- a/pyxform/validators/pyxform/choices.py +++ b/pyxform/validators/pyxform/choices.py @@ -1,12 +1,6 @@ from pyxform import constants from pyxform.errors import ErrorCode, PyXFormError -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" -) - def validate_headers( headers: tuple[tuple[str, ...], ...], warnings: list[str] @@ -15,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()) diff --git a/tests/test_sheet_columns.py b/tests/test_sheet_columns.py index 054bbba0..e02d5ebc 100644 --- a/tests/test_sheet_columns.py +++ b/tests/test_sheet_columns.py @@ -14,7 +14,6 @@ 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 @@ -603,7 +602,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): 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) From c2e8c47a30b8b68db55a2542bbeb2867f4194f04 Mon Sep 17 00:00:00 2001 From: lindsay stevens Date: Tue, 16 Dec 2025 22:48:57 +1100 Subject: [PATCH 14/33] chg: sort ErrorCode enum --- pyxform/errors.py | 32 ++++++++++++++++---------------- 1 file changed, 16 insertions(+), 16 deletions(-) diff --git a/pyxform/errors.py b/pyxform/errors.py index 78603e46..6632b571 100644 --- a/pyxform/errors.py +++ b/pyxform/errors.py @@ -75,6 +75,22 @@ class ErrorCode(Enum): "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=( @@ -132,14 +148,6 @@ class ErrorCode(Enum): "Learn more: https://xlsform.org/#choice-names." ), ) - 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" - ), - ) PYREF_001: Detail = Detail( name="PyXForm Reference Parsing Failed", msg=( @@ -170,14 +178,6 @@ class ErrorCode(Enum): "'{q}' appears more than once." ), ) - 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}'." - ), - ) class PyXFormError(Exception): From 2b08797890e95da01241de91b3fc62c559d07d4d Mon Sep 17 00:00:00 2001 From: lindsay stevens Date: Tue, 16 Dec 2025 22:54:27 +1100 Subject: [PATCH 15/33] chg: move xls2json.SURVEY_001 error into ErrorCode enum - organising error messages --- pyxform/errors.py | 7 +++++++ pyxform/xls2json.py | 11 ++--------- tests/test_group.py | 14 ++++++++------ 3 files changed, 17 insertions(+), 15 deletions(-) diff --git a/pyxform/errors.py b/pyxform/errors.py index 6632b571..5ac26aa0 100644 --- a/pyxform/errors.py +++ b/pyxform/errors.py @@ -178,6 +178,13 @@ class ErrorCode(Enum): "'{q}' appears more than once." ), ) + 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}'." + ), + ) class PyXFormError(Exception): diff --git a/pyxform/xls2json.py b/pyxform/xls2json.py index 2bac009f..dae29547 100644 --- a/pyxform/xls2json.py +++ b/pyxform/xls2json.py @@ -21,7 +21,7 @@ validate_entity_repeat_target, validate_entity_saveto, ) -from pyxform.errors import Detail, PyXFormError +from pyxform.errors import Detail, 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 @@ -60,13 +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=( @@ -769,7 +762,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), diff --git a/tests/test_group.py b/tests/test_group.py index 60164184..431af996 100644 --- a/tests/test_group.py +++ b/tests/test_group.py @@ -6,7 +6,7 @@ from pyxform.builder import create_survey_element_from_dict from pyxform.errors import ErrorCode -from pyxform.xls2json import SURVEY_001, SURVEY_002 +from pyxform.xls2json import SURVEY_002 from pyxform.xls2xform import convert from tests.pyxform_test_case import PyxformTestCase @@ -532,7 +532,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): @@ -562,7 +562,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): @@ -576,7 +576,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): @@ -593,7 +595,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", ) @@ -613,7 +615,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): From 9c9429f52a658d624d08d6d3d8c1bd3a93677167 Mon Sep 17 00:00:00 2001 From: lindsay stevens Date: Tue, 16 Dec 2025 22:59:01 +1100 Subject: [PATCH 16/33] chg: move xls2json.SURVEY_002 error into ErrorCode enum - organising error messages --- pyxform/errors.py | 7 +++++++ pyxform/xls2json.py | 11 ++--------- tests/test_group.py | 9 ++++++--- 3 files changed, 15 insertions(+), 12 deletions(-) diff --git a/pyxform/errors.py b/pyxform/errors.py index 5ac26aa0..3ee84608 100644 --- a/pyxform/errors.py +++ b/pyxform/errors.py @@ -185,6 +185,13 @@ class ErrorCode(Enum): "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}'." + ), + ) class PyXFormError(Exception): diff --git a/pyxform/xls2json.py b/pyxform/xls2json.py index dae29547..b0ed84de 100644 --- a/pyxform/xls2json.py +++ b/pyxform/xls2json.py @@ -21,7 +21,7 @@ validate_entity_repeat_target, validate_entity_saveto, ) -from pyxform.errors import Detail, ErrorCode, 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 @@ -60,13 +60,6 @@ RE_OSM = re.compile( r"(?P(" + "|".join(aliases.osm) + r")) (?P\S+)" ) -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): @@ -1390,7 +1383,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/test_group.py b/tests/test_group.py index 431af996..3c3673ff 100644 --- a/tests/test_group.py +++ b/tests/test_group.py @@ -6,7 +6,6 @@ from pyxform.builder import create_survey_element_from_dict from pyxform.errors import ErrorCode -from pyxform.xls2json import SURVEY_002 from pyxform.xls2xform import convert from tests.pyxform_test_case import PyxformTestCase @@ -517,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): @@ -548,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): From 0f1882b4b0ca82365f4144737b4d844b7c231921 Mon Sep 17 00:00:00 2001 From: lindsay stevens Date: Tue, 16 Dec 2025 23:05:47 +1100 Subject: [PATCH 17/33] chg: move entities_parsing.ENTITY_001 error into ErrorCode enum - organising error messages --- pyxform/entities/entities_parsing.py | 12 ++---------- pyxform/errors.py | 8 ++++++++ tests/entities/test_create_repeat.py | 2 +- 3 files changed, 11 insertions(+), 11 deletions(-) diff --git a/pyxform/entities/entities_parsing.py b/pyxform/entities/entities_parsing.py index ccd7486d..572a2a75 100644 --- a/pyxform/entities/entities_parsing.py +++ b/pyxform/entities/entities_parsing.py @@ -3,19 +3,11 @@ from pyxform import constants as const from pyxform.elements import action -from pyxform.errors import Detail, PyXFormError +from pyxform.errors import Detail, 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=( @@ -263,7 +255,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 diff --git a/pyxform/errors.py b/pyxform/errors.py index 3ee84608..34a7e270 100644 --- a/pyxform/errors.py +++ b/pyxform/errors.py @@ -41,6 +41,14 @@ 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." + ), + ) HEADER_001: Detail = Detail( name="Invalid missing header row.", msg=( diff --git a/tests/entities/test_create_repeat.py b/tests/entities/test_create_repeat.py index f05d00de..bb39bb1c 100644 --- a/tests/entities/test_create_repeat.py +++ b/tests/entities/test_create_repeat.py @@ -382,7 +382,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): From e766a5d9a71e0f36c9b56c8684bd28c3bc4e7de9 Mon Sep 17 00:00:00 2001 From: lindsay stevens Date: Tue, 16 Dec 2025 23:08:35 +1100 Subject: [PATCH 18/33] chg: move entities_parsing.ENTITY_002 error into ErrorCode enum - organising error messages --- pyxform/entities/entities_parsing.py | 9 +-------- pyxform/errors.py | 7 +++++++ tests/entities/test_create_repeat.py | 4 +++- 3 files changed, 11 insertions(+), 9 deletions(-) diff --git a/pyxform/entities/entities_parsing.py b/pyxform/entities/entities_parsing.py index 572a2a75..7153cd6c 100644 --- a/pyxform/entities/entities_parsing.py +++ b/pyxform/entities/entities_parsing.py @@ -8,13 +8,6 @@ from pyxform.validators.pyxform.pyxform_reference import parse_pyxform_references EC = const.EntityColumns -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=( @@ -362,7 +355,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"] diff --git a/pyxform/errors.py b/pyxform/errors.py index 34a7e270..3e08d665 100644 --- a/pyxform/errors.py +++ b/pyxform/errors.py @@ -49,6 +49,13 @@ class ErrorCode(Enum): "(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." + ), + ) HEADER_001: Detail = Detail( name="Invalid missing header row.", msg=( diff --git a/tests/entities/test_create_repeat.py b/tests/entities/test_create_repeat.py index bb39bb1c..558c23e9 100644 --- a/tests/entities/test_create_repeat.py +++ b/tests/entities/test_create_repeat.py @@ -399,7 +399,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): From 482d0cf503284db41316a7b882f4181bae65e7aa Mon Sep 17 00:00:00 2001 From: lindsay stevens Date: Tue, 16 Dec 2025 23:10:34 +1100 Subject: [PATCH 19/33] chg: move entities_parsing.ENTITY_003 error into ErrorCode enum - organising error messages --- pyxform/entities/entities_parsing.py | 9 +-------- pyxform/errors.py | 7 +++++++ tests/entities/test_create_repeat.py | 8 ++++++-- 3 files changed, 14 insertions(+), 10 deletions(-) diff --git a/pyxform/entities/entities_parsing.py b/pyxform/entities/entities_parsing.py index 7153cd6c..9dc88517 100644 --- a/pyxform/entities/entities_parsing.py +++ b/pyxform/entities/entities_parsing.py @@ -8,13 +8,6 @@ from pyxform.validators.pyxform.pyxform_reference import parse_pyxform_references EC = const.EntityColumns -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=( @@ -366,7 +359,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 diff --git a/pyxform/errors.py b/pyxform/errors.py index 3e08d665..047370fd 100644 --- a/pyxform/errors.py +++ b/pyxform/errors.py @@ -56,6 +56,13 @@ class ErrorCode(Enum): "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." + ), + ) HEADER_001: Detail = Detail( name="Invalid missing header row.", msg=( diff --git a/tests/entities/test_create_repeat.py b/tests/entities/test_create_repeat.py index 558c23e9..4d4d4b13 100644 --- a/tests/entities/test_create_repeat.py +++ b/tests/entities/test_create_repeat.py @@ -418,7 +418,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): @@ -439,7 +441,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): From 6eee9979b401d35d96d21b0174d9e1923934a909 Mon Sep 17 00:00:00 2001 From: lindsay stevens Date: Tue, 16 Dec 2025 23:12:21 +1100 Subject: [PATCH 20/33] chg: move entities_parsing.ENTITY_004 error into ErrorCode enum - organising error messages --- pyxform/entities/entities_parsing.py | 9 +-------- pyxform/errors.py | 7 +++++++ tests/entities/test_create_repeat.py | 4 +++- 3 files changed, 11 insertions(+), 9 deletions(-) diff --git a/pyxform/entities/entities_parsing.py b/pyxform/entities/entities_parsing.py index 9dc88517..b8714b58 100644 --- a/pyxform/entities/entities_parsing.py +++ b/pyxform/entities/entities_parsing.py @@ -8,13 +8,6 @@ from pyxform.validators.pyxform.pyxform_reference import parse_pyxform_references EC = const.EntityColumns -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=( @@ -368,7 +361,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 047370fd..c74e1357 100644 --- a/pyxform/errors.py +++ b/pyxform/errors.py @@ -63,6 +63,13 @@ class ErrorCode(Enum): "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." + ), + ) HEADER_001: Detail = Detail( name="Invalid missing header row.", msg=( diff --git a/tests/entities/test_create_repeat.py b/tests/entities/test_create_repeat.py index 4d4d4b13..ffe62799 100644 --- a/tests/entities/test_create_repeat.py +++ b/tests/entities/test_create_repeat.py @@ -462,7 +462,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( From b92ca3f2f78841b777b48af81d0a17af7e74f2f6 Mon Sep 17 00:00:00 2001 From: lindsay stevens Date: Tue, 16 Dec 2025 23:14:37 +1100 Subject: [PATCH 21/33] chg: move entities_parsing.ENTITY_005 error into ErrorCode enum - organising error messages --- pyxform/entities/entities_parsing.py | 12 +++--------- pyxform/errors.py | 8 ++++++++ tests/entities/test_create_repeat.py | 4 +++- 3 files changed, 14 insertions(+), 10 deletions(-) diff --git a/pyxform/entities/entities_parsing.py b/pyxform/entities/entities_parsing.py index b8714b58..c3459bc2 100644 --- a/pyxform/entities/entities_parsing.py +++ b/pyxform/entities/entities_parsing.py @@ -8,14 +8,6 @@ from pyxform.validators.pyxform.pyxform_reference import parse_pyxform_references EC = const.EntityColumns -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=( @@ -268,7 +260,9 @@ 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 diff --git a/pyxform/errors.py b/pyxform/errors.py index c74e1357..987ce61b 100644 --- a/pyxform/errors.py +++ b/pyxform/errors.py @@ -70,6 +70,14 @@ class ErrorCode(Enum): "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." + ), + ) HEADER_001: Detail = Detail( name="Invalid missing header row.", msg=( diff --git a/tests/entities/test_create_repeat.py b/tests/entities/test_create_repeat.py index ffe62799..2a67abf6 100644 --- a/tests/entities/test_create_repeat.py +++ b/tests/entities/test_create_repeat.py @@ -560,5 +560,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")], ) From 239808e7e1acdcdf5a0820c195d91b18cb8f5942 Mon Sep 17 00:00:00 2001 From: lindsay stevens Date: Tue, 16 Dec 2025 23:16:53 +1100 Subject: [PATCH 22/33] chg: move entities_parsing.ENTITY_006 error into ErrorCode enum - organising error messages --- pyxform/entities/entities_parsing.py | 12 +++--------- pyxform/errors.py | 8 ++++++++ tests/entities/test_create_repeat.py | 17 ++++++++++++----- 3 files changed, 23 insertions(+), 14 deletions(-) diff --git a/pyxform/entities/entities_parsing.py b/pyxform/entities/entities_parsing.py index c3459bc2..6718bc50 100644 --- a/pyxform/entities/entities_parsing.py +++ b/pyxform/entities/entities_parsing.py @@ -8,14 +8,6 @@ from pyxform.validators.pyxform.pyxform_reference import parse_pyxform_references EC = const.EntityColumns -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=( @@ -269,7 +261,9 @@ def validate_entity_saveto( # 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: diff --git a/pyxform/errors.py b/pyxform/errors.py index 987ce61b..78b2a7ca 100644 --- a/pyxform/errors.py +++ b/pyxform/errors.py @@ -78,6 +78,14 @@ class ErrorCode(Enum): "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." + ), + ) HEADER_001: Detail = Detail( name="Invalid missing header row.", msg=( diff --git a/tests/entities/test_create_repeat.py b/tests/entities/test_create_repeat.py index 2a67abf6..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 @@ -483,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): @@ -501,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): @@ -521,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): @@ -541,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): From b575d3eddbeabe05de85d88829d7dd87a0f92506 Mon Sep 17 00:00:00 2001 From: lindsay stevens Date: Tue, 16 Dec 2025 23:19:30 +1100 Subject: [PATCH 23/33] chg: move entities_parsing.ENTITY_007 error into ErrorCode enum - organising error messages --- pyxform/entities/entities_parsing.py | 14 ++++---------- pyxform/errors.py | 8 ++++++++ tests/entities/test_create_survey.py | 4 ++-- 3 files changed, 14 insertions(+), 12 deletions(-) diff --git a/pyxform/entities/entities_parsing.py b/pyxform/entities/entities_parsing.py index 6718bc50..c3302303 100644 --- a/pyxform/entities/entities_parsing.py +++ b/pyxform/entities/entities_parsing.py @@ -3,19 +3,11 @@ from pyxform import constants as const from pyxform.elements import action -from pyxform.errors import Detail, ErrorCode, 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 -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( @@ -267,7 +259,9 @@ def validate_entity_saveto( # 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)) + raise PyXFormError( + ErrorCode.ENTITY_007.value.format(row=row_number, value=save_to) + ) error_start = f"{const.ROW_FORMAT_STRING % row_number} Invalid save_to name:" diff --git a/pyxform/errors.py b/pyxform/errors.py index 78b2a7ca..e52763f2 100644 --- a/pyxform/errors.py +++ b/pyxform/errors.py @@ -86,6 +86,14 @@ class ErrorCode(Enum): "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=( diff --git a/tests/entities/test_create_survey.py b/tests/entities/test_create_survey.py index 58e11ec4..075bdf78 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 @@ -369,7 +369,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): From 0b45bd3a4904f503fcdcc48bb65150419eec2661 Mon Sep 17 00:00:00 2001 From: lindsay stevens Date: Wed, 17 Dec 2025 23:05:44 +1100 Subject: [PATCH 24/33] chg: move entity dataset name character error to ErrorCode enum - organising error messages - reworded error message to be consistent with the underlying XML "Name" rule/token, except that ODK form clients generally don't support having a colon for the first character, and not mentioning non-ascii unicode letters since that's probably too technical. - preceding code applies the additional entity dataset name rules for not allowing names starting with a period or two underscores. --- pyxform/entities/entities_parsing.py | 7 +++---- pyxform/errors.py | 8 ++++++++ tests/entities/test_create_survey.py | 4 +++- 3 files changed, 14 insertions(+), 5 deletions(-) diff --git a/pyxform/entities/entities_parsing.py b/pyxform/entities/entities_parsing.py index c3302303..d2cee49a 100644 --- a/pyxform/entities/entities_parsing.py +++ b/pyxform/entities/entities_parsing.py @@ -188,11 +188,10 @@ def get_validated_dataset_name(entity): ) if not is_xml_tag(dataset): - if isinstance(dataset, bytes): - dataset = dataset.decode("utf-8") - 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 diff --git a/pyxform/errors.py b/pyxform/errors.py index e52763f2..565e88b2 100644 --- a/pyxform/errors.py +++ b/pyxform/errors.py @@ -201,6 +201,14 @@ class ErrorCode(Enum): "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." + ), + ) PYREF_001: Detail = Detail( name="PyXForm Reference Parsing Failed", msg=( diff --git a/tests/entities/test_create_survey.py b/tests/entities/test_create_survey.py index 075bdf78..0a8ae329 100644 --- a/tests/entities/test_create_survey.py +++ b/tests/entities/test_create_survey.py @@ -93,7 +93,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 + ) ], ) From b87159b02c85e95666095eacd4c4c7965f8cd7b7 Mon Sep 17 00:00:00 2001 From: lindsay stevens Date: Wed, 17 Dec 2025 23:20:52 +1100 Subject: [PATCH 25/33] chg: replace entity save_to name error with same ErrorCode.NAMES_008 - organising error messages - preceding code applies additional entity save_to name rules for not allowing reserved words or names starting two underscores. --- pyxform/entities/entities_parsing.py | 7 +++---- tests/entities/test_create_survey.py | 4 +++- 2 files changed, 6 insertions(+), 5 deletions(-) diff --git a/pyxform/entities/entities_parsing.py b/pyxform/entities/entities_parsing.py index d2cee49a..020bdeb2 100644 --- a/pyxform/entities/entities_parsing.py +++ b/pyxform/entities/entities_parsing.py @@ -275,11 +275,10 @@ def validate_entity_saveto( ) if not is_xml_tag(save_to): - if isinstance(save_to, bytes): - save_to = save_to.decode("utf-8") - 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 + ) ) diff --git a/tests/entities/test_create_survey.py b/tests/entities/test_create_survey.py index 0a8ae329..7f21dbd7 100644 --- a/tests/entities/test_create_survey.py +++ b/tests/entities/test_create_survey.py @@ -333,7 +333,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 + ) ], ) From ae8728b9d7ef67d17c45b890aed0cc19bf8112ec Mon Sep 17 00:00:00 2001 From: lindsay stevens Date: Thu, 18 Dec 2025 00:36:09 +1100 Subject: [PATCH 26/33] chg: replace select_from_file name error with same ErrorCode.NAMES_008 - organising error messages - the replaced validation regex is pretty much the XML name rule and the error message sounds like that was the intent (although the value is put into an attribute like `` not a tag). --- .../validators/pyxform/select_from_file.py | 33 +++++-------------- tests/test_external_instances_for_selects.py | 18 +++++----- 2 files changed, 19 insertions(+), 32 deletions(-) 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/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): From 1000d7510051ea16fe197d2352d91f44c928df6f Mon Sep 17 00:00:00 2001 From: lindsay stevens Date: Thu, 18 Dec 2025 00:40:33 +1100 Subject: [PATCH 27/33] chg: replace workbook_to_json name error with same ErrorCode.NAMES_008 - organising error messages --- pyxform/xls2json.py | 5 +++-- tests/test_fields.py | 9 +++++++-- 2 files changed, 10 insertions(+), 4 deletions(-) diff --git a/pyxform/xls2json.py b/pyxform/xls2json.py index b0ed84de..f12e5720 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 ( @@ -777,7 +776,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,)) diff --git a/tests/test_fields.py b/tests/test_fields.py index f99ed87e..d904807d 100644 --- a/tests/test_fields.py +++ b/tests/test_fields.py @@ -2,6 +2,7 @@ Test duplicate survey question field name. """ +from pyxform import constants as co from pyxform.errors import ErrorCode from tests.pyxform_test_case import PyxformTestCase @@ -577,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): @@ -592,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): From f69d5454b5c20f06ee65c2b8f35c010e85470cf7 Mon Sep 17 00:00:00 2001 From: lindsay stevens Date: Thu, 18 Dec 2025 01:27:36 +1100 Subject: [PATCH 28/33] chg: replace element/form name error with same ErrorCode.NAMES_009 - organising error messages - in survey_element.py the code that shows the exact offending character seems to have been added for the purpose of checking the form_name, which corresponds to the Survey.name attribute, which is used as the tag name of the primary instance element. When uploading a XLSForm to an online converter this name defaults to "data", and it is only set to the basename the XLS/X file name when using the CLI, or some other value when using pyxform as a library. This code would not apply to most other names since workbook_to_json (or other) code checks names before survey_element.py sees them. Also the regex INVALID_XFORM_TAG_REGEXP is different to the rule in is_xml_tag. So the per-character check was removed since it seems unlikely to be useful and is inconsistent with other name validation. --- pyxform/constants.py | 1 - pyxform/errors.py | 8 ++++++++ pyxform/survey_element.py | 9 ++------- pyxform/utils.py | 2 -- pyxform/validators/pyxform/settings.py | 12 ++++++++++++ pyxform/xls2json.py | 2 ++ tests/test_choices_sheet.py | 16 ++++++++++++++++ tests/test_settings.py | 26 ++++++++++++++++++++++++++ tests/test_sheet_columns.py | 15 --------------- tests/test_survey_element.py | 10 ++++++++++ 10 files changed, 76 insertions(+), 25 deletions(-) create mode 100644 pyxform/validators/pyxform/settings.py 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/errors.py b/pyxform/errors.py index 565e88b2..cea4d53c 100644 --- a/pyxform/errors.py +++ b/pyxform/errors.py @@ -209,6 +209,14 @@ class ErrorCode(Enum): "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." + ), + ) PYREF_001: Detail = Detail( name="PyXForm Reference Parsing Failed", msg=( diff --git a/pyxform/survey_element.py b/pyxform/survey_element.py index 24671589..b1bc3bcc 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, 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/settings.py b/pyxform/validators/pyxform/settings.py new file mode 100644 index 00000000..8f9c0959 --- /dev/null +++ b/pyxform/validators/pyxform/settings.py @@ -0,0 +1,12 @@ +from pyxform.errors import ErrorCode, PyXFormError +from pyxform.parsing.expression import is_xml_tag + + +def validate_form_name(form_name: str | None): + """ + The form_name must be a valid XML tag since it is used for the primary instance element. + + :param form_name: The value to check. + """ + if form_name is not None and not is_xml_tag(value=form_name): + raise PyXFormError(ErrorCode.NAMES_009.value.format(name="form_name")) diff --git a/pyxform/xls2json.py b/pyxform/xls2json.py index f12e5720..7c962271 100644 --- a/pyxform/xls2json.py +++ b/pyxform/xls2json.py @@ -31,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 ( @@ -263,6 +264,7 @@ def workbook_to_json( raise PyXFormError(msg) # Make sure the passed in vars are unicode + validate_settings.validate_form_name(form_name=form_name) form_name = str(coalesce(form_name, constants.DEFAULT_FORM_NAME)) default_language = str(coalesce(default_language, constants.DEFAULT_LANGUAGE_VALUE)) diff --git a/tests/test_choices_sheet.py b/tests/test_choices_sheet.py index 01ebe2da..f9dac041 100644 --- a/tests/test_choices_sheet.py +++ b/tests/test_choices_sheet.py @@ -572,3 +572,19 @@ def test_missing_name__error(self): 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_settings.py b/tests/test_settings.py index d7b2ed28..ac2708d4 100644 --- a/tests/test_settings.py +++ b/tests/test_settings.py @@ -43,6 +43,32 @@ def test_form_id(self): xml__xpath_match=[xps.form_id("my_form")], ) + def test_form_name__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", + ) + + def test_form_name__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 e02d5ebc..8f6d7ff8 100644 --- a/tests/test_sheet_columns.py +++ b/tests/test_sheet_columns.py @@ -280,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=""" 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] + ) From bdbc302aa2956b7ec5e58f7e75877b69b5115757 Mon Sep 17 00:00:00 2001 From: lindsay stevens Date: Thu, 18 Dec 2025 02:08:36 +1100 Subject: [PATCH 29/33] chg: move entity name underscores error ErrorCode enum - organising error messages --- pyxform/entities/entities_parsing.py | 8 ++++++-- pyxform/errors.py | 7 +++++++ tests/entities/test_create_survey.py | 8 ++++++-- 3 files changed, 19 insertions(+), 4 deletions(-) diff --git a/pyxform/entities/entities_parsing.py b/pyxform/entities/entities_parsing.py index 020bdeb2..f5b7d208 100644 --- a/pyxform/entities/entities_parsing.py +++ b/pyxform/entities/entities_parsing.py @@ -179,7 +179,9 @@ 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: @@ -271,7 +273,9 @@ def validate_entity_saveto( if save_to.startswith(const.ENTITIES_RESERVED_PREFIX): raise PyXFormError( - f"{error_start} the entity property name '{save_to}' starts with reserved prefix {const.ENTITIES_RESERVED_PREFIX}." + ErrorCode.NAMES_010.value.format( + sheet=const.SURVEY, row=row_number, column=const.ENTITIES_SAVETO + ) ) if not is_xml_tag(save_to): diff --git a/pyxform/errors.py b/pyxform/errors.py index cea4d53c..dcb8fe2d 100644 --- a/pyxform/errors.py +++ b/pyxform/errors.py @@ -217,6 +217,13 @@ class ErrorCode(Enum): "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." + ), + ) PYREF_001: Detail = Detail( name="PyXForm Reference Parsing Failed", msg=( diff --git a/tests/entities/test_create_survey.py b/tests/entities/test_create_survey.py index 7f21dbd7..dd772908 100644 --- a/tests/entities/test_create_survey.py +++ b/tests/entities/test_create_survey.py @@ -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 + ) ], ) @@ -316,7 +318,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 + ) ], ) From 0da79a6fcea31455ce82b708e5adc1eddc651d2e Mon Sep 17 00:00:00 2001 From: lindsay stevens Date: Thu, 18 Dec 2025 02:24:31 +1100 Subject: [PATCH 30/33] chg: move entity name period error ErrorCode enum - organising error messages --- pyxform/entities/entities_parsing.py | 10 +++++----- pyxform/errors.py | 7 +++++++ tests/entities/test_create_survey.py | 4 +++- 3 files changed, 15 insertions(+), 6 deletions(-) diff --git a/pyxform/entities/entities_parsing.py b/pyxform/entities/entities_parsing.py index f5b7d208..b5efd6c2 100644 --- a/pyxform/entities/entities_parsing.py +++ b/pyxform/entities/entities_parsing.py @@ -183,13 +183,13 @@ def get_validated_dataset_name(entity): 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): + elif not is_xml_tag(dataset): raise PyXFormError( ErrorCode.NAMES_008.value.format( sheet=const.ENTITIES, row=2, column=EC.DATASET.value diff --git a/pyxform/errors.py b/pyxform/errors.py index dcb8fe2d..6a23dc98 100644 --- a/pyxform/errors.py +++ b/pyxform/errors.py @@ -224,6 +224,13 @@ class ErrorCode(Enum): "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." + ), + ) PYREF_001: Detail = Detail( name="PyXForm Reference Parsing Failed", msg=( diff --git a/tests/entities/test_create_survey.py b/tests/entities/test_create_survey.py index dd772908..f9066dfa 100644 --- a/tests/entities/test_create_survey.py +++ b/tests/entities/test_create_survey.py @@ -114,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 + ) ], ) From 26c1239316a2d6800de64c69206f0d11450c2de6 Mon Sep 17 00:00:00 2001 From: lindsay stevens Date: Thu, 18 Dec 2025 02:48:12 +1100 Subject: [PATCH 31/33] chg: move entity name save_to reserved words error ErrorCode enum - organising error messages --- pyxform/entities/entities_parsing.py | 15 +++++++-------- pyxform/errors.py | 7 +++++++ tests/entities/test_create_survey.py | 16 ++++++++++++---- 3 files changed, 26 insertions(+), 12 deletions(-) diff --git a/pyxform/entities/entities_parsing.py b/pyxform/entities/entities_parsing.py index b5efd6c2..f2d4c076 100644 --- a/pyxform/entities/entities_parsing.py +++ b/pyxform/entities/entities_parsing.py @@ -264,21 +264,20 @@ def validate_entity_saveto( ErrorCode.ENTITY_007.value.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: + # Error: naming rules + if save_to.lower() in {const.NAME, const.LABEL}: raise PyXFormError( - f"{error_start} the entity property name '{save_to}' is reserved." + ErrorCode.NAMES_011.value.format( + sheet=const.SURVEY, row=row_number, column=const.ENTITIES_SAVETO + ) ) - - if save_to.startswith(const.ENTITIES_RESERVED_PREFIX): + 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 ) ) - - if not is_xml_tag(save_to): + elif not is_xml_tag(save_to): raise PyXFormError( ErrorCode.NAMES_008.value.format( sheet=const.SURVEY, row=row_number, column=const.ENTITIES_SAVETO diff --git a/pyxform/errors.py b/pyxform/errors.py index 6a23dc98..ef2147f9 100644 --- a/pyxform/errors.py +++ b/pyxform/errors.py @@ -231,6 +231,13 @@ class ErrorCode(Enum): "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=( diff --git a/tests/entities/test_create_survey.py b/tests/entities/test_create_survey.py index f9066dfa..50dbf395 100644 --- a/tests/entities/test_create_survey.py +++ b/tests/entities/test_create_survey.py @@ -252,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 + ) ], ) @@ -269,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 + ) ], ) @@ -286,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 + ) ], ) @@ -303,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 + ) ], ) From 3e497c9d3ebc87c4ea0496a499bbef99642660cd Mon Sep 17 00:00:00 2001 From: lindsay stevens Date: Thu, 18 Dec 2025 02:59:04 +1100 Subject: [PATCH 32/33] fix: missing f-string prefix on big-image error message - not caught by relevant test since it only requires the substring "must also specify an image". --- pyxform/survey_element.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyxform/survey_element.py b/pyxform/survey_element.py index b1bc3bcc..8076961e 100644 --- a/pyxform/survey_element.py +++ b/pyxform/survey_element.py @@ -514,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 From 51e2de468a8e596f1b5685a10cc9c5b11a14d47d Mon Sep 17 00:00:00 2001 From: lindsay stevens Date: Fri, 19 Dec 2025 16:52:04 +1100 Subject: [PATCH 33/33] add: check the "name" setting as well as "form_name" - it's possible to set the primary instance root node name via the setting "name" (which overrides other ways to set the name), so check the settings code path as well. --- pyxform/validators/pyxform/settings.py | 19 +++++++++---- pyxform/xls2json.py | 3 +- tests/test_settings.py | 38 ++++++++++++++++++++++++-- tests/xpath_helpers/settings.py | 10 +++++++ 4 files changed, 62 insertions(+), 8 deletions(-) diff --git a/pyxform/validators/pyxform/settings.py b/pyxform/validators/pyxform/settings.py index 8f9c0959..2007f6eb 100644 --- a/pyxform/validators/pyxform/settings.py +++ b/pyxform/validators/pyxform/settings.py @@ -1,12 +1,21 @@ +from pyxform import constants as co from pyxform.errors import ErrorCode, PyXFormError from pyxform.parsing.expression import is_xml_tag -def validate_form_name(form_name: str | None): +def validate_name(name: str | None, from_sheet: bool = True): """ - The form_name must be a valid XML tag since it is used for the primary instance element. + The name must be a valid XML Name since it is used for the primary instance element. - :param form_name: The value to check. + :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 form_name is not None and not is_xml_tag(value=form_name): - raise PyXFormError(ErrorCode.NAMES_009.value.format(name="form_name")) + 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/xls2json.py b/pyxform/xls2json.py index 7c962271..1130f620 100644 --- a/pyxform/xls2json.py +++ b/pyxform/xls2json.py @@ -264,7 +264,7 @@ def workbook_to_json( raise PyXFormError(msg) # Make sure the passed in vars are unicode - validate_settings.validate_form_name(form_name=form_name) + 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)) @@ -308,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: diff --git a/tests/test_settings.py b/tests/test_settings.py index ac2708d4..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,7 +44,39 @@ def test_form_id(self): xml__xpath_match=[xps.form_id("my_form")], ) - def test_form_name__valid_characters(self): + 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 | @@ -53,9 +86,10 @@ def test_form_name__valid_characters(self): self.assertPyxformXform( md=md, name="master-form_v2.1", + xml__xpath_match=[xps.name("master-form_v2.1")], ) - def test_form_name__invalid_characters__error(self): + def test_name__from_file__invalid_characters__error(self): """Should raise an error if the form_name is not a valid name.""" md = """ | survey | 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."""