From 1599d1f8503d91a332b11c67c65be1fccd00500a Mon Sep 17 00:00:00 2001 From: Lindsay Stevens Date: Sat, 1 Jun 2024 09:23:20 +1000 Subject: [PATCH] chg: update supported python versions, deps, apply py3.10 formatting (#706) - python 3.10, 3.11, 3.12 with 3.10 as the default - update ruff and lxml - formatting changes are generally one of the following: - line at top of file after module docstring - typing: - Union[x, y] -> x | y - imports from typing to use either builtins or stdlib - replace simple string subs "%s" with f-strings - many still remain due to use of common templates etc - explicitly state new default strict=False for zip, map - parenthesis allowed in with --- .github/workflows/release.yml | 2 +- .github/workflows/verify.yml | 4 +- README.rst | 15 ++- pyproject.toml | 8 +- pyxform/aliases.py | 1 + pyxform/builder.py | 41 +++++---- pyxform/constants.py | 1 + pyxform/entities/entities_parsing.py | 10 +- pyxform/external_instance.py | 1 + pyxform/file_utils.py | 1 + pyxform/instance.py | 3 +- pyxform/parsing/expression.py | 2 +- pyxform/parsing/instance_expression.py | 6 +- pyxform/question.py | 5 +- pyxform/question_type_dictionary.py | 1 + pyxform/section.py | 1 + pyxform/survey.py | 62 ++++++------- pyxform/survey_element.py | 21 +++-- pyxform/translator.py | 1 + pyxform/utils.py | 15 +-- .../validators/enketo_validate/__init__.py | 1 + pyxform/validators/error_cleaner.py | 3 +- pyxform/validators/odk_validate/__init__.py | 1 + .../pyxform/android_package_name.py | 3 +- .../validators/pyxform/parameters_generic.py | 7 +- .../validators/pyxform/translations_checks.py | 24 ++--- pyxform/validators/updater.py | 11 +-- pyxform/validators/util.py | 9 +- pyxform/xform2json.py | 17 ++-- pyxform/xform_instance_parser.py | 2 +- pyxform/xls2json.py | 77 ++++++++-------- pyxform/xls2json_backends.py | 42 +++++---- pyxform/xls2xform.py | 1 + pyxform/xlsparseutils.py | 4 +- tests/pyxform_test_case.py | 92 ++++++++++--------- tests/test_area.py | 1 + tests/test_audit.py | 1 + tests/test_bind_conversions.py | 1 + tests/test_bug_missing_headers.py | 1 + tests/test_bug_round_calculation.py | 1 + tests/test_builder.py | 1 + tests/test_dump_and_load.py | 1 + tests/test_dynamic_default.py | 6 +- tests/test_external_instances.py | 1 + tests/test_external_instances_for_selects.py | 1 + tests/test_fieldlist_labels.py | 1 + tests/test_fields.py | 3 +- tests/test_file.py | 1 + tests/test_file_utils.py | 1 + tests/test_for_loop.py | 1 + tests/test_form_name.py | 1 + tests/test_geo.py | 1 + tests/test_group.py | 1 + tests/test_groups.py | 1 + tests/test_guidance_hint.py | 1 + tests/test_image_app_parameter.py | 1 + tests/test_j2x_creation.py | 1 + tests/test_j2x_instantiation.py | 1 + tests/test_j2x_question.py | 1 + tests/test_j2x_xform_build_preparation.py | 1 + tests/test_js2x_import_from_json.py | 1 + tests/test_json2xform.py | 1 + tests/test_language_warnings.py | 1 + tests/test_last_saved.py | 1 + tests/test_loop.py | 1 + tests/test_metadata.py | 1 + tests/test_notes.py | 4 +- tests/test_osm.py | 1 + tests/test_parameters_rows.py | 1 + tests/test_pyxform_test_case.py | 6 +- tests/test_pyxformtestcase.py | 1 + tests/test_randomize_itemsets.py | 1 + tests/test_range.py | 1 + tests/test_rank.py | 1 + tests/test_repeat.py | 1 + tests/test_repeat_template.py | 1 + tests/test_secondary_instance_translations.py | 1 + tests/test_set_geopoint.py | 1 + tests/test_settings.py | 1 - tests/test_settings_auto_send_delete.py | 1 + tests/test_sheet_columns.py | 1 + tests/test_sms.py | 1 + tests/test_table_list.py | 1 + tests/test_translations.py | 13 +-- tests/test_tutorial_xls.py | 1 + tests/test_unicode_rtl.py | 1 + tests/test_upload_question.py | 1 + tests/test_utils/md_table.py | 4 +- tests/test_validator_update.py | 34 ++++--- tests/test_validator_util.py | 1 + tests/test_validators.py | 1 + tests/test_warnings.py | 1 + tests/test_whitespace.py | 1 + tests/test_xform2json.py | 1 + tests/test_xls2json_backends.py | 5 +- tests/test_xls2json_xls.py | 19 ++-- tests/test_xlsform_headers.py | 1 + tests/utils.py | 7 +- .../xform_test_case/test_attribute_columns.py | 8 +- tests/xform_test_case/test_bugs.py | 22 +++-- tests/xform_test_case/test_xlsform_spec.py | 8 +- tests/xform_test_case/test_xml.py | 1 + tests/xpath_helpers/choices.py | 12 +-- 103 files changed, 389 insertions(+), 311 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index a3f9e939..191c0de2 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -9,7 +9,7 @@ jobs: runs-on: ${{ matrix.os }} strategy: matrix: - python: ['3.8'] + python: ['3.10'] os: [ubuntu-latest] steps: - uses: actions/checkout@v4 diff --git a/.github/workflows/verify.yml b/.github/workflows/verify.yml index f8648b10..87721363 100644 --- a/.github/workflows/verify.yml +++ b/.github/workflows/verify.yml @@ -7,7 +7,7 @@ jobs: runs-on: ${{ matrix.os }} strategy: matrix: - python: ['3.8'] + python: ['3.10'] os: [ubuntu-latest] steps: - uses: actions/checkout@v4 @@ -39,7 +39,7 @@ jobs: # Run all matrix jobs even if one of them fails. fail-fast: false matrix: - python: ['3.7', '3.8', '3.9'] + python: ['3.10', '3.11', '3.12'] os: [ubuntu-latest, macos-latest, windows-latest] include: - os: windows-latest diff --git a/README.rst b/README.rst index 271e4686..eac4c64b 100644 --- a/README.rst +++ b/README.rst @@ -2,17 +2,14 @@ pyxform ======== -|pypi| |python| |black| +|pypi| |python| .. |pypi| image:: https://badge.fury.io/py/pyxform.svg :target: https://badge.fury.io/py/pyxform -.. |python| image:: https://img.shields.io/badge/python-3.7,3.8,3.9-blue.svg +.. |python| image:: https://img.shields.io/badge/python-3.10,3.11,3.12-blue.svg :target: https://www.python.org/downloads -.. |black| image:: https://img.shields.io/badge/code%20style-black-000000.svg - :target: https://github.com/python/black - ``pyxform`` is a Python library that simplifies writing forms for ODK Collect and Enketo by converting spreadsheets that follow the `XLSForm standard `_ into `ODK XForms `_. The XLSForms format is used in a `number of tools `_. Project status @@ -47,14 +44,14 @@ The ``xls2xform`` command can then be used:: xls2xform path_to_XLSForm [output_path] -The currently supported Python versions for ``pyxform`` are 3.7, 3.8 and 3.9. +The currently supported Python versions for ``pyxform`` are 3.10, 3.11 and 3.12. Running pyxform from local source --------------------------------- Note that you must uninstall any globally installed ``pyxform`` instance in order to use local modules. Please install java 8 or newer version. -From the command line, complete the following. These steps use a `virtualenv `_ to make dependency management easier, and to keep the global site-packages directory clean:: +From the command line, complete the following. These steps use a `virtualenv `_ to make dependency management easier, and to keep the global site-packages directory clean:: # Get a copy of the repository. mkdir -P ~/repos/pyxform @@ -62,7 +59,7 @@ From the command line, complete the following. These steps use a `virtualenv Union["SurveyElement", List["SurveyElement"]]: + self, d: dict[str, Any] + ) -> Union["SurveyElement", list["SurveyElement"]]: """ Convert from a nested python dictionary/array structure (a json dict I call it because it corresponds directly with a json object) @@ -161,10 +162,10 @@ def _save_trigger_as_setvalue_and_remove_calculate(self, d): @staticmethod def _create_question_from_dict( - d: Dict[str, Any], - question_type_dictionary: Dict[str, Any], + d: dict[str, Any], + question_type_dictionary: dict[str, Any], add_none_option: bool = False, - ) -> Union[Question, List[Question]]: + ) -> Question | list[Question]: question_type_str = d[const.TYPE] d_copy = d.copy() @@ -197,7 +198,7 @@ def _create_question_from_dict( return [] @staticmethod - def _add_other_option_to_multiple_choice_question(d: Dict[str, Any]) -> None: + def _add_other_option_to_multiple_choice_question(d: dict[str, Any]) -> None: # ideally, we'd just be pulling from children choice_list = d.get(const.CHOICES, d.get(const.CHILDREN, [])) if len(choice_list) <= 0: @@ -207,8 +208,8 @@ def _add_other_option_to_multiple_choice_question(d: Dict[str, Any]) -> None: @staticmethod def _get_or_other_choice( - choice_list: List[Dict[str, Any]], - ) -> Dict[str, Union[str, Dict]]: + choice_list: list[dict[str, Any]], + ) -> dict[str, str | dict]: """ If the choices have any translations, return an OR_OTHER choice for each lang. """ @@ -257,12 +258,12 @@ def _get_question_class(question_type_str, question_type_dictionary): return QUESTION_CLASSES[control_tag] @staticmethod - def _create_specify_other_question_from_dict(d: Dict[str, Any]) -> InputQuestion: + def _create_specify_other_question_from_dict(d: dict[str, Any]) -> InputQuestion: kwargs = { const.TYPE: "text", - const.NAME: "%s_other" % d[const.NAME], + const.NAME: f"{d[const.NAME]}_other", const.LABEL: "Specify other.", - const.BIND: {"relevant": "selected(../%s, 'other')" % d[const.NAME]}, + const.BIND: {"relevant": f"selected(../{d[const.NAME]}, 'other')"}, } return InputQuestion(**kwargs) @@ -386,11 +387,11 @@ def create_survey_from_xls(path_or_file, default_name=None): def create_survey( - name_of_main_section: Optional[str] = None, - sections: Optional[Dict[str, Dict]] = None, - main_section: Optional[Dict[str, Any]] = None, - id_string: Optional[str] = None, - title: Optional[str] = None, + name_of_main_section: str | None = None, + sections: dict[str, dict] | None = None, + main_section: dict[str, Any] | None = None, + id_string: str | None = None, + title: str | None = None, ) -> Survey: """ name_of_main_section -- a string key used to find the main section in the diff --git a/pyxform/constants.py b/pyxform/constants.py index 3759cf12..6bd41cc6 100644 --- a/pyxform/constants.py +++ b/pyxform/constants.py @@ -5,6 +5,7 @@ the literal names can be easily changed, typos can be avoided, and references are easier to find. """ + # TODO: Replace matching strings in the json2xforms code (builder.py, # survey.py, survey_element.py, question.py) with these constants from pyxform.util.enum import StrEnum diff --git a/pyxform/entities/entities_parsing.py b/pyxform/entities/entities_parsing.py index 948d61db..34853797 100644 --- a/pyxform/entities/entities_parsing.py +++ b/pyxform/entities/entities_parsing.py @@ -1,4 +1,4 @@ -from typing import Any, Dict, List +from typing import Any from pyxform import constants as const from pyxform.errors import PyXFormError @@ -8,8 +8,8 @@ def get_entity_declaration( - entities_sheet: List[Dict], workbook_dict: Dict[str, List[Dict]], warnings: List[str] -) -> Dict[str, Any]: + entities_sheet: list[dict], workbook_dict: dict[str, list[dict]], warnings: list[str] +) -> dict[str, Any]: if len(entities_sheet) == 0: similar = find_sheet_misspellings(key=const.ENTITIES, keys=workbook_dict.keys()) if similar is not None: @@ -82,7 +82,7 @@ def get_validated_dataset_name(entity): def validate_entity_saveto( - row: Dict, row_number: int, entity_declaration: Dict[str, Any], in_repeat: bool + row: dict, row_number: int, entity_declaration: dict[str, Any], in_repeat: bool ): save_to = row.get(const.BIND, {}).get("entities:saveto", "") if not save_to: @@ -124,7 +124,7 @@ def validate_entity_saveto( ) -def validate_entities_columns(row: Dict): +def validate_entities_columns(row: dict): extra = {k: None for k in row.keys() if k not in EC.value_list()} if 0 < len(extra): fmt_extra = ", ".join(f"'{k}'" for k in extra.keys()) diff --git a/pyxform/external_instance.py b/pyxform/external_instance.py index c90f06c2..50ea4a70 100644 --- a/pyxform/external_instance.py +++ b/pyxform/external_instance.py @@ -1,6 +1,7 @@ """ ExternalInstance class module """ + from pyxform.survey_element import SurveyElement diff --git a/pyxform/file_utils.py b/pyxform/file_utils.py index 862ef115..430f40f8 100644 --- a/pyxform/file_utils.py +++ b/pyxform/file_utils.py @@ -1,6 +1,7 @@ """ The pyxform file utility functions. """ + import glob import os diff --git a/pyxform/instance.py b/pyxform/instance.py index 98a60087..fb427af2 100644 --- a/pyxform/instance.py +++ b/pyxform/instance.py @@ -1,6 +1,7 @@ """ SurveyInstance class module. """ + from pyxform.errors import PyXFormError from pyxform.xform_instance_parser import parse_xform_instance @@ -58,7 +59,7 @@ def to_xml(self): pumped out in order, etc) """ open_str = f"""<{self._name} id="{self._id}">""" - close_str = """""" % self._name + close_str = f"""""" vals = "" for k, v in self._answers.items(): vals += f"<{k}>{v!s}" diff --git a/pyxform/parsing/expression.py b/pyxform/parsing/expression.py index edab5371..de99b66b 100644 --- a/pyxform/parsing/expression.py +++ b/pyxform/parsing/expression.py @@ -1,4 +1,4 @@ -from typing import Iterable +from collections.abc import Iterable from pyxform.utils import parse_expression diff --git a/pyxform/parsing/instance_expression.py b/pyxform/parsing/instance_expression.py index 9b908e0f..5a913c37 100644 --- a/pyxform/parsing/instance_expression.py +++ b/pyxform/parsing/instance_expression.py @@ -1,5 +1,5 @@ import re -from typing import TYPE_CHECKING, List, Tuple +from typing import TYPE_CHECKING from pyxform.utils import BRACKETED_TAG_REGEX, EXPRESSION_LEXER, ExpLexerToken @@ -20,7 +20,7 @@ def instance_func_start(token: ExpLexerToken) -> bool: return token.name == "FUNC_CALL" and token.value == "instance(" -def find_boundaries(xml_text: str) -> List[Tuple[int, int]]: +def find_boundaries(xml_text: str) -> list[tuple[int, int]]: """ Find token boundaries of any instance() expression. @@ -91,7 +91,7 @@ def find_boundaries(xml_text: str) -> List[Tuple[int, int]]: # Pair up the boundaries [1, 2, 3, 4] -> [(1, 2), (3, 4)]. bounds = iter(boundaries) - pos_bounds = list(zip(bounds, bounds)) + pos_bounds = list(zip(bounds, bounds, strict=False)) return pos_bounds diff --git a/pyxform/question.py b/pyxform/question.py index 684b4097..9c4810c6 100644 --- a/pyxform/question.py +++ b/pyxform/question.py @@ -1,6 +1,7 @@ """ XForm Survey element classes for different question types. """ + import os.path from pyxform.constants import ( @@ -32,7 +33,7 @@ def validate(self): # make sure that the type of this question exists in the # question type dictionary. if self.type not in QUESTION_TYPE_DICT: - raise PyXFormError("Unknown question type '%s'." % self.type) + raise PyXFormError(f"Unknown question type '{self.type}'.") def xml_instance(self, **kwargs): survey = self.get_root() @@ -74,7 +75,7 @@ def nest_setvalues(self, xml_node): for setvalue in nested_setvalues: setvalue_attrs = { "ref": self.get_root() - .insert_xpaths("${%s}" % setvalue[0], self.get_root()) + .insert_xpaths(f"${{{setvalue[0]}}}", self.get_root()) .strip(), "event": "xforms-value-changed", } diff --git a/pyxform/question_type_dictionary.py b/pyxform/question_type_dictionary.py index 84f35fbf..669504f1 100644 --- a/pyxform/question_type_dictionary.py +++ b/pyxform/question_type_dictionary.py @@ -1,6 +1,7 @@ """ XForm survey question type mapping dictionary module. """ + from pyxform.xls2json import QuestionTypesReader, print_pyobj_to_json diff --git a/pyxform/section.py b/pyxform/section.py index 30f467cc..a52ea9e9 100644 --- a/pyxform/section.py +++ b/pyxform/section.py @@ -1,6 +1,7 @@ """ Section survey element module. """ + from pyxform.errors import PyXFormError from pyxform.external_instance import ExternalInstance from pyxform.survey_element import SurveyElement diff --git a/pyxform/survey.py b/pyxform/survey.py index 2c1cc996..3a2b8e15 100644 --- a/pyxform/survey.py +++ b/pyxform/survey.py @@ -1,14 +1,15 @@ """ Survey module with XForm Survey objects and utility functions. """ + import os import re import tempfile import xml.etree.ElementTree as ETree from collections import defaultdict +from collections.abc import Generator, Iterator from datetime import datetime from functools import lru_cache -from typing import Generator, Iterator, List, Optional, Tuple from pyxform import aliases, constants from pyxform.constants import EXTERNAL_INSTANCE_EXTENSIONS, NSMAP @@ -50,16 +51,16 @@ class InstanceInfo: def __init__( self, type: str, - context: Optional[str], + context: str | None, name: str, - src: Optional[str], + src: str | None, instance: "DetachableElement", ): self.type: str = type - self.context: Optional[str] = context + self.context: str | None = context self.name: str = name - self.src: Optional[str] = src - self.instance: "DetachableElement" = instance + self.src: str | None = src + self.instance: DetachableElement = instance def register_nsmap(): @@ -232,15 +233,14 @@ def _validate_uniqueness_of_section_names(self): if element.name == root_node_name: # The root node name is rarely explictly set; explain # the problem in a more helpful way (#510) - raise PyXFormError( - 'The name "%s" is the same as the form name. ' - "Use a different section name " - '(or change the form name in the "name" column of ' - "the settings sheet)." % element.name + msg = ( + f"The name '{element.name}' is the same as the form name. " + "Use a different section name (or change the form name in " + "the 'name' column of the settings sheet)." ) - raise PyXFormError( - "There are two sections with the name %s." % element.name - ) + raise PyXFormError(msg) + msg = f"There are two sections with the name {element.name}." + raise PyXFormError(msg) section_names.append(element.name) def get_nsmap(self): @@ -297,7 +297,7 @@ def xml(self): ) def get_setvalues_for_question_name(self, question_name): - return self.setvalues_by_triggering_ref.get("${%s}" % question_name) + return self.setvalues_by_triggering_ref.get(f"${{{question_name}}}") def _generate_static_instances(self, list_name, choice_list) -> InstanceInfo: """ @@ -348,7 +348,7 @@ def _generate_static_instances(self, list_name, choice_list) -> InstanceInfo: ) @staticmethod - def _generate_external_instances(element) -> Optional[InstanceInfo]: + def _generate_external_instances(element) -> InstanceInfo | None: if isinstance(element, ExternalInstance): name = element["name"] extension = element["type"].split("-")[0] @@ -394,7 +394,7 @@ def _validate_external_instances(instances) -> None: raise ValidationError("\n".join(errors)) @staticmethod - def _generate_pulldata_instances(element) -> Optional[List[InstanceInfo]]: + def _generate_pulldata_instances(element) -> list[InstanceInfo] | None: def get_pulldata_functions(element): """ Returns a list of different pulldata(... function strings if @@ -444,7 +444,7 @@ def get_instance_info(element, file_id): return None @staticmethod - def _generate_from_file_instances(element) -> Optional[InstanceInfo]: + def _generate_from_file_instances(element) -> InstanceInfo | None: itemset = element.get("itemset") file_id, ext = os.path.splitext(itemset) if itemset and ext in EXTERNAL_INSTANCE_EXTENSIONS: @@ -586,13 +586,13 @@ def xml_model(self): entity_features = getattr(self, constants.ENTITY_FEATURES, []) if len(entity_features) > 0: if "update" in entity_features: - model_kwargs[ - "entities:entities-version" - ] = constants.CURRENT_ENTITIES_VERSION + model_kwargs["entities:entities-version"] = ( + constants.CURRENT_ENTITIES_VERSION + ) else: - model_kwargs[ - "entities:entities-version" - ] = constants.ENTITIES_CREATE_VERSION + model_kwargs["entities:entities-version"] = ( + constants.ENTITIES_CREATE_VERSION + ) model_children = [] if self._translations: @@ -701,7 +701,7 @@ def _setup_translations(self): def _setup_choice_translations( name, choice_value, itext_id - ) -> Generator[Tuple[List[str], str], None, None]: + ) -> Generator[tuple[list[str], str], None, None]: for media_or_lang, value in choice_value.items(): if isinstance(value, dict): for language, val in value.items(): @@ -981,12 +981,12 @@ def __repr__(self): return self.__unicode__() def __unicode__(self): - return "" % hex(id(self)) + return f"" def _setup_xpath_dictionary(self): self._xpath = {} # pylint: disable=attribute-defined-outside-init for element in self.iter_descendants(): - if isinstance(element, (Question, Section)): + if isinstance(element, Question | Section): if element.name in self._xpath: self._xpath[element.name] = None else: @@ -1022,7 +1022,7 @@ def _in_secondary_instance_predicate() -> bool: return False return False - def _relative_path(ref_name: str, _use_current: bool) -> Optional[str]: + def _relative_path(ref_name: str, _use_current: bool) -> str | None: """Given name in ${name}, return relative xpath to ${name}.""" return_path = None xpath = self._xpath[ref_name] @@ -1038,7 +1038,7 @@ def _relative_path(ref_name: str, _use_current: bool) -> Optional[str]: self, xpath, context_xpath, reference_parent ) if steps: - ref_path = ref_path if ref_path.endswith(ref_name) else "/%s" % name + ref_path = ref_path if ref_path.endswith(ref_name) else f"/{name}" prefix = " current()/" if _use_current else " " return_path = prefix + "/".join([".."] * steps) + ref_path + " " @@ -1135,8 +1135,8 @@ def _var_repl_output_function(self, matchobj, context): def insert_output_values( self, text: str, - context: Optional[SurveyElement] = None, - ) -> Tuple[str, bool]: + context: SurveyElement | None = None, + ) -> tuple[str, bool]: """ Replace all the ${variables} in text with xpaths. Returns that and a boolean indicating if there were any ${variables} diff --git a/pyxform/survey_element.py b/pyxform/survey_element.py index 490a4449..504e83d0 100644 --- a/pyxform/survey_element.py +++ b/pyxform/survey_element.py @@ -1,11 +1,12 @@ """ Survey Element base class for all survey elements. """ + import json import re from collections import deque from functools import lru_cache -from typing import TYPE_CHECKING, Any, ClassVar, Dict, List +from typing import TYPE_CHECKING, Any, ClassVar from pyxform import aliases as alias from pyxform import constants as const @@ -82,7 +83,7 @@ class SurveyElement(dict): """ __name__ = "SurveyElement" - FIELDS: ClassVar[Dict[str, Any]] = FIELDS.copy() + FIELDS: ClassVar[dict[str, Any]] = FIELDS.copy() def _default(self): # TODO: need way to override question type dictionary @@ -349,7 +350,7 @@ def get_media_keys(self): @deprected I'm leaving this in just in case it has outside references. """ - return {"media": "%s:media" % self.get_xpath()} + return {"media": f"{self.get_xpath()}:media"} def needs_itext_ref(self): return isinstance(self.label, dict) or ( @@ -378,7 +379,7 @@ def xml_label(self): if self.needs_itext_ref(): # If there is a dictionary label, or non-empty media dict, # then we need to make a label with an itext ref - ref = "jr:itext('%s')" % self._translation_path("label") + ref = f"""jr:itext('{self._translation_path("label")}')""" return node("label", ref=ref) else: survey = self.get_root() @@ -388,12 +389,12 @@ def xml_label(self): def xml_hint(self): if isinstance(self.hint, dict) or self.guidance_hint: path = self._translation_path("hint") - return node("hint", ref="jr:itext('%s')" % path) + return node("hint", ref=f"jr:itext('{path}')") else: hint, output_inserted = self.get_root().insert_output_values(self.hint, self) return node("hint", hint, toParseString=output_inserted) - def xml_label_and_hint(self) -> List["DetachableElement"]: + def xml_label_and_hint(self) -> list["DetachableElement"]: """ Return a list containing one node for the label and if there is a hint one node for the hint. @@ -409,7 +410,7 @@ def xml_label_and_hint(self) -> List["DetachableElement"]: result.append(self.xml_label()) result.append(self.xml_hint()) - msg = "The survey element named '%s' has no label or hint." % self.name + msg = f"The survey element named '{self.name}' has no label or hint." if len(result) == 0: raise PyXFormError(msg) @@ -451,13 +452,13 @@ def xml_bindings(self): if k == "jr:constraintMsg" and ( isinstance(v, dict) or re.search(BRACKETED_TAG_REGEX, v) ): - v = "jr:itext('%s')" % self._translation_path("jr:constraintMsg") + v = f"""jr:itext('{self._translation_path("jr:constraintMsg")}')""" if k == "jr:requiredMsg" and ( isinstance(v, dict) or re.search(BRACKETED_TAG_REGEX, v) ): - v = "jr:itext('%s')" % self._translation_path("jr:requiredMsg") + v = f"""jr:itext('{self._translation_path("jr:requiredMsg")}')""" if k == "jr:noAppErrorString" and isinstance(v, dict): - v = "jr:itext('%s')" % self._translation_path("jr:noAppErrorString") + v = f"""jr:itext('{self._translation_path("jr:noAppErrorString")}')""" bind_dict[k] = survey.insert_xpaths(v, context=self) return [node("bind", nodeset=self.get_xpath(), **bind_dict)] return None diff --git a/pyxform/translator.py b/pyxform/translator.py index e8556496..bfd99720 100644 --- a/pyxform/translator.py +++ b/pyxform/translator.py @@ -1,6 +1,7 @@ """ Translator class module. """ + from collections import defaultdict diff --git a/pyxform/utils.py b/pyxform/utils.py index d6f34f21..a29b2d6c 100644 --- a/pyxform/utils.py +++ b/pyxform/utils.py @@ -1,13 +1,14 @@ """ pyxform utils module. """ + import copy import csv import json import os import re from json.decoder import JSONDecodeError -from typing import Dict, List, NamedTuple, Tuple +from typing import NamedTuple from xml.dom import Node from xml.dom.minidom import Element, Text, _write_data @@ -53,7 +54,7 @@ def writexml(self, writer, indent="", addindent="", newl=""): attrs = self._get_attributes() for a_name in attrs.keys(): - writer.write(' %s="' % a_name) + writer.write(f' {a_name}="') _write_data(writer, attrs[a_name].value) writer.write('"') if self.childNodes: @@ -136,7 +137,7 @@ def node(*args, **kwargs) -> DetachableElement: text_node.data = unicode_args[0] result.appendChild(text_node) for n in args: - if isinstance(n, (int, float, bytes)): + if isinstance(n, int | float | bytes): text_node = PatchedText() text_node.data = str(n) result.appendChild(text_node) @@ -187,7 +188,7 @@ def xls_sheet_to_csv(workbook_path, csv_path, sheet_name): for row_idx in range(sheet.nrows): csv_data = [] try: - for v, m in zip(sheet.row(row_idx), mask): + for v, m in zip(sheet.row(row_idx), mask, strict=False): if m: value = v.value value_type = v.ctype @@ -215,7 +216,7 @@ def xlsx_sheet_to_csv(workbook_path, csv_path, sheet_name): for row in sheet.rows: csv_data = [] try: - for v, m in zip(row, mask): + 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 @@ -299,7 +300,7 @@ def default_is_dynamic(element_default, element_type=None): return False -def has_dynamic_label(choice_list: "List[Dict[str, str]]") -> bool: +def has_dynamic_label(choice_list: "list[dict[str, str]]") -> bool: """ If the first or second choice label includes a reference, we must use itext. @@ -442,7 +443,7 @@ class ExpLexerToken(NamedTuple): EXPRESSION_LEXER = get_expression_lexer() -def parse_expression(text: str) -> Tuple[List[ExpLexerToken], str]: +def parse_expression(text: str) -> tuple[list[ExpLexerToken], str]: """ Parse a "default" expression, well enough to identify dynamic defaults vs. not. diff --git a/pyxform/validators/enketo_validate/__init__.py b/pyxform/validators/enketo_validate/__init__.py index ef2935da..becdeb91 100644 --- a/pyxform/validators/enketo_validate/__init__.py +++ b/pyxform/validators/enketo_validate/__init__.py @@ -1,6 +1,7 @@ """ Validate XForms using Enketo validator. """ + import os from typing import TYPE_CHECKING diff --git a/pyxform/validators/error_cleaner.py b/pyxform/validators/error_cleaner.py index f837f150..8305780c 100644 --- a/pyxform/validators/error_cleaner.py +++ b/pyxform/validators/error_cleaner.py @@ -1,6 +1,7 @@ """ Cleans up error messages from the validators. """ + import re @@ -17,7 +18,7 @@ def _replace_xpath_with_tokens(match): ) or strmatch.endswith("/item/value"): return strmatch line = match.group().split("/") - return "${%s}" % line[len(line) - 1] + return f"${{{line[len(line) - 1]}}}" @staticmethod def _cleanup_errors(error_message): diff --git a/pyxform/validators/odk_validate/__init__.py b/pyxform/validators/odk_validate/__init__.py index 9616fafb..14251a44 100644 --- a/pyxform/validators/odk_validate/__init__.py +++ b/pyxform/validators/odk_validate/__init__.py @@ -2,6 +2,7 @@ odk_validate.py A python wrapper around ODK Validate """ + import logging import os import shutil diff --git a/pyxform/validators/pyxform/android_package_name.py b/pyxform/validators/pyxform/android_package_name.py index b1c0cd03..643a339d 100644 --- a/pyxform/validators/pyxform/android_package_name.py +++ b/pyxform/validators/pyxform/android_package_name.py @@ -1,10 +1,9 @@ import re -from typing import Optional PACKAGE_NAME_REGEX = re.compile(r"[^a-zA-Z0-9._]") -def validate_android_package_name(name: str) -> Optional[str]: +def validate_android_package_name(name: str) -> str | None: prefix = "Parameter 'app' has an invalid Android package name - " if not name.strip(): diff --git a/pyxform/validators/pyxform/parameters_generic.py b/pyxform/validators/pyxform/parameters_generic.py index c855e422..cd95843d 100644 --- a/pyxform/validators/pyxform/parameters_generic.py +++ b/pyxform/validators/pyxform/parameters_generic.py @@ -1,8 +1,9 @@ -from typing import Any, Dict, Sequence +from collections.abc import Sequence +from typing import Any from pyxform.errors import PyXFormError -PARAMETERS_TYPE = Dict[str, Any] +PARAMETERS_TYPE = dict[str, Any] # Label and value are used to match against user-specified files so case should be preserved. CASE_SENSITIVE_VALUES = ["label", "value"] @@ -32,7 +33,7 @@ def parse(raw_parameters: str) -> PARAMETERS_TYPE: def validate( parameters: PARAMETERS_TYPE, allowed: Sequence[str], -) -> Dict[str, str]: +) -> dict[str, str]: """ Raise an error if 'parameters' includes any keys not named in 'allowed'. """ diff --git a/pyxform/validators/pyxform/translations_checks.py b/pyxform/validators/pyxform/translations_checks.py index 01121d76..588814a6 100644 --- a/pyxform/validators/pyxform/translations_checks.py +++ b/pyxform/validators/pyxform/translations_checks.py @@ -6,10 +6,10 @@ from pyxform.errors import PyXFormError if TYPE_CHECKING: - from typing import Dict, List, Optional, Sequence, Set, Tuple + from collections.abc import Sequence - SheetData = Tuple[Tuple[str, ...]] - Warnings = List[str] + SheetData = tuple[tuple[str, ...]] + Warnings = list[str] OR_OTHER_WARNING = ( @@ -20,8 +20,8 @@ def format_missing_translations_msg( - _in: "Dict[str, Dict[str, Sequence]]", -) -> "Optional[str]": + _in: "dict[str, dict[str, Sequence]]", +) -> str | None: """ Format the missing translations data into a warning message. @@ -76,22 +76,22 @@ class Translations: def __init__( self, sheet_data: "SheetData", - translatable_columns: "Dict[str, str]", + translatable_columns: dict[str, str], ): """ :param sheet_data: The survey or choices sheet data. :param translatable_columns: The translatable columns for a sheet. The structure should be Dict[internal_name, external_name]. See the aliases module. """ - self.seen: "defaultdict[str, List[str]]" = defaultdict(list) - self.columns_seen: "Set[str]" = set() - self.missing: "defaultdict[str, List[str]]" = defaultdict(list) + self.seen: defaultdict[str, list[str]] = defaultdict(list) + self.columns_seen: set[str] = set() + self.missing: defaultdict[str, list[str]] = defaultdict(list) self._find_translations(sheet_data, translatable_columns) self._find_missing() def _find_translations( - self, sheet_data: "SheetData", translatable_columns: "Dict[str, str]" + self, sheet_data: "SheetData", translatable_columns: dict[str, str] ): def process_header(head): if head[0] in translatable_columns.keys(): @@ -134,11 +134,11 @@ def __init__( :param survey_sheet: The survey sheet data. :param choices_sheet: The choices sheet data. """ - self.survey: "Translations" = Translations( + self.survey: Translations = Translations( sheet_data=survey_sheet, translatable_columns=aliases.TRANSLATABLE_SURVEY_COLUMNS, ) - self.choices: "Translations" = Translations( + self.choices: Translations = Translations( sheet_data=choices_sheet, translatable_columns=aliases.TRANSLATABLE_CHOICES_COLUMNS, ) diff --git a/pyxform/validators/updater.py b/pyxform/validators/updater.py index 6cefc099..adb2a7d0 100644 --- a/pyxform/validators/updater.py +++ b/pyxform/validators/updater.py @@ -1,6 +1,7 @@ """ pyxform_validator_update - command to update XForm validators. """ + import argparse import fnmatch import json @@ -232,10 +233,8 @@ def _find_download_url(update_info, json_data, file_name): ) elif 1 < urls_len: raise PyXFormError( - "{c} files with the name '{n}' attached to release '{r}'." - "\n\n{h}".format( - c=urls_len, n=file_name, r=rel_name, h=update_info.manual_msg - ) + f"{urls_len} files with the name '{file_name}' attached to release '{rel_name}'." + f"\n\n{update_info.manual_msg}" ) else: return file_urls[0] @@ -267,9 +266,7 @@ def _get_bin_paths(update_info, file_path): main_bin = "*validate" else: raise PyXFormError( - "Did not find a supported main binary for file: {p}.\n\n{h}".format( - p=file_path, h=update_info.manual_msg - ) + f"Did not find a supported main binary for file: {file_path}.\n\n{update_info.manual_msg}" ) return [ (main_bin, update_info.validator_basename), diff --git a/pyxform/validators/util.py b/pyxform/validators/util.py index ce09682c..835150da 100644 --- a/pyxform/validators/util.py +++ b/pyxform/validators/util.py @@ -1,6 +1,7 @@ """ The validators utility functions. """ + import logging import os import signal @@ -10,7 +11,7 @@ import time from contextlib import closing from subprocess import PIPE, Popen -from typing import Dict, List, NamedTuple +from typing import NamedTuple from urllib.error import HTTPError, URLError from urllib.request import Request, urlopen @@ -75,7 +76,7 @@ def _kill_process_after_a_timeout(pid): watchdog.start() (stdout, stderr) = p.communicate() watchdog.cancel() # if it's still waiting to run - timeout = kill_check.isSet() + timeout = kill_check.is_set() kill_check.clear() return PopenResult( return_code=p.returncode, timeout=timeout, stdout=stdout, stderr=stderr @@ -129,8 +130,8 @@ def request_get(url): class _LoggingWatcher(NamedTuple): - records: List - output: Dict + records: list + output: dict class CapturingHandler(logging.Handler): diff --git a/pyxform/xform2json.py b/pyxform/xform2json.py index caa72a5b..e2011e6c 100644 --- a/pyxform/xform2json.py +++ b/pyxform/xform2json.py @@ -1,13 +1,14 @@ """ xform2json module - Transform an XForm to a JSON dictionary. """ + import copy import json import logging import re from collections.abc import Mapping from operator import itemgetter -from typing import Any, Dict, List +from typing import Any from xml.etree.ElementTree import Element from defusedxml.ElementTree import ParseError, XMLParser, fromstring, parse @@ -200,7 +201,7 @@ def __init__(self, root): def get_dict(self): json_str = json.dumps(self._dict) for uri in NSMAP.values(): - json_str = json_str.replace("{%s}" % uri, "") + json_str = json_str.replace(f"{{{uri}}}", "") return json.loads(json_str) @@ -325,7 +326,7 @@ def _cleanup_bind_list(self): for item in self._bind_list: ref = item["nodeset"] name = self._get_name_from_ref(ref) - parent_ref = ref[: ref.find("/%s" % name)] + parent_ref = ref[: ref.find(f"/{name}")] question = self._get_question_params_from_bindings(ref) question["name"] = name question["__order"] = self._get_question_order(ref) @@ -562,7 +563,7 @@ def _get_question_type(question_type): return QUESTION_TYPES[question_type] return question_type - def _get_translations(self) -> List[Dict]: + def _get_translations(self) -> list[dict]: if "itext" not in self.model: return [] if "translation" not in self.model["itext"]: @@ -617,7 +618,7 @@ def _get_text_from_translation(self, ref, key="label"): if value["form"] == "image": v = v.replace("jr://images/", "") else: - v = v.replace("jr://%s/" % value["form"], "") + v = v.replace(f"jr://{value['form']}/", "") if v == "-": # skip blank continue text = {value["form"]: v} @@ -630,7 +631,7 @@ def _get_text_from_translation(self, ref, key="label"): if m_type == "image": v = v.replace("jr://images/", "") else: - v = v.replace("jr://%s/" % m_type, "") + v = v.replace(f"jr://{m_type}/", "") if v == "-": continue if k not in label: @@ -664,7 +665,7 @@ 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]: + def _get_choices(self) -> dict[str, Any]: """ Get all form choices, using the model/instance and model/itext. """ @@ -704,7 +705,7 @@ def get_last_item(xpath_str): return last_item[len(last_item) - 1].strip() def replace_function(match): - return "${%s}" % get_last_item(match.group()) + return f"${{{get_last_item(match.group())}}}" # moving re flags into compile for python 2.6 compat pattern = "( /[a-z0-9-_]+(?:/[a-z0-9-_]+)+ )" diff --git a/pyxform/xform_instance_parser.py b/pyxform/xform_instance_parser.py index 7c41dc1d..427d64c9 100644 --- a/pyxform/xform_instance_parser.py +++ b/pyxform/xform_instance_parser.py @@ -64,7 +64,7 @@ def _flatten_dict(d, prefix): # implemented that [0] should be the first node, but # according to the W3C standard it should have been # [1]. I'm adding 1 to i to start at 1. - item_prefix[-1] += "[%s]" % str(i + 1) + item_prefix[-1] += f"[{i + 1}]" if isinstance(item, dict): for pair in _flatten_dict(item, item_prefix): yield pair diff --git a/pyxform/xls2json.py b/pyxform/xls2json.py index d4ea1a64..ad25baa6 100644 --- a/pyxform/xls2json.py +++ b/pyxform/xls2json.py @@ -1,12 +1,13 @@ """ A Python script to convert excel files into JSON. """ + import json import os import re import sys from collections import Counter -from typing import IO, Any, Dict, List, Optional, Tuple +from typing import IO, Any from pyxform import aliases, constants from pyxform.constants import ( @@ -100,18 +101,18 @@ def replace_smart_quotes_in_dict(_d): class DealiasAndGroupHeadersResult: __slots__ = ("headers", "data") - def __init__(self, headers: Tuple[Tuple[str, ...], ...], data: List[Dict]): + def __init__(self, headers: tuple[tuple[str, ...], ...], data: list[dict]): """ :param headers: Distinct headers seen in the sheet, parsed / split if applicable. :param data: Sheet data rows, in grouped dict format. """ - self.headers: Tuple[Tuple[str, ...], ...] = headers - self.data: List[Dict] = data + self.headers: tuple[tuple[str, ...], ...] = headers + self.data: list[dict] = data def dealias_and_group_headers( - dict_array: List[Dict], - header_aliases: Dict[str, str], + dict_array: list[dict], + header_aliases: dict[str, str], use_double_colons: bool, default_language: str = constants.DEFAULT_LANGUAGE_VALUE, ignore_case: bool = False, @@ -292,8 +293,8 @@ def add_flat_annotations(prompt_list, parent_relevant="", name_prefix=""): def process_range_question_type( - row: Dict[str, Any], parameters: parameters_generic.PARAMETERS_TYPE -) -> Dict[str, Any]: + row: dict[str, Any], parameters: parameters_generic.PARAMETERS_TYPE +) -> dict[str, Any]: """ Returns a new row that includes the Range parameters start, end and step. @@ -341,9 +342,9 @@ def process_image_default(default_value): def add_choices_info_to_question( - question: Dict[str, Any], + question: dict[str, Any], list_name: str, - choices: Dict[str, list], + choices: dict[str, list], choice_filter: str, file_extension: str, ): @@ -392,11 +393,11 @@ def add_choices_info_to_question( def workbook_to_json( workbook_dict, - form_name: Optional[str] = None, - fallback_form_name: Optional[str] = None, + form_name: str | None = None, + fallback_form_name: str | None = None, default_language: str = constants.DEFAULT_LANGUAGE_VALUE, - warnings: Optional[List[str]] = None, -) -> Dict[str, Any]: + warnings: list[str] | None = None, +) -> dict[str, Any]: """ workbook_dict -- nested dictionaries representing a spreadsheet. should be similar to those returned by xls_to_dict @@ -771,10 +772,11 @@ def workbook_to_json( parameters[constants.TRACK_CHANGES] != "true" and parameters[constants.TRACK_CHANGES] != "false" ): - raise PyXFormError( - constants.TRACK_CHANGES + " must be set to true or false: " - "'%s' is an invalid value" % parameters[constants.TRACK_CHANGES] + msg = ( + f"{constants.TRACK_CHANGES} must be set to true or false: " + f"'{parameters[constants.TRACK_CHANGES]}' is an invalid value." ) + raise PyXFormError(msg) else: new_dict["bind"] = new_dict.get("bind", {}) new_dict["bind"].update( @@ -801,10 +803,11 @@ def workbook_to_json( parameters[constants.IDENTIFY_USER] != "true" and parameters[constants.IDENTIFY_USER] != "false" ): - raise PyXFormError( - constants.IDENTIFY_USER + " must be set to true or false: " - "'%s' is an invalid value" % parameters[constants.IDENTIFY_USER] + msg = ( + f"{constants.IDENTIFY_USER} must be set to true or false: " + f"'{parameters[constants.IDENTIFY_USER]}' is an invalid value." ) + raise PyXFormError(msg) else: new_dict["bind"] = new_dict.get("bind", {}) new_dict["bind"].update( @@ -828,13 +831,12 @@ def workbook_to_json( "balanced", "high-accuracy", ]: - raise PyXFormError( - "Parameter " - + constants.LOCATION_PRIORITY - + " must be set to no-power, low-power, balanced," - " or high-accuracy: '%s' is an invalid value" - % parameters[constants.LOCATION_PRIORITY] + msg = ( + f"Parameter {constants.LOCATION_PRIORITY} must be set to " + "no-power, low-power, balanced, or high-accuracy:" + f"'{parameters[constants.LOCATION_PRIORITY]}' is an invalid value" ) + raise PyXFormError(msg) try: int(parameters[constants.LOCATION_MIN_INTERVAL]) @@ -1016,8 +1018,7 @@ def workbook_to_json( msg_dict = {"name": row.get("name"), "type": row.get("type")} warnings.append( ROW_FORMAT_STRING % row_number - + " %s has no label: " % control_type.capitalize() - + str(msg_dict) + + f" {control_type.capitalize()} has no label: {msg_dict}" ) new_json_dict = row.copy() @@ -1122,10 +1123,8 @@ def workbook_to_json( and constants.CHOICE_FILTER not in row ): warnings.append( - ROW_FORMAT_STRING - % row_number - + " select one external is only meant for" - " filtered selects." + ROW_FORMAT_STRING % row_number + + " select one external is only meant for filtered selects." ) list_name = parse_dict[constants.LIST_NAME_U] file_extension = os.path.splitext(list_name)[1] @@ -1226,7 +1225,7 @@ def workbook_to_json( ): raise PyXFormError( "randomize must be set to true or false: " - "'%s' is an invalid value" % parameters["randomize"] + f"""'{parameters["randomize"]}' is an invalid value""" ) if "seed" in parameters.keys(): @@ -1308,9 +1307,9 @@ def workbook_to_json( if constants.CONTROL not in new_json_dict: new_json_dict[constants.CONTROL] = {} - new_json_dict[constants.CONTROL][ - constants.APPEARANCE - ] = constants.LIST_NOLABEL + new_json_dict[constants.CONTROL][constants.APPEARANCE] = ( + constants.LIST_NOLABEL + ) parent_children_array.append(new_json_dict) if specify_other_question: parent_children_array.append(specify_other_question) @@ -1594,9 +1593,9 @@ def parse_file_to_json( path: str, default_name: str = "data", default_language: str = constants.DEFAULT_LANGUAGE_VALUE, - warnings: Optional[List[str]] = None, - file_object: Optional[IO] = None, -) -> Dict[str, Any]: + warnings: list[str] | None = None, + file_object: IO | None = None, +) -> dict[str, Any]: """ A wrapper for workbook_to_json """ diff --git a/pyxform/xls2json_backends.py b/pyxform/xls2json_backends.py index eca89014..9a5b10d4 100644 --- a/pyxform/xls2json_backends.py +++ b/pyxform/xls2json_backends.py @@ -1,15 +1,17 @@ """ XLS-to-dict and csv-to-dict are essentially backends for xls2json. """ + import csv import datetime import os import re from collections import OrderedDict +from collections.abc import Callable, Iterator from contextlib import closing from functools import reduce from io import StringIO -from typing import Any, Callable, Dict, Iterator, List, Optional, Tuple, Union +from typing import Any from zipfile import BadZipFile import xlrd @@ -25,7 +27,7 @@ from pyxform import constants from pyxform.errors import PyXFormError -aCell = Union[xlrdCell, pyxlCell] +aCell = xlrdCell | pyxlCell XL_DATE_AMBIGOUS_MSG = ( "The xls file provided has an invalid date on the %s sheet, under" " the %s column on row number %s" @@ -40,7 +42,7 @@ def _list_to_dict_list(list_items): if list_items: k = OrderedDict() for item in list_items: - k["%s" % item] = "" + k[str(item)] = "" return [k] return [] @@ -55,7 +57,7 @@ def trim_trailing_empty(a_list: list, n_empty: int) -> list: return a_list -def get_excel_column_headers(first_row: Iterator[Optional[str]]) -> List[Optional[str]]: +def get_excel_column_headers(first_row: Iterator[str | None]) -> list[str | None]: """Get column headers from the first row; stop if there's a run of empty columns.""" max_adjacent_empty_columns = 20 column_header_list = [] @@ -72,7 +74,7 @@ def get_excel_column_headers(first_row: Iterator[Optional[str]]) -> List[Optiona adjacent_empty_cols = 0 # Check for duplicate column headers. if column_header in column_header_list: - raise PyXFormError("Duplicate column header: %s" % column_header) + raise PyXFormError(f"Duplicate column header: {column_header}") # Strip whitespaces from the header. clean_header = re.sub(r"( )+", " ", column_header.strip()) column_header_list.append(clean_header) @@ -81,10 +83,10 @@ def get_excel_column_headers(first_row: Iterator[Optional[str]]) -> List[Optiona def get_excel_rows( - headers: Iterator[Optional[str]], - rows: Iterator[Tuple[aCell, ...]], + headers: Iterator[str | None], + rows: Iterator[tuple[aCell, ...]], cell_func: Callable[[aCell, int, str], Any], -) -> List[Dict[str, Any]]: +) -> list[dict[str, Any]]: """Get rows of cleaned data; stop if there's a run of empty rows.""" max_adjacent_empty_rows = 60 col_header_enum = list(enumerate(headers)) @@ -129,7 +131,7 @@ def xls_to_dict(path_or_file): def xls_clean_cell( wb: xlrdBook, wb_sheet: xlrdSheet, cell: xlrdCell, row_n: int, col_key: str - ) -> Optional[str]: + ) -> str | None: value = cell.value if isinstance(value, str): value = value.strip() @@ -153,7 +155,7 @@ def xls_to_dict_normal_sheet(wb: xlrdBook, wb_sheet: xlrdSheet): ) # Inject wb/sheet as closure since functools.partial isn't typing friendly. - def clean_func(cell: xlrdCell, row_n: int, col_key: str) -> Optional[str]: + def clean_func(cell: xlrdCell, row_n: int, col_key: str) -> str | None: return xls_clean_cell( wb=wb, wb_sheet=wb_sheet, cell=cell, row_n=row_n, col_key=col_key ) @@ -172,19 +174,19 @@ def process_workbook(wb: xlrdBook): if len(wb.sheets()) == 1: ( result_book[constants.SURVEY], - result_book["%s_header" % constants.SURVEY], + result_book[f"{constants.SURVEY}_header"], ) = xls_to_dict_normal_sheet(wb=wb, wb_sheet=wb_sheet) else: continue else: ( result_book[wb_sheet.name], - result_book["%s_header" % wb_sheet.name], + result_book[f"{wb_sheet.name}_header"], ) = xls_to_dict_normal_sheet(wb=wb, wb_sheet=wb_sheet) return result_book try: - if isinstance(path_or_file, (str, bytes, os.PathLike)): + if isinstance(path_or_file, str | bytes | os.PathLike): file = open(path_or_file, mode="rb") else: file = path_or_file @@ -195,7 +197,7 @@ def process_workbook(wb: xlrdBook): finally: workbook.release_resources() except xlrd.XLRDError as read_err: - raise PyXFormError("Error reading .xls file: %s" % read_err) from read_err + raise PyXFormError(f"Error reading .xls file: {read_err}") from read_err def xls_value_to_unicode(value, value_type, datemode) -> str: @@ -238,7 +240,7 @@ def xlsx_to_dict(path_or_file): All the keys and leaf elements are strings. """ - def xlsx_clean_cell(cell: pyxlCell, row_n: int, col_key: str) -> Optional[str]: + def xlsx_clean_cell(cell: pyxlCell, row_n: int, col_key: str) -> str | None: value = cell.value if isinstance(value, str): value = value.strip() @@ -279,7 +281,7 @@ def process_workbook(wb: pyxlWorkbook): return result_book try: - if isinstance(path_or_file, (str, bytes, os.PathLike)): + if isinstance(path_or_file, str | bytes | os.PathLike): file = open(path_or_file, mode="rb") else: file = path_or_file @@ -292,7 +294,7 @@ def process_workbook(wb: pyxlWorkbook): reader.wb.close() reader.archive.close() except (OSError, BadZipFile, KeyError) as read_err: - raise PyXFormError("Error reading .xlsx file: %s" % read_err) from read_err + raise PyXFormError(f"Error reading .xlsx file: {read_err}") from read_err def xlsx_value_to_str(value) -> str: @@ -306,7 +308,7 @@ def xlsx_value_to_str(value) -> str: elif isinstance(value, float) and value.is_integer(): # Try to display as an int if possible. return str(int(value)) - elif isinstance(value, (int, datetime.datetime, datetime.time)): + elif isinstance(value, int | datetime.datetime | datetime.time): return str(value) else: # ensure unicode and replace nbsp spaces with normal ones @@ -394,10 +396,10 @@ def first_column_as_sheet_name(row): if content is not None: if current_headers is None: current_headers = content - _dict["%s_header" % sheet_name] = _list_to_dict_list(current_headers) + _dict[f"{sheet_name}_header"] = _list_to_dict_list(current_headers) else: _d = OrderedDict() - for key, val in zip(current_headers, content): + 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 diff --git a/pyxform/xls2xform.py b/pyxform/xls2xform.py index e31d5e1a..d9644b60 100644 --- a/pyxform/xls2xform.py +++ b/pyxform/xls2xform.py @@ -2,6 +2,7 @@ xls2xform converts properly formatted Excel documents into XForms for use with ODK Collect. """ + import argparse import json import logging diff --git a/pyxform/xlsparseutils.py b/pyxform/xlsparseutils.py index 7b1c995d..73d07823 100644 --- a/pyxform/xlsparseutils.py +++ b/pyxform/xlsparseutils.py @@ -1,5 +1,5 @@ import re -from typing import KeysView, Optional +from collections.abc import KeysView from pyxform import constants from pyxform.utils import levenshtein_distance @@ -10,7 +10,7 @@ XFORM_TAG_REGEXP = f"{TAG_START_CHAR}{TAG_CHAR}*" -def find_sheet_misspellings(key: str, keys: "KeysView") -> "Optional[str]": +def find_sheet_misspellings(key: str, keys: "KeysView") -> "str | None": """ Find possible sheet name misspellings to warn the user about. diff --git a/tests/pyxform_test_case.py b/tests/pyxform_test_case.py index 835668bb..1ca027f9 100644 --- a/tests/pyxform_test_case.py +++ b/tests/pyxform_test_case.py @@ -1,12 +1,14 @@ """ PyxformTestCase base class using markdown to define the XLSForm. """ + import logging import os import re import tempfile +from collections.abc import Iterable from dataclasses import dataclass -from typing import TYPE_CHECKING, Dict, Iterable, List, Optional, Set, Tuple, Union +from typing import TYPE_CHECKING, Optional from unittest import TestCase from lxml import etree @@ -30,7 +32,7 @@ if TYPE_CHECKING: from pyxform.survey import Survey - NSMAPSubs: "List[Tuple[str, str]]" + NSMAPSubs: "list[tuple[str, str]]" class PyxformTestError(Exception): @@ -40,7 +42,7 @@ class PyxformTestError(Exception): @dataclass class MatcherContext: debug: bool - nsmap_xpath: "Dict[str, str]" + nsmap_xpath: "dict[str, str]" nsmap_subs: "NSMAPSubs" content_str: str @@ -51,12 +53,12 @@ class PyxformMarkdown: def md_to_pyxform_survey( self, md_raw: str, - name: Optional[str] = None, - title: Optional[str] = None, - id_string: Optional[str] = None, + name: str | None = None, + title: str | None = None, + id_string: str | None = None, debug: bool = False, autoname: bool = True, - warnings: Optional[List[str]] = None, + warnings: list[str] | None = None, ): if autoname: kwargs = self._autoname_inputs(name=name, title=title, id_string=id_string) @@ -106,11 +108,11 @@ def _row_to_dict(row): @staticmethod def _ss_structure_to_pyxform_survey( - ss_structure: Dict, - name: Optional[str] = None, - title: Optional[str] = None, - id_string: Optional[str] = None, - warnings: Optional[List[str]] = None, + ss_structure: dict, + name: str | None = None, + title: str | None = None, + id_string: str | None = None, + warnings: list[str] | None = None, ): # using existing methods from the builder imported_survey_json = workbook_to_json( @@ -147,10 +149,10 @@ def _run_odk_validate(xml): @staticmethod def _autoname_inputs( - name: Optional[str] = None, - title: Optional[str] = None, - id_string: Optional[str] = None, - ) -> Dict[str, str]: + name: str | None = None, + title: str | None = None, + id_string: str | None = None, + ) -> dict[str, str]: """ Fill in any blank inputs with default values. """ @@ -167,34 +169,34 @@ class PyxformTestCase(PyxformMarkdown, TestCase): def assertPyxformXform( self, # Survey input - md: Optional[str] = None, - ss_structure: Optional[Dict] = None, + md: str | None = None, + ss_structure: dict | None = None, survey: Optional["Survey"] = None, # XForm assertions - xml__xpath_match: Optional[Iterable[str]] = None, - xml__xpath_exact: Optional[Iterable[Tuple[str, Set[str]]]] = None, - xml__xpath_count: Optional[Iterable[Tuple[str, int]]] = None, + xml__xpath_match: Iterable[str] | None = None, + xml__xpath_exact: Iterable[tuple[str, set[str]]] | None = None, + xml__xpath_count: Iterable[tuple[str, int]] | None = None, # XForm assertions - deprecated - xml__contains: Optional[Iterable[str]] = None, - xml__excludes: Optional[Iterable[str]] = None, - model__contains: Optional[Iterable[str]] = None, - model__excludes: Optional[Iterable[str]] = None, - itext__contains: Optional[Iterable[str]] = None, - itext__excludes: Optional[Iterable[str]] = None, - instance__contains: Optional[Iterable[str]] = None, + xml__contains: Iterable[str] | None = None, + xml__excludes: Iterable[str] | None = None, + model__contains: Iterable[str] | None = None, + model__excludes: Iterable[str] | None = None, + itext__contains: Iterable[str] | None = None, + itext__excludes: Iterable[str] | None = None, + instance__contains: Iterable[str] | None = None, # Errors assertions - error__contains: Optional[Iterable[str]] = None, - error__not_contains: Optional[Iterable[str]] = None, - odk_validate_error__contains: Optional[Iterable[str]] = None, - warnings__contains: Optional[Iterable[str]] = None, - warnings__not_contains: Optional[Iterable[str]] = None, - warnings_count: Optional[int] = None, + error__contains: Iterable[str] | None = None, + error__not_contains: Iterable[str] | None = None, + odk_validate_error__contains: Iterable[str] | None = None, + warnings__contains: Iterable[str] | None = None, + warnings__not_contains: Iterable[str] | None = None, + warnings_count: int | None = None, errored: bool = False, # Optional extras - name: Optional[str] = None, - id_string: Optional[str] = None, - title: Optional[str] = None, - warnings: Optional[List[str]] = None, + 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, ): @@ -295,7 +297,7 @@ def assertPyxformXform( def _pull_xml_node_from_root(element_selector): _r = root.findall( - ".//n:%s" % element_selector, + f".//n:{element_selector}", namespaces={"n": "http://www.w3.org/2002/xforms"}, ) if _r: @@ -455,7 +457,7 @@ def assertContains(self, content, text, count=None, msg_prefix=""): else: self.assertTrue( real_count != 0, - msg_prefix + "Couldn't find %s in content:\n" % text_repr + content, + f"{msg_prefix}Couldn't find {text_repr} in content:\n{content}", ) def assertNotContains(self, content, text, msg_prefix=""): @@ -469,7 +471,7 @@ def assertNotContains(self, content, text, msg_prefix=""): ) self.assertEqual( - real_count, 0, msg_prefix + "Response should not contain %s" % text_repr + real_count, 0, f"{msg_prefix}Response should not contain {text_repr}" ) def assert_xpath_exact( @@ -477,7 +479,7 @@ def assert_xpath_exact( matcher_context: "MatcherContext", content: "_Element", xpath: str, - expected: "Set[str]", + expected: "set[str]", case_num: int, ) -> None: """ @@ -565,8 +567,8 @@ def reorder_attributes(root): def xpath_clean_result_strings( - nsmap_subs: "NSMAPSubs", results: "Set[_Element]" -) -> "Set[str]": + nsmap_subs: "NSMAPSubs", results: "set[_Element]" +) -> "set[str]": """ Clean XPath results: stringify, remove namespace declarations, clean up whitespace. @@ -589,7 +591,7 @@ def xpath_clean_result_strings( def xpath_evaluate( matcher_context: "MatcherContext", content: "_Element", xpath: str, for_exact=False -) -> "Union[Set[_Element], Set[str]]": +) -> "set[_Element] | set[str]": """ Evaluate an XPath and return the results. diff --git a/tests/test_area.py b/tests/test_area.py index 10cf6360..e09bb3e3 100644 --- a/tests/test_area.py +++ b/tests/test_area.py @@ -1,6 +1,7 @@ """ AreaTest - test enclosed-area(geo_shape) calculation. """ + from tests.pyxform_test_case import PyxformTestCase diff --git a/tests/test_audit.py b/tests/test_audit.py index 6ef65a29..651bb5e7 100644 --- a/tests/test_audit.py +++ b/tests/test_audit.py @@ -1,6 +1,7 @@ """ AuditTest - test audit question type. """ + from tests.pyxform_test_case import PyxformTestCase diff --git a/tests/test_bind_conversions.py b/tests/test_bind_conversions.py index d6d58016..2ff3d111 100644 --- a/tests/test_bind_conversions.py +++ b/tests/test_bind_conversions.py @@ -1,6 +1,7 @@ """ BindConversionsTest - test bind conversions. """ + from tests.pyxform_test_case import PyxformTestCase diff --git a/tests/test_bug_missing_headers.py b/tests/test_bug_missing_headers.py index 7013d871..dd61942e 100644 --- a/tests/test_bug_missing_headers.py +++ b/tests/test_bug_missing_headers.py @@ -1,6 +1,7 @@ """ Test missing headers in XLSForm. """ + from tests.pyxform_test_case import PyxformTestCase diff --git a/tests/test_bug_round_calculation.py b/tests/test_bug_round_calculation.py index 64f52e35..706e0138 100644 --- a/tests/test_bug_round_calculation.py +++ b/tests/test_bug_round_calculation.py @@ -1,6 +1,7 @@ """ Test round(number, precision) calculation. """ + from tests.pyxform_test_case import PyxformTestCase diff --git a/tests/test_builder.py b/tests/test_builder.py index 209d15fe..dc20f7c1 100644 --- a/tests/test_builder.py +++ b/tests/test_builder.py @@ -1,6 +1,7 @@ """ Test builder module functionality. """ + import os import re from unittest import TestCase diff --git a/tests/test_dump_and_load.py b/tests/test_dump_and_load.py index 5ddb2d03..f114d45d 100644 --- a/tests/test_dump_and_load.py +++ b/tests/test_dump_and_load.py @@ -1,6 +1,7 @@ """ Test multiple XLSForm can be generated successfully. """ + import os from unittest import TestCase diff --git a/tests/test_dynamic_default.py b/tests/test_dynamic_default.py index 79644f5d..3cf68057 100644 --- a/tests/test_dynamic_default.py +++ b/tests/test_dynamic_default.py @@ -1,11 +1,11 @@ """ Test handling dynamic default in forms """ + import os import unittest from dataclasses import dataclass from time import perf_counter -from typing import Optional, Tuple from unittest.mock import patch import psutil @@ -32,7 +32,7 @@ class Case: is_dynamic: bool q_type: str q_default: str = "" - q_value: Optional[str] = None + q_value: str | None = None q_label_fr: str = "" @@ -127,7 +127,7 @@ def body_input(qnum: int, case: Case): """ @staticmethod - def body_select1(q_num: int, choices: Tuple[Tuple[str, str], ...]): + def body_select1(q_num: int, choices: tuple[tuple[str, str], ...]): """Expected structure for body elements for select1 types.""" choices_xp = "\n and ".join( ( diff --git a/tests/test_external_instances.py b/tests/test_external_instances.py index 1f072d0c..e0d33ab9 100644 --- a/tests/test_external_instances.py +++ b/tests/test_external_instances.py @@ -3,6 +3,7 @@ See also test_external_instances_for_selects """ + from textwrap import dedent from pyxform.errors import PyXFormError diff --git a/tests/test_external_instances_for_selects.py b/tests/test_external_instances_for_selects.py index 32b78b28..b49aa9eb 100644 --- a/tests/test_external_instances_for_selects.py +++ b/tests/test_external_instances_for_selects.py @@ -3,6 +3,7 @@ See also test_external_instances """ + import os from dataclasses import dataclass, field diff --git a/tests/test_fieldlist_labels.py b/tests/test_fieldlist_labels.py index bf3a4bad..bd7d5be7 100644 --- a/tests/test_fieldlist_labels.py +++ b/tests/test_fieldlist_labels.py @@ -1,6 +1,7 @@ """ Test field-list labels """ + from tests.pyxform_test_case import PyxformTestCase diff --git a/tests/test_fields.py b/tests/test_fields.py index 86afb9c7..6c44fc9e 100644 --- a/tests/test_fields.py +++ b/tests/test_fields.py @@ -1,6 +1,7 @@ """ Test duplicate survey question field name. """ + from tests.pyxform_test_case import PyxformTestCase from tests.xpath_helpers.choices import xpc from tests.xpath_helpers.questions import xpq @@ -193,7 +194,7 @@ def test_duplicate_form_name_in_section_name(self): | | end group | | | """, errored=True, - error__contains=['The name "foo" is the same as the form name'], + error__contains=["The name 'foo' is the same as the form name"], ) def test_field_name_may_match_form_name(self): diff --git a/tests/test_file.py b/tests/test_file.py index 21970c01..847f34f2 100644 --- a/tests/test_file.py +++ b/tests/test_file.py @@ -1,6 +1,7 @@ """ Test file question type. """ + from tests.pyxform_test_case import PyxformTestCase diff --git a/tests/test_file_utils.py b/tests/test_file_utils.py index df50d123..8266235d 100644 --- a/tests/test_file_utils.py +++ b/tests/test_file_utils.py @@ -1,6 +1,7 @@ """ Test xls2json_backends util functions. """ + from unittest import TestCase from pyxform.xls2json_backends import convert_file_to_csv_string diff --git a/tests/test_for_loop.py b/tests/test_for_loop.py index 03f7d258..0720ea21 100644 --- a/tests/test_for_loop.py +++ b/tests/test_for_loop.py @@ -1,6 +1,7 @@ """ Test loop question type. """ + from tests.pyxform_test_case import PyxformTestCase diff --git a/tests/test_form_name.py b/tests/test_form_name.py index 090f643f..866fec60 100644 --- a/tests/test_form_name.py +++ b/tests/test_form_name.py @@ -1,6 +1,7 @@ """ Test setting form name to data. """ + from tests.pyxform_test_case import PyxformTestCase diff --git a/tests/test_geo.py b/tests/test_geo.py index 548295e5..0144bbda 100644 --- a/tests/test_geo.py +++ b/tests/test_geo.py @@ -1,6 +1,7 @@ """ Test geo widgets. """ + from tests.pyxform_test_case import PyxformTestCase diff --git a/tests/test_group.py b/tests/test_group.py index 68597cc7..9d99fbe5 100644 --- a/tests/test_group.py +++ b/tests/test_group.py @@ -1,6 +1,7 @@ """ Testing simple cases for Xls2Json """ + from unittest import TestCase from pyxform.builder import create_survey_element_from_dict diff --git a/tests/test_groups.py b/tests/test_groups.py index 5853fcfe..1d8d6efe 100644 --- a/tests/test_groups.py +++ b/tests/test_groups.py @@ -1,6 +1,7 @@ """ Test XForm groups. """ + from tests.pyxform_test_case import PyxformTestCase diff --git a/tests/test_guidance_hint.py b/tests/test_guidance_hint.py index 8566a9c4..896a6953 100644 --- a/tests/test_guidance_hint.py +++ b/tests/test_guidance_hint.py @@ -1,6 +1,7 @@ """ Guidance hint test module. """ + from tests.pyxform_test_case import PyxformTestCase diff --git a/tests/test_image_app_parameter.py b/tests/test_image_app_parameter.py index 36f55bab..eab62071 100644 --- a/tests/test_image_app_parameter.py +++ b/tests/test_image_app_parameter.py @@ -1,6 +1,7 @@ """ Test image max-pixels and app parameters. """ + from tests.pyxform_test_case import PyxformTestCase diff --git a/tests/test_j2x_creation.py b/tests/test_j2x_creation.py index e8f79ad3..c0d7b41c 100644 --- a/tests/test_j2x_creation.py +++ b/tests/test_j2x_creation.py @@ -1,6 +1,7 @@ """ Testing creation of Surveys using verbose methods """ + from unittest import TestCase from pyxform import MultipleChoiceQuestion, Survey, create_survey_from_xls diff --git a/tests/test_j2x_instantiation.py b/tests/test_j2x_instantiation.py index 0b419be1..d7f928bc 100644 --- a/tests/test_j2x_instantiation.py +++ b/tests/test_j2x_instantiation.py @@ -1,6 +1,7 @@ """ Testing the instance object for pyxform. """ + from unittest import TestCase from pyxform import Survey, SurveyInstance diff --git a/tests/test_j2x_question.py b/tests/test_j2x_question.py index 7ba69f9e..8e332995 100644 --- a/tests/test_j2x_question.py +++ b/tests/test_j2x_question.py @@ -1,6 +1,7 @@ """ Testing creation of Surveys using verbose methods """ + from unittest import TestCase from pyxform import Survey diff --git a/tests/test_j2x_xform_build_preparation.py b/tests/test_j2x_xform_build_preparation.py index 3740c908..062a2280 100644 --- a/tests/test_j2x_xform_build_preparation.py +++ b/tests/test_j2x_xform_build_preparation.py @@ -1,6 +1,7 @@ """ Testing preparation of values for XForm exporting """ + from unittest import TestCase from pyxform import MultipleChoiceQuestion, Survey diff --git a/tests/test_js2x_import_from_json.py b/tests/test_js2x_import_from_json.py index 7a698c8d..bc639e64 100644 --- a/tests/test_js2x_import_from_json.py +++ b/tests/test_js2x_import_from_json.py @@ -1,6 +1,7 @@ """ Testing our ability to import from a JSON text file. """ + from unittest import TestCase from pyxform.builder import ( diff --git a/tests/test_json2xform.py b/tests/test_json2xform.py index c169debd..b06bfe73 100644 --- a/tests/test_json2xform.py +++ b/tests/test_json2xform.py @@ -1,6 +1,7 @@ """ Testing simple cases for pyxform """ + from unittest import TestCase from pyxform.builder import create_survey_element_from_dict diff --git a/tests/test_language_warnings.py b/tests/test_language_warnings.py index 2d17a9a7..9892d0ca 100644 --- a/tests/test_language_warnings.py +++ b/tests/test_language_warnings.py @@ -1,6 +1,7 @@ """ Test language warnings. """ + import os import tempfile diff --git a/tests/test_last_saved.py b/tests/test_last_saved.py index 05f771a0..094efde0 100644 --- a/tests/test_last_saved.py +++ b/tests/test_last_saved.py @@ -1,6 +1,7 @@ """ The last-saved virtual instance can be queried to get values from the last saved instance of the form being authored. """ + from tests.pyxform_test_case import PyxformTestCase diff --git a/tests/test_loop.py b/tests/test_loop.py index 5a079950..d4ca0d61 100644 --- a/tests/test_loop.py +++ b/tests/test_loop.py @@ -1,6 +1,7 @@ """ Test loop syntax. """ + from unittest import TestCase from pyxform.builder import create_survey_from_xls diff --git a/tests/test_metadata.py b/tests/test_metadata.py index 0fcd1a8e..2861d06c 100644 --- a/tests/test_metadata.py +++ b/tests/test_metadata.py @@ -1,6 +1,7 @@ """ Test language warnings. """ + import os import tempfile diff --git a/tests/test_notes.py b/tests/test_notes.py index 9c8e24f9..6cbb9a55 100644 --- a/tests/test_notes.py +++ b/tests/test_notes.py @@ -1,8 +1,8 @@ """ Test the "note" question type. """ + from dataclasses import dataclass -from typing import Set from tests.pyxform_test_case import PyxformTestCase from tests.xpath_helpers.questions import xpq @@ -16,7 +16,7 @@ class Case: label: str xpath: str - match: Set[str] + match: set[str] class TestNotes(PyxformTestCase): diff --git a/tests/test_osm.py b/tests/test_osm.py index 1905a4df..7740a21d 100644 --- a/tests/test_osm.py +++ b/tests/test_osm.py @@ -1,6 +1,7 @@ """ Test OSM widgets. """ + from tests.pyxform_test_case import PyxformTestCase expected_xml_output = """ diff --git a/tests/test_parameters_rows.py b/tests/test_parameters_rows.py index 03555608..50c14be3 100644 --- a/tests/test_parameters_rows.py +++ b/tests/test_parameters_rows.py @@ -1,6 +1,7 @@ """ Test text rows parameter. """ + from tests.pyxform_test_case import PyxformTestCase diff --git a/tests/test_pyxform_test_case.py b/tests/test_pyxform_test_case.py index 8de8fa1f..ceea0c4b 100644 --- a/tests/test_pyxform_test_case.py +++ b/tests/test_pyxform_test_case.py @@ -1,17 +1,13 @@ import unittest from dataclasses import dataclass -from typing import TYPE_CHECKING from tests.pyxform_test_case import PyxformTestCase -if TYPE_CHECKING: - from typing import Set - @dataclass class CaseData: xpath: str - exact: "Set[str]" + exact: "set[str]" count: int @property diff --git a/tests/test_pyxformtestcase.py b/tests/test_pyxformtestcase.py index 35a35a02..f6e2599a 100644 --- a/tests/test_pyxformtestcase.py +++ b/tests/test_pyxformtestcase.py @@ -2,6 +2,7 @@ Ensuring that the pyxform_test_case.PyxformTestCase class does some internal conversions correctly. """ + from tests.pyxform_test_case import PyxformTestCase diff --git a/tests/test_randomize_itemsets.py b/tests/test_randomize_itemsets.py index 6496e441..b3e41d73 100644 --- a/tests/test_randomize_itemsets.py +++ b/tests/test_randomize_itemsets.py @@ -1,6 +1,7 @@ """ Test randomize itemsets. """ + from tests.pyxform_test_case import PyxformTestCase diff --git a/tests/test_range.py b/tests/test_range.py index 14bb5553..10cd8682 100644 --- a/tests/test_range.py +++ b/tests/test_range.py @@ -1,6 +1,7 @@ """ Test range widget. """ + from tests.pyxform_test_case import PyxformTestCase diff --git a/tests/test_rank.py b/tests/test_rank.py index f41386e4..f0ed2b89 100644 --- a/tests/test_rank.py +++ b/tests/test_rank.py @@ -1,6 +1,7 @@ """ Test rank widget. """ + from tests.pyxform_test_case import PyxformTestCase from tests.xpath_helpers.choices import xpc from tests.xpath_helpers.questions import xpq diff --git a/tests/test_repeat.py b/tests/test_repeat.py index 4bb0cb99..aa06a28c 100644 --- a/tests/test_repeat.py +++ b/tests/test_repeat.py @@ -1,6 +1,7 @@ """ Test reapeat structure. """ + from tests.pyxform_test_case import PyxformTestCase diff --git a/tests/test_repeat_template.py b/tests/test_repeat_template.py index 15595216..2607243c 100644 --- a/tests/test_repeat_template.py +++ b/tests/test_repeat_template.py @@ -1,6 +1,7 @@ """ Test repeat template and instance structure. """ + from tests.pyxform_test_case import PyxformTestCase diff --git a/tests/test_secondary_instance_translations.py b/tests/test_secondary_instance_translations.py index bd80f709..78e47e5c 100644 --- a/tests/test_secondary_instance_translations.py +++ b/tests/test_secondary_instance_translations.py @@ -1,6 +1,7 @@ """ Testing inlining translation when no translation is specified. """ + from tests.pyxform_test_case import PyxformTestCase from tests.xpath_helpers.choices import xpc from tests.xpath_helpers.questions import xpq diff --git a/tests/test_set_geopoint.py b/tests/test_set_geopoint.py index 0540cbbf..c52196ed 100644 --- a/tests/test_set_geopoint.py +++ b/tests/test_set_geopoint.py @@ -1,6 +1,7 @@ """ Test setgeopoint widget. """ + from tests.pyxform_test_case import PyxformTestCase diff --git a/tests/test_settings.py b/tests/test_settings.py index 86fd70f9..f05ac211 100644 --- a/tests/test_settings.py +++ b/tests/test_settings.py @@ -38,7 +38,6 @@ def test_form_id(self): """ self.assertPyxformXform( md=md, - debug=True, xml__xpath_match=[ "/h:html/h:head/x:model/x:instance/x:test_name[@id='my_form']" ], diff --git a/tests/test_settings_auto_send_delete.py b/tests/test_settings_auto_send_delete.py index ac8df5af..eec4a7c7 100644 --- a/tests/test_settings_auto_send_delete.py +++ b/tests/test_settings_auto_send_delete.py @@ -1,6 +1,7 @@ """ Test settins auto settings. """ + from tests.pyxform_test_case import PyxformTestCase diff --git a/tests/test_sheet_columns.py b/tests/test_sheet_columns.py index 5b60c549..a5b85a42 100644 --- a/tests/test_sheet_columns.py +++ b/tests/test_sheet_columns.py @@ -1,6 +1,7 @@ """ Test XLSForm sheet names. """ + from tests.pyxform_test_case import PyxformTestCase from tests.utils import prep_for_xml_contains from tests.xpath_helpers.choices import xpc diff --git a/tests/test_sms.py b/tests/test_sms.py index 00578071..6bfa434b 100644 --- a/tests/test_sms.py +++ b/tests/test_sms.py @@ -1,6 +1,7 @@ """ Test sms syntax. """ + from tests.pyxform_test_case import PyxformTestCase diff --git a/tests/test_table_list.py b/tests/test_table_list.py index 155c157c..3cee939e 100644 --- a/tests/test_table_list.py +++ b/tests/test_table_list.py @@ -1,6 +1,7 @@ """ Test table list appearance syntax. """ + from tests.pyxform_test_case import PyxformTestCase MD = ''' diff --git a/tests/test_translations.py b/tests/test_translations.py index 4da39620..f4228e41 100644 --- a/tests/test_translations.py +++ b/tests/test_translations.py @@ -1,6 +1,7 @@ """ Test translations syntax. """ + import unittest from dataclasses import dataclass from time import perf_counter @@ -372,13 +373,13 @@ def test_missing_translations_check_performance(self): """ Should find the translations check costs a fraction of a second for large forms. - Results with Python 3.8.9 on VM with 4CPU 8GB RAM, x questions with 2 choices - each, average of 10 runs (seconds), with and without the check, per question: + Results with Python 3.10.14 on VM with 2vCPU (i7-7700HQ) 4GB RAM, x questions + with 2 choices each, average of 10 runs (seconds), with and without the check, + per question: | num | with | without | - | 500 | 1.0192 | 0.9950 | - | 1000 | 2.0054 | 2.1026 | - | 2000 | 4.0714 | 4.0926 | - | 3000 | 6.0266 | 6.2476 | + | 500 | 3.0420 | 3.0427 | + | 1000 | 9.7641 | 9.6972 | + | 2000 | 30.645 | 28.869 | """ survey_header = """ | survey | | | | | diff --git a/tests/test_tutorial_xls.py b/tests/test_tutorial_xls.py index 57b15d53..f57264d8 100644 --- a/tests/test_tutorial_xls.py +++ b/tests/test_tutorial_xls.py @@ -1,6 +1,7 @@ """ Test tutorial XLSForm. """ + from unittest import TestCase from pyxform.builder import create_survey_from_path diff --git a/tests/test_unicode_rtl.py b/tests/test_unicode_rtl.py index ec28ad80..322bec1b 100644 --- a/tests/test_unicode_rtl.py +++ b/tests/test_unicode_rtl.py @@ -1,6 +1,7 @@ """ Test unicode rtl in XLSForms. """ + from tests.pyxform_test_case import PyxformTestCase diff --git a/tests/test_upload_question.py b/tests/test_upload_question.py index c611e940..92d0708c 100644 --- a/tests/test_upload_question.py +++ b/tests/test_upload_question.py @@ -1,6 +1,7 @@ """ Test upload (image, audio, file) question types in XLSForm """ + from tests.pyxform_test_case import PyxformTestCase diff --git a/tests/test_utils/md_table.py b/tests/test_utils/md_table.py index 9903c417..7d17821b 100644 --- a/tests/test_utils/md_table.py +++ b/tests/test_utils/md_table.py @@ -1,8 +1,8 @@ """ Markdown table utility functions. """ + import re -from typing import List, Tuple from openpyxl import Workbook @@ -35,7 +35,7 @@ def _is_null_row(r_arr): return True -def md_table_to_ss_structure(mdstr: str) -> List[Tuple[str, List[List[str]]]]: +def md_table_to_ss_structure(mdstr: str) -> list[tuple[str, list[list[str]]]]: ss_arr = [] for item in mdstr.split("\n"): arr = _extract_array(item) diff --git a/tests/test_validator_update.py b/tests/test_validator_update.py index 9f7e3cb9..5aeb0283 100644 --- a/tests/test_validator_update.py +++ b/tests/test_validator_update.py @@ -1,11 +1,11 @@ """ Test validator update cli command. """ + import os import platform from datetime import datetime, timedelta from stat import S_IXGRP, S_IXUSR -from typing import Optional from unittest import TestCase, skipIf from zipfile import ZipFile @@ -77,7 +77,7 @@ def test_get_temp_dir(self): class TestUpdateHandler(TestCase): - server: "Optional[ThreadingServerInThread]" = None + server: "ThreadingServerInThread | None" = None @classmethod def setUpClass(cls): @@ -366,9 +366,10 @@ def test_unzip_find_zip_jobs__ok_real_current(self): def test_unzip_find_zip_jobs__ok_real_ideal(self): """Should return a list of zip jobs same length as search.""" - with get_temp_dir() as temp_dir, ZipFile( - self.zip_file_ideal, mode="r" - ) as zip_file: + with ( + get_temp_dir() as temp_dir, + ZipFile(self.zip_file_ideal, mode="r") as zip_file, + ): bin_paths = self.updater._get_bin_paths( update_info=self.update_info, file_path=self.zip_file_ideal ) @@ -380,9 +381,10 @@ def test_unzip_find_zip_jobs__ok_real_ideal(self): def test_unzip_find_zip_jobs__ok_real_dupes(self): """Should return a list of zip jobs same length as search.""" - with get_temp_dir() as temp_dir, ZipFile( - self.zip_file_dupes, mode="r" - ) as zip_file: + with ( + get_temp_dir() as temp_dir, + ZipFile(self.zip_file_dupes, mode="r") as zip_file, + ): bin_paths = self.updater._get_bin_paths( update_info=self.update_info, file_path=self.zip_file_dupes ) @@ -396,9 +398,11 @@ def test_unzip_find_zip_jobs__not_found_raises(self): """Should raise an error if zip jobs isn't same length as search.""" bin_paths = [(".non_existent", ".non_existent")] - with get_temp_dir() as temp_dir, ZipFile( - self.zip_file, mode="r" - ) as zip_file, self.assertRaises(PyXFormError) as ctx: + with ( + get_temp_dir() as temp_dir, + ZipFile(self.zip_file, mode="r") as zip_file, + self.assertRaises(PyXFormError) as ctx, + ): self.updater._unzip_find_jobs( open_zip_file=zip_file, bin_paths=bin_paths, out_path=temp_dir ) @@ -416,9 +420,11 @@ def test_unzip_extract_file__ok(self): def test_unzip_extract_file__bad_crc_raises(self): """Should raise an error if the zip file CRC doesn't match.""" - with get_temp_dir() as temp_dir, ZipFile( - self.zip_file, mode="r" - ) as zip_file, self.assertRaises(BadZipFile) as ctx: + with ( + get_temp_dir() as temp_dir, + ZipFile(self.zip_file, mode="r") as zip_file, + self.assertRaises(BadZipFile) as ctx, + ): zip_item = next( x for x in zip_file.infolist() if x.filename.endswith("validate") ) diff --git a/tests/test_validator_util.py b/tests/test_validator_util.py index 147bf728..550ab0c0 100644 --- a/tests/test_validator_util.py +++ b/tests/test_validator_util.py @@ -1,6 +1,7 @@ """ Test pyxform.validators.utils module. """ + import os from unittest import TestCase diff --git a/tests/test_validators.py b/tests/test_validators.py index 0d5a08e0..868bb449 100644 --- a/tests/test_validators.py +++ b/tests/test_validators.py @@ -1,6 +1,7 @@ """ Test validators. """ + from unittest import TestCase from unittest.mock import patch diff --git a/tests/test_warnings.py b/tests/test_warnings.py index 2f8a3872..470efd53 100644 --- a/tests/test_warnings.py +++ b/tests/test_warnings.py @@ -1,6 +1,7 @@ """ Test warnings. """ + from tests.pyxform_test_case import PyxformTestCase diff --git a/tests/test_whitespace.py b/tests/test_whitespace.py index 9e3c9c01..29750455 100644 --- a/tests/test_whitespace.py +++ b/tests/test_whitespace.py @@ -1,6 +1,7 @@ """ Test whitespace around output variables in XForms. """ + from tests.pyxform_test_case import PyxformTestCase diff --git a/tests/test_xform2json.py b/tests/test_xform2json.py index afc1d8d7..4f74fc60 100644 --- a/tests/test_xform2json.py +++ b/tests/test_xform2json.py @@ -1,6 +1,7 @@ """ Test xform2json module. """ + import json import os from unittest import TestCase diff --git a/tests/test_xls2json_backends.py b/tests/test_xls2json_backends.py index 55a9fcfc..088bb51c 100644 --- a/tests/test_xls2json_backends.py +++ b/tests/test_xls2json_backends.py @@ -1,6 +1,7 @@ """ Test xls2json_backends module functionality. """ + import os from datetime import datetime from unittest import TestCase @@ -68,8 +69,8 @@ def test_equivalency(self): "yes_or_no_question", ] for fixture in equivalent_fixtures: - xls_path = utils.path_to_text_fixture("%s.xls" % fixture) - xlsx_path = utils.path_to_text_fixture("%s.xlsx" % fixture) + xls_path = utils.path_to_text_fixture(f"{fixture}.xls") + xlsx_path = utils.path_to_text_fixture(f"{fixture}.xlsx") xls_inp = xls_to_dict(xls_path) xlsx_inp = xlsx_to_dict(xlsx_path) self.maxDiff = None diff --git a/tests/test_xls2json_xls.py b/tests/test_xls2json_xls.py index 1f914968..efe7eb8d 100644 --- a/tests/test_xls2json_xls.py +++ b/tests/test_xls2json_xls.py @@ -1,6 +1,7 @@ """ Testing simple cases for Xls2Json """ + import json import os from unittest import TestCase @@ -34,9 +35,10 @@ def test_simple_yes_or_no_question(self): with open(output_path, mode="w", encoding="utf-8") as fp: json.dump(x_results, fp=fp, ensure_ascii=False, indent=4) # Compare with the expected output: - with open(expected_output_path, encoding="utf-8") as expected, open( - output_path, encoding="utf-8" - ) as observed: + with ( + open(expected_output_path, encoding="utf-8") as expected, + open(output_path, encoding="utf-8") as observed, + ): self.assertEqual(json.load(expected), json.load(observed)) def test_hidden(self): @@ -128,9 +130,10 @@ def test_table(self): with open(output_path, mode="w", encoding="utf-8") as fp: json.dump(x_results, fp=fp, ensure_ascii=False, indent=4) # Compare with the expected output: - with open(expected_output_path, encoding="utf-8") as expected, open( - output_path, encoding="utf-8" - ) as observed: + with ( + open(expected_output_path, encoding="utf-8") as expected, + open(output_path, encoding="utf-8") as observed, + ): self.assertEqual(json.load(expected), json.load(observed)) def test_choice_filter_choice_fields(self): @@ -217,8 +220,8 @@ def test_equivalency(self): "yes_or_no_question", ] for fixture in equivalent_fixtures: - xls_path = utils.path_to_text_fixture("%s.xls" % fixture) - csv_path = utils.path_to_text_fixture("%s.csv" % fixture) + xls_path = utils.path_to_text_fixture(f"{fixture}.xls") + csv_path = utils.path_to_text_fixture(f"{fixture}.csv") xls_inp = xls_to_dict(xls_path) csv_inp = csv_to_dict(csv_path) self.maxDiff = None diff --git a/tests/test_xlsform_headers.py b/tests/test_xlsform_headers.py index b1fa3422..fdf2ee97 100644 --- a/tests/test_xlsform_headers.py +++ b/tests/test_xlsform_headers.py @@ -1,6 +1,7 @@ """ Test XLSForm headers syntax. """ + from tests.pyxform_test_case import PyxformTestCase diff --git a/tests/utils.py b/tests/utils.py index b2b2344d..89edc588 100644 --- a/tests/utils.py +++ b/tests/utils.py @@ -1,22 +1,19 @@ """ The tests utils module functionality. """ + import configparser import os import shutil import tempfile import textwrap from contextlib import contextmanager -from typing import TYPE_CHECKING from pyxform import file_utils from pyxform.builder import create_survey, create_survey_from_path from tests import example_xls -if TYPE_CHECKING: - from typing import Tuple - def path_to_text_fixture(filename): return os.path.join(example_xls.PATH, filename) @@ -46,7 +43,7 @@ def prep_class_config(cls, test_dir="tests"): cls.cls_name = cls.__name__ -def prep_for_xml_contains(text: str) -> "Tuple[str]": +def prep_for_xml_contains(text: str) -> "tuple[str]": """Prep string for finding an exact match to formatted XML text.""" # noinspection PyRedundantParentheses return (textwrap.indent(textwrap.dedent(text), " "),) diff --git a/tests/xform_test_case/test_attribute_columns.py b/tests/xform_test_case/test_attribute_columns.py index e560ff3b..612c87e6 100644 --- a/tests/xform_test_case/test_attribute_columns.py +++ b/tests/xform_test_case/test_attribute_columns.py @@ -1,6 +1,7 @@ """ Some tests for the new (v0.9) spec is properly implemented. """ + import os import pyxform @@ -30,7 +31,8 @@ def test_conversion(self): survey.print_xform_to_file(self.output_path, warnings=warnings) # print warnings # Compare with the expected output: - with open(expected_output_path, encoding="utf-8") as expected, open( - self.output_path, encoding="utf-8" - ) as observed: + with ( + open(expected_output_path, encoding="utf-8") as expected, + open(self.output_path, encoding="utf-8") as observed, + ): self.assertXFormEqual(expected.read(), observed.read()) diff --git a/tests/xform_test_case/test_bugs.py b/tests/xform_test_case/test_bugs.py index df627593..350fbb2a 100644 --- a/tests/xform_test_case/test_bugs.py +++ b/tests/xform_test_case/test_bugs.py @@ -1,6 +1,7 @@ """ Some tests for the new (v0.9) spec is properly implemented. """ + import os from unittest import TestCase @@ -93,9 +94,10 @@ def test_conversion(self): survey.print_xform_to_file(self.output_path, warnings=warnings) # print warnings # Compare with the expected output: - with open(expected_output_path, encoding="utf-8") as expected, open( - self.output_path, encoding="utf-8" - ) as observed: + with ( + open(expected_output_path, encoding="utf-8") as expected, + open(self.output_path, encoding="utf-8") as observed, + ): self.assertXFormEqual(expected.read(), observed.read()) @@ -118,9 +120,10 @@ def test_conversion(self): survey.print_xform_to_file(self.output_path, warnings=warnings) # print warnings # Compare with the expected output: - with open(expected_output_path, encoding="utf-8") as expected, open( - self.output_path, encoding="utf-8" - ) as observed: + with ( + open(expected_output_path, encoding="utf-8") as expected, + open(self.output_path, encoding="utf-8") as observed, + ): self.assertXFormEqual(expected.read(), observed.read()) @@ -145,9 +148,10 @@ def test_conversion(self): survey.print_xform_to_file(output_path, warnings=warnings) # print warnings # Compare with the expected output: - with open(expected_output_path, encoding="utf-8") as expected, open( - output_path, encoding="utf-8" - ) as observed: + with ( + open(expected_output_path, encoding="utf-8") as expected, + open(output_path, encoding="utf-8") as observed, + ): self.assertXFormEqual(expected.read(), observed.read()) diff --git a/tests/xform_test_case/test_xlsform_spec.py b/tests/xform_test_case/test_xlsform_spec.py index 80c5ba5c..a5f21513 100644 --- a/tests/xform_test_case/test_xlsform_spec.py +++ b/tests/xform_test_case/test_xlsform_spec.py @@ -1,6 +1,7 @@ """ Some tests for the new (v0.9) spec is properly implemented. """ + import os from unittest import TestCase @@ -35,9 +36,10 @@ def compare_xform(self, file_name: str, set_default_name: bool = True): survey = builder.create_survey_element_from_dict(json_survey) survey.print_xform_to_file(self.output_path, warnings=warnings) # Compare with the expected output: - with open(expected_output_path, encoding="utf-8") as ef, open( - self.output_path, encoding="utf-8" - ) as af: + with ( + open(expected_output_path, encoding="utf-8") as ef, + open(self.output_path, encoding="utf-8") as af, + ): expected = ef.read() observed = af.read() self.assertXFormEqual(expected, observed) diff --git a/tests/xform_test_case/test_xml.py b/tests/xform_test_case/test_xml.py index cfeb4271..e1e19f86 100644 --- a/tests/xform_test_case/test_xml.py +++ b/tests/xform_test_case/test_xml.py @@ -1,6 +1,7 @@ """ Test XForm XML syntax. """ + from unittest import TestCase from xml.dom.minidom import getDOMImplementation diff --git a/tests/xpath_helpers/choices.py b/tests/xpath_helpers/choices.py index 4489199c..967ba08b 100644 --- a/tests/xpath_helpers/choices.py +++ b/tests/xpath_helpers/choices.py @@ -1,5 +1,3 @@ -from typing import Tuple - JR_PREFIXES = { "audio": "jr://audio/", "image": "jr://images/", @@ -14,7 +12,7 @@ class XPathHelper: """ @staticmethod - def model_instance_choices_label(cname: str, choices: Tuple[Tuple[str, str], ...]): + def model_instance_choices_label(cname: str, choices: tuple[tuple[str, str], ...]): """Model instance has choices elements with name and label.""" choices_xp = "\n and ".join( ( @@ -29,7 +27,7 @@ def model_instance_choices_label(cname: str, choices: Tuple[Tuple[str, str], ... """ @staticmethod - def model_instance_choices_itext(cname: str, choices: Tuple[str, ...]): + def model_instance_choices_itext(cname: str, choices: tuple[str, ...]): """Model instance has choices elements with name but no label.""" choices_xp = "\n and ".join( ( @@ -67,7 +65,7 @@ def model_itext_choice_text_label_by_ref(qname, lang, cname, label): """ @staticmethod - def model_itext_choice_text_label_by_pos(lang, cname, choices: Tuple[str, ...]): + def model_itext_choice_text_label_by_pos(lang, cname, choices: tuple[str, ...]): """Model itext has a text label and no other forms. Lookup by position.""" choices_xp = "\n and ".join( ( @@ -89,7 +87,7 @@ def model_itext_choice_text_label_by_pos(lang, cname, choices: Tuple[str, ...]): @staticmethod def model_itext_choice_media_by_pos( - lang, cname, choices: Tuple[Tuple[Tuple[str, ...]]] + lang, cname, choices: tuple[tuple[tuple[str, ...]]] ): """ Model itext has a text label and no other forms. Lookup by position. @@ -118,7 +116,7 @@ def model_itext_choice_media_by_pos( @staticmethod def model_no_itext_choice_media_by_pos( - lang, cname, choices: Tuple[Tuple[Tuple[str, ...]]] + lang, cname, choices: tuple[tuple[tuple[str, ...]]] ): """ Model itext has a text label and no other forms. Lookup by position.