From 8b718502a8de88cd93133188de0d37e2c6c129f0 Mon Sep 17 00:00:00 2001 From: Garen Dye Date: Fri, 24 Apr 2026 20:16:01 -0700 Subject: [PATCH 1/2] Fix whitespace handling in select-from-file parameters --- .../validators/pyxform/parameters_generic.py | 4 +++- tests/test_external_instances_for_selects.py | 21 +++++++++++++++++++ 2 files changed, 24 insertions(+), 1 deletion(-) diff --git a/pyxform/validators/pyxform/parameters_generic.py b/pyxform/validators/pyxform/parameters_generic.py index ee5769a6..7da825fe 100644 --- a/pyxform/validators/pyxform/parameters_generic.py +++ b/pyxform/validators/pyxform/parameters_generic.py @@ -26,7 +26,9 @@ def parse(raw_parameters: str) -> PARAMETERS_TYPE: ) k, v = param.split("=")[:2] key = maybe_strip(k.lower()) - params[key] = v if key in CASE_SENSITIVE_VALUES else maybe_strip(v.lower()) + params[key] = ( + maybe_strip(v) if key in CASE_SENSITIVE_VALUES else maybe_strip(v.lower()) + ) return params diff --git a/tests/test_external_instances_for_selects.py b/tests/test_external_instances_for_selects.py index c559bd37..09d1bfc4 100644 --- a/tests/test_external_instances_for_selects.py +++ b/tests/test_external_instances_for_selects.py @@ -265,6 +265,27 @@ def test_param_value_case_preserved(self): ], ) + def test_param_value_and_label_whitespace_trimmed_case_preserved(self): + """Should trim outer spaces for value/label params while preserving case.""" + md = """ + | survey | | | | | + | | type | name | label | parameters | + | | select_one_from_file cities{ext} | city | City | value = VAL , label = lBl | + | | select_multiple_from_file suburbs{ext} | suburbs | Suburbs | value = VAL , label = lBl | + """ + for ext, xp_city, xp_subs in self.xp_test_args: + with self.subTest(msg=ext): + self.assertPyxformXform( + name="test", + md=md.format(ext=ext), + xml__xpath_match=[ + xp_city.model_external_instance_and_bind(), + xp_subs.model_external_instance_and_bind(), + xp_city.body_itemset_nodeset_and_refs(value="VAL", label="lBl"), + xp_subs.body_itemset_nodeset_and_refs(value="VAL", label="lBl"), + ], + ) + def test_expected_error_message(self): """Should get helpful error when select_from_file is missing a file extension.""" md = """ From 75efbf636a4666fa0df10275d87a54d15320d67e Mon Sep 17 00:00:00 2001 From: Garen Dye Date: Fri, 24 Apr 2026 20:17:13 -0700 Subject: [PATCH 2/2] Validate invalid submission_url values earlier --- pyxform/errors.py | 15 ++++++ pyxform/validators/pyxform/settings.py | 25 +++++++++ pyxform/xls2json.py | 3 ++ tests/test_settings.py | 71 ++++++++++++++++++++++++++ 4 files changed, 114 insertions(+) diff --git a/pyxform/errors.py b/pyxform/errors.py index bcb118cc..66902a10 100644 --- a/pyxform/errors.py +++ b/pyxform/errors.py @@ -221,6 +221,13 @@ class ErrorCode(Enum): "or update pyxform." ), ) + HEADER_006: Detail = Detail( + name="Headers - invalid translated non-translatable column", + msg=( + "[row : 1] On the '{sheet_name}' sheet, the column name '{column}' is invalid. " + "The column '{base}' is not translatable on this sheet." + ), + ) INTERNAL_001: Detail = Detail( name="Internal error - incorrectly processed question trigger data", msg=( @@ -245,6 +252,14 @@ class ErrorCode(Enum): "Learn more: https://xlsform.org/en/#setting-up-your-worksheets" ), ) + SETTINGS_001: Detail = Detail( + name="Settings - invalid submission_url", + msg=( + "[row : 1] On the 'settings' sheet, the 'submission_url' value is invalid. " + "Submission URLs must be full HTTP or HTTPS URLs, for example " + "'https://example.com/submission'." + ), + ) NAMES_001: Detail = Detail( name="Names - invalid duplicate name in same context", msg=( diff --git a/pyxform/validators/pyxform/settings.py b/pyxform/validators/pyxform/settings.py index 2007f6eb..e4046ac4 100644 --- a/pyxform/validators/pyxform/settings.py +++ b/pyxform/validators/pyxform/settings.py @@ -1,3 +1,5 @@ +from urllib.parse import urlsplit + from pyxform import constants as co from pyxform.errors import ErrorCode, PyXFormError from pyxform.parsing.expression import is_xml_tag @@ -19,3 +21,26 @@ def validate_name(name: str | None, from_sheet: bool = True): ) else: raise PyXFormError(ErrorCode.NAMES_009.value.format(name="form_name")) + + +def validate_submission_url(submission_url: str | None): + """ + The submission_url must be a full HTTP(S) URL. + + :param submission_url: The value to check. + """ + if submission_url in {None, ""}: + return + + try: + parsed = urlsplit(submission_url) + except ValueError as err: + raise PyXFormError(ErrorCode.SETTINGS_001.value.format()) from err + + if ( + any(c.isspace() for c in submission_url) + or parsed.scheme not in {"http", "https"} + or not parsed.netloc + or parsed.hostname is None + ): + raise PyXFormError(ErrorCode.SETTINGS_001.value.format()) diff --git a/pyxform/xls2json.py b/pyxform/xls2json.py index 6a0b2022..0e89f45a 100644 --- a/pyxform/xls2json.py +++ b/pyxform/xls2json.py @@ -311,6 +311,9 @@ def workbook_to_json( ) settings = settings_sheet.data[0] validate_settings.validate_name(name=settings.get(constants.NAME, None)) + validate_settings.validate_submission_url( + submission_url=settings.get(constants.SUBMISSION_URL, 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 c954901c..d4c96613 100644 --- a/tests/test_settings.py +++ b/tests/test_settings.py @@ -29,6 +29,28 @@ def test_form_title(self): xml__xpath_match=[xps.form_title("My Form")], ) + def test_form_title__translated__error(self): + """Should raise a clear error for translated form_title columns.""" + md = """ + | settings | + | | form_title::French (fr) | + | | Mon formulaire | + | survey | | | | + | | type | name | label | + | | text | q1 | hello | + """ + self.assertPyxformXform( + md=md, + errored=True, + error__contains=[ + ErrorCode.HEADER_006.value.format( + sheet_name=co.SETTINGS, + column="form_title::French (fr)", + base="form_title", + ) + ], + ) + def test_form_id(self): """Should find the instance id set in the XForm.""" md = """ @@ -103,6 +125,55 @@ def test_name__from_file__invalid_characters__error(self): error__contains=[ErrorCode.NAMES_009.value.format(name="form_name")], ) + def test_submission_url__http__valid(self): + """Should allow full HTTP submission URLs.""" + md = """ + | settings | + | | submission_url | + | | http://example.com/submit | + + | survey | + | | type | name | label | + | | text | q1 | hello | + """ + self.assertPyxformXform( + md=md, + xml__xpath_match=[ + """ + /h:html/h:head/x:model/x:submission[ + @action='http://example.com/submit' + and @method='post' + ] + """ + ], + ) + + def test_submission_url__invalid__error(self): + """Should raise an error for obviously invalid submission URLs.""" + md = """ + | settings | + | | submission_url | + | | {submission_url} | + + | survey | + | | type | name | label | + | | text | q1 | hello | + """ + bad_urls = ( + "not_a_url", + "/submission", + "ftp://example.com/submission", + "https://", + "https://example .com/submission", + ) + for submission_url in bad_urls: + with self.subTest(msg=submission_url): + self.assertPyxformXform( + md=md.format(submission_url=submission_url), + errored=True, + error__contains=[ErrorCode.SETTINGS_001.value.format()], + ) + def test_clean_text_values__yes(self): """Should find clean_text_values=yes (default) collapses survey sheet whitespace.""" md = """