Skip to content
Merged
Show file tree
Hide file tree
Changes from 6 commits
Commits
Show all changes
36 commits
Select commit Hold shift + click to select a range
86e7479
chg: move NAMES001 error into ErrorCode enum
lindsay-stevens Dec 16, 2025
c443766
chg: move NAMES002 error into ErrorCode enum
lindsay-stevens Dec 16, 2025
b8c5265
chg: move NAMES003 error into ErrorCode enum
lindsay-stevens Dec 16, 2025
15b2283
chg: move NAMES004 error into ErrorCode enum
lindsay-stevens Dec 16, 2025
c33241e
chg: move NAMES005 error into ErrorCode enum
lindsay-stevens Dec 16, 2025
c0312b1
chg: move choices.INVALID_NAME error into ErrorCode enum
lindsay-stevens Dec 16, 2025
7342b89
chg: move choices.INVALID_LABEL error into ErrorCode enum
lindsay-stevens Dec 16, 2025
dbeb2c1
chg: move choices.INVALID_DUPLICATE error into ErrorCode enum
lindsay-stevens Dec 16, 2025
0fa7cd9
chg: move sheet_headers.INVALID_HEADER error into ErrorCode enum
lindsay-stevens Dec 16, 2025
555a511
fix: tests providing single strings to error__contains
lindsay-stevens Dec 16, 2025
76c1bc9
chg: move sheet_headers.INVALID_DUPLICATE error into ErrorCode enum
lindsay-stevens Dec 16, 2025
f9a7c74
chg: move sheet_headers.INVALID_MISSING_REQUIRED error into ErrorCode
lindsay-stevens Dec 16, 2025
318ff0b
chg: move choices.INVALID_HEADER error into ErrorCode enum
lindsay-stevens Dec 16, 2025
f37d857
chg: sort ErrorCode enum
lindsay-stevens Dec 16, 2025
abc6d11
chg: move xls2json.SURVEY_001 error into ErrorCode enum
lindsay-stevens Dec 16, 2025
c2969e3
chg: move xls2json.SURVEY_002 error into ErrorCode enum
lindsay-stevens Dec 16, 2025
cd26763
chg: move entities_parsing.ENTITY_001 error into ErrorCode enum
lindsay-stevens Dec 16, 2025
10e2dff
chg: move entities_parsing.ENTITY_002 error into ErrorCode enum
lindsay-stevens Dec 16, 2025
cbe3a0d
chg: move entities_parsing.ENTITY_003 error into ErrorCode enum
lindsay-stevens Dec 16, 2025
d498e59
chg: move entities_parsing.ENTITY_004 error into ErrorCode enum
lindsay-stevens Dec 16, 2025
751a838
chg: move entities_parsing.ENTITY_005 error into ErrorCode enum
lindsay-stevens Dec 16, 2025
6d18dc5
chg: move entities_parsing.ENTITY_006 error into ErrorCode enum
lindsay-stevens Dec 16, 2025
3d3fb24
chg: move entities_parsing.ENTITY_007 error into ErrorCode enum
lindsay-stevens Dec 16, 2025
cc6fc7e
chg: move entity dataset name character error to ErrorCode enum
lindsay-stevens Dec 17, 2025
1ab9d88
chg: replace entity save_to name error with same ErrorCode.NAMES_008
lindsay-stevens Dec 17, 2025
c3beb3e
chg: replace select_from_file name error with same ErrorCode.NAMES_008
lindsay-stevens Dec 17, 2025
0069f41
chg: replace workbook_to_json name error with same ErrorCode.NAMES_008
lindsay-stevens Dec 17, 2025
ead8652
chg: replace element/form name error with same ErrorCode.NAMES_009
lindsay-stevens Dec 17, 2025
a06d685
chg: move entity name underscores error ErrorCode enum
lindsay-stevens Dec 17, 2025
06607da
chg: move entity name period error ErrorCode enum
lindsay-stevens Dec 17, 2025
83ca653
chg: move entity name save_to reserved words error ErrorCode enum
lindsay-stevens Dec 17, 2025
d42a43c
fix: missing f-string prefix on big-image error message
lindsay-stevens Dec 17, 2025
41ac57b
add: check the "name" setting as well as "form_name"
lindsay-stevens Dec 19, 2025
c53b8c1
add: comment explaining recently added test for choice name validation
lindsay-stevens Jan 29, 2026
187b35d
add: docstrings to explain intended usage of ErrorCode and Detail
lindsay-stevens Jan 29, 2026
05f4fae
chg: make format and casing of errorcode names more consistent
lindsay-stevens Jan 29, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
48 changes: 48 additions & 0 deletions pyxform/errors.py
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,54 @@ 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."
),
)
NAMES_002: Detail = Detail(
name="Invalid duplicate name in context (case-insensitive)",
msg=(
"[row : {row}] On the 'survey' sheet, the 'name' value '{value}' is problematic. "
"The name is a case-insensitive match to another name. Questions, groups, and "
"repeats should be unique within the nearest parent group or repeat, or the survey "
"if not inside a group or repeat. Some data processing tools are not "
"case-sensitive, so the current names may make analysis difficult."
),
)
NAMES_003: Detail = Detail(
name="Invalid repeat name same as survey",
msg=(
"[row : {row}] On the 'survey' sheet, the 'name' value '{value}' is invalid. "
"Repeat names must not be the same as the survey root (which defaults to 'data')."
),
)
NAMES_004: Detail = Detail(
name="Invalid duplicate repeat name in the survey",
msg=(
"[row : {row}] On the 'survey' sheet, the 'name' value '{value}' is invalid. "
"Repeat names must unique anywhere in the survey, at all levels of group or "
"repeat nesting."
),
)
NAMES_005: Detail = Detail(
name="Invalid duplicate meta name in the survey",
msg=(
"[row : {row}] On the 'survey' sheet, the 'name' value 'meta' is invalid. "
"The name 'meta' is reserved for form metadata."
),
)
NAMES_006: Detail = Detail(
name="Invalid missing name in the choices sheet",
Comment thread
lindsay-stevens marked this conversation as resolved.
Outdated
msg=(
"[row : {row}] On the 'choices' sheet, the 'name' value is invalid. "
Comment thread
lindsay-stevens marked this conversation as resolved.
"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=(
Expand Down
9 changes: 2 additions & 7 deletions pyxform/validators/pyxform/choices.py
Original file line number Diff line number Diff line change
@@ -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. "
Expand Down Expand Up @@ -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"]))

Expand Down
59 changes: 12 additions & 47 deletions pyxform/validators/pyxform/unique_names.py
Original file line number Diff line number Diff line change
@@ -1,46 +1,5 @@
from pyxform import constants as const
from pyxform.errors import Detail, PyXFormError

NAMES001 = Detail(
name="Invalid duplicate name in same context",
msg=(
"[row : {row}] On the 'survey' sheet, the 'name' value '{value}' is invalid. "
"Questions, groups, and repeats must be unique within their nearest parent group "
"or repeat, or the survey if not inside a group or repeat."
),
)
NAMES002 = Detail(
name="Invalid duplicate name in context (case-insensitive)",
msg=(
"[row : {row}] On the 'survey' sheet, the 'name' value '{value}' is problematic. "
"The name is a case-insensitive match to another name. Questions, groups, and "
"repeats should be unique within the nearest parent group or repeat, or the survey "
"if not inside a group or repeat. Some data processing tools are not "
"case-sensitive, so the current names may make analysis difficult."
),
)
NAMES003 = Detail(
name="Invalid repeat name same as survey",
msg=(
"[row : {row}] On the 'survey' sheet, the 'name' value '{value}' is invalid. "
"Repeat names must not be the same as the survey root (which defaults to 'data')."
),
)
NAMES004 = Detail(
name="Invalid duplicate repeat name in the survey",
msg=(
"[row : {row}] On the 'survey' sheet, the 'name' value '{value}' is invalid. "
"Repeat names must unique anywhere in the survey, at all levels of group or "
"repeat nesting."
),
)
NAMES005 = Detail(
name="Invalid duplicate meta name in the survey",
msg=(
"[row : {row}] On the 'survey' sheet, the 'name' value 'meta' is invalid. "
"The name 'meta' is reserved for form metadata."
),
)
from pyxform.errors import ErrorCode, PyXFormError


def validate_question_group_repeat_name(
Expand Down Expand Up @@ -73,15 +32,17 @@ def validate_question_group_repeat_name(

if name in seen_names:
if name == const.META:
raise PyXFormError(NAMES005.format(row=row_number))
raise PyXFormError(ErrorCode.NAMES_005.value.format(row=row_number))
else:
raise PyXFormError(NAMES001.format(row=row_number, value=name))
raise PyXFormError(
ErrorCode.NAMES_001.value.format(row=row_number, value=name)
)
seen_names.add(name)

question_name_lower = name.lower()
if question_name_lower in seen_names_lower:
# No case-insensitive warning for 'meta' since it's not an exported data table.
warnings.append(NAMES002.format(row=row_number, value=name))
warnings.append(ErrorCode.NAMES_002.value.format(row=row_number, value=name))
seen_names_lower.add(question_name_lower)


Expand All @@ -107,7 +68,11 @@ def validate_repeat_name(
"""
if control_type == const.REPEAT:
if name == instance_element_name:
raise PyXFormError(NAMES003.format(row=row_number, value=name))
raise PyXFormError(
ErrorCode.NAMES_003.value.format(row=row_number, value=name)
)
elif name in seen_names:
raise PyXFormError(NAMES004.format(row=row_number, value=name))
raise PyXFormError(
ErrorCode.NAMES_004.value.format(row=row_number, value=name)
)
seen_names.add(name)
17 changes: 17 additions & 0 deletions tests/test_choices_sheet.py
Original file line number Diff line number Diff line change
Expand Up @@ -558,3 +558,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)],
)
4 changes: 2 additions & 2 deletions tests/test_external_instances.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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):
Expand Down
46 changes: 27 additions & 19 deletions tests/test_fields.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,6 @@
"""

from pyxform.errors import ErrorCode
from pyxform.validators.pyxform import unique_names

from tests.pyxform_test_case import PyxformTestCase

Expand Down Expand Up @@ -173,7 +172,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):
Expand All @@ -189,7 +188,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):
Expand All @@ -205,7 +204,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):
Expand All @@ -221,7 +220,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):
Expand All @@ -239,7 +238,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):
Expand All @@ -257,7 +256,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):
Expand All @@ -273,7 +272,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):
Expand All @@ -291,7 +290,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):
Expand All @@ -309,7 +308,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(
Expand All @@ -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(
Expand All @@ -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(
Expand All @@ -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(
Expand All @@ -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(
Expand All @@ -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(
Expand All @@ -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(
Expand All @@ -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(
Expand All @@ -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(
Expand All @@ -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):
Expand Down
Loading