Skip to content
Merged
Show file tree
Hide file tree
Changes from 14 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
115 changes: 107 additions & 8 deletions pyxform/errors.py
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,113 @@ 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'."
),
)
HEADER_002: Detail = Detail(
name="Invalid duplicate header.",
msg=(
"Invalid headers provided for sheet: '{sheet_name}'. Headers that are different "
"names for the same column were found: '{other}', '{header}'. Rename or remove one "
"of these columns."
),
)
HEADER_003: Detail = Detail(
name="Invalid missing required header.",
msg=(
"Invalid headers provided for sheet: '{sheet_name}'. One or more required column "
"headers were not found: {missing}. "
"Learn more: https://xlsform.org/en/#setting-up-your-worksheets"
),
)
HEADER_004: Detail = Detail(
name="Invalid choices header.",
msg=(
"[row : 1] On the 'choices' sheet, the '{column}' value is invalid. "
"Column headers must not be empty and must not contain spaces. "
"Learn more: https://xlsform.org/en/#setting-up-your-worksheets"
),
)
INTERNAL_001: Detail = Detail(
name="Internal error: Incorrectly Processed Question Trigger Data",
Comment thread
lindsay-stevens marked this conversation as resolved.
Outdated
msg=(
"Internal error: "
"PyXForm expected processed trigger data as a tuple, but received a "
"type '{type}' with value '{value}'."
),
)
LABEL_001: Detail = Detail(
name="Invalid missing label in the choices sheet",
msg=(
"[row : {row}] On the 'choices' sheet, the 'label' value is invalid. "
"Choices should have a label. "
"Learn more: https://xlsform.org/en/#setting-up-your-worksheets"
),
)
NAMES_001: Detail = Detail(
name="Invalid duplicate name in same context",
msg=(
"[row : {row}] On the 'survey' sheet, the 'name' value '{value}' is invalid. "
"Questions, groups, and repeats must be unique within their nearest parent group "
"or repeat, or the survey if not inside a group or repeat."
),
)
NAMES_002: Detail = Detail(
name="Invalid duplicate name in context (case-insensitive)",
msg=(
"[row : {row}] On the 'survey' sheet, the 'name' value '{value}' is problematic. "
"The name is a case-insensitive match to another name. Questions, groups, and "
"repeats should be unique within the nearest parent group or repeat, or the survey "
"if not inside a group or repeat. Some data processing tools are not "
"case-sensitive, so the current names may make analysis difficult."
),
)
NAMES_003: Detail = Detail(
name="Invalid repeat name same as survey",
msg=(
"[row : {row}] On the 'survey' sheet, the 'name' value '{value}' is invalid. "
"Repeat names must not be the same as the survey root (which defaults to 'data')."
),
)
NAMES_004: Detail = Detail(
name="Invalid duplicate repeat name in the survey",
msg=(
"[row : {row}] On the 'survey' sheet, the 'name' value '{value}' is invalid. "
"Repeat names must unique anywhere in the survey, at all levels of group or "
"repeat nesting."
),
)
NAMES_005: Detail = Detail(
name="Invalid duplicate meta name in the survey",
msg=(
"[row : {row}] On the 'survey' sheet, the 'name' value 'meta' is invalid. "
"The name 'meta' is reserved for form metadata."
),
)
NAMES_006: Detail = Detail(
name="Invalid missing name in the choices sheet",
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"
),
)
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."
),
)
PYREF_001: Detail = Detail(
name="PyXForm Reference Parsing Failed",
msg=(
Expand Down Expand Up @@ -71,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}'."
),
)
SURVEY_003: Detail = Detail(
name="Survey Sheet - invalid geoshape/geotrace parameter 'incremental'",
msg=(
Expand Down
25 changes: 4 additions & 21 deletions pyxform/parsing/sheet_headers.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,29 +4,12 @@
from typing import Any

from pyxform import constants
from pyxform.errors import PyXFormError
from pyxform.errors import ErrorCode, PyXFormError
from pyxform.parsing.expression import maybe_strip
from pyxform.xls2json_backends import RE_WHITESPACE

SMART_QUOTES = {"\u2018": "'", "\u2019": "'", "\u201c": '"', "\u201d": '"'}
RE_SMART_QUOTES = re.compile(r"|".join(re.escape(old) for old in SMART_QUOTES))
INVALID_HEADER = (
"Invalid headers provided for sheet: '{sheet_name}'. For XLSForms, this may be due "
"a missing header row, in which case add a header row as per the reference template "
"https://xlsform.org/en/ref-table/. For internal API usage, may be due to a missing "
"mapping for '{header}', in which case ensure that the full set of headers appear "
"within the first 100 rows, or specify the header row in '{sheet_name}_header'."
)
INVALID_DUPLICATE = (
"Invalid headers provided for sheet: '{sheet_name}'. Headers that are different "
"names for the same column were found: '{other}', '{header}'. Rename or remove one "
"of these columns."
)
INVALID_MISSING_REQUIRED = (
"Invalid headers provided for sheet: '{sheet_name}'. One or more required column "
"headers were not found: {missing}. "
"Learn more: https://xlsform.org/en/#setting-up-your-worksheets"
)


def clean_text_values(
Expand Down Expand Up @@ -196,7 +179,7 @@ def process_row(
tokens = header_key.get(header, None)
if not tokens:
raise PyXFormError(
INVALID_HEADER.format(sheet_name=sheet_name, header=header)
ErrorCode.HEADER_001.value.format(sheet_name=sheet_name, header=header)
)
elif len(tokens) == 1:
out_row[tokens[0]] = val
Expand Down Expand Up @@ -268,7 +251,7 @@ def dealias_and_group_headers(
other_header = tokens_key.get(tokens)
if other_header and new_header != header:
raise PyXFormError(
INVALID_DUPLICATE.format(
ErrorCode.HEADER_002.value.format(
sheet_name=sheet_name,
other=other_header,
header=header,
Expand All @@ -293,7 +276,7 @@ def dealias_and_group_headers(
missing = {h for h in headers_required if h not in {h[0] for h in tokens_key}}
if missing:
raise PyXFormError(
INVALID_MISSING_REQUIRED.format(
ErrorCode.HEADER_003.value.format(
sheet_name=sheet_name, missing=", ".join(f"'{h}'" for h in missing)
)
)
Expand Down
34 changes: 7 additions & 27 deletions pyxform/validators/pyxform/choices.py
Original file line number Diff line number Diff line change
@@ -1,27 +1,5 @@
from pyxform import constants
from pyxform.errors import PyXFormError

INVALID_NAME = (
"[row : {row}] On the 'choices' sheet, the 'name' value is invalid. "
"Choices must have a name. "
"Learn more: https://xlsform.org/en/#setting-up-your-worksheets"
)
INVALID_LABEL = (
"[row : {row}] On the 'choices' sheet, the 'label' value is invalid. "
"Choices should have a label. "
"Learn more: https://xlsform.org/en/#setting-up-your-worksheets"
)
INVALID_HEADER = (
"[row : 1] On the 'choices' sheet, the '{column}' value is invalid. "
"Column headers must not be empty and must not contain spaces. "
"Learn more: https://xlsform.org/en/#setting-up-your-worksheets"
)
INVALID_DUPLICATE = (
"[row : {row}] On the 'choices' sheet, the 'name' value is invalid. "
"Choice names must be unique for each choice list. "
"If this is intentional, use the setting 'allow_choice_duplicates'. "
"Learn more: https://xlsform.org/#choice-names."
)
from pyxform.errors import ErrorCode, PyXFormError


def validate_headers(
Expand All @@ -31,7 +9,7 @@ def check():
for header in headers:
header = header[0]
if header != constants.LIST_NAME_S and (" " in header or header == ""):
warnings.append(INVALID_HEADER.format(column=header))
warnings.append(ErrorCode.HEADER_004.value.format(column=header))
yield header

return tuple(check())
Expand All @@ -44,14 +22,16 @@ def validate_choice_list(
duplicate_errors = []
for option in options:
if constants.NAME not in option:
raise PyXFormError(INVALID_NAME.format(row=option["__row"]))
raise PyXFormError(ErrorCode.NAMES_006.value.format(row=option["__row"]))
elif constants.LABEL not in option:
warnings.append(INVALID_LABEL.format(row=option["__row"]))
warnings.append(ErrorCode.LABEL_001.value.format(row=option["__row"]))

if not allow_duplicates:
name = option[constants.NAME]
if name in seen_options:
duplicate_errors.append(INVALID_DUPLICATE.format(row=option["__row"]))
duplicate_errors.append(
ErrorCode.NAMES_007.value.format(row=option["__row"])
)
else:
seen_options.add(name)

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)
33 changes: 28 additions & 5 deletions tests/pyxform_test_case.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
8 changes: 7 additions & 1 deletion tests/test_builder.py
Original file line number Diff line number Diff line change
Expand Up @@ -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__)
Expand Down
Loading