From 4b743dbe7d8e54f380310f91d6fe74e00aee5ba8 Mon Sep 17 00:00:00 2001 From: Christopher Pruijsen Date: Fri, 9 Oct 2026 13:31:35 +0100 Subject: [PATCH 1/2] fix: error when a 'required' value is used on a group or repeat --- pyxform/errors.py | 9 ++++++++ pyxform/section.py | 7 ++++++ pyxform/xls2json.py | 4 ++++ tests/test_group.py | 55 ++++++++++++++++++++++++++++++++++++++++++++- 4 files changed, 74 insertions(+), 1 deletion(-) diff --git a/pyxform/errors.py b/pyxform/errors.py index 4e288f0c..3798b56f 100644 --- a/pyxform/errors.py +++ b/pyxform/errors.py @@ -593,6 +593,15 @@ class ErrorCode(Enum): "Move this question out of the repeat, or choose a different question type." ), ) + SURVEY_011 = Detail( + name="Survey sheet - invalid 'required' on group or repeat", + msg=( + "[row : {row}] On the 'survey' sheet, the 'required' value is invalid. " + "Groups, repeats, and loops cannot be required. " + "Either remove the 'required' value in this row, or move it to a " + "question inside." + ), + ) class PyXFormError(Exception): diff --git a/pyxform/section.py b/pyxform/section.py index 49d4b0ba..31fed6fc 100644 --- a/pyxform/section.py +++ b/pyxform/section.py @@ -6,6 +6,7 @@ from xml.dom.minidom import Attr from pyxform import constants +from pyxform.errors import PyXFormError from pyxform.external_instance import ExternalInstance from pyxform.survey_element import SURVEY_ELEMENT_FIELDS, SurveyElement from pyxform.utils import DetachableElement, node @@ -90,6 +91,12 @@ def add_children(self, children): def validate(self): super().validate() + if isinstance(self.bind, dict) and self.bind.get("required"): + raise PyXFormError( + f"The 'required' value on the {self.type} '{self.name}' is invalid. " + "Groups, repeats, and loops cannot be required. " + "Either remove the 'required' value, or move it to a question inside." + ) for element in self.children: element.validate() self._validate_uniqueness_of_element_names() diff --git a/pyxform/xls2json.py b/pyxform/xls2json.py index 093c6d40..91269d11 100644 --- a/pyxform/xls2json.py +++ b/pyxform/xls2json.py @@ -795,6 +795,10 @@ def workbook_to_json( + f" {control_type.capitalize()} has no label: {msg_dict}" ) + bind = row.get(constants.BIND, {}) + if isinstance(bind, dict) and bind.get("required"): + raise PyXFormError(ErrorCode.SURVEY_011.value.format(row=row_number)) + new_json_dict = row.copy() new_json_dict[constants.TYPE] = control_type child_list = [] diff --git a/tests/test_group.py b/tests/test_group.py index 1051834c..30dce670 100644 --- a/tests/test_group.py +++ b/tests/test_group.py @@ -3,7 +3,7 @@ from unittest import TestCase from pyxform.builder import create_survey_element_from_dict -from pyxform.errors import ErrorCode +from pyxform.errors import ErrorCode, PyXFormError from pyxform.xls2xform import convert from tests.pyxform_test_case import PyxformTestCase @@ -799,6 +799,36 @@ def test_group__no_begin_error__with_another_closed_repeat(self): error__contains=[ErrorCode.SURVEY_001.value.format(row=4, type="group")], ) + def test_group__required__error(self): + """Should raise an error if 'required' is used on a group.""" + md = """ + | survey | + | | type | name | label | required | + | | begin group | g1 | G1 | yes | + | | text | q1 | Q1 | | + | | end group | | | | + """ + self.assertPyxformXform( + md=md, + errored=True, + error__contains=[ErrorCode.SURVEY_011.value.format(row=2)], + ) + + def test_repeat__required__error(self): + """Should raise an error if 'required' is used on a repeat.""" + md = """ + | survey | + | | type | name | label | required | + | | begin repeat | r1 | R1 | yes | + | | text | q1 | Q1 | | + | | end repeat | | | | + """ + self.assertPyxformXform( + md=md, + errored=True, + error__contains=[ErrorCode.SURVEY_011.value.format(row=2)], + ) + def test_empty_group__no_question__error(self): """Should raise an error for an empty group with no questions.""" md = """ @@ -918,3 +948,26 @@ def test_to_json_round_trip(self): expected = convert(xlsform=md, form_name="group")._survey.to_json_dict() observed = create_survey_element_from_dict(expected).to_json_dict() self.assertEqual(expected, observed) + + def test_group_required_bind__error(self): + """Should raise an error if a group is built with a required bind.""" + d = { + "name": "data", + "title": "data", + "type": "survey", + "id_string": "data", + "children": [ + { + "type": "group", + "name": "g1", + "label": "G1", + "bind": {"required": "true()"}, + "children": [ + {"type": "text", "name": "q1", "label": "Q1"}, + ], + } + ], + } + survey = create_survey_element_from_dict(d) + with self.assertRaises(PyXFormError): + survey.xml() From 0e6bedaeb2eed735621dcc4d21c0e99ad7de89db Mon Sep 17 00:00:00 2001 From: Christopher Pruijsen Date: Fri, 9 Oct 2026 13:31:35 +0100 Subject: [PATCH 2/2] fix: allow an explicit 'no' required value on a group or repeat --- pyxform/section.py | 5 +++-- pyxform/xls2json.py | 3 ++- tests/test_group.py | 11 +++++++++++ 3 files changed, 16 insertions(+), 3 deletions(-) diff --git a/pyxform/section.py b/pyxform/section.py index 31fed6fc..3a42f914 100644 --- a/pyxform/section.py +++ b/pyxform/section.py @@ -5,7 +5,7 @@ from typing import TYPE_CHECKING from xml.dom.minidom import Attr -from pyxform import constants +from pyxform import aliases, constants from pyxform.errors import PyXFormError from pyxform.external_instance import ExternalInstance from pyxform.survey_element import SURVEY_ELEMENT_FIELDS, SurveyElement @@ -91,7 +91,8 @@ def add_children(self, children): def validate(self): super().validate() - if isinstance(self.bind, dict) and self.bind.get("required"): + required = self.bind.get("required") if isinstance(self.bind, dict) else None + if required and aliases.yes_no.get(required, True): raise PyXFormError( f"The 'required' value on the {self.type} '{self.name}' is invalid. " "Groups, repeats, and loops cannot be required. " diff --git a/pyxform/xls2json.py b/pyxform/xls2json.py index 91269d11..a180036b 100644 --- a/pyxform/xls2json.py +++ b/pyxform/xls2json.py @@ -796,7 +796,8 @@ def workbook_to_json( ) bind = row.get(constants.BIND, {}) - if isinstance(bind, dict) and bind.get("required"): + required = bind.get("required") if isinstance(bind, dict) else None + if required and aliases.yes_no.get(required, True): raise PyXFormError(ErrorCode.SURVEY_011.value.format(row=row_number)) new_json_dict = row.copy() diff --git a/tests/test_group.py b/tests/test_group.py index 30dce670..20cc78f0 100644 --- a/tests/test_group.py +++ b/tests/test_group.py @@ -829,6 +829,17 @@ def test_repeat__required__error(self): error__contains=[ErrorCode.SURVEY_011.value.format(row=2)], ) + def test_group__required_no__ok(self): + """Should not raise an error if 'required' is explicitly 'no' on a group.""" + md = """ + | survey | + | | type | name | label | required | + | | begin group | g1 | G1 | no | + | | text | q1 | Q1 | | + | | end group | | | | + """ + self.assertPyxformXform(md=md) + def test_empty_group__no_question__error(self): """Should raise an error for an empty group with no questions.""" md = """