diff --git a/pyxform/builder.py b/pyxform/builder.py index 95e52936..3632a76a 100644 --- a/pyxform/builder.py +++ b/pyxform/builder.py @@ -344,7 +344,7 @@ def create_survey( id_string=None, title=None, default_language=None, -): +) -> Survey: """ name_of_main_section -- a string key used to find the main section in the sections dict if it is not supplied in the @@ -384,7 +384,7 @@ def create_survey( return survey -def create_survey_from_path(path, include_directory=False): +def create_survey_from_path(path, include_directory=False) -> Survey: """ include_directory -- Switch to indicate that all the survey forms in the same directory as the specified file should be read @@ -398,6 +398,4 @@ def create_survey_from_path(path, include_directory=False): else: main_section_name, section = file_utils.load_file_to_dict(path) sections = {main_section_name: section} - pkg = {"name_of_main_section": main_section_name, "sections": sections} - - return create_survey(**pkg) + return create_survey(name_of_main_section=main_section_name, sections=sections) diff --git a/pyxform/xform2json.py b/pyxform/xform2json.py index f4a39051..c8497733 100644 --- a/pyxform/xform2json.py +++ b/pyxform/xform2json.py @@ -8,9 +8,12 @@ import logging import re import xml.etree.ElementTree as ETree +from collections import Mapping from operator import itemgetter +from typing import Any, Dict, List from pyxform import builder +from pyxform.errors import PyXFormError from pyxform.utils import NSMAP logger = logging.getLogger(__name__) @@ -223,6 +226,12 @@ def __init__(self, xml_file): self.bindings = copy.deepcopy(self.model["bind"]) self._bind_list = copy.deepcopy(self.model["bind"]) self.title = doc_as_dict["html"]["head"]["title"] + secondary = [] + if isinstance(self.model["instance"], list): + secondary = self.model["instance"][1:] + self.secondary_instances = secondary + self.translations = self._get_translations() + self.choices = self._get_choices() self.new_doc = { "type": "survey", "title": self.title, @@ -230,6 +239,7 @@ def __init__(self, xml_file): "id_string": self.title, "sms_keyword": self.title, "default_language": "default", + "choices": self.choices, } self._set_submission_info() self._set_survey_name() @@ -237,9 +247,6 @@ def __init__(self, xml_file): self.ordered_binding_refs = [] self._set_binding_order() - # set self.translations - self._set_translations() - for key, obj in iter(self.body.items()): if isinstance(obj, dict): self.children.append(self._get_question_from_object(obj, type=key)) @@ -256,10 +263,16 @@ def _set_binding_order(self): self.ordered_binding_refs.append(bind["nodeset"]) def _set_survey_name(self): - obj = self.bindings[0] - name = obj["nodeset"].split("/")[1] + name = self.bindings[0]["nodeset"].split("/")[1] self.new_doc["name"] = name - self.new_doc["id_string"] = self.model["instance"][name]["id"] + instances = self.model["instance"] + if isinstance(instances, Mapping): + id_string = instances[name]["id"] + elif isinstance(instances, list): + id_string = instances[0][name]["id"] + else: + raise PyXFormError(f"Unexpected type for model instances: {type(instances)}") + self.new_doc["id_string"] = id_string def _set_submission_info(self): if "submission" in self.model: @@ -451,6 +464,19 @@ def _get_question_from_object(self, obj, type=None): del question["hint"] if "type" not in question and type: question["type"] = question_type + if "itemset" in obj: + # Secondary instances + nodeset = obj["itemset"]["nodeset"] + choices_name = re.findall(r"^instance\('(.*?)'\)", nodeset)[0] + question["itemset"] = choices_name + question["list_name"] = choices_name + question["choices"] = self.choices[choices_name] + # Choice filters - attempt parsing XPath like "/node/name[./something =cf]" + filter_ref = re.findall(rf"\[ /{self.new_doc['name']}/(.*?) ", nodeset) + filter_exp = re.findall(rf"{filter_ref} (.*?)]$", nodeset) + if 1 == len(filter_ref) and 1 == len(filter_exp): + question["choice_filter"] = f"${{{filter_ref[0]}}}{filter_exp[0]}" + question["query"] = choices_name return question def _get_children_questions(self, obj): @@ -525,16 +551,16 @@ def _get_question_type(self, type): return self.QUESTION_TYPES[type] return type - def _set_translations(self): + def _get_translations(self) -> List[Dict]: if "itext" not in self.model: - self.translations = [] - return + return [] assert "translation" in self.model["itext"] - self.translations = self.model["itext"]["translation"] - if isinstance(self.translations, dict): - self.translations = [self.translations] - assert "text" in self.translations[0] - assert "lang" in self.translations[0] + translations = self.model["itext"]["translation"] + if isinstance(translations, dict): + translations = [translations] + assert "text" in translations[0] + assert "lang" in translations[0] + return translations def _get_label(self, label_obj, key="label"): if isinstance(label_obj, dict): @@ -609,7 +635,7 @@ def _get_text_from_translation(self, ref, key="label"): label[lang] = text break - if key == "media" and label.keys() == ["default"]: + if key == "media" and list(label.keys()) == ["default"]: label = label["default"] return key, label @@ -624,6 +650,24 @@ def _get_constraint_msg(self, constraint_msg): k, constraint_msg = self._get_text_from_translation(ref) return constraint_msg + def _get_choices(self) -> Dict[str, Any]: + """ + Get all form choices, using the model/instance and model/itext. + """ + choices = {} + for instance in self.secondary_instances: + items = [] + for choice in instance["root"]["item"]: + item = copy.deepcopy(choice) + if "itextId" in choice: + key, label = self._get_text_from_translation( + ref=item.pop("itextId"), key="label" + ) + item[key] = label + items.append(item) + choices[instance["id"]] = items + return choices + def _get_name_from_ref(self, ref): """given /xlsform_spec_test/launch, return the string after the last occurance of the character '/' diff --git a/pyxform/xls2json.py b/pyxform/xls2json.py index d27fbdfc..b056db7e 100644 --- a/pyxform/xls2json.py +++ b/pyxform/xls2json.py @@ -1150,7 +1150,12 @@ def workbook_to_json( # Always generate secondary instance for selects. new_json_dict["itemset"] = list_name - json_dict["choices"] = choices + # Only add the choice if it's being used. + if list_name in choices: + # Initialise choices output if none added already. + if constants.CHOICES not in json_dict: + json_dict[constants.CHOICES] = {} + json_dict[constants.CHOICES][list_name] = choices[list_name] if row.get("choice_filter"): # External selects e.g. type = "select_one_external city". @@ -1183,6 +1188,12 @@ def workbook_to_json( # Then this row is the first select in a table list if not isinstance(table_list, str): table_list = list_name + if row.get("choice_filter", None) is not None: + msg = ( + ROW_FORMAT_STRING % row_number + + " Choice filter not supported for table-list appearance." + ) + raise PyXFormError(msg) table_list_header = { constants.TYPE: select_type, constants.NAME: "reserved_name_for_field_list_labels_" @@ -1190,8 +1201,7 @@ def workbook_to_json( # Adding row number for uniqueness # noqa constants.CONTROL: {"appearance": "label"}, constants.CHOICES: choices[list_name], - # Do we care about filtered selects in table lists? - # 'itemset' : list_name, + "itemset": list_name, } parent_children_array.append(table_list_header) diff --git a/tests/example_xls/field-list.xlsx b/tests/example_xls/field-list.xlsx new file mode 100644 index 00000000..977b86a7 Binary files /dev/null and b/tests/example_xls/field-list.xlsx differ diff --git a/tests/test_expected_output/repeat_date_test.xml b/tests/test_expected_output/repeat_date_test.xml index 785d9f5f..d276b3e7 100644 --- a/tests/test_expected_output/repeat_date_test.xml +++ b/tests/test_expected_output/repeat_date_test.xml @@ -22,6 +22,18 @@ + + + + + yes + + + + no + + + @@ -40,25 +52,17 @@ - - - yes - - - - no - + + + - - - yes - - - - no - + + + diff --git a/tests/test_expected_output/xml_escaping.xml b/tests/test_expected_output/xml_escaping.xml index 55236e10..dd6b849d 100644 --- a/tests/test_expected_output/xml_escaping.xml +++ b/tests/test_expected_output/xml_escaping.xml @@ -1,5 +1,5 @@ - + xml_escaping @@ -13,6 +13,18 @@ + + + + + yes + + + + no + + + @@ -22,14 +34,10 @@ - - yes - - - - no - + < + + diff --git a/tests/test_table_list.py b/tests/test_table_list.py index f1a0d930..ab692055 100644 --- a/tests/test_table_list.py +++ b/tests/test_table_list.py @@ -24,10 +24,10 @@ - - - yes - + + + diff --git a/tests/xform2json_test.py b/tests/xform2json_test.py index 8c270522..31f6a670 100644 --- a/tests/xform2json_test.py +++ b/tests/xform2json_test.py @@ -21,6 +21,7 @@ def setUp(self): self.excel_files = [ "gps.xls", # "include.xls", + "choice_filter_test.xlsx", "specify_other.xls", "loop.xls", "text_and_integer.xls", @@ -31,6 +32,8 @@ def setUp(self): "simple_loop.xls", "yes_or_no_question.xls", "xlsform_spec_test.xlsx", + "field-list.xlsx", + "table-list.xls", "group.xls", ] self.surveys = {} @@ -41,9 +44,12 @@ def setUp(self): def test_load_from_dump(self): for filename, survey in iter(self.surveys.items()): - survey.json_dump() - survey_from_dump = create_survey_element_from_xml(survey.to_xml()) - self.assertXFormEqual(survey.to_xml(), survey_from_dump.to_xml()) + with self.subTest(msg=filename): + survey.json_dump() + survey_from_dump = create_survey_element_from_xml(survey.to_xml()) + expected = survey.to_xml() + observed = survey_from_dump.to_xml() + self.assertXFormEqual(expected, observed) def tearDown(self): for filename, survey in self.surveys.items(): diff --git a/tests/xls2xform_tests.py b/tests/xls2xform_tests.py index 504550b7..5d9b8898 100644 --- a/tests/xls2xform_tests.py +++ b/tests/xls2xform_tests.py @@ -244,8 +244,8 @@ def test_get_xml_path_function(self): """Should return an xml path in the same directory as the xlsx file""" xlsx_path = "/home/user/Desktop/xlsform.xlsx" expected = "/home/user/Desktop/xlsform.xml" - assert expected == get_xml_path(xlsx_path) + self.assertEqual(expected, get_xml_path(xlsx_path)) # check that it also handles spaced routes xlsx_path = "/home/user/Desktop/my xlsform.xlsx" expected = "/home/user/Desktop/my xlsform.xml" - assert expected == get_xml_path(xlsx_path) + self.assertEqual(expected, get_xml_path(xlsx_path))