From e8f8fdc58752ff87e7eeba2c671ef5b327e33204 Mon Sep 17 00:00:00 2001 From: lindsay stevens Date: Wed, 26 Jun 2024 04:04:00 +1000 Subject: [PATCH 1/2] add: library API for non-path input, accept markdown and dict input - add: xls2xform.convert for library users to call pyxform without needing to use files (accepts bytes/file handles/strings) - accepts markdown input since this is widely used by pyxform - accepts dict to avoid needing to use internal funcs that may change - chg: avoid writing to files unless validate=True (for ODK Validate) - also avoid assuming any files were written, e.g. missing_ok=True - chg: move xls/x_sheet_to_csv, sheet_to_csv from utils.py to xls2json_backends.py because they are backends for csv input. - chg: move md_to_dict from test directory into xls2json_backends.py - chg: refactor pyxform_test_case.py to use xls2xform.convert only, instead of internal funcs associated with md_to_dict, so that the existing tests check API stability e.g. file types, dict input, etc. --- clean_for_build.py | 24 - pyxform/constants.py | 1 + pyxform/entities/entities_parsing.py | 4 +- pyxform/errors.py | 4 + pyxform/instance.py | 4 +- pyxform/survey.py | 28 +- pyxform/utils.py | 95 ++-- pyxform/xls2json.py | 19 +- pyxform/xls2json_backends.py | 457 +++++++++++++++--- pyxform/xls2xform.py | 145 +++++- .../calculate_without_calculation.xls | Bin .../duplicate_columns.xlsx | Bin .../default_time_demo.xls | Bin tests/example_xls/group.csv | 2 +- tests/example_xls/group.md | 7 + tests/example_xls/group.xls | Bin 19456 -> 6144 bytes tests/example_xls/group.xlsx | Bin 9448 -> 6400 bytes tests/pyxform_test_case.py | 170 ++----- tests/test_area.py | 7 +- tests/test_builder.py | 6 +- tests/test_dump_and_load.py | 5 +- tests/test_dynamic_default.py | 156 +++--- tests/test_external_instances.py | 171 ++++--- tests/test_external_instances_for_selects.py | 2 +- tests/test_fieldlist_labels.py | 68 +-- tests/test_form_name.py | 34 +- tests/test_group.py | 10 +- tests/test_image_app_parameter.py | 15 +- tests/test_language_warnings.py | 79 +-- tests/test_metadata.py | 53 +- tests/test_pyxform_test_case.py | 2 +- tests/test_pyxformtestcase.py | 11 +- tests/test_repeat.py | 142 ++---- tests/test_repeat_template.py | 182 +++---- tests/test_secondary_instance_translations.py | 18 +- tests/test_settings.py | 4 +- tests/test_translations.py | 100 ++-- tests/test_trigger.py | 6 - tests/test_typed_calculates.py | 40 +- tests/test_utils/__init__.py | 0 tests/test_utils/md_table.py | 71 --- tests/test_xform2json.py | 25 +- tests/test_xls2json.py | 33 +- tests/test_xls2json_xls.py | 59 +-- tests/test_xls2xform.py | 182 ++++++- tests/test_xlsform_headers.py | 45 +- tests/utils.py | 4 +- .../xform_test_case/test_attribute_columns.py | 38 -- tests/xform_test_case/test_bugs.py | 155 +----- .../xform_test_case/test_xform_conversion.py | 42 ++ tests/xform_test_case/test_xlsform_spec.py | 74 --- 51 files changed, 1385 insertions(+), 1414 deletions(-) delete mode 100644 clean_for_build.py rename tests/{example_xls => bug_example_xls}/calculate_without_calculation.xls (100%) rename tests/{example_xls => bug_example_xls}/duplicate_columns.xlsx (100%) rename tests/{bug_example_xls => example_xls}/default_time_demo.xls (100%) create mode 100644 tests/example_xls/group.md delete mode 100644 tests/test_utils/__init__.py delete mode 100644 tests/test_utils/md_table.py delete mode 100644 tests/xform_test_case/test_attribute_columns.py create mode 100644 tests/xform_test_case/test_xform_conversion.py delete mode 100644 tests/xform_test_case/test_xlsform_spec.py diff --git a/clean_for_build.py b/clean_for_build.py deleted file mode 100644 index 7cee04975..000000000 --- a/clean_for_build.py +++ /dev/null @@ -1,24 +0,0 @@ -import os -import os.path -import shutil - - -def clean(): - """ - Delete directories which are created during sdist / wheel build process. - - Cross-platform method as an alternative to juggling between: - Linux/mac: rm -rf [dirs] - Windows: rm -Recurse -Force [dirs] - """ - here = os.path.dirname(__file__) - dirs = ["build", "dist", "pyxform.egg-info"] - for d in dirs: - path = os.path.join(here, d) - if os.path.exists(path): - print("Removing:", path) - shutil.rmtree(path) - - -if __name__ == "__main__": - clean() diff --git a/pyxform/constants.py b/pyxform/constants.py index 6bd41cc6a..74adefbe6 100644 --- a/pyxform/constants.py +++ b/pyxform/constants.py @@ -34,6 +34,7 @@ SUBMISSION_URL = "submission_url" AUTO_SEND = "auto_send" AUTO_DELETE = "auto_delete" +DEFAULT_FORM_NAME = "data" DEFAULT_LANGUAGE_KEY = "default_language" DEFAULT_LANGUAGE_VALUE = "default" LABEL = "label" diff --git a/pyxform/entities/entities_parsing.py b/pyxform/entities/entities_parsing.py index 348537972..51f21bc67 100644 --- a/pyxform/entities/entities_parsing.py +++ b/pyxform/entities/entities_parsing.py @@ -72,7 +72,7 @@ def get_validated_dataset_name(entity): if not is_valid_xml_tag(dataset): if isinstance(dataset, bytes): - dataset = dataset.encode("utf-8") + dataset = dataset.decode("utf-8") raise PyXFormError( f"Invalid entity list name: '{dataset}'. Names must begin with a letter, colon, or underscore. Other characters can include numbers or dashes." @@ -117,7 +117,7 @@ def validate_entity_saveto( if not is_valid_xml_tag(save_to): if isinstance(save_to, bytes): - save_to = save_to.encode("utf-8") + save_to = save_to.decode("utf-8") raise PyXFormError( f"{error_start} '{save_to}'. Entity property names {const.XML_IDENTIFIER_ERROR_MESSAGE}" diff --git a/pyxform/errors.py b/pyxform/errors.py index 51aaf3845..89ca6ff96 100644 --- a/pyxform/errors.py +++ b/pyxform/errors.py @@ -9,3 +9,7 @@ class PyXFormError(Exception): class ValidationError(PyXFormError): """Common base class for pyxform validation exceptions.""" + + +class PyXFormReadError(PyXFormError): + """Common base class for pyxform exceptions occuring during reading XLSForm data.""" diff --git a/pyxform/instance.py b/pyxform/instance.py index fb427af26..17b77f9f7 100644 --- a/pyxform/instance.py +++ b/pyxform/instance.py @@ -2,6 +2,8 @@ SurveyInstance class module. """ +import os.path + from pyxform.errors import PyXFormError from pyxform.xform_instance_parser import parse_xform_instance @@ -76,8 +78,6 @@ def answers(self): return self._answers def import_from_xml(self, xml_string_or_filename): - import os.path - if os.path.isfile(xml_string_or_filename): xml_str = open(xml_string_or_filename, encoding="utf-8").read() else: diff --git a/pyxform/survey.py b/pyxform/survey.py index 3a2b8e158..d5337b60a 100644 --- a/pyxform/survey.py +++ b/pyxform/survey.py @@ -10,6 +10,7 @@ from collections.abc import Generator, Iterator from datetime import datetime from functools import lru_cache +from pathlib import Path from pyxform import aliases, constants from pyxform.constants import EXTERNAL_INSTANCE_EXTENSIONS, NSMAP @@ -970,10 +971,10 @@ def date_stamp(self): """Returns a date string with the format of %Y_%m_%d.""" return self._created.strftime("%Y_%m_%d") - def _to_ugly_xml(self): + def _to_ugly_xml(self) -> str: return '' + self.xml().toxml() - def _to_pretty_xml(self): + def _to_pretty_xml(self) -> str: """Get the XForm with human readable formatting.""" return '\n' + self.xml().toprettyxml(indent=" ") @@ -1171,10 +1172,9 @@ def _var_repl_output_function(matchobj): else: return text, False - # pylint: disable=too-many-arguments def print_xform_to_file( self, path=None, validate=True, pretty_print=True, warnings=None, enketo=False - ): + ) -> str: """ Print the xForm to a file and optionally validate it as well by throwing exceptions and adding warnings to the warnings array. @@ -1183,12 +1183,13 @@ def print_xform_to_file( warnings = [] if not path: path = self._print_name + ".xml" + if pretty_print: + xml = self._to_pretty_xml() + else: + xml = self._to_ugly_xml() try: with open(path, mode="w", encoding="utf-8") as file_obj: - if pretty_print: - file_obj.write(self._to_pretty_xml()) - else: - file_obj.write(self._to_ugly_xml()) + file_obj.write(xml) except Exception: if os.path.exists(path): os.unlink(path) @@ -1210,6 +1211,7 @@ def print_xform_to_file( + ". " + "Learn more: http://xlsform.org#multiple-language-support" ) + return xml def to_xml(self, validate=True, pretty_print=True, warnings=None, enketo=False): """ @@ -1227,7 +1229,7 @@ def to_xml(self, validate=True, pretty_print=True, warnings=None, enketo=False): tmp.close() try: # this will throw an exception if the xml is not valid - self.print_xform_to_file( + xml = self.print_xform_to_file( path=tmp.name, validate=validate, pretty_print=pretty_print, @@ -1235,12 +1237,8 @@ def to_xml(self, validate=True, pretty_print=True, warnings=None, enketo=False): enketo=enketo, ) finally: - if os.path.exists(tmp.name): - os.remove(tmp.name) - if pretty_print: - return self._to_pretty_xml() - - return self._to_ugly_xml() + Path(tmp.name).unlink(missing_ok=True) + return xml def instantiate(self): """ diff --git a/pyxform/utils.py b/pyxform/utils.py index a29b2d6cb..5e362e8da 100644 --- a/pyxform/utils.py +++ b/pyxform/utils.py @@ -7,17 +7,16 @@ import json import os import re +from io import StringIO from json.decoder import JSONDecodeError -from typing import NamedTuple +from typing import Any, NamedTuple from xml.dom import Node from xml.dom.minidom import Element, Text, _write_data -import openpyxl -import xlrd from defusedxml.minidom import parseString +from pyxform import constants as const from pyxform.errors import PyXFormError -from pyxform.xls2json_backends import is_empty, xls_value_to_unicode, xlsx_value_to_str SEP = "_" @@ -167,66 +166,32 @@ def flatten(li): yield from subli -def sheet_to_csv(workbook_path, csv_path, sheet_name): - if workbook_path.endswith(".xls"): - return xls_sheet_to_csv(workbook_path, csv_path, sheet_name) - else: - return xlsx_sheet_to_csv(workbook_path, csv_path, sheet_name) - +def external_choices_to_csv( + workbook_dict: dict[str, Any], warnings: list | None = None +) -> str | None: + """ + Convert the 'external_choices' sheet data to CSV. -def xls_sheet_to_csv(workbook_path, csv_path, sheet_name): - wb = xlrd.open_workbook(workbook_path) - try: - sheet = wb.sheet_by_name(sheet_name) - except xlrd.biffh.XLRDError: - return False - if not sheet or sheet.nrows < 2: - return False - with open(csv_path, mode="w", encoding="utf-8", newline="") as f: - writer = csv.writer(f, quoting=csv.QUOTE_ALL) - mask = [v and len(v.strip()) > 0 for v in sheet.row_values(0)] - for row_idx in range(sheet.nrows): - csv_data = [] - try: - for v, m in zip(sheet.row(row_idx), mask, strict=False): - if m: - value = v.value - value_type = v.ctype - data = xls_value_to_unicode(value, value_type, wb.datemode) - # clean the values of leading and trailing whitespaces - data = data.strip() - csv_data.append(data) - except TypeError: - continue - writer.writerow(csv_data) - - return True - - -def xlsx_sheet_to_csv(workbook_path, csv_path, sheet_name): - wb = openpyxl.open(workbook_path, read_only=True, data_only=True) + :param workbook_dict: The result from xls2json.workbook_to_json. + :param warnings: The conversions warnings list. + """ + warnings = coalesce(warnings, []) + if const.EXTERNAL_CHOICES not in workbook_dict: + warnings.append( + f"Could not export itemsets.csv, the '{const.EXTERNAL_CHOICES}' sheet is missing." + ) + return None + + itemsets = StringIO(newline="") + csv_writer = csv.writer(itemsets, quoting=csv.QUOTE_ALL) try: - sheet = wb[sheet_name] - except KeyError: - return False - - with open(csv_path, mode="w", encoding="utf-8", newline="") as f: - writer = csv.writer(f, quoting=csv.QUOTE_ALL) - mask = [not is_empty(cell.value) for cell in sheet[1]] - for row in sheet.rows: - csv_data = [] - try: - for v, m in zip(row, mask, strict=False): - if m: - data = xlsx_value_to_str(v.value) - # clean the values of leading and trailing whitespaces - data = data.strip() - csv_data.append(data) - except TypeError: - continue - writer.writerow(csv_data) - wb.close() - return True + header = workbook_dict["external_choices_header"][0] + except (IndexError, KeyError, TypeError): + header = {k for d in workbook_dict[const.EXTERNAL_CHOICES] for k in d} + csv_writer.writerow(header) + for row in workbook_dict[const.EXTERNAL_CHOICES]: + csv_writer.writerow(row.values()) + return itemsets.getvalue() def has_external_choices(json_struct): @@ -235,7 +200,11 @@ def has_external_choices(json_struct): """ if isinstance(json_struct, dict): for k, v in json_struct.items(): - if k == "type" and isinstance(v, str) and v.startswith("select one external"): + if ( + k == const.TYPE + and isinstance(v, str) + and v.startswith(const.SELECT_ONE_EXTERNAL) + ): return True elif has_external_choices(v): return True diff --git a/pyxform/xls2json.py b/pyxform/xls2json.py index ad25baa63..1bce494d7 100644 --- a/pyxform/xls2json.py +++ b/pyxform/xls2json.py @@ -22,7 +22,7 @@ ) from pyxform.errors import PyXFormError from pyxform.parsing.expression import is_single_token_expression -from pyxform.utils import PYXFORM_REFERENCE_REGEX, default_is_dynamic +from pyxform.utils import PYXFORM_REFERENCE_REGEX, coalesce, default_is_dynamic from pyxform.validators.pyxform import parameters_generic, select_from_file from pyxform.validators.pyxform.android_package_name import validate_android_package_name from pyxform.validators.pyxform.translations_checks import SheetTranslations @@ -395,7 +395,7 @@ def workbook_to_json( workbook_dict, form_name: str | None = None, fallback_form_name: str | None = None, - default_language: str = constants.DEFAULT_LANGUAGE_VALUE, + default_language: str | None = None, warnings: list[str] | None = None, ) -> dict[str, Any]: """ @@ -416,8 +416,7 @@ def workbook_to_json( returns a nested dictionary equivalent to the format specified in the json form spec. """ - if warnings is None: - warnings = [] + warnings = coalesce(warnings, []) is_valid = False # Sheet names should be case-insensitive workbook_dict = {x.lower(): y for x, y in workbook_dict.items()} @@ -441,8 +440,8 @@ def workbook_to_json( ) # Make sure the passed in vars are unicode - form_name = str(form_name) - default_language = str(default_language) + form_name = str(coalesce(form_name, constants.DEFAULT_FORM_NAME)) + default_language = str(coalesce(default_language, constants.DEFAULT_LANGUAGE_VALUE)) # We check for double columns to determine whether to use them # or single colons to delimit grouped headers. @@ -500,7 +499,9 @@ def workbook_to_json( ) # Here we create our json dict root with default settings: - id_string = settings.get(constants.ID_STRING, fallback_form_name) + id_string = settings.get( + constants.ID_STRING, coalesce(fallback_form_name, constants.DEFAULT_FORM_NAME) + ) sms_keyword = settings.get(constants.SMS_KEYWORD, id_string) json_dict = { constants.TYPE: constants.SURVEY, @@ -970,7 +971,7 @@ def workbook_to_json( question_name = str(row[constants.NAME]) if not is_valid_xml_tag(question_name): if isinstance(question_name, bytes): - question_name = question_name.encode("utf-8") + question_name = question_name.decode("utf-8") raise PyXFormError( f"{ROW_FORMAT_STRING % row_number} Invalid question name '{question_name}'. Names {XML_IDENTIFIER_ERROR_MESSAGE}" @@ -1591,7 +1592,7 @@ def get_filename(path): def parse_file_to_json( path: str, - default_name: str = "data", + default_name: str = constants.DEFAULT_FORM_NAME, default_language: str = constants.DEFAULT_LANGUAGE_VALUE, warnings: list[str] | None = None, file_object: IO | None = None, diff --git a/pyxform/xls2json_backends.py b/pyxform/xls2json_backends.py index 9a5b10d40..6a81a8117 100644 --- a/pyxform/xls2json_backends.py +++ b/pyxform/xls2json_backends.py @@ -4,16 +4,19 @@ import csv import datetime -import os import re from collections import OrderedDict from collections.abc import Callable, Iterator -from contextlib import closing +from dataclasses import dataclass +from enum import Enum from functools import reduce -from io import StringIO +from io import BytesIO, IOBase, StringIO +from os import PathLike +from pathlib import Path from typing import Any from zipfile import BadZipFile +import openpyxl import xlrd from openpyxl.cell import Cell as pyxlCell from openpyxl.reader.excel import ExcelReader @@ -25,7 +28,7 @@ from xlrd.xldate import XLDateAmbiguous from pyxform import constants -from pyxform.errors import PyXFormError +from pyxform.errors import PyXFormError, PyXFormReadError aCell = xlrdCell | pyxlCell XL_DATE_AMBIGOUS_MSG = ( @@ -186,18 +189,14 @@ def process_workbook(wb: xlrdBook): return result_book try: - if isinstance(path_or_file, str | bytes | os.PathLike): - file = open(path_or_file, mode="rb") - else: - file = path_or_file - with closing(file) as wb_file: - workbook = xlrd.open_workbook(file_contents=wb_file.read()) - try: - return process_workbook(wb=workbook) - finally: - workbook.release_resources() - except xlrd.XLRDError as read_err: - raise PyXFormError(f"Error reading .xls file: {read_err}") from read_err + wb_file = get_definition_data(definition=path_or_file) + workbook = xlrd.open_workbook(file_contents=wb_file.data.getvalue()) + try: + return process_workbook(wb=workbook) + finally: + workbook.release_resources() + except (AttributeError, TypeError, xlrd.XLRDError) as read_err: + raise PyXFormReadError(f"Error reading .xls file: {read_err}") from read_err def xls_value_to_unicode(value, value_type, datemode) -> str: @@ -281,20 +280,16 @@ def process_workbook(wb: pyxlWorkbook): return result_book try: - if isinstance(path_or_file, str | bytes | os.PathLike): - file = open(path_or_file, mode="rb") - else: - file = path_or_file - with closing(file) as wb_file: - reader = ExcelReader(wb_file, read_only=True, data_only=True) - reader.read() - try: - return process_workbook(wb=reader.wb) - finally: - reader.wb.close() - reader.archive.close() - except (OSError, BadZipFile, KeyError) as read_err: - raise PyXFormError(f"Error reading .xlsx file: {read_err}") from read_err + wb_file = get_definition_data(definition=path_or_file) + reader = ExcelReader(wb_file.data, read_only=True, data_only=True) + reader.read() + try: + return process_workbook(wb=reader.wb) + finally: + reader.wb.close() + reader.archive.close() + except (BadZipFile, KeyError, OSError, TypeError) as read_err: + raise PyXFormReadError(f"Error reading .xlsx file: {read_err}") from read_err def xlsx_value_to_str(value) -> str: @@ -360,13 +355,6 @@ def replace_prefix(d, prefix): def csv_to_dict(path_or_file): - if isinstance(path_or_file, str): - csv_data = open(path_or_file, encoding="utf-8", newline="") - else: - csv_data = path_or_file - - _dict = OrderedDict() - def first_column_as_sheet_name(row): if len(row) == 0: return None, None @@ -383,31 +371,41 @@ def first_column_as_sheet_name(row): content = None return s_or_c, content - reader = csv.reader(csv_data) - sheet_name = None - current_headers = None - for row in reader: - survey_or_choices, content = first_column_as_sheet_name(row) - if survey_or_choices is not None: - sheet_name = survey_or_choices - if sheet_name not in _dict: - _dict[str(sheet_name)] = [] - current_headers = None - if content is not None: - if current_headers is None: - current_headers = content - _dict[f"{sheet_name}_header"] = _list_to_dict_list(current_headers) - else: - _d = OrderedDict() - for key, val in zip(current_headers, content, strict=False): - if val != "": - # Slight modification so values are striped - # this is because csvs often spaces following commas - # (but the csv reader might already handle that.) - _d[str(key)] = str(val.strip()) - _dict[sheet_name].append(_d) - csv_data.close() - return _dict + def process_csv_data(rd): + _dict = OrderedDict() + sheet_name = None + current_headers = None + for row in rd: + survey_or_choices, content = first_column_as_sheet_name(row) + if survey_or_choices is not None: + sheet_name = survey_or_choices + if sheet_name not in _dict: + _dict[str(sheet_name)] = [] + current_headers = None + if content is not None: + if current_headers is None: + current_headers = content + _dict[f"{sheet_name}_header"] = _list_to_dict_list(current_headers) + else: + _d = OrderedDict() + for key, val in zip(current_headers, content, strict=False): + if val != "": + # Slight modification so values are striped + # this is because csvs often spaces following commas + # (but the csv reader might already handle that.) + _d[str(key)] = str(val.strip()) + _dict[sheet_name].append(_d) + return _dict + + try: + csv_data = get_definition_data(definition=path_or_file) + csv_str = csv_data.data.getvalue().decode("utf-8") + if not is_csv(data=csv_str): + raise PyXFormError("The input data does not appear to be a valid XLSForm.") # noqa: TRY301 + reader = csv.reader(StringIO(initial_value=csv_str, newline="")) + return process_csv_data(rd=reader) + except (AttributeError, PyXFormError) as read_err: + raise PyXFormReadError(f"Error reading .csv file: {read_err}") from read_err """ @@ -460,3 +458,338 @@ def convert_file_to_csv_string(path): for out_row in out_rows: writer.writerow([None, *out_row]) return foo.getvalue() + + +def sheet_to_csv(workbook_path, csv_path, sheet_name): + if workbook_path.endswith(".xls"): + return xls_sheet_to_csv(workbook_path, csv_path, sheet_name) + else: + return xlsx_sheet_to_csv(workbook_path, csv_path, sheet_name) + + +def xls_sheet_to_csv(workbook_path, csv_path, sheet_name): + wb = xlrd.open_workbook(workbook_path) + try: + sheet = wb.sheet_by_name(sheet_name) + except xlrd.biffh.XLRDError: + return False + if not sheet or sheet.nrows < 2: + return False + with open(csv_path, mode="w", encoding="utf-8", newline="") as f: + writer = csv.writer(f, quoting=csv.QUOTE_ALL) + mask = [v and len(v.strip()) > 0 for v in sheet.row_values(0)] + for row_idx in range(sheet.nrows): + csv_data = [] + try: + for v, m in zip(sheet.row(row_idx), mask, strict=False): + if m: + value = v.value + value_type = v.ctype + data = xls_value_to_unicode(value, value_type, wb.datemode) + # clean the values of leading and trailing whitespaces + data = data.strip() + csv_data.append(data) + except TypeError: + continue + writer.writerow(csv_data) + + return True + + +def xlsx_sheet_to_csv(workbook_path, csv_path, sheet_name): + wb = openpyxl.open(workbook_path, read_only=True, data_only=True) + try: + sheet = wb[sheet_name] + except KeyError: + return False + + with open(csv_path, mode="w", encoding="utf-8", newline="") as f: + writer = csv.writer(f, quoting=csv.QUOTE_ALL) + mask = [not is_empty(cell.value) for cell in sheet[1]] + for row in sheet.rows: + csv_data = [] + try: + for v, m in zip(row, mask, strict=False): + if m: + data = xlsx_value_to_str(v.value) + # clean the values of leading and trailing whitespaces + data = data.strip() + csv_data.append(data) + except TypeError: + continue + writer.writerow(csv_data) + wb.close() + return True + + +MD_COMMENT = re.compile(r"^\s*#") +MD_COMMENT_INLINE = re.compile(r"^(.*)(#[^|]+)$") +MD_CELL = re.compile(r"\s*\|(.*)\|\s*") +MD_SEPARATOR = re.compile(r"^[\|-]+$") +MD_PIPE_OR_ESCAPE = re.compile(r"(? list[tuple[str, list[list[str]]]]: + ss_arr = [] + for item in mdstr.split("\n"): + arr = _md_extract_array(item) + if arr: + ss_arr.append(arr) + sheet_name = False + sheet_arr = False + sheets = [] + for row in ss_arr: + if row[0] is not None: + if sheet_arr: + sheets.append((sheet_name, sheet_arr)) + sheet_arr = [] + sheet_name = row[0] + excluding_first_col = row[1:] + if sheet_name and not _md_is_null_row(excluding_first_col): + sheet_arr.append(excluding_first_col) + sheets.append((sheet_name, sheet_arr)) + + return sheets + + +def md_to_dict(md: str | BytesIO): + def _row_to_dict(row, headers): + out_dict = {} + for i in range(len(row)): + col = row[i] + if col not in [None, ""]: + out_dict[headers[i]] = col + return out_dict + + def list_to_dicts(arr): + return [_row_to_dict(r, arr[0]) for r in arr[1:]] + + def process_md_data(md_: str): + _md = [] + for line in md_.split("\n"): + if re.match(MD_COMMENT, line): + # ignore lines which start with pound sign + continue + elif re.match(MD_COMMENT_INLINE, line): + # keep everything before the # outside of the last occurrence of | + _md.append(re.match(MD_COMMENT_INLINE, line).groups()[0].strip()) + else: + _md.append(line.strip()) + md_ = "\n".join(_md) + sheets = {} + for sheet, contents in _md_table_to_ss_structure(md_): + sheets[sheet] = list_to_dicts(contents) + return sheets + + try: + md_data = get_definition_data(definition=md) + md_str = md_data.data.getvalue().decode("utf-8") + if not is_markdown_table(data=md_str): + raise PyXFormError("The input data does not appear to be a valid XLSForm.") # noqa: TRY301 + return process_md_data(md_=md_str) + except (AttributeError, PyXFormError, TypeError) as read_err: + raise PyXFormReadError(f"Error reading .md file: {read_err}") from read_err + + +def md_table_to_workbook(mdstr: str) -> pyxlWorkbook: + """ + Convert Markdown table string to an openpyxl.Workbook. Call wb.save() to persist. + """ + md_data = _md_table_to_ss_structure(mdstr=mdstr) + wb = pyxlWorkbook(write_only=True) + for key, rows in md_data: + sheet = wb.create_sheet(title=key) + for r in rows: + sheet.append(r) + return wb + + +def count_characters_limit(data: str, find: str, limit: int) -> int: + count = 0 + for c in data: + if c == find: + count += 1 + if count == limit: + break + return count + + +def is_markdown_table(data: str) -> bool: + """ + Does the string look like a markdown table? Checks the first 5KB. + + A minimal form with one question requires 10 pipe characters: + + | survey | + | | type | name | + | | integer | a | + + Minimum to parse at all is 5 pipes. + + | survey | + | | foo | + + :param data: The data to check. + """ + return 5 <= count_characters_limit(data[:5000], "|", 5) + + +def is_csv(data: str) -> bool: + """ + Does the string look like a CSV? Checks the first 5KB. + + A minimal form with one question requires 4 comma characters: + + "survey" + ,"type","name" + ,"integer","a" + + :param data: The data to check. + """ + return 4 <= count_characters_limit(data[:5000], ",", 4) + + +class SupportedFileTypes(Enum): + xlsx = ".xlsx" + xlsm = ".xlsm" + xls = ".xls" + md = ".md" + csv = ".csv" + + @staticmethod + def get_processors(): + return { + SupportedFileTypes.xlsx: xlsx_to_dict, + SupportedFileTypes.xls: xls_to_dict, + SupportedFileTypes.md: md_to_dict, + SupportedFileTypes.csv: csv_to_dict, + } + + +@dataclass +class Definition: + data: BytesIO + file_type: SupportedFileTypes | None + file_path_stem: str | None + + +def definition_to_dict( + definition: str | PathLike[str] | bytes | BytesIO | IOBase | Definition, + file_type: str | None = None, +) -> dict: + """ + Convert raw definition data to a dict ready for conversion to a XForm. + + :param definition: XLSForm definition data. + :param file_type: If provided, attempt parsing the data only as this type. Otherwise, + parsing of supported data types will be attempted until one of them succeeds. + :return: + """ + supported = f"Must be one of: {', '.join(t.value for t in SupportedFileTypes)}" + processors = SupportedFileTypes.get_processors() + if file_type is not None: + try: + ft = SupportedFileTypes(file_type) + except ValueError as err: + raise PyXFormError( + f"Argument 'file_type' is not a supported type. {supported}" + ) from err + else: + processors = {ft: processors[ft]} + + for func in processors.values(): + try: + return func(definition) + except PyXFormReadError: # noqa: PERF203 + continue + + raise PyXFormError( + f"Argument 'definition' was not recognized as a supported type. {supported}" + ) + + +def get_definition_data( + definition: str | PathLike[str] | bytes | BytesIO | IOBase | Definition, +) -> Definition: + """ + Get the form definition data from a path or bytes. + + :param definition: The path to the file to upload (string or PathLike), or the + form definition in memory (string or bytes). + """ + if isinstance(definition, Definition): + return definition + definition_data = None + file_type = None + file_path_stem = None + + # Read in data from paths, or failing that try to process the string. + if isinstance(definition, str | PathLike): + file_read = False + try: + file_path = Path(definition) + + except TypeError as err: + raise PyXFormError( + "Parameter 'definition' does not appear to be a valid file path." + ) from err + try: + file_exists = file_path.is_file() + except (FileNotFoundError, OSError): + pass + else: + if file_exists: + file_path_stem = file_path.stem + try: + file_type = SupportedFileTypes(file_path.suffix) + except ValueError: + # The suffix was not a useful hint but we can try to parse anyway. + pass + definition = BytesIO(file_path.read_bytes()) + file_read = True + if not file_read and isinstance(definition, str): + definition = definition.encode("utf-8") + + # io.IOBase seems about at close as possible to the hint typing.BinaryIO. + if isinstance(definition, bytes | BytesIO | IOBase): + # Normalise to BytesIO. + if isinstance(definition, bytes): + definition_data = BytesIO(definition) + elif isinstance(definition, BytesIO): # BytesIO is a subtype of IOBase + definition_data = definition + else: + definition_data = BytesIO(definition.read()) + + return Definition( + data=definition_data, + file_type=file_type, + file_path_stem=file_path_stem, + ) diff --git a/pyxform/xls2xform.py b/pyxform/xls2xform.py index d9644b604..f9af81a3e 100644 --- a/pyxform/xls2xform.py +++ b/pyxform/xls2xform.py @@ -6,12 +6,23 @@ import argparse import json import logging -import os +from dataclasses import dataclass +from io import BytesIO +from os import PathLike from os.path import splitext +from pathlib import Path +from typing import TYPE_CHECKING, BinaryIO from pyxform import builder, xls2json -from pyxform.utils import has_external_choices, sheet_to_csv +from pyxform.utils import coalesce, external_choices_to_csv, has_external_choices from pyxform.validators.odk_validate import ODKValidateError +from pyxform.xls2json_backends import ( + definition_to_dict, + get_definition_data, +) + +if TYPE_CHECKING: + from pyxform.survey import Survey logger = logging.getLogger(__name__) logger.addHandler(logging.StreamHandler()) @@ -28,35 +39,117 @@ def get_xml_path(path): return splitext(path)[0] + ".xml" -def xls2xform_convert( - xlsform_path, xform_path, validate=True, pretty_print=True, enketo=False -): - warnings = [] +@dataclass +class ConvertResult: + """ + Result data from the XLSForm to XForm conversion. + + :param xform: The result XForm + :param warnings: Warnings raised during conversion. + :param itemsets: If the XLSForm defined external itemsets, a CSV version of them. + :param _pyxform: Internal representation of the XForm, may change without notice. + :param _survey: Internal representation of the XForm, may change without notice. + """ + + xform: str + warnings: list[str] + itemsets: str | None + _pyxform: dict + _survey: "Survey" + - json_survey = xls2json.parse_file_to_json(xlsform_path, warnings=warnings) - survey = builder.create_survey_element_from_dict(json_survey) - # Setting validate to false will cause the form not to be processed by - # ODK Validate. - # This may be desirable since ODK Validate requires launching a subprocess - # that runs some java code. - survey.print_xform_to_file( - xform_path, +def convert( + xlsform: str | PathLike[str] | bytes | BytesIO | BinaryIO | dict, + warnings: list[str] | None = None, + validate: bool = False, + pretty_print: bool = False, + enketo: bool = False, + form_name: str | None = None, + default_language: str | None = None, + file_type: str | None = None, +) -> ConvertResult: + """ + Run the XLSForm to XForm conversion. + + This function avoids result file IO so it is more suited to library usage of pyxform. + + If validate=True or Enketo=True, then the XForm will be written to a temporary file + to be checked by ODK Validate and/or Enketo Validate. These validators are run as + external processes. A recent version of ODK Validate is distributed with pyxform, + while Enketo Validate is not. A script to download or update these validators is + provided in `validators/updater.py`. + + :param xlsform: The input XLSForm file path or content. If the content is bytes or + supports read (a class that has read() -> bytes) it's assumed to relate to the file + bytes content, not a path. + :param warnings: The conversions warnings list. + :param validate: If True, check the XForm with ODK Validate + :param pretty_print: If True, format the XForm with spaces, line breaks, etc. + :param enketo: If True, check the XForm with Enketo Validate. + :param form_name: Used for the main instance root node name. + :param default_language: The name of the default language for the form. + :param file_type: If provided, attempt parsing the data only as this type. Otherwise, + parsing of supported data types will be attempted until one of them succeeds. If the + xlsform is provided as a dict, then it is used directly and this argument is ignored. + """ + warnings = coalesce(warnings, []) + if isinstance(xlsform, dict): + workbook_dict = xlsform + fallback_form_name = None + else: + definition = get_definition_data(definition=xlsform) + if file_type is None: + file_type = definition.file_type + workbook_dict = definition_to_dict(definition=definition, file_type=file_type) + fallback_form_name = definition.file_path_stem + pyxform_data = xls2json.workbook_to_json( + workbook_dict=workbook_dict, + form_name=form_name, + fallback_form_name=fallback_form_name, + default_language=default_language, + warnings=warnings, + ) + survey = builder.create_survey_element_from_dict(pyxform_data) + xform = survey.to_xml( validate=validate, pretty_print=pretty_print, warnings=warnings, enketo=enketo, ) - output_dir = os.path.split(xform_path)[0] - if has_external_choices(json_survey): - itemsets_csv = os.path.join(output_dir, "itemsets.csv") - choices_exported = sheet_to_csv(xlsform_path, itemsets_csv, "external_choices") - if not choices_exported: - warnings.append( - "Could not export itemsets.csv, perhaps the " - "external choices sheet is missing." - ) - else: - logger.info("External choices csv is located at: %s", itemsets_csv) + itemsets = None + if has_external_choices(json_struct=pyxform_data): + itemsets = external_choices_to_csv(workbook_dict=workbook_dict) + return ConvertResult( + xform=xform, + warnings=warnings, + itemsets=itemsets, + _pyxform=pyxform_data, + _survey=survey, + ) + + +def xls2xform_convert( + xlsform_path: str | PathLike[str], + xform_path: str | PathLike[str], + validate: bool = True, + pretty_print: bool = True, + enketo: bool = False, +) -> list[str]: + warnings = [] + result = convert( + xlsform=xlsform_path, + validate=validate, + pretty_print=pretty_print, + enketo=enketo, + warnings=warnings, + ) + with open(xform_path, mode="w", encoding="utf-8") as f: + f.write(result.xform) + if result.itemsets is not None: + itemsets_path = Path(xform_path).parent / "itemsets.csv" + with open(itemsets_path, mode="w", encoding="utf-8", newline="") as f: + f.write(result.itemsets) + logger.info("External choices csv is located at: %s", itemsets_path) return warnings @@ -179,7 +272,7 @@ def main_cli(): logger.exception("EnvironmentError during conversion") except ODKValidateError: # Remove output file if there is an error - os.remove(args.output_path) + Path(args.output_path).unlink(missing_ok=True) logger.exception("ODKValidateError during conversion.") else: if len(warnings) > 0: diff --git a/tests/example_xls/calculate_without_calculation.xls b/tests/bug_example_xls/calculate_without_calculation.xls similarity index 100% rename from tests/example_xls/calculate_without_calculation.xls rename to tests/bug_example_xls/calculate_without_calculation.xls diff --git a/tests/example_xls/duplicate_columns.xlsx b/tests/bug_example_xls/duplicate_columns.xlsx similarity index 100% rename from tests/example_xls/duplicate_columns.xlsx rename to tests/bug_example_xls/duplicate_columns.xlsx diff --git a/tests/bug_example_xls/default_time_demo.xls b/tests/example_xls/default_time_demo.xls similarity index 100% rename from tests/bug_example_xls/default_time_demo.xls rename to tests/example_xls/default_time_demo.xls diff --git a/tests/example_xls/group.csv b/tests/example_xls/group.csv index 710d4d280..b8d4c4a5e 100644 --- a/tests/example_xls/group.csv +++ b/tests/example_xls/group.csv @@ -1,5 +1,5 @@ "survey",,, -,"type","name","label:English" +,"type","name","label:English (en)" ,"text","family_name","What's your family name?" ,"begin group","father","Father" ,"phone number","phone_number","What's your father's phone number?" diff --git a/tests/example_xls/group.md b/tests/example_xls/group.md new file mode 100644 index 000000000..d492fc928 --- /dev/null +++ b/tests/example_xls/group.md @@ -0,0 +1,7 @@ +| survey | +| | type | name | label:English (en) | +| | text | family_name | What's your family name? | +| | begin group | father | Father | +| | phone number | phone_number | What's your father's phone number? | +| | integer | age | How old is your father? | +| | end group | | | diff --git a/tests/example_xls/group.xls b/tests/example_xls/group.xls index b4958e24dc6d8481d8b4bbf09eb1040294a4f052..7b89251ba14dec6134b359b75db8d077d71fd891 100644 GIT binary patch literal 6144 zcmeHLU2IfE6h8O1TW+DW+d>h+)@v2o(rP3=h#K7GS1=+3Yaj*#+xB+5vfXXATcwG_ zMdVEq4G+GM7!qFm*ZAOrM%yQS&}c9`$W!sf7>z6jVo0Ft_nojesV=62MYGGhi9u zHo$Vg?SK`4m4G_{Er3?Qod6eb7oZJ5o9^*j;_bjYe0q)FPOowvn=230I@raUkbJuM zYo>h7{pS%8A68FjT>J>$J{e|gY>FlO+<&WVR2yOzV{;rqx*nJ%C&8_k50v|v*6TI; zAQTmM9U8t!E?H5v{kYa`TGykt{XoE_4rL#4nU;mxFx^HGtAui-Kwr8&kW6@ z&G=xf1>pf%lxJrD0`EXaj##(}oN=5iaiY?Ckv_y-;NVVhkn5d4a2;Gln37Mxq}j1y z4^1jIW>N;f=^* z^BOhjHvG4ATL<^|V_TS@8g(neaQM}?E}q%ZyDPF^`8j=z>pZdwfniBOj-Y3vdX0|g z()wkUEndS%mG_F)t1n8@bB9XpJ_jQA<`MZPe~g-_4=XE!q1j&tJ_*$ zS95gYz+|M@TJkh{967AZ#udJbj{;Geqk3AVX&aI~PfpWtuhr3LXtDXAFjtRxh&VPw zh`9N#j9Lvs9>cSg$I!*6D!Zg8huN>=VRx?M@w`}pvDfseV;(UghO+Ra;pB$0K~W3~I)U2L2 z6eamMGZZE1RZ_H~0Y8ynWfxjIzYREIEKjZKI826>>U@XtYx8CoKm>{{3w0a~ z;LMNOP4YPxBT-g<*`QW43Yc74IJ@albwJ5N9MdxvrUGWikx1e+$uBq$eC3d$3(6O4 zU!e-!lngtZ4mnh7*~&NPjznK3J(3>Cx?2wQc}e%72k-4+CQ5p`9~%<`1LoOa#>~3L zdW_Opk2C|Ous$BJ(n3E?9((imD?10n@4W6vSNq35aC=?^Fp$_!!}&gs0_dS901S`4 z0A`|o07GIJKn*`%GjDI=YXMO}?(t{2i;A;liE3>s;?eh#3tGQCSKPWlFW>Fu?WYd= z>^Emm-nTM*;tkl}edYbl;Dt(fy!epwbv;zTkmFd|Iujq=^JR`aFqhx|x_>2Su$-Ap z1>^pa-h+Q1GICIQ^?!(i<-~8uYeKXzUXYD62i#CN(D}BXXe`+T%|kdNJ7L3f0g1{` z>gRfxe^@FRbvJ~z`+^qqCdI2{SXmifE=IGd%LONXzYNu<*CuZHZq{*)ov3=Ms1gQx#M&CZ^6|6u){(4F=5&qXs^ M{|Enn`LFf=37e8&7XSbN literal 19456 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 V`2qWW;Jq4{tJ?1C}jWu diff --git a/tests/example_xls/group.xlsx b/tests/example_xls/group.xlsx index 064e6e94f150b2c4765450de0f8fad820be19dfc..acfd2c17099cd3e2aee259b49f7b6ddfeded8a9f 100644 GIT binary patch literal 6400 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 literal 9448 zcmeHNgFFNi5Ri*;ks29Ao2LU(UN`or@k)IYS12d5BqS2?|DO4EKnr)d zzZs)cuUKeszH3EUYYA6yxMT|FI~0G~+KR=?@Z>wYJYpoB*8-87VWA4Gjyj8pTC>x2 zGN|2|xrt}S*c5(=Gc@)i*5cSQa%;N)l@c#mZOPlyQoa6I8@3Mbm)>tWm}4SORjyn3 z2`!D-^|QnRXagrd15|7x>xe42?ZNl6oN|z#8aVG&s~G*!g15l)~HoJrrdPQTpw8_x$3n@a0gi{0b`jvE-OfD4mz7ye5v#vw52_}BGXj26tb!|$#Qy1=V@eS+j%0D-hqGT&90m= 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 dict[str, str]: - """ - Fill in any blank inputs with default values. - """ - return { - "name": coalesce(name, "test_name"), - "title": coalesce(title, "test_title"), - "id_string": coalesce(id_string, "test_id"), - } - - -class PyxformTestCase(PyxformMarkdown, TestCase): +def _run_odk_validate(xml): + # On Windows, NamedTemporaryFile must be opened exclusively. + # So it must be explicitly created, opened, closed, and removed + tmp = tempfile.NamedTemporaryFile(suffix=".xml", delete=False) + tmp.close() + try: + with open(tmp.name, mode="w", encoding="utf-8") as fp: + fp.write(xml) + fp.close() + check_xform(tmp.name) + finally: + # Clean up the temporary file + tmp_path = Path(tmp.name) + tmp_path.unlink(missing_ok=True) + if tmp_path.is_file(): + raise PyXFormError(f"Temporary file still exists: {tmp.name}") + + +class PyxformTestCase(TestCase): maxDiff = None def assertPyxformXform( @@ -194,12 +92,10 @@ def assertPyxformXform( errored: bool = False, # Optional extras name: str | None = None, - id_string: str | None = None, - title: str | None = None, warnings: list[str] | None = None, run_odk_validate: bool = False, debug: bool = False, - ): + ) -> ConvertResult | None: """ One survey input: :param md: a markdown formatted xlsform (easy to read in code). Escape a literal @@ -248,8 +144,6 @@ def assertPyxformXform( Optional extra parameters: :param name: a valid xml tag, for the root element in the XForm main instance. - :param id_string: an identifier, for the XForm main instance @id attribute. - :param title: a name, for the XForm header title. :param warnings: a list to use for storing warnings for inspection. :param run_odk_validate: If True, run ODK Validate on the XForm output. :param debug: If True, log details of the test to stdout. Details include the @@ -261,20 +155,16 @@ def assertPyxformXform( odk_validate_error__contains = coalesce(odk_validate_error__contains, []) survey_valid = True + result = None try: - if md is not None: - survey = self.md_to_pyxform_survey( - md_raw=md, + if survey is None: + result = convert( + xlsform=coalesce(md, ss_structure), + form_name=coalesce(name, "test_name"), warnings=warnings, - **self._autoname_inputs(name=name, title=title, id_string=id_string), - ) - elif ss_structure is not None: - survey = self._ss_structure_to_pyxform_survey( - ss_structure=ss_structure, - warnings=warnings, - **self._autoname_inputs(name=name, title=title, id_string=id_string), ) + survey = result._survey xml = survey._to_pretty_xml() root = etree.fromstring(xml.encode("utf-8")) @@ -310,7 +200,7 @@ def _pull_xml_node_from_root(element_selector): if debug: logger.debug(xml) if run_odk_validate: - self._run_odk_validate(xml=xml) + _run_odk_validate(xml=xml) if odk_validate_error__contains: raise PyxformTestError("ODKValidateError was not raised") @@ -423,6 +313,8 @@ def get_xpath_matcher_context(): raise PyxformTestError("warnings_count must be an integer.") self.assertEqual(warnings_count, len(warnings)) + return result + @staticmethod def _assert_contains(content, text, msg_prefix): if msg_prefix: diff --git a/tests/test_area.py b/tests/test_area.py index e09bb3e3e..58ebf58fb 100644 --- a/tests/test_area.py +++ b/tests/test_area.py @@ -17,7 +17,6 @@ def test_area(self): "38.25146813817506 21.758421137528785" ) self.assertPyxformXform( - name="area", md=f""" | survey | | | | | | | | type | name | label | calculation | default | @@ -25,8 +24,8 @@ def test_area(self): | | calculate | result | | enclosed-area(${{geoshape1}}) | | """, xml__xpath_match=[ - "/h:html/h:head/x:model/x:bind[@calculate='enclosed-area( /area/geoshape1 )' " - + " and @nodeset='/area/result' and @type='string']", - "/h:html/h:head/x:model/x:instance/x:area[x:geoshape1]", + "/h:html/h:head/x:model/x:bind[@calculate='enclosed-area( /test_name/geoshape1 )' " + + " and @nodeset='/test_name/result' and @type='string']", + "/h:html/h:head/x:model/x:instance/x:test_name[x:geoshape1]", ], ) diff --git a/tests/test_builder.py b/tests/test_builder.py index dc20f7c1c..83e10372a 100644 --- a/tests/test_builder.py +++ b/tests/test_builder.py @@ -4,6 +4,7 @@ import os import re +from pathlib import Path from unittest import TestCase import defusedxml.ElementTree as ETree @@ -59,8 +60,7 @@ def test_create_from_file_object(): def tearDown(self): fixture_path = utils.path_to_text_fixture("how_old_are_you.json") - if os.path.exists(fixture_path): - os.remove(fixture_path) + Path(fixture_path).unlink(missing_ok=True) def test_create_table_from_dict(self): d = { @@ -547,7 +547,7 @@ def test_style_column(self): def test_style_not_added_to_body_if_not_present(self): survey = utils.create_survey_from_fixture("widgets", filetype=FIXTURE_FILETYPE) - xml = survey.to_xml() + xml = survey.to_xml(pretty_print=False) # find the body tag root_elm = ETree.fromstring(xml.encode("utf-8")) body_elms = [ diff --git a/tests/test_dump_and_load.py b/tests/test_dump_and_load.py index f114d45d5..45702e513 100644 --- a/tests/test_dump_and_load.py +++ b/tests/test_dump_and_load.py @@ -3,6 +3,7 @@ """ import os +from pathlib import Path from unittest import TestCase from pyxform.builder import create_survey_from_path @@ -41,5 +42,5 @@ def test_load_from_dump(self): def tearDown(self): for survey in self.surveys.values(): - path = survey.name + ".json" - os.remove(path) + path = Path(survey.name + ".json") + path.unlink(missing_ok=True) diff --git a/tests/test_dynamic_default.py b/tests/test_dynamic_default.py index 3cf680579..595dd31e5 100644 --- a/tests/test_dynamic_default.py +++ b/tests/test_dynamic_default.py @@ -80,7 +80,7 @@ def model(q_num: int, case: Case): value_cmp = f"""and @value="{q_default_final}" """ return rf""" /h:html/h:head/x:model - /x:instance/x:test_name[@id="test_id"]/x:q{q_num}[ + /x:instance/x:test_name[@id="data"]/x:q{q_num}[ not(text()) and ancestor::x:model/x:bind[ @nodeset='/test_name/q{q_num}' @@ -102,7 +102,7 @@ def model(q_num: int, case: Case): q_default_cmp = f"""and text()='{q_default_final}' """ return rf""" /h:html/h:head/x:model - /x:instance/x:test_name[@id="test_id"]/x:q{q_num}[ + /x:instance/x:test_name[@id="data"]/x:q{q_num}[ ancestor::x:model/x:bind[ @nodeset='/test_name/q{q_num}' and @type='{q_bind}' @@ -169,7 +169,7 @@ def test_static_default_in_repeat(self): # Repeat template and first row. """ /h:html/h:head/x:model/x:instance/x:test_name[ - @id="test_id" + @id="data" and ./x:r1[@jr:template=''] and ./x:r1[not(@jr:template)] ] @@ -178,13 +178,13 @@ def test_static_default_in_repeat(self): """ /h:html/h:head/x:model[ ./x:bind[@nodeset='/test_name/r1/q1' and @type='int'] - ]/x:instance/x:test_name[@id="test_id"]/x:r1[@jr:template='']/x:q1[text()='12'] + ]/x:instance/x:test_name[@id="data"]/x:r1[@jr:template='']/x:q1[text()='12'] """, # q1 static default value in repeat row. """ /h:html/h:head/x:model[ ./x:bind[@nodeset='/test_name/r1/q1' and @type='int'] - ]/x:instance/x:test_name[@id="test_id"]/x:r1[not(@jr:template)]/x:q1[text()='12'] + ]/x:instance/x:test_name[@id="data"]/x:r1[not(@jr:template)]/x:q1[text()='12'] """, ], ) @@ -200,14 +200,12 @@ def test_dynamic_default_in_repeat(self): | | end repeat | r1 | | | """ self.assertPyxformXform( - name="test", - id_string="test", md=md, xml__xpath_match=[ # Repeat template and first row. """ - /h:html/h:head/x:model/x:instance/x:test[ - @id="test" + /h:html/h:head/x:model/x:instance/x:test_name[ + @id="data" and ./x:r1[@jr:template=''] and ./x:r1[not(@jr:template)] ] @@ -215,39 +213,39 @@ def test_dynamic_default_in_repeat(self): # q0 dynamic default value not in repeat template. """ /h:html/h:head/x:model[ - ./x:bind[@nodeset='/test/r1/q0' and @type='int'] - ]/x:instance/x:test[@id="test"]/x:r1[@jr:template='']/x:q0[not(text())] + ./x:bind[@nodeset='/test_name/r1/q0' and @type='int'] + ]/x:instance/x:test_name[@id="data"]/x:r1[@jr:template='']/x:q0[not(text())] """, # q0 dynamic default value not in repeat row. """ /h:html/h:head/x:model[ - ./x:bind[@nodeset='/test/r1/q0' and @type='int'] - ]/x:instance/x:test[@id="test"]/x:r1[not(@jr:template)]/x:q0[not(text())] + ./x:bind[@nodeset='/test_name/r1/q0' and @type='int'] + ]/x:instance/x:test_name[@id="data"]/x:r1[not(@jr:template)]/x:q0[not(text())] """, # q0 dynamic default value not in model setvalue. """ - /h:html/h:head/x:model[not(./x:setvalue[@ref='test/r1/q0'])] + /h:html/h:head/x:model[not(./x:setvalue[@ref='data/r1/q0'])] """, # q0 dynamic default value in body group setvalue, with 2 events. """ - /h:html/h:body/x:group[@ref='/test/r1']/x:repeat[@nodeset='/test/r1'] + /h:html/h:body/x:group[@ref='/test_name/r1']/x:repeat[@nodeset='/test_name/r1'] /x:setvalue[ @event='odk-instance-first-load odk-new-repeat' - and @ref='/test/r1/q0' + and @ref='/test_name/r1/q0' and @value='random()' ] """, # q1 static default value in repeat template. """ /h:html/h:head/x:model[ - ./x:bind[@nodeset='/test/r1/q1' and @type='string'] - ]/x:instance/x:test[@id="test"]/x:r1[@jr:template='']/x:q1[text()='not_func$'] + ./x:bind[@nodeset='/test_name/r1/q1' and @type='string'] + ]/x:instance/x:test_name[@id="data"]/x:r1[@jr:template='']/x:q1[text()='not_func$'] """, # q1 static default value in repeat row. """ /h:html/h:head/x:model[ - ./x:bind[@nodeset='/test/r1/q1' and @type='string'] - ]/x:instance/x:test[@id="test"]/x:r1[not(@jr:template)]/x:q1[text()='not_func$'] + ./x:bind[@nodeset='/test_name/r1/q1' and @type='string'] + ]/x:instance/x:test_name[@id="data"]/x:r1[not(@jr:template)]/x:q1[text()='not_func$'] """, ], ) @@ -263,29 +261,27 @@ def test_dynamic_default_in_group(self): | | end group | g1 | | | """ self.assertPyxformXform( - name="test", - id_string="test", md=md, xml__xpath_match=[ # q0 element in instance. - """/h:html/h:head/x:model/x:instance/x:test[@id="test"]/x:q0""", + """/h:html/h:head/x:model/x:instance/x:test_name[@id="data"]/x:q0""", # Group element in instance. - """/h:html/h:head/x:model/x:instance/x:test[@id="test"]/x:g1""", + """/h:html/h:head/x:model/x:instance/x:test_name[@id="data"]/x:g1""", # q1 dynamic default not in instance. - """/h:html/h:head/x:model/x:instance/x:test[@id="test"]/x:g1/x:q1[not(text())]""", + """/h:html/h:head/x:model/x:instance/x:test_name[@id="data"]/x:g1/x:q1[not(text())]""", # q1 dynamic default value in model setvalue, with 1 event. """ /h:html/h:head/x:model/x:setvalue[ @event="odk-instance-first-load" - and @ref='/test/g1/q1' - and @value=' /test/q0 ' + and @ref='/test_name/g1/q1' + and @value=' /test_name/q0 ' ] """, # q1 dynamic default value not in body group setvalue. """ /h:html/h:body/x:group[ - @ref='/test/g1' - and not(child::setvalue[@ref='/test/g1/q1']) + @ref='/test_name/g1' + and not(child::setvalue[@ref='/test_name/g1/q1']) ] """, ], @@ -302,29 +298,27 @@ def test_sibling_dynamic_default_in_group(self): | | end group | g1 | | | """ self.assertPyxformXform( - name="test", - id_string="test", md=md, xml__xpath_match=[ # Group element in instance. - """/h:html/h:head/x:model/x:instance/x:test[@id="test"]/x:g1""", + """/h:html/h:head/x:model/x:instance/x:test_name[@id="data"]/x:g1""", # q0 element in group. - """/h:html/h:head/x:model/x:instance/x:test[@id="test"]/x:g1/x:q0""", + """/h:html/h:head/x:model/x:instance/x:test_name[@id="data"]/x:g1/x:q0""", # q1 dynamic default not in instance. - """/h:html/h:head/x:model/x:instance/x:test[@id="test"]/x:g1/x:q1[not(text())]""", + """/h:html/h:head/x:model/x:instance/x:test_name[@id="data"]/x:g1/x:q1[not(text())]""", # q1 dynamic default value in model setvalue, with 1 event. """ /h:html/h:head/x:model/x:setvalue[ @event="odk-instance-first-load" - and @ref='/test/g1/q1' - and @value=' /test/g1/q0 ' + and @ref='/test_name/g1/q1' + and @value=' /test_name/g1/q0 ' ] """, # q1 dynamic default value not in body group setvalue. """ /h:html/h:body/x:group[ - @ref='/test/g1' - and not(child::setvalue[@ref='/test/g1/q1']) + @ref='/test_name/g1' + and not(child::setvalue[@ref='/test_name/g1/q1']) ] """, ], @@ -341,14 +335,12 @@ def test_sibling_dynamic_default_in_repeat(self): | | end repeat | r1 | | | """ self.assertPyxformXform( - name="test", - id_string="test", md=md, xml__xpath_match=[ # Repeat template and first row. """ - /h:html/h:head/x:model/x:instance/x:test[ - @id="test" + /h:html/h:head/x:model/x:instance/x:test_name[ + @id="data" and ./x:r1[@jr:template=''] and ./x:r1[not(@jr:template)] ] @@ -356,25 +348,25 @@ def test_sibling_dynamic_default_in_repeat(self): # q0 element in repeat template. """ /h:html/h:head/x:model[ - ./x:bind[@nodeset='/test/r1/q0' and @type='int'] - ]/x:instance/x:test[@id="test"]/x:r1[@jr:template='']/x:q0 + ./x:bind[@nodeset='/test_name/r1/q0' and @type='int'] + ]/x:instance/x:test_name[@id="data"]/x:r1[@jr:template='']/x:q0 """, # q0 element in repeat row. """ /h:html/h:head/x:model[ - ./x:bind[@nodeset='/test/r1/q0' and @type='int'] - ]/x:instance/x:test[@id="test"]/x:r1[not(@jr:template)]/x:q0 + ./x:bind[@nodeset='/test_name/r1/q0' and @type='int'] + ]/x:instance/x:test_name[@id="data"]/x:r1[not(@jr:template)]/x:q0 """, # q1 dynamic default value not in model setvalue. """ - /h:html/h:head/x:model[not(./x:setvalue[@ref='test/r1/q1'])] + /h:html/h:head/x:model[not(./x:setvalue[@ref='data/r1/q1'])] """, # q1 dynamic default value in body group setvalue, with 2 events. """ - /h:html/h:body/x:group[@ref='/test/r1']/x:repeat[@nodeset='/test/r1'] + /h:html/h:body/x:group[@ref='/test_name/r1']/x:repeat[@nodeset='/test_name/r1'] /x:setvalue[ @event='odk-instance-first-load odk-new-repeat' - and @ref='/test/r1/q1' + and @ref='/test_name/r1/q1' and @value=' ../q0 ' ] """, @@ -394,14 +386,12 @@ def test_dynamic_default_in_group_nested_in_repeat(self): | | end repeat | r1 | | | """ self.assertPyxformXform( - name="test", - id_string="test", md=md, xml__xpath_match=[ # Repeat template and first row contains the group. """ - /h:html/h:head/x:model/x:instance/x:test[ - @id="test" + /h:html/h:head/x:model/x:instance/x:test_name[ + @id="data" and ./x:r1[@jr:template='']/x:g1 and ./x:r1[not(@jr:template)]/x:g1 ] @@ -409,25 +399,25 @@ def test_dynamic_default_in_group_nested_in_repeat(self): # q0 element in repeat template. """ /h:html/h:head/x:model[ - ./x:bind[@nodeset='/test/r1/g1/q0' and @type='int'] - ]/x:instance/x:test[@id="test"]/x:r1[@jr:template='']/x:g1/x:q0 + ./x:bind[@nodeset='/test_name/r1/g1/q0' and @type='int'] + ]/x:instance/x:test_name[@id="data"]/x:r1[@jr:template='']/x:g1/x:q0 """, # q0 element in repeat row. """ /h:html/h:head/x:model[ - ./x:bind[@nodeset='/test/r1/g1/q0' and @type='int'] - ]/x:instance/x:test[@id="test"]/x:r1[not(@jr:template)]/x:g1/x:q0 + ./x:bind[@nodeset='/test_name/r1/g1/q0' and @type='int'] + ]/x:instance/x:test_name[@id="data"]/x:r1[not(@jr:template)]/x:g1/x:q0 """, # q1 dynamic default value not in model setvalue. """ - /h:html/h:head/x:model[not(./x:setvalue[@ref='test/r1/g1/q1'])] + /h:html/h:head/x:model[not(./x:setvalue[@ref='data/r1/g1/q1'])] """, # q1 dynamic default value in body group setvalue, with 2 events. """ - /h:html/h:body/x:group[@ref='/test/r1']/x:repeat[@nodeset='/test/r1'] + /h:html/h:body/x:group[@ref='/test_name/r1']/x:repeat[@nodeset='/test_name/r1'] /x:setvalue[ @event='odk-instance-first-load odk-new-repeat' - and @ref='/test/r1/g1/q1' + and @ref='/test_name/r1/g1/q1' and @value=' ../q0 ' ] """, @@ -448,14 +438,12 @@ def test_dynamic_default_in_repeat_nested_in_repeat(self): | | end repeat | r1 | | | """ self.assertPyxformXform( - name="test", - id_string="test", md=md, xml__xpath_match=[ # Repeat templates and first rows. """ - /h:html/h:head/x:model/x:instance/x:test[ - @id="test" + /h:html/h:head/x:model/x:instance/x:test_name[ + @id="data" and ./x:r1[@jr:template='']/x:r2[@jr:template=''] and ./x:r1[not(@jr:template)]/x:r2[not(@jr:template)] ] @@ -463,63 +451,63 @@ def test_dynamic_default_in_repeat_nested_in_repeat(self): # q0 element in repeat template. """ /h:html/h:head/x:model[ - ./x:bind[@nodeset='/test/r1/q0' and @type='date'] - ]/x:instance/x:test[@id="test"]/x:r1[@jr:template='']/x:q0 + ./x:bind[@nodeset='/test_name/r1/q0' and @type='date'] + ]/x:instance/x:test_name[@id="data"]/x:r1[@jr:template='']/x:q0 """, # q0 element in repeat row. """ /h:html/h:head/x:model[ - ./x:bind[@nodeset='/test/r1/q0' and @type='date'] - ]/x:instance/x:test[@id="test"]/x:r1[not(@jr:template)]/x:q0 + ./x:bind[@nodeset='/test_name/r1/q0' and @type='date'] + ]/x:instance/x:test_name[@id="data"]/x:r1[not(@jr:template)]/x:q0 """, # q0 dynamic default value not in model setvalue. """ - /h:html/h:head/x:model[not(./x:setvalue[@ref='test/r1/q0'])] + /h:html/h:head/x:model[not(./x:setvalue[@ref='data/r1/q0'])] """, # q0 dynamic default value in body group setvalue, with 2 events. """ - /h:html/h:body/x:group[@ref='/test/r1']/x:repeat[@nodeset='/test/r1'] + /h:html/h:body/x:group[@ref='/test_name/r1']/x:repeat[@nodeset='/test_name/r1'] /x:setvalue[ @event='odk-instance-first-load odk-new-repeat' - and @ref='/test/r1/q0' + and @ref='/test_name/r1/q0' and @value='now()' ] """, # q1 element in repeat template. """ /h:html/h:head/x:model[ - ./x:bind[@nodeset='/test/r1/q1' and @type='int'] - ]/x:instance/x:test[@id="test"]/x:r1[@jr:template='']/x:q1 + ./x:bind[@nodeset='/test_name/r1/q1' and @type='int'] + ]/x:instance/x:test_name[@id="data"]/x:r1[@jr:template='']/x:q1 """, # q1 element in repeat row. """ /h:html/h:head/x:model[ - ./x:bind[@nodeset='/test/r1/q1' and @type='int'] - ]/x:instance/x:test[@id="test"]/x:r1[not(@jr:template)]/x:q1 + ./x:bind[@nodeset='/test_name/r1/q1' and @type='int'] + ]/x:instance/x:test_name[@id="data"]/x:r1[not(@jr:template)]/x:q1 """, # q2 element in repeat template. """ /h:html/h:head/x:model[ - ./x:bind[@nodeset='/test/r1/q1' and @type='int'] - ]/x:instance/x:test[@id="test"]/x:r1[@jr:template='']/x:r2[@jr:template='']/x:q2 + ./x:bind[@nodeset='/test_name/r1/q1' and @type='int'] + ]/x:instance/x:test_name[@id="data"]/x:r1[@jr:template='']/x:r2[@jr:template='']/x:q2 """, # q2 element in repeat row. """ /h:html/h:head/x:model[ - ./x:bind[@nodeset='/test/r1/q1' and @type='int'] - ]/x:instance/x:test[@id="test"]/x:r1[not(@jr:template)]/x:r2[not(@jr:template)]/x:q2 + ./x:bind[@nodeset='/test_name/r1/q1' and @type='int'] + ]/x:instance/x:test_name[@id="data"]/x:r1[not(@jr:template)]/x:r2[not(@jr:template)]/x:q2 """, # q2 dynamic default value not in model setvalue. """ - /h:html/h:head/x:model[not(./x:setvalue[@ref='test/r1/r2/q2'])] + /h:html/h:head/x:model[not(./x:setvalue[@ref='data/r1/r2/q2'])] """, # q2 dynamic default value in body group setvalue, with 2 events. """ - /h:html/h:body/x:group[@ref='/test/r1']/x:repeat[@nodeset='/test/r1'] - /x:group[@ref='/test/r1/r2']/x:repeat[@nodeset='/test/r1/r2'] + /h:html/h:body/x:group[@ref='/test_name/r1']/x:repeat[@nodeset='/test_name/r1'] + /x:group[@ref='/test_name/r1/r2']/x:repeat[@nodeset='/test_name/r1/r2'] /x:setvalue[ @event='odk-instance-first-load odk-new-repeat' - and @ref='/test/r1/r2/q2' + and @ref='/test_name/r1/r2/q2' and @value=' ../../q1 ' ] """, @@ -548,7 +536,7 @@ def test_dynamic_default_on_calculate(self): """, xml__xpath_match=[ xp.model(1, Case(True, "calculate", "random() + 0.5")), - xp.model(2, Case(True, "calculate", "if( /test/q1 < 1,'A','B')")), + xp.model(2, Case(True, "calculate", "if( /test_name/q1 < 1,'A','B')")), # Nothing in body since both questions are calculations. "/h:html/h:body[not(text) and count(./*) = 0]", ], diff --git a/tests/test_external_instances.py b/tests/test_external_instances.py index e0d33ab92..a206aaa0e 100644 --- a/tests/test_external_instances.py +++ b/tests/test_external_instances.py @@ -6,8 +6,6 @@ from textwrap import dedent -from pyxform.errors import PyXFormError - from tests.pyxform_test_case import PyxformTestCase, PyxformTestError from tests.xpath_helpers.choices import xpc @@ -249,15 +247,17 @@ def test_cannot__use_different_src_same_id__select_then_internal(self): | | states | 1 | Pass | | | | states | 2 | Fail | | """ - with self.assertRaises(PyXFormError) as ctx: - survey = self.md_to_pyxform_survey(md_raw=md) - survey._to_pretty_xml() - self.assertIn( - "Instance name: 'states', " - "Existing type: 'file', Existing URI: 'jr://file-csv/states.csv', " - "Duplicate type: 'choice', Duplicate URI: 'None', " - "Duplicate context: 'survey'.", - repr(ctx.exception), + self.assertPyxformXform( + md=md, + errored=True, + error__contains=[ + ( + "Instance name: 'states', " + "Existing type: 'file', Existing URI: 'jr://file-csv/states.csv', " + "Duplicate type: 'choice', Duplicate URI: 'None', " + "Duplicate context: 'survey'." + ) + ], ) def test_cannot__use_different_src_same_id__external_then_pulldata(self): @@ -273,15 +273,17 @@ def test_cannot__use_different_src_same_id__external_then_pulldata(self): | | note | note | Fruity! ${f_csv} | | | | end group | g1 | | | """ - with self.assertRaises(PyXFormError) as ctx: - survey = self.md_to_pyxform_survey(md_raw=md) - survey._to_pretty_xml() - self.assertIn( - "Instance name: 'fruits', " - "Existing type: 'external', Existing URI: 'jr://file/fruits.xml', " - "Duplicate type: 'pulldata', Duplicate URI: 'jr://file-csv/fruits.csv', " - "Duplicate context: '[type: group, name: g1]'.", - repr(ctx.exception), + self.assertPyxformXform( + md=md, + errored=True, + error__contains=[ + ( + "Instance name: 'fruits', " + "Existing type: 'external', Existing URI: 'jr://file/fruits.xml', " + "Duplicate type: 'pulldata', Duplicate URI: 'jr://file-csv/fruits.csv', " + "Duplicate context: '[type: group, name: g1]'." + ) + ], ) def test_cannot__use_different_src_same_id__pulldata_then_external(self): @@ -297,15 +299,17 @@ def test_cannot__use_different_src_same_id__pulldata_then_external(self): | | note | note | Fruity! ${f_csv} | | | | end group | g1 | | | """ - with self.assertRaises(PyXFormError) as ctx: - survey = self.md_to_pyxform_survey(md_raw=md) - survey._to_pretty_xml() - self.assertIn( - "Instance name: 'fruits', " - "Existing type: 'pulldata', Existing URI: 'jr://file-csv/fruits.csv', " - "Duplicate type: 'external', Duplicate URI: 'jr://file/fruits.xml', " - "Duplicate context: '[type: group, name: g1]'.", - repr(ctx.exception), + self.assertPyxformXform( + md=md, + errored=True, + error__contains=[ + ( + "Instance name: 'fruits', " + "Existing type: 'pulldata', Existing URI: 'jr://file-csv/fruits.csv', " + "Duplicate type: 'external', Duplicate URI: 'jr://file/fruits.xml', " + "Duplicate context: '[type: group, name: g1]'." + ) + ], ) def test_can__reuse_csv__selects_then_pulldata(self): @@ -320,13 +324,17 @@ def test_can__reuse_csv__selects_then_pulldata(self): | | calculate | f_csv | pd | pulldata('pain_locations', 'name', 'name', 'arm') | | | note | note | Arm ${f_csv} | | """ - expected = """ - -""" - self.assertPyxformXform(md=md, model__contains=[expected]) - survey = self.md_to_pyxform_survey(md_raw=md) - xml = survey._to_pretty_xml() - self.assertEqual(1, xml.count(expected)) + self.assertPyxformXform( + md=md, + xml__xpath_match=[ + """ + /h:html/h:head/x:model/x:instance[ + @id='pain_locations' + and @src='jr://file-csv/pain_locations.csv' + ] + """ + ], + ) def test_can__reuse_csv__pulldata_then_selects(self): """Re-using the same csv external data source id and URI is OK.""" @@ -340,10 +348,17 @@ def test_can__reuse_csv__pulldata_then_selects(self): | | select_one_from_file pain_locations.csv | pmonth | Location of worst pain this month. | | | | select_one_from_file pain_locations.csv | pyear | Location of worst pain this year. | | """ - expected = ( - """""" + self.assertPyxformXform( + md=md, + xml__xpath_match=[ + """ + /h:html/h:head/x:model/x:instance[ + @id='pain_locations' + and @src='jr://file-csv/pain_locations.csv' + ] + """ + ], ) - self.assertPyxformXform(md=md, model__contains=[expected]) def test_can__reuse_xml__selects_then_external(self): """Re-using the same xml external data source id and URI is OK.""" @@ -356,12 +371,17 @@ def test_can__reuse_xml__selects_then_external(self): | | select_one_from_file pain_locations.xml | pyear | Location of worst pain this year. | | | xml-external | pain_locations | | """ - expected = """ - -""" - survey = self.md_to_pyxform_survey(md_raw=md) - xml = survey._to_pretty_xml() - self.assertEqual(1, xml.count(expected)) + self.assertPyxformXform( + md=md, + xml__xpath_match=[ + """ + /h:html/h:head/x:model/x:instance[ + @id='pain_locations' + and @src='jr://file/pain_locations.xml' + ] + """ + ], + ) def test_can__reuse_xml__external_then_selects(self): """Re-using the same xml external data source id and URI is OK.""" @@ -374,13 +394,17 @@ def test_can__reuse_xml__external_then_selects(self): | | select_one_from_file pain_locations.xml | pmonth | Location of worst pain this month. | | | select_one_from_file pain_locations.xml | pyear | Location of worst pain this year. | """ - expected = ( - """""" + self.assertPyxformXform( + md=md, + xml__xpath_match=[ + """ + /h:html/h:head/x:model/x:instance[ + @id='pain_locations' + and @src='jr://file/pain_locations.xml' + ] + """ + ], ) - self.assertPyxformXform(md=md, model__contains=[expected]) - survey = self.md_to_pyxform_survey(md_raw=md) - xml = survey._to_pretty_xml() - self.assertEqual(1, xml.count(expected)) def test_external_instance_pulldata_constraint(self): """ @@ -570,10 +594,17 @@ def test_external_instance_pulldata(self): | | type | name | label | relevant | required | constraint | | | text | Part_ID | Participant ID | pulldata('ID', 'ParticipantID', 'ParticipantIDValue',.) | pulldata('ID', 'ParticipantID', 'ParticipantIDValue',.) | pulldata('ID', 'ParticipantID', 'ParticipantIDValue',.) | """ - node = """""" - survey = self.md_to_pyxform_survey(md_raw=md) - xml = survey._to_pretty_xml() - self.assertEqual(1, xml.count(node)) + self.assertPyxformXform( + md=md, + xml__xpath_match=[ + """ + /h:html/h:head/x:model/x:instance[ + @id='ID' + and @src='jr://file-csv/ID.csv' + ] + """ + ], + ) def test_external_instances_multiple_diff_pulldatas(self): """ @@ -583,17 +614,27 @@ def test_external_instances_multiple_diff_pulldatas(self): columns but pulling data from different csv files """ md = """ - | survey | | | | | | - | | type | name | label | relevant | required | - | | text | Part_ID | Participant ID | pulldata('fruits', 'name', 'name_key', 'mango') | pulldata('OtherID', 'ParticipantID', ParticipantIDValue',.) | + | survey | | | | | | + | | type | name | label | relevant | required | + | | text | Part_ID | Participant ID | pulldata('fruits', 'name', 'name_key', 'mango') | pulldata('OtherID', 'ParticipantID', ParticipantIDValue',.) | """ - node1 = '' - node2 = '' - - survey = self.md_to_pyxform_survey(md_raw=md) - xml = survey._to_pretty_xml() - self.assertEqual(1, xml.count(node1)) - self.assertEqual(1, xml.count(node2)) + self.assertPyxformXform( + md=md, + xml__xpath_match=[ + """ + /h:html/h:head/x:model/x:instance[ + @id='fruits' + and @src='jr://file-csv/fruits.csv' + ] + """, + """ + /h:html/h:head/x:model/x:instance[ + @id='OtherID' + and @src='jr://file-csv/OtherID.csv' + ] + """, + ], + ) def test_mixed_quotes_and_functions_in_pulldata(self): # re: https://github.com/XLSForm/pyxform/issues/398 diff --git a/tests/test_external_instances_for_selects.py b/tests/test_external_instances_for_selects.py index b49aa9eb4..75ee04a90 100644 --- a/tests/test_external_instances_for_selects.py +++ b/tests/test_external_instances_for_selects.py @@ -10,10 +10,10 @@ from pyxform import aliases from pyxform.constants import EXTERNAL_INSTANCE_EXTENSIONS from pyxform.errors import PyXFormError +from pyxform.xls2json_backends import md_table_to_workbook from pyxform.xls2xform import get_xml_path, xls2xform_convert from tests.pyxform_test_case import PyxformTestCase -from tests.test_utils.md_table import md_table_to_workbook from tests.utils import get_temp_dir from tests.xpath_helpers.choices import xpc from tests.xpath_helpers.questions import xpq diff --git a/tests/test_fieldlist_labels.py b/tests/test_fieldlist_labels.py index bd7d5be78..469fd3a45 100644 --- a/tests/test_fieldlist_labels.py +++ b/tests/test_fieldlist_labels.py @@ -9,101 +9,77 @@ class FieldListLabels(PyxformTestCase): """Test unlabeled group""" def test_unlabeled_group(self): - warnings = [] - - self.md_to_pyxform_survey( - """ + self.assertPyxformXform( + md=""" | survey | | | | | | type | name | label | | | begin_group | my-group | | | | text | my-text | my-text | | | end_group | | | """, - warnings=warnings, + warnings_count=1, + warnings__contains=["[row : 2] Group has no label"], ) - self.assertTrue(len(warnings) == 1) - self.assertTrue("[row : 2] Group has no label" in warnings[0]) - def test_unlabeled_group_alternate_syntax(self): - warnings = [] - - self.md_to_pyxform_survey( - """ + self.assertPyxformXform( + md=""" | survey | | | | | | type | name | label::English (en) | | | begin group | my-group | | | | text | my-text | my-text | | | end group | | | """, - warnings=warnings, + warnings_count=1, + warnings__contains=["[row : 2] Group has no label"], ) - self.assertTrue(len(warnings) == 1) - self.assertTrue("[row : 2] Group has no label" in warnings[0]) - def test_unlabeled_group_fieldlist(self): - warnings = [] - - self.md_to_pyxform_survey( - """ + self.assertPyxformXform( + md=""" | survey | | | | | | | type | name | label | appearance | | | begin_group | my-group | | field-list | | | text | my-text | my-text | | | | end_group | | | | """, - warnings=warnings, + warnings_count=0, ) - self.assertTrue(len(warnings) == 0) - def test_unlabeled_group_fieldlist_alternate_syntax(self): - warnings = [] - - self.md_to_pyxform_survey( - """ + self.assertPyxformXform( + md=""" | survey | | | | | | | type | name | label | appearance | | | begin group | my-group | | field-list | | | text | my-text | my-text | | | | end group | | | | """, - warnings=warnings, + warnings_count=0, ) - self.assertTrue(len(warnings) == 0) - def test_unlabeled_repeat(self): - warnings = [] - - self.md_to_pyxform_survey( - """ + self.assertPyxformXform( + md=""" | survey | | | | | | type | name | label | | | begin_repeat | my-repeat | | | | text | my-text | my-text | | | end_repeat | | | """, - warnings=warnings, + warnings_count=1, + warnings__contains=["[row : 2] Repeat has no label"], ) - self.assertTrue(len(warnings) == 1) - self.assertTrue("[row : 2] Repeat has no label" in warnings[0]) - def test_unlabeled_repeat_fieldlist(self): - warnings = [] - - self.md_to_pyxform_survey( - """ + self.assertPyxformXform( + md=""" | survey | | | | | | | type | name | label | appearance | | | begin_repeat | my-repeat | | field-list | | | text | my-text | my-text | | | | end_repeat | | | | """, - warnings=warnings, + warnings_count=1, + warnings__contains=["[row : 2] Repeat has no label"], ) - - self.assertTrue(len(warnings) == 1) - self.assertTrue("[row : 2] Repeat has no label" in warnings[0]) diff --git a/tests/test_form_name.py b/tests/test_form_name.py index 866fec600..e3f395307 100644 --- a/tests/test_form_name.py +++ b/tests/test_form_name.py @@ -7,33 +7,17 @@ class TestFormName(PyxformTestCase): def test_default_to_data_when_no_name(self): - """ - Test no form_name will default to survey name to 'data'. - """ - survey = self.md_to_pyxform_survey( - """ + """Should default to form_name of 'test_name', and form id of 'data'.""" + self.assertPyxformXform( + md=""" | survey | | | | | | type | name | label | | | text | city | City Name | """, - autoname=False, - ) - - # We're passing autoname false when creating the survey object. - self.assertEqual(survey.id_string, None) - self.assertEqual(survey.name, "data") - self.assertEqual(survey.title, None) - - # Set required fields because we need them if we want to do xml comparison. - survey.id_string = "some-id" - survey.title = "data" - - self.assertPyxformXform( - survey=survey, - instance__contains=[''], - model__contains=[''], + instance__contains=[''], + model__contains=[''], xml__contains=[ - '', + '', "", "", ], @@ -50,8 +34,7 @@ def test_default_to_data(self): | | text | city | City Name | """, name="data", - id_string="some-id", - instance__contains=[''], + instance__contains=[''], model__contains=[''], xml__contains=[ '', @@ -72,8 +55,7 @@ def test_default_form_name_to_superclass_definition(self): | | text | city | City Name | """, name="some-name", - id_string="some-id", - instance__contains=[''], + instance__contains=[''], model__contains=[''], xml__contains=[ '', diff --git a/tests/test_group.py b/tests/test_group.py index 9d99fbe50..7daa235f0 100644 --- a/tests/test_group.py +++ b/tests/test_group.py @@ -25,22 +25,24 @@ def test_json(self): { "name": "family_name", "type": "text", - "label": {"English": "What's your family name?"}, + "label": {"English (en)": "What's your family name?"}, }, { "name": "father", "type": "group", - "label": {"English": "Father"}, + "label": {"English (en)": "Father"}, "children": [ { "name": "phone_number", "type": "phone number", - "label": {"English": "What's your father's phone number?"}, + "label": { + "English (en)": "What's your father's phone number?" + }, }, { "name": "age", "type": "integer", - "label": {"English": "How old is your father?"}, + "label": {"English (en)": "How old is your father?"}, }, ], }, diff --git a/tests/test_image_app_parameter.py b/tests/test_image_app_parameter.py index eab62071d..8d65db32f 100644 --- a/tests/test_image_app_parameter.py +++ b/tests/test_image_app_parameter.py @@ -150,21 +150,20 @@ def test_string_extra_params(self): ) def test_image_with_no_max_pixels_should_warn(self): - warnings = [] - - self.md_to_pyxform_survey( - """ + self.assertPyxformXform( + md=""" | survey | | | | | | type | name | label | | | image | my_image | Image | | | image | my_image_1 | Image 1 | """, - warnings=warnings, + warnings_count=2, + warnings__contains=[ + "[row : 2] Use the max-pixels parameter to speed up submission sending and save storage space. Learn more: https://xlsform.org/#image", + "[row : 3] Use the max-pixels parameter to speed up submission sending and save storage space. Learn more: https://xlsform.org/#image", + ], ) - self.assertTrue(len(warnings) == 2) - self.assertTrue("max-pixels" in warnings[0] and "max-pixels" in warnings[1]) - def test_max_pixels_and_app(self): self.assertPyxformXform( name="data", diff --git a/tests/test_language_warnings.py b/tests/test_language_warnings.py index 9892d0ca3..ee255bf83 100644 --- a/tests/test_language_warnings.py +++ b/tests/test_language_warnings.py @@ -2,9 +2,6 @@ Test language warnings. """ -import os -import tempfile - from tests.pyxform_test_case import PyxformTestCase @@ -14,67 +11,46 @@ class LanguageWarningTest(PyxformTestCase): """ def test_label_with_valid_subtag_should_not_warn(self): - survey = self.md_to_pyxform_survey( - """ + self.assertPyxformXform( + md=""" | survey | | | | | | type | name | label::English (en) | | | note | my_note | My note | - """ + """, + warnings_count=0, ) - warnings = [] - tmp = tempfile.NamedTemporaryFile(suffix=".xml", delete=False) - tmp.close() - survey.print_xform_to_file(tmp.name, warnings=warnings) - - self.assertTrue(len(warnings) == 0) - os.unlink(tmp.name) - def test_label_with_no_subtag_should_warn(self): - survey = self.md_to_pyxform_survey( - """ + self.assertPyxformXform( + md=""" | survey | | | | | | type | name | label::English | | | note | my_note | My note | - """ - ) - - warnings = [] - tmp = tempfile.NamedTemporaryFile(suffix=".xml", delete=False) - tmp.close() - survey.print_xform_to_file(tmp.name, warnings=warnings) - - self.assertTrue(len(warnings) == 1) - self.assertTrue( - "do not contain valid machine-readable codes: English. Learn more" - in warnings[0] + """, + warnings_count=1, + warnings__contains=[ + "The following language declarations do not contain valid machine-readable " + "codes: English. Learn more: http://xlsform.org#multiple-language-support" + ], ) - os.unlink(tmp.name) def test_label_with_unknown_subtag_should_warn(self): - survey = self.md_to_pyxform_survey( - """ + self.assertPyxformXform( + md=""" | survey | | | | | | type | name | label::English (schm) | | | note | my_note | My note | - """ + """, + warnings_count=1, + warnings__contains=[ + "The following language declarations do not contain valid machine-readable " + "codes: English (schm). Learn more: http://xlsform.org#multiple-language-support" + ], ) - warnings = [] - tmp = tempfile.NamedTemporaryFile(suffix=".xml", delete=False) - tmp.close() - survey.print_xform_to_file(tmp.name, warnings=warnings) - - self.assertTrue(len(warnings) == 1) - self.assertTrue( - "do not contain valid machine-readable codes: English (schm). Learn more" - in warnings[0] - ) - os.unlink(tmp.name) - def test_default_language_only_should_not_warn(self): - survey = self.md_to_pyxform_survey( - """ + self.assertPyxformXform( + md=""" | survey | | | | | | | type | name | label | choice_filter | | | select_one opts | opt | My opt | fake = 1 | @@ -82,13 +58,6 @@ def test_default_language_only_should_not_warn(self): | | list_name | name | label | fake | | | opts | opt1 | Opt1 | 1 | | | opts | opt2 | Opt2 | 1 | - """ + """, + warnings_count=0, ) - - warnings = [] - tmp = tempfile.NamedTemporaryFile(suffix=".xml", delete=False) - tmp.close() - survey.print_xform_to_file(tmp.name, warnings=warnings) - - self.assertTrue(len(warnings) == 0) - os.unlink(tmp.name) diff --git a/tests/test_metadata.py b/tests/test_metadata.py index 2861d06c3..e008dbe77 100644 --- a/tests/test_metadata.py +++ b/tests/test_metadata.py @@ -2,9 +2,6 @@ Test language warnings. """ -import os -import tempfile - from tests.pyxform_test_case import PyxformTestCase @@ -39,45 +36,31 @@ def test_metadata_bindings(self): ) def test_simserial_deprecation_warning(self): - warnings = [] - survey = self.md_to_pyxform_survey( - """ - | survey | | | | - | | type | name | label | - | | simserial | simserial | | - | | note | simserial_test_output | simserial_test_output: ${simserial} | + self.assertPyxformXform( + md=""" + | survey | | | | + | | type | name | label | + | | simserial | simserial | | + | | note | simserial_test_output | simserial_test_output: ${simserial} | """, - warnings=warnings, - ) - tmp = tempfile.NamedTemporaryFile(suffix=".xml", delete=False) - tmp.close() - survey.print_xform_to_file(tmp.name, warnings=warnings) - self.assertTrue(len(warnings) == 1) - warning_expected = ( - "[row : 2] simserial is no longer supported on most devices. " - "Only old versions of Collect on Android versions older than 11 still support it." + warnings_count=1, + warnings__contains=[ + "[row : 2] simserial is no longer supported on most devices. " + "Only old versions of Collect on Android versions older than 11 still support it." + ], ) - self.assertEqual(warning_expected, warnings[0]) - os.unlink(tmp.name) def test_subscriber_id_deprecation_warning(self): - warnings = [] - survey = self.md_to_pyxform_survey( - """ + self.assertPyxformXform( + md=""" | survey | | | | | | type | name | label | | | subscriberid | subscriberid | sub id - extra warning generated w/o this | | | note | subscriberid_test_output | subscriberid_test_output: ${subscriberid} | """, - warnings=warnings, - ) - tmp = tempfile.NamedTemporaryFile(suffix=".xml", delete=False) - tmp.close() - survey.print_xform_to_file(tmp.name, warnings=warnings) - self.assertTrue(len(warnings) == 1) - warning_expected = ( - "[row : 2] subscriberid is no longer supported on most devices. " - "Only old versions of Collect on Android versions older than 11 still support it." + warnings_count=1, + warnings__contains=[ + "[row : 2] subscriberid is no longer supported on most devices. " + "Only old versions of Collect on Android versions older than 11 still support it." + ], ) - self.assertEqual(warning_expected, warnings[0]) - os.unlink(tmp.name) diff --git a/tests/test_pyxform_test_case.py b/tests/test_pyxform_test_case.py index ceea0c4bd..87fa566a1 100644 --- a/tests/test_pyxform_test_case.py +++ b/tests/test_pyxform_test_case.py @@ -104,7 +104,7 @@ class TestPyxformTestCaseXmlXpath(PyxformTestCase): exact={ ( """\n""" - """ \n""" + """ \n""" """ \n""" """ \n""" """ \n""" diff --git a/tests/test_pyxformtestcase.py b/tests/test_pyxformtestcase.py index f6e2599aa..3fb2ec3ab 100644 --- a/tests/test_pyxformtestcase.py +++ b/tests/test_pyxformtestcase.py @@ -56,14 +56,13 @@ def test_formid_is_not_none(self): When the form id is not set, it should never use python's None. Fixing because this messes up other tests. """ - s1 = self.md_to_pyxform_survey( - """ + self.assertPyxformXform( + md=""" | survey | | | | | | type | name | label | | | note | q | Q | """, - autoname=True, + xml__xpath_match=[ + "/h:html/h:head/x:model/x:instance/x:test_name[@id='data']" + ], ) - - if s1.id_string in ["None", None]: - self.assertRaises(Exception, lambda: s1.validate()) diff --git a/tests/test_repeat.py b/tests/test_repeat.py index aa06a28c6..93cd52fa0 100644 --- a/tests/test_repeat.py +++ b/tests/test_repeat.py @@ -15,8 +15,6 @@ def test_repeat_relative_reference(self): Test relative reference in repeats. """ self.assertPyxformXform( - name="test_repeat", - title="Relative Paths in repeats", md=""" | survey | | | | | | | type | name | relevant | label | @@ -72,27 +70,27 @@ def test_repeat_relative_reference(self): "", ], model__contains=[ - """""", - """""", + """""", - """""", - """""", - """""", - """""", ], xml__contains=[ - '', + '', "", "", """""", - """""", + """""", """""", ], @@ -100,9 +98,8 @@ def test_repeat_relative_reference(self): def test_calculate_relative_path(self): """Test relative paths in calculate column.""" + # Paths in a calculate within a repeat are relative. self.assertPyxformXform( - name="data", - title="Paths in a calculate within a repeat are relative.", md=""" | survey | | | | | | | type | name | label | calculation | @@ -122,17 +119,16 @@ def test_calculate_relative_path(self): """, # pylint: disable=line-too-long model__contains=[ """""", + """nodeset="/test_name/rep/a" type="string"/>""", """""", + """nodeset="/test_name/rep/group/b" type="string"/>""", ], ) - def test_choice_filter_relative_path(self): # pylint: disable=invalid-name + def test_choice_filter_relative_path(self): """Test relative paths in choice_filter column.""" + # Choice filter uses relative path self.assertPyxformXform( - name="data", - title="Choice filter uses relative path", md=""" | survey | | | | | | | type | name | label | choice_filter | @@ -158,9 +154,8 @@ def test_choice_filter_relative_path(self): # pylint: disable=invalid-name def test_indexed_repeat_relative_path(self): """Test relative path not used with indexed-repeat().""" + # Paths in a calculate within a repeat are relative. self.assertPyxformXform( - name="data", - title="Paths in a calculate within a repeat are relative.", md=""" | survey | | | | | | | type | name | label | calculation | @@ -183,7 +178,7 @@ def test_indexed_repeat_relative_path(self): | | crop_list | kale | Kale | | """, # pylint: disable=line-too-long model__contains=[ - """""" # pylint: disable=line-too-long + """""" # pylint: disable=line-too-long ], ) @@ -200,7 +195,6 @@ def test_output_with_translation_relative_path(self): self.assertPyxformXform( md=md, - name="inside-repeat-relative-path", xml__contains=[ '', ' Name of ', @@ -223,7 +217,6 @@ def test_output_with_guidance_hint_translation_relative_path(self): self.assertPyxformXform( md=md, - name="inside-repeat-relative-path", xml__contains=[ '', ' Name of ', @@ -244,7 +237,6 @@ def test_output_with_multiple_translations_relative_path(self): self.assertPyxformXform( md=md, - name="inside-repeat-relative-path", xml__contains=[ '', ' Name of ', @@ -320,10 +312,9 @@ def test_choice_from_previous_repeat_answers(self): | | select one ${name} | choice | Choose name | """ self.assertPyxformXform( - name="data", md=xlsform_md, xml__contains=[ - "", + "", '', '