diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml
index 02a02c774..f3666088b 100644
--- a/.github/workflows/release.yml
+++ b/.github/workflows/release.yml
@@ -9,17 +9,17 @@ jobs:
runs-on: ${{ matrix.os }}
strategy:
matrix:
- python: ['3.12']
+ python: ['3.13']
os: [ubuntu-latest]
steps:
- - uses: actions/checkout@v4
+ - uses: actions/checkout@v7
- name: Set up Python
- uses: actions/setup-python@v5
+ uses: actions/setup-python@v6
with:
python-version: ${{ matrix.python }}
# Install dependencies.
- - uses: actions/cache@v4
+ - uses: actions/cache@v6
name: Python cache with dependencies.
id: python-cache
with:
@@ -35,7 +35,7 @@ jobs:
- name: Publish release to PyPI
if: success()
run: |
- pip install flit==3.9.0
+ pip install flit==3.12.0
flit --debug publish --no-use-vcs
env:
FLIT_USERNAME: __token__
diff --git a/.github/workflows/verify.yml b/.github/workflows/verify.yml
index 6f7131cac..d3451bd0d 100644
--- a/.github/workflows/verify.yml
+++ b/.github/workflows/verify.yml
@@ -7,17 +7,17 @@ jobs:
runs-on: ${{ matrix.os }}
strategy:
matrix:
- python: ['3.12']
+ python: ['3.13']
os: [ubuntu-latest]
steps:
- - uses: actions/checkout@v4
+ - uses: actions/checkout@v7
- name: Set up Python
- uses: actions/setup-python@v5
+ uses: actions/setup-python@v6
with:
python-version: ${{ matrix.python }}
# Install dependencies.
- - uses: actions/cache@v4
+ - uses: actions/cache@v6
name: Python cache with dependencies.
id: python-cache
with:
@@ -39,24 +39,24 @@ jobs:
# Run all matrix jobs even if one of them fails.
fail-fast: false
matrix:
- python: ['3.10', '3.11', '3.12', '3.13']
+ python: ['3.11', '3.12', '3.13', '3.14']
os: [ubuntu-latest, macos-latest, windows-latest]
PYXFORM_TESTS_RUN_ODK_VALIDATE: ['false']
# Extra job to check PyxformTestCase tests pass with ODK Validate.
# Keep versions sync with default dev version (same as 'lint' job above).
include:
- - python: '3.12'
+ - python: '3.13'
os: ubuntu-latest
PYXFORM_TESTS_RUN_ODK_VALIDATE: 'true'
steps:
- - uses: actions/checkout@v4
+ - uses: actions/checkout@v7
- name: Set up Python
- uses: actions/setup-python@v5
+ uses: actions/setup-python@v6
with:
python-version: ${{ matrix.python }}
# Install dependencies.
- - uses: actions/cache@v4
+ - uses: actions/cache@v6
name: Python cache with dependencies.
id: python-cache
with:
@@ -78,11 +78,11 @@ jobs:
- name: Build sdist and wheel.
if: success() && matrix.PYXFORM_TESTS_RUN_ODK_VALIDATE == 'false'
run: |
- pip install flit==3.9.0
+ pip install flit==3.12.0
flit --debug build --no-use-vcs
- name: Upload sdist and wheel.
if: success() && matrix.PYXFORM_TESTS_RUN_ODK_VALIDATE == 'false'
- uses: actions/upload-artifact@v4
+ uses: actions/upload-artifact@v7
with:
name: pyxform--on-${{ matrix.os }}--py${{ matrix.python }}
path: ${{ github.workspace }}/dist/pyxform*
diff --git a/README.md b/README.md
new file mode 100644
index 000000000..0f003fee6
--- /dev/null
+++ b/README.md
@@ -0,0 +1,210 @@
+# pyxform
+
+`pyxform` is a Python library that simplifies writing forms for ODK Collect and Enketo by converting spreadsheets that follow the [XLSForm standard](http://xlsform.org/) into [ODK XForms](https://github.com/opendatakit/xforms-spec). The XLSForms format is used in a [number of tools](http://xlsform.org/en/#tools-that-support-xlsforms).
+
+## Project status
+
+`pyxform` is actively maintained by [ODK](https://getodk.org/about/team.html).
+
+Current goals for the project include:
+
+- Enable more complex workflows through sophisticated XPath expressions and [entities](https://getodk.github.io/xforms-spec/entities)
+- Improve error messages and make troubleshooting easier
+- Improve experience, particularly for multi-language forms
+
+`pyxform` was started at the [Sustainable Engineering Lab at Columbia University](https://qsel.columbia.edu/), and until 2018 was maintained primarily by [Ona](https://ona.io/).
+
+## Using `pyxform`
+
+For user support, please start by posting to [the ODK forum](https://forum.getodk.org/c/support/6) where your question will get the most visibility.
+
+There are 3 main ways that `pyxform` is used:
+
+- Through a form server, such as the [pyxform-http service wrapper](https://github.com/getodk/pyxform-http), or [ODK Central](https://docs.getodk.org/getting-started/).
+- The command line utility `xls2xform`, which can be helpful for troubleshooting or as part of a broader form creation pipeline.
+- As a library, meaning that another python project imports functionality from `pyxform`.
+
+### Running the latest release of pyxform
+
+To convert forms at the command line, the latest official release of pyxform can be installed using [pip](https://en.wikipedia.org/wiki/Pip_(package_manager)):
+
+ pip install pyxform
+
+The `xls2xform` command can then be used:
+
+ xls2xform path_to_XLSForm [output_path]
+
+The currently supported Python versions for `pyxform` are 3.11 to 3.14 (the primary development version is 3.13). If this is different from the version you use for other projects, consider using [pyenv](https://github.com/pyenv/pyenv) to manage multiple versions of Python.
+
+### 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:
+
+ # Get a copy of the repository.
+ mkdir -P ~/repos/pyxform
+ cd ~/repos/pyxform
+ git clone https://github.com/XLSForm/pyxform.git repo
+
+ # Create and activate a virtual environment for the install.
+ /usr/local/bin/python -m venv venv
+ . venv/bin/activate
+
+ # Install the pyxform and it's production dependencies.
+ (venv)$ cd repo
+ # If this doesn't work, upgrade pip ``pip install --upgrade pip`` and retry.
+ (venv)$ pip install -e .
+ (venv)$ python pyxform/xls2xform.py --help
+ (venv)$ xls2xform --help # same effect as previous line
+ (venv)$ which xls2xform # ~/repos/pyxform/venv/bin/xls2xform
+
+To leave and return to the virtualenv:
+
+ (venv)$ deactivate # leave the venv, scripts not on $PATH
+ $ xls2xform --help
+ # -bash: xls2xform: command not found
+ $ . ~/repos/pyxform/venv/bin/activate # reactivate the venv
+ (venv)$ which xls2xform # scripts available on $PATH again
+ ~/repos/pyxform/venv/bin/xls2xform
+
+### Installing pyxform from remote source
+
+`pip` can install from the GitHub repository. Only do this if you want to install from the master branch, which is likely to have pre-release code. To install the latest release, see above.:
+
+ pip install git+https://github.com/XLSForm/pyxform.git@master#egg=pyxform
+
+You can then run xls2xform from the commandline:
+
+ xls2xform path_to_XLSForm [output_path]
+
+## Development
+
+To set up for development / contributing, first complete the above steps for "Running pyxform from local source". Then repeat the command used to install pyxform, but with `[dev]` appended to the end, e.g.:
+
+ pip install -e .[dev]
+
+You can run tests with:
+
+ python -m unittest
+
+Before committing, make sure to format and lint the code using `ruff`:
+
+ ruff format pyxform tests
+ ruff check pyxform tests
+
+If you are using a copy of `ruff` outside your virtualenv, make sure it is the same version as listed in `pyproject.toml`. Use the project configuration for `ruff` in `pyproject.toml`, which occurs automatically if `ruff` is run from the project root (where `pyproject.toml` is).
+
+### Contributions
+
+We welcome contributions that have a clearly-stated goal and are tightly focused. In general, successful contributions will first be discussed on [the ODK forum](https://forum.getodk.org/) or in an issue. We prefer discussion threads on the ODK forum because `pyxform` issues generally involve considerations for other tools and specifications in ODK and its broader ecosystem. Opening up an issue or a pull request directly may be appropriate if there is a clear bug or an issue that only affects `pyxform` developers.
+
+### Writing tests
+
+Make sure to include tests for the changes you're working on. When writing new tests you should add them in `tests` folder. Add to an existing test module, or create a new test module. Test modules are named after the corresponding source file, or if the tests concern many files then module name is the topic or feature under test.
+
+When creating new test cases, where possible use `PyxformTestCase` as a base class instead of `unittest.TestCase`. The `PyxformTestCase` is a toolkit for writing XLSForms as MarkDown tables, compiling example XLSForms, and making assertions on the resulting XForm. This makes code review much easier by putting the XLSForm content inline with the test, instead of in a separate file. A `unittest.TestCase` may be used if the new tests do not involve compiling an XLSForm (but most will). Do not add new tests using the old style `XFormTestCase`.
+
+When writing new `PyxformTestCase` tests that make content assertions, it is strongly recommended that the `xml__xpath*` matchers are used, in particular `xml__xpath_match`. Most older tests use matchers like `xml__contains` and `xml__excludes`, which are simple string matches of XML snippets against the result XForm. The `xml__xpath_match` kwarg accepts an XPath expression and expects 1 match. The main benefits of using XPath are 1) it allows specifying a document location, and 2) it does not require a particular document order for elements or attributes or whitespace output. To take full advantage of 1), the XPath expressions should specify the full document path (e.g. `/h:html/h:head/x:model`) rather than a search (e.g. `.//x:model`). To take full advantage of 2), the expression should include element predicates that specify the expected attribute values, e.g. `/h:html/h:body/x:input[@ref='/trigger-column/a']`. To specify the absence of an element, an expression like the following may be used with `xml__xpath_match`: `/h:html[not(descendant::x:input)]`, or alternatively `xml__xpath_count`: `.//x:input` with an expected count of 0 (zero).
+
+## Documentation
+
+For developers, `pyxform` uses docstrings, type annotations, and test cases. Most modern IDEs can display docstrings and type annotations in an easily navigable format, so no additional docs are compiled (e.g. sphinx). In addition to the user documentation, developers should be familiar with the ODK XForms Specification https://getodk.github.io/xforms-spec/.
+
+For users, `pyxform` has documentation at the following locations:
+
+- [XLSForm docs](https://xlsform.org/)
+- [XLSForm template](https://docs.google.com/spreadsheets/d/1v9Bumt3R0vCOGEKQI6ExUf2-8T72-XXp_CbKKTACuko/edit#gid=1052905058)
+- [ODK Docs](https://docs.getodk.org/)
+
+## Change Log
+
+[Changelog](CHANGES.txt)
+
+## Releasing pyxform
+
+1. Make sure the version of ODK Validate in the repo is up-to-date:
+
+ pyxform_validator_update odk update ODK-Validate-vx.x.x.jar
+
+2. Run all tests through ODK Validate as follows:
+
+ > PYXFORM_TESTS_RUN_ODK_VALIDATE=true python -m unittest --verbose
+
+3. Draft a new GitHub release with the list of merged PRs. Follow the title and description pattern of the previous release.
+
+4. Checkout a release branch from latest upstream master.
+
+5. Update `CHANGES.txt` with the text of the draft release.
+
+6. Update `pyproject.toml`, `pyxform/__init__.py` with the new release version number.
+
+7. Commit, push the branch, and initiate a pull request. Wait for tests to pass, then merge the PR.
+
+8. Tag the release and it will automatically be published
+
+## Manually releasing
+
+Releases are now automatic. These instructions are provided for forks or for a future change in process.
+
+1. In a clean new release only directory, check out master.
+
+2. Create a new virtualenv in this directory to ensure a clean Python environment:
+
+ /usr/local/bin/python -m venv pyxform-release
+ . pyxform-release/bin/activate
+
+3. Install the production and packaging requirements:
+
+ pip install -e .
+ pip install flit==3.12.0
+
+4. Clean up build and dist folders:
+
+ rm -rf build dist pyxform.egg-info
+
+5. Prepare `sdist` and `bdist_wheel` distributions, and publish to PyPI:
+
+ flit --debug publish --no-use-vcs
+
+6. Tag the GitHub release and publish it.
+
+## Related projects
+
+These projects are not vetted or endorsed but are linked here for reference.
+
+**Converters**
+
+*To XLSForm*
+
+- [cueform](https://github.com/freddieptf/cueform) (Go): from CUE
+- [md2xlsform](https://github.com/joshuaberetta/md2xlsform) (Python): from MarkDown
+- [xlsform](https://github.com/networkearth/xlsform) (Python): from JSON
+- [yxf](https://github.com/Sjlver/yxf) (Python): from YAML
+
+*From XLSForm*
+
+- [ODK2Doc](https://github.com/zaeendesouza/ODK2Doc) (R): to Word
+- [OdkGraph](https://github.com/jkpr/OdkGraph) (Python): to a graph
+- [Pureser](https://github.com/SwissTPH/Pureser) (Swift): to HTML
+- [ppp](https://github.com/pmaengineering/ppp) (Python): to HTML, PDF, Word
+- [QuestionnaireHTML](https://github.com/hedibmustapha/QuestionnaireHTML) (R): to HTML
+- [xlsform-converter](https://github.com/wq/xlsform-converter) (Python): to Django modules
+- [xlsform](https://github.com/networkearth/xlsform) (Python): to JSON
+- [xlsform2json](https://github.com/owengrant/xlsform2json) (Java): to JSON
+- [XLSform2PDF](https://github.com/HEDERA-PLATFORM/XLSform2PDF) (Python): to PDF
+- [xlson](https://github.com/opensrp/xlson) (Python): to OpenSRP JSON
+- [yxf](https://github.com/Sjlver/yxf) (Python): to YAML
+
+**Management Tools**
+
+- [surveydesignr](https://github.com/williameoswald/surveydesignr) (R): compare XLSForms
+- [ipacheckscto](https://github.com/PovertyAction/ipacheckscto) (Stata): check XLSForm for errors or design issues
+- [kobocruncher](https://github.com/Edouard-Legoupil/kobocruncher) (R): generate analysis Rmd from XLSForm
+- [odkmeta](https://github.com/PovertyAction/odkmeta) (Stata): use XLSForm to import ODK data to Stata
+- [odktools](https://github.com/ilri/odktools) (C++): convert pyxform internal data model to MySQL
+- [pmix](https://github.com/pmaengineering/pmix) (Python): manage XLSForm authoring
+- [pyxform-docker](https://github.com/seadowg/pyxform-docker) (Dockerfile): image for pyxform development
+- [xform-test](https://github.com/PMA-2020/xform-test) (Java): test XLSForms
+- [xlsformpo](https://github.com/delcroip/xlsformpo) (Python): use .po files for XLSForm translations
+- [XlsFormUtil](https://github.com/unhcr-americas/XlsFormUtil) (R): manage XLSForm authoring
diff --git a/README.rst b/README.rst
deleted file mode 100644
index 8c7d43cf7..000000000
--- a/README.rst
+++ /dev/null
@@ -1,217 +0,0 @@
-========
-pyxform
-========
-
-|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.10,3.11,3.12,3.13-blue.svg
- :target: https://www.python.org/downloads
-
-``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
-===============
-``pyxform`` is actively maintained by `ODK `_.
-
-Current goals for the project include:
-
-* Enable more complex workflows through sophisticated XPath expressions and `entities `_
-* Improve error messages and make troubleshooting easier
-* Improve experience, particularly for multi-language forms
-
-``pyxform`` was started at the `Sustainable Engineering Lab at Columbia University `_, and until 2018 was maintained primarily by `Ona `_.
-
-Using ``pyxform``
-==================
-For user support, please start by posting to `the ODK forum `__ where your question will get the most visibility.
-
-There are 3 main ways that ``pyxform`` is used:
-
-* Through a form server, such as the `pyxform-http service wrapper `_, or `ODK Central `_.
-* The command line utility ``xls2xform``, which can be helpful for troubleshooting or as part of a broader form creation pipeline.
-* As a library, meaning that another python project imports functionality from ``pyxform``.
-
-Running the latest release of pyxform
--------------------------------------
-To convert forms at the command line, the latest official release of pyxform can be installed using `pip `_::
-
- pip install pyxform
-
-The ``xls2xform`` command can then be used::
-
- xls2xform path_to_XLSForm [output_path]
-
-The currently supported Python versions for ``pyxform`` are 3.10 to 3.13 (the primary development version is 3.12). If this is different from the version you use for other projects, consider using `pyenv `_ to manage multiple versions of Python.
-
-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::
-
- # Get a copy of the repository.
- mkdir -P ~/repos/pyxform
- cd ~/repos/pyxform
- git clone https://github.com/XLSForm/pyxform.git repo
-
- # Create and activate a virtual environment for the install.
- /usr/local/bin/python -m venv venv
- . venv/bin/activate
-
- # Install the pyxform and it's production dependencies.
- (venv)$ cd repo
- # If this doesn't work, upgrade pip ``pip install --upgrade pip`` and retry.
- (venv)$ pip install -e .
- (venv)$ python pyxform/xls2xform.py --help
- (venv)$ xls2xform --help # same effect as previous line
- (venv)$ which xls2xform # ~/repos/pyxform/venv/bin/xls2xform
-
-To leave and return to the virtualenv::
-
- (venv)$ deactivate # leave the venv, scripts not on $PATH
- $ xls2xform --help
- # -bash: xls2xform: command not found
- $ . ~/repos/pyxform/venv/bin/activate # reactivate the venv
- (venv)$ which xls2xform # scripts available on $PATH again
- ~/repos/pyxform/venv/bin/xls2xform
-
-Installing pyxform from remote source
--------------------------------------
-``pip`` can install from the GitHub repository. Only do this if you want to install from the master branch, which is likely to have pre-release code. To install the latest release, see above.::
-
- pip install git+https://github.com/XLSForm/pyxform.git@master#egg=pyxform
-
-You can then run xls2xform from the commandline::
-
- xls2xform path_to_XLSForm [output_path]
-
-Development
-===========
-To set up for development / contributing, first complete the above steps for "Running pyxform from local source". Then repeat the command used to install pyxform, but with ``[dev]`` appended to the end, e.g.::
-
- pip install -e .[dev]
-
-You can run tests with::
-
- python -m unittest
-
-Before committing, make sure to format and lint the code using ``ruff``::
-
- ruff format pyxform tests
- ruff check pyxform tests
-
-If you are using a copy of ``ruff`` outside your virtualenv, make sure it is the same version as listed in ``pyproject.toml``. Use the project configuration for ``ruff`` in ``pyproject.toml``, which occurs automatically if ``ruff`` is run from the project root (where ``pyproject.toml`` is).
-
-Contributions
--------------
-We welcome contributions that have a clearly-stated goal and are tightly focused. In general, successful contributions will first be discussed on `the ODK forum `__ or in an issue. We prefer discussion threads on the ODK forum because ``pyxform`` issues generally involve considerations for other tools and specifications in ODK and its broader ecosystem. Opening up an issue or a pull request directly may be appropriate if there is a clear bug or an issue that only affects ``pyxform`` developers.
-
-Writing tests
--------------
-Make sure to include tests for the changes you're working on. When writing new tests you should add them in ``tests`` folder. Add to an existing test module, or create a new test module. Test modules are named after the corresponding source file, or if the tests concern many files then module name is the topic or feature under test.
-
-When creating new test cases, where possible use ``PyxformTestCase`` as a base class instead of ``unittest.TestCase``. The ``PyxformTestCase`` is a toolkit for writing XLSForms as MarkDown tables, compiling example XLSForms, and making assertions on the resulting XForm. This makes code review much easier by putting the XLSForm content inline with the test, instead of in a separate file. A ``unittest.TestCase`` may be used if the new tests do not involve compiling an XLSForm (but most will). Do not add new tests using the old style ``XFormTestCase``.
-
-When writing new ``PyxformTestCase`` tests that make content assertions, it is strongly recommended that the ``xml__xpath*`` matchers are used, in particular ``xml__xpath_match``. Most older tests use matchers like ``xml__contains`` and ``xml__excludes``, which are simple string matches of XML snippets against the result XForm. The ``xml__xpath_match`` kwarg accepts an XPath expression and expects 1 match. The main benefits of using XPath are 1) it allows specifying a document location, and 2) it does not require a particular document order for elements or attributes or whitespace output. To take full advantage of 1), the XPath expressions should specify the full document path (e.g. ``/h:html/h:head/x:model``) rather than a search (e.g. ``.//x:model``). To take full advantage of 2), the expression should include element predicates that specify the expected attribute values, e.g. ``/h:html/h:body/x:input[@ref='/trigger-column/a']``. To specify the absence of an element, an expression like the following may be used with ``xml__xpath_match``: ``/h:html[not(descendant::x:input)]``, or alternatively ``xml__xpath_count``: ``.//x:input`` with an expected count of 0 (zero).
-
-Documentation
-=============
-For developers, ``pyxform`` uses docstrings, type annotations, and test cases. Most modern IDEs can display docstrings and type annotations in a easily navigable format, so no additional docs are compiled (e.g. sphinx). In addition to the user documentation, developers should be familiar with the `ODK XForms Specification https://getodk.github.io/xforms-spec/`.
-
-For users, ``pyxform`` has documentation at the following locations:
-
-* `XLSForm docs `_
-* `XLSForm template `_
-* `ODK Docs `_
-
-Change Log
-==========
-`Changelog `_
-
-Releasing pyxform
-=================
-
-1. Make sure the version of ODK Validate in the repo is up-to-date::
-
- pyxform_validator_update odk update ODK-Validate-vx.x.x.jar
-
-2. Run all tests through ODK Validate as follows:
-
- PYXFORM_TESTS_RUN_ODK_VALIDATE=true python -m unittest --verbose
-
-3. Draft a new GitHub release with the list of merged PRs. Follow the title and description pattern of the previous release.
-4. Checkout a release branch from latest upstream master.
-5. Update ``CHANGES.txt`` with the text of the draft release.
-6. Update ``pyproject.toml``, ``pyxform/__init__.py`` with the new release version number.
-7. Commit, push the branch, and initiate a pull request. Wait for tests to pass, then merge the PR.
-8. Tag the release and it will automatically be published
-
-Manually releasing
-===================
-Releases are now automatic. These instructions are provided for forks or for a future change in process.
-
-1. In a clean new release only directory, check out master.
-2. Create a new virtualenv in this directory to ensure a clean Python environment::
-
- /usr/local/bin/python -m venv pyxform-release
- . pyxform-release/bin/activate
-
-3. Install the production and packaging requirements::
-
- pip install -e .
- pip install flit==3.9.0
-
-4. Clean up build and dist folders::
-
- rm -rf build dist pyxform.egg-info
-
-5. Prepare ``sdist`` and ``bdist_wheel`` distributions, and publish to PyPI::
-
- flit --debug publish --no-use-vcs
-
-6. Tag the GitHub release and publish it.
-
-Related projects
-================
-
-These projects are not vetted or endorsed but are linked here for reference.
-
-**Converters**
-
-*To XLSForm*
-
-* `cueform `_ (Go): from CUE
-* `md2xlsform `_ (Python): from MarkDown
-* `xlsform `_ (Python): from JSON
-* `yxf `_ (Python): from YAML
-
-*From XLSForm*
-
-* `ODK2Doc `_ (R): to Word
-* `OdkGraph `_ (Python): to a graph
-* `Pureser `_ (Swift): to HTML
-* `ppp `_ (Python): to HTML, PDF, Word
-* `QuestionnaireHTML `_ (R): to HTML
-* `xlsform-converter `_ (Python): to Django modules
-* `xlsform `_ (Python): to JSON
-* `xlsform2json `_ (Java): to JSON
-* `XLSform2PDF `_ (Python): to PDF
-* `xlson `_ (Python): to OpenSRP JSON
-* `yxf `_ (Python): to YAML
-
-**Management Tools**
-
-* `surveydesignr `_ (R): compare XLSForms
-* `ipacheckscto `_ (Stata): check XLSForm for errors or design issues
-* `kobocruncher `_ (R): generate analysis Rmd from XLSForm
-* `odkmeta `_ (Stata): use XLSForm to import ODK data to Stata
-* `odktools `_ (C++): convert pyxform internal data model to MySQL
-* `pmix `_ (Python): manage XLSForm authoring
-* `pyxform-docker `_ (Dockerfile): image for pyxform development
-* `xform-test `_ (Java): test XLSForms
-* `xlsformpo `_ (Python): use .po files for XLSForm translations
-* `XlsFormUtil `_ (R): manage XLSForm authoring
diff --git a/pyproject.toml b/pyproject.toml
index 4afb9a166..854a7b3ac 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -5,10 +5,9 @@ authors = [
{name = "github.com/xlsform", email = "support@getodk.org"},
]
description = "A Python package to create XForms for ODK Collect."
-readme = "README.rst"
-requires-python = ">=3.10"
+readme = "README.md"
+requires-python = ">=3.11"
dependencies = [
- "xlrd==2.0.1", # Read XLS files
"openpyxl==3.1.5", # Read XLSX files
"defusedxml==0.7.1", # Parse XML
"lark==1.3.1", # Parse custom grammars
@@ -18,9 +17,9 @@ dependencies = [
# Install with `pip install pyxform[dev]`.
dev = [
"formencode==2.1.1", # Compare XML
- "lxml==6.0.0", # XPath test expressions
- "psutil==7.0.0", # Process info for performance tests
- "ruff==0.12.4", # Format and lint
+ "lxml==6.1.1", # XPath test expressions
+ "psutil==7.2.2", # Process info for performance tests
+ "ruff==0.15.21", # Format and lint
]
[project.urls]
@@ -32,6 +31,7 @@ xls2xform = "pyxform.xls2xform:main_cli"
pyxform_validator_update = "pyxform.validators.updater:main_cli"
[build-system]
+# As of 3.12.0 this is still the flit readme template.
requires = ["flit_core >=3.2,<4"]
build-backend = "flit_core.buildapi"
@@ -43,7 +43,7 @@ exclude = ["docs", "tests"]
[tool.ruff]
line-length = 90
-target-version = "py310"
+target-version = "py311"
fix = true
show-fixes = true
output-format = "full"
@@ -54,9 +54,12 @@ src = ["pyxform", "tests"]
select = [
"B", # flake8-bugbear
"C4", # flake8-comprehensions
+ "D", # pydocstyle
"E", # pycodestyle error
# "ERA", # eradicate (commented out code)
"F", # pyflakes
+ "FLY", # flynt
+ "FURB", # refurb
"I", # isort
"PERF", # perflint
"PIE", # flake8-pie
@@ -72,8 +75,17 @@ select = [
"W", # pycodestyle warning
]
ignore = [
+ "D100", # undocumented-public-module (many instances requiring manual resolution)
+ "D101", # undocumented-public-class (many instances requiring manual resolution)
+ "D102", # undocumented-public-method (many instances requiring manual resolution)
+ "D103", # undocumented-public-function (many instances requiring manual resolution)
+ "D104", # undocumented-public-package (many instances requiring manual resolution)
+ "D105", # undocumented-magic-method (many instances requiring manual resolution)
+ "D107", # undocumented-public-init (many instances requiring manual resolution)
+ "D205", # missing-blank-line-after-summary (many instances requiring manual resolution)
+ "D401", # non-imperative-mood (many instances requiring manual resolution)
"E501", # line-too-long (we have a lot of long strings)
- "F821", # undefined-name (doesn't work well with type hints, ruff 0.1.11).
+ "F821", # undefined-name (doesn't work well with type hints, ruff 0.15.21).
"PERF401", # manual-list-comprehension (false positives on selective transforms)
"PERF402", # manual-list-copy (false positives on selective transforms)
"PLC0415", # import-outside-top-level (pyxform has a few to avoid circular imports)
@@ -84,8 +96,10 @@ ignore = [
"PLR2004", # magic-value-comparison (many tests expect certain numbers of things)
"PLW2901", # redefined-loop-name (usually not a bug)
"RUF001", # ambiguous-unicode-character-string (false positives on unicode tests)
- "S310", # suspicious-url-open-usage (prone to false positives, ruff 0.1.11)
- "S603", # subprocess-without-shell-equals-true (prone to false positives, ruff 0.1.11)
+ "S310", # suspicious-url-open-usage (prone to false positives, ruff 0.15.21)
+ "S603", # subprocess-without-shell-equals-true (prone to false positives, ruff 0.15.21)
"TRY003", # raise-vanilla-args (reasonable lint but would require large refactor)
]
-# per-file-ignores = {"tests/*" = ["E501"]}
+
+[tool.ruff.lint.pydocstyle]
+convention = "pep257"
diff --git a/pyxform/aliases.py b/pyxform/aliases.py
index 140f43e34..36d8bef4c 100644
--- a/pyxform/aliases.py
+++ b/pyxform/aliases.py
@@ -24,24 +24,26 @@
"select one from file": constants.SELECT_ONE,
"select multiple from file": constants.SELECT_ALL_THAT_APPLY,
}
-select = {
+select_one = {
"add select one prompt using": constants.SELECT_ONE,
- "add select multiple prompt using": constants.SELECT_ALL_THAT_APPLY,
- "select all that apply from": constants.SELECT_ALL_THAT_APPLY,
"select one from": constants.SELECT_ONE,
"select1": constants.SELECT_ONE,
"select_one": constants.SELECT_ONE,
"select one": constants.SELECT_ONE,
+}
+select_multiple = {
+ "add select multiple prompt using": constants.SELECT_ALL_THAT_APPLY,
+ "select all that apply from": constants.SELECT_ALL_THAT_APPLY,
"select_multiple": constants.SELECT_ALL_THAT_APPLY,
"select all that apply": constants.SELECT_ALL_THAT_APPLY,
- "select_one_external": constants.SELECT_ONE_EXTERNAL,
+}
+select = {
+ **select_one,
+ **select_multiple,
**select_from_file,
+ "select_one_external": constants.SELECT_ONE_EXTERNAL,
"rank": constants.RANK,
}
-cascading = {
- "cascading select": constants.CASCADING_SELECT,
- "cascading_select": constants.CASCADING_SELECT,
-}
settings_header = {
"form_title": constants.TITLE,
"set_form_title": constants.TITLE,
diff --git a/pyxform/builder.py b/pyxform/builder.py
index 6a8b2c260..47ab4511e 100644
--- a/pyxform/builder.py
+++ b/pyxform/builder.py
@@ -1,6 +1,4 @@
-"""
-Survey builder functionality.
-"""
+"""Survey builder functionality."""
import os
from collections import defaultdict
@@ -68,7 +66,7 @@ def __init__(self, **kwargs):
def set_sections(self, sections):
"""
- sections is a dict of python objects, a key in this dict is
+ Sections is a dict of python objects, a key in this dict is
the name of the section and the value is a dict that can be
used to create a whole survey.
"""
@@ -82,7 +80,7 @@ def create_survey_element_from_dict(
"""
Convert from a nested python dictionary/array structure (a json dict I
call it because it corresponds directly with a json object)
- to a survey object
+ to a survey object.
:param d: data to use for constructing SurveyElements.
"""
@@ -202,7 +200,7 @@ def _get_question_class(question_type_str, question_type_dictionary):
"""
Read the type string from the json format,
and find what class it maps to going through
- type_dictionary -> QUESTION_CLASSES
+ type_dictionary -> QUESTION_CLASSES.
"""
by_type = QUESTION_CLASSES["__by_type__"].get(question_type_str, None)
if by_type is not None:
@@ -246,7 +244,7 @@ def _create_loop_from_dict(
):
"""
Takes a json_dict of "loop" type
- Returns a GroupedSection
+ Returns a GroupedSection.
"""
children = d.get(const.CHILDREN)
result = GroupedSection(**d)
@@ -308,9 +306,7 @@ def create_survey_element_from_json(self, str_or_path):
def create_survey_element_from_dict(d, sections=None):
- """
- Creates a Survey from a dictionary in the format provided by SurveyReader
- """
+ """Creates a Survey from a dictionary in the format provided by SurveyReader."""
if sections is None:
sections = {}
builder = SurveyElementBuilder()
@@ -380,7 +376,7 @@ def create_survey_from_path(path: str, include_directory: bool = False) -> Surve
include_directory -- Switch to indicate that all the survey forms in the
same directory as the specified file should be read
so they can be included through include types.
- @see: create_survey
+ @see: create_survey.
"""
directory, file_name = os.path.split(path)
if include_directory:
diff --git a/pyxform/constants.py b/pyxform/constants.py
index 722f11ba7..2fdda5fbb 100644
--- a/pyxform/constants.py
+++ b/pyxform/constants.py
@@ -72,7 +72,6 @@
# XLS Specific constants
LIST_NAME_S = "list name"
LIST_NAME_U = "list_name"
-CASCADING_SELECT = "cascading_select"
TABLE_LIST = "table-list" # hyphenated because it goes in appearance, and convention for appearance column is dashes
FIELD_LIST = "field-list"
LIST_NOLABEL = "list-nolabel"
diff --git a/pyxform/elements/action.py b/pyxform/elements/action.py
index 97ccf3a3b..ce4557a67 100644
--- a/pyxform/elements/action.py
+++ b/pyxform/elements/action.py
@@ -6,9 +6,7 @@
class Event(StrEnum):
- """
- Supported W3C XForms 1.1 Events and ODK extensions.
- """
+ """Supported W3C XForms 1.1 Events and ODK extensions."""
# For actions in model under /html/head/model
ODK_INSTANCE_FIRST_LOAD = "odk-instance-first-load"
@@ -128,9 +126,7 @@ def to_dict(self):
class ActionLibrary(Enum):
- """
- A collection of action/event configs used by pyxform.
- """
+ """A collection of action/event configs used by pyxform."""
setvalue_first_load = LibraryMember(
name=Setvalue.name,
diff --git a/pyxform/elements/element.py b/pyxform/elements/element.py
index 9240d27f9..5573da5aa 100644
--- a/pyxform/elements/element.py
+++ b/pyxform/elements/element.py
@@ -15,7 +15,5 @@ class Element:
name: str
def node(self) -> "DetachableElement":
- """
- Create the element.
- """
+ """Create the element."""
raise NotImplementedError()
diff --git a/pyxform/entities/entities_parsing.py b/pyxform/entities/entities_parsing.py
index 6a619676b..859b3dd3b 100644
--- a/pyxform/entities/entities_parsing.py
+++ b/pyxform/entities/entities_parsing.py
@@ -15,9 +15,7 @@
@dataclass(frozen=True, slots=True)
class ContainerNode:
- """
- Details of a XForm container: the survey root, a group, or a repeat.
- """
+ """Details of a XForm container: the survey root, a group, or a repeat."""
name: str
type: str
@@ -38,16 +36,12 @@ def __str__(self):
@classmethod
def default(cls) -> "ContainerPath":
- """
- Create the default ContainerPath, which is the '/survey' root path.
- """
+ """Create the default ContainerPath, which is the '/survey' root path."""
return cls((ContainerNode(name=const.SURVEY, type=const.SURVEY),))
@classmethod
def from_stack(cls, stack: list[dict[str, Any]]) -> "ContainerPath":
- """
- Create a ContainerPath from the workbook_to_json container stack.
- """
+ """Create a ContainerPath from the workbook_to_json container stack."""
if len(stack) > 1:
return cls(
(
@@ -62,9 +56,7 @@ def from_stack(cls, stack: list[dict[str, Any]]) -> "ContainerPath":
return cls.default()
def get_scope_boundary(self) -> "ContainerPath":
- """
- Get the full path to the nearest ancestor boundary scope node.
- """
+ """Get the full path to the nearest ancestor boundary scope node."""
for i in range(len(self.nodes) - 1, -1, -1):
if self.nodes[i].type in {const.REPEAT, const.SURVEY}:
return ContainerPath(self.nodes[: i + 1])
@@ -122,9 +114,7 @@ class EntityReferences:
)
def get_allocation_request(self) -> "AllocationRequest":
- """
- Find/validate the preferred path for each entity declaration.
- """
+ """Find/validate the preferred path for each entity declaration."""
deepest_scope_ref = None
deepest_scope_boundary = None
deepest_scope_boundary_node_count = None
@@ -530,9 +520,7 @@ def validate_saveto(
def get_entity_declarations(
entities_sheet: Iterable[dict],
) -> dict[str, dict[str, Any]]:
- """
- Collect all entity declarations from the entities sheet.
- """
+ """Collect all entity declarations from the entities sheet."""
entities = {}
for row_number, row in enumerate(entities_sheet, start=2):
entity = get_entity_declaration(row=row, row_number=row_number)
@@ -576,9 +564,7 @@ def get_entity_references_by_question(
is_container_begin: bool,
is_container_end: bool,
) -> None:
- """
- For each question store the saveto or variable references that link it to an entity.
- """
+ """For each question store the saveto or variable references that link it to an entity."""
# Collect references for later reconciliation, because otherwise the first
# referent found will determine the scope but there may be deeper refs.
saveto = row.get(const.BIND, {}).get(const.ENTITIES_SAVETO_NS)
@@ -639,9 +625,7 @@ def allocate_entities_to_containers(
entity_declarations: dict[str, dict[str, Any]],
entity_references_by_question: dict[str, EntityReferences],
) -> dict[ContainerPath, str]:
- """
- Get the paths into which the entities will be placed.
- """
+ """Get the paths into which the entities will be placed."""
allocations: dict[ContainerPath, str] = {}
scope_paths: defaultdict[ContainerPath, list[AllocationRequest]] = defaultdict(list)
survey_path = ContainerPath.default()
@@ -734,9 +718,7 @@ def inject_entities_into_json(
entities_allocated: set[str] | None = None,
has_repeat_ancestor: bool = False,
) -> dict[str, Any]:
- """
- Recursively traverse the json_dict to inject entity declarations.
- """
+ """Recursively traverse the json_dict to inject entity declarations."""
if entities_allocated is None:
entities_allocated = set()
diff --git a/pyxform/entities/entity_declaration.py b/pyxform/entities/entity_declaration.py
index 8f01c1840..b62aae69c 100644
--- a/pyxform/entities/entity_declaration.py
+++ b/pyxform/entities/entity_declaration.py
@@ -18,7 +18,7 @@ class EntityDeclaration(Section):
"""
An entity declaration produces an entity instance node with optional label child,
some variable attributes, and corresponding bindings. The ODK XForms Entities
- specification can be found at https://getodk.github.io/xforms-spec/entities
+ specification can be found at `https://getodk.github.io/xforms-spec/entities`.
"""
__slots__ = ENTITY_EXTRA_FIELDS
diff --git a/pyxform/errors.py b/pyxform/errors.py
index f8c71c296..ec84efa20 100644
--- a/pyxform/errors.py
+++ b/pyxform/errors.py
@@ -1,6 +1,4 @@
-"""
-Common base classes for pyxform exceptions.
-"""
+"""Common base classes for pyxform exceptions."""
from enum import Enum
from string import Formatter
@@ -566,7 +564,7 @@ def __init__(
"""
super().__init__(*args)
self.code: ErrorCode | None = code
- self.context: dict = context if context else {}
+ self.context: dict = context or {}
def __str__(self):
return self.__repr__()
diff --git a/pyxform/external_instance.py b/pyxform/external_instance.py
index 50301ddb5..130fd5911 100644
--- a/pyxform/external_instance.py
+++ b/pyxform/external_instance.py
@@ -1,6 +1,4 @@
-"""
-ExternalInstance class module
-"""
+"""ExternalInstance class module."""
from typing import TYPE_CHECKING
diff --git a/pyxform/file_utils.py b/pyxform/file_utils.py
index 430f40f88..c5e506eae 100644
--- a/pyxform/file_utils.py
+++ b/pyxform/file_utils.py
@@ -1,17 +1,16 @@
-"""
-The pyxform file utility functions.
-"""
+"""The pyxform file utility functions."""
-import glob
import os
+from pathlib import Path
from pyxform import utils
+from pyxform.constants import SUPPORTED_FILE_EXTENSIONS
from pyxform.xls2json import SurveyReader
def _section_name(path_or_file_name):
- directory, filename = os.path.split(path_or_file_name)
- section_name, extension = os.path.splitext(filename)
+ _, filename = os.path.split(path_or_file_name)
+ section_name, _ = os.path.splitext(filename)
return section_name
@@ -32,12 +31,8 @@ def load_file_to_dict(path):
def collect_compatible_files_in_directory(directory):
- """
- create a giant dict out of all the spreadsheets and json forms
- in the given directory
- """
- available_files = glob.glob(os.path.join(directory, "*.xls")) + glob.glob(
- os.path.join(directory, "*.json")
- )
-
+ """Create a giant dict out of all the spreadsheets and json forms in the given directory."""
+ types = SUPPORTED_FILE_EXTENSIONS | {".json", ".md", ".csv"}
+ path = Path(directory)
+ available_files = [str(p) for t in types for p in path.glob(f"*{t}")]
return dict([load_file_to_dict(f) for f in available_files])
diff --git a/pyxform/instance.py b/pyxform/instance.py
index eb36f4de9..8823af052 100644
--- a/pyxform/instance.py
+++ b/pyxform/instance.py
@@ -1,6 +1,4 @@
-"""
-SurveyInstance class module.
-"""
+"""SurveyInstance class module."""
import os.path
@@ -58,7 +56,7 @@ def to_json_dict(self):
def to_xml(self):
"""
A horrible way to do this, but it works (until we need the attributes
- pumped out in order, etc)
+ pumped out in order, etc).
"""
open_str = f"""<{self._name} id="{self._id}">"""
close_str = f"""{self._name}>"""
@@ -73,7 +71,7 @@ def answers(self):
"""
This returns "_answers", which is a dict with the key-value
responses for this given instance. This could be pumped to xml
- or returned as a dict for maximum convenience (i.e. testing.)
+ or returned as a dict for maximum convenience (i.e. testing.).
"""
return self._answers
diff --git a/pyxform/parsing/expression.py b/pyxform/parsing/expression.py
index 014d4e354..c43a5dacb 100644
--- a/pyxform/parsing/expression.py
+++ b/pyxform/parsing/expression.py
@@ -118,9 +118,7 @@ def parse_expression(text: str) -> tuple[Token, ...]:
def is_xml_tag(value: str) -> bool:
- """
- Does the input string contain only a valid XML tag / element name?
- """
+ """Check if the input string contains only a valid XML tag / element name."""
return value and bool(RE_NCNAME_NAMESPACED.fullmatch(value))
diff --git a/pyxform/parsing/parameters.py b/pyxform/parsing/parameters.py
index 1696aead9..31812d99e 100644
--- a/pyxform/parsing/parameters.py
+++ b/pyxform/parsing/parameters.py
@@ -22,7 +22,7 @@
class ParameterTransformer(Transformer):
@staticmethod
def start(pairs: list[tuple[str, str]]) -> dict[str, str]:
- """Combine (key, value) tuples into a dict"""
+ """Combine (key, value) tuples into a dict."""
return dict(pairs)
@staticmethod
diff --git a/pyxform/parsing/sheet_headers.py b/pyxform/parsing/sheet_headers.py
index 8343071a2..4242d7e33 100644
--- a/pyxform/parsing/sheet_headers.py
+++ b/pyxform/parsing/sheet_headers.py
@@ -63,9 +63,7 @@ def merge_dicts(
def list_to_nested_dict(lst: Sequence) -> dict:
- """
- [1,2,3,4] -> {1:{2:{3:4}}}
- """
+ """Example: `[1,2,3,4] -> {1:{2:{3:4}}}`."""
if len(lst) > 1:
return {lst[0]: list_to_nested_dict(lst[1:])}
else:
@@ -224,7 +222,6 @@ def dealias_and_group_headers(
in the data rows.
:param add_row_number: If True, add a "__row" key with the row number from the input data.
"""
-
header_key: dict[str, tuple[str, ...]] = {}
tokens_key: dict[tuple[str, ...], str] = {}
diff --git a/pyxform/question.py b/pyxform/question.py
index f870d308f..cd00b8313 100644
--- a/pyxform/question.py
+++ b/pyxform/question.py
@@ -1,6 +1,4 @@
-"""
-XForm Survey element classes for different question types.
-"""
+"""XForm Survey element classes for different question types."""
import os.path
from collections.abc import Callable, Generator, Iterable
@@ -198,9 +196,7 @@ def xml_control(self, survey: "Survey"):
return xml_node
def xml_actions(self, survey: "Survey", in_repeat: bool = False):
- """
- Return the action(s) for this survey element.
- """
+ """Return the action(s) for this survey element."""
action_fields = {"name", "event", "value"}
if self.actions:
for _action in self.actions:
@@ -227,9 +223,7 @@ def xml_actions(self, survey: "Survey", in_repeat: bool = False):
).node()
def _build_xml(self, survey: "Survey") -> DetachableElement | None:
- """
- Initial control node result for further processing depending on Question type.
- """
+ """Initial control node result for further processing depending on Question type."""
control_dict = self.control
result = node(
control_dict["tag"],
diff --git a/pyxform/question_type_dictionary.py b/pyxform/question_type_dictionary.py
index f3ee4e24c..2969623d1 100644
--- a/pyxform/question_type_dictionary.py
+++ b/pyxform/question_type_dictionary.py
@@ -1,6 +1,4 @@
-"""
-XForm survey question type mapping dictionary module.
-"""
+"""XForm survey question type mapping dictionary module."""
from collections.abc import Sequence
from types import MappingProxyType
diff --git a/pyxform/section.py b/pyxform/section.py
index 552e1283c..1b710cc60 100644
--- a/pyxform/section.py
+++ b/pyxform/section.py
@@ -1,6 +1,4 @@
-"""
-Section survey element module.
-"""
+"""Section survey element module."""
from collections.abc import Callable, Generator, Iterable
from itertools import chain
@@ -126,9 +124,7 @@ def _validate_uniqueness_of_element_names(self):
)
def xml_instance(self, survey: "Survey", **kwargs):
- """
- Creates an xml representation of the section
- """
+ """Creates an xml representation of the section."""
append_template = kwargs.pop("append_template", False)
attributes = {}
@@ -174,9 +170,7 @@ def generate_repeating_template(self, survey: "Survey", **kwargs):
return result
def xml_instance_array(self, survey: "Survey"):
- """
- This method is used for generating flat instances.
- """
+ """This method is used for generating flat instances."""
for child in self.children:
if hasattr(child, "flat") and child.get("flat"):
yield from child.xml_instance_array(survey=survey)
@@ -186,7 +180,7 @@ def xml_instance_array(self, survey: "Survey"):
def xml_control(self, survey: "Survey"):
"""
Ideally, we'll have groups up and rolling soon, but for now
- let's just yield controls from all the children of this section
+ let's just yield controls from all the children of this section.
"""
for e in self.children:
control = e.xml_control(survey=survey)
@@ -227,6 +221,8 @@ def __init__(
def xml_control(self, survey: "Survey"):
"""
+ Example
+ ```
@@ -238,6 +234,7 @@ def xml_control(self, survey: "Survey"):
+ ```.
"""
# Resolve field references in attributes
if self.control:
diff --git a/pyxform/survey.py b/pyxform/survey.py
index 3d89911ee..936f8007d 100644
--- a/pyxform/survey.py
+++ b/pyxform/survey.py
@@ -1,6 +1,4 @@
-"""
-Survey module with XForm Survey objects and utility functions.
-"""
+"""Survey module with XForm Survey objects and utility functions."""
import os
import re
@@ -70,7 +68,7 @@ def __init__(
def register_nsmap():
- """Function to register NSMAP namespaces with ETree"""
+ """Function to register NSMAP namespaces with ETree."""
for prefix, uri in NSMAP.items():
prefix_no_xmlns = prefix.replace("xmlns", "").replace(":", "")
ETree.register_namespace(prefix_no_xmlns, uri)
@@ -159,7 +157,7 @@ def recursive_dict():
"sms_keyword",
"sms_response",
"sms_separator",
- "style",
+ constants.STYLE,
"submission_url",
"title",
"version",
@@ -173,9 +171,7 @@ def recursive_dict():
class Survey(Section):
- """
- Survey class - represents the full XForm XML.
- """
+ """Survey class - represents the full XForm XML."""
__slots__ = SURVEY_EXTRA_FIELDS
@@ -262,7 +258,7 @@ def _validate_uniqueness_of_section_names(self):
)
def get_nsmap(self):
- """Add additional namespaces"""
+ """Add additional namespaces."""
if self.entity_version:
entities_ns = " entities=http://www.opendatakit.org/xforms/entities"
if self.namespaces is None:
@@ -289,9 +285,7 @@ def get_nsmap(self):
return NSMAP
def xml(self):
- """
- calls necessary preparation methods, then returns the xml.
- """
+ """Calls necessary preparation methods, then returns the xml."""
self.validate()
self._setup_xpath_dictionary()
@@ -310,9 +304,7 @@ def xml(self):
def _generate_static_instances(
self, list_name: str, itemset: Itemset
) -> InstanceInfo:
- """
- Generate elements for static data (e.g. choices for selects)
- """
+ """Generate elements for static data (e.g. choices for selects)."""
def choice_nodes(idx, choice):
# Add a unique id to the choice element in case there are itext references
@@ -397,7 +389,7 @@ def get_pulldata_functions(element):
"""
Returns a list of different pulldata(... function strings if
pulldata function is defined at least once for any of:
- calculate, constraint, readonly, required, relevant
+ calculate, constraint, readonly, required, relevant.
"""
functions_present = []
for formula_name in constants.EXTERNAL_INSTANCES:
@@ -466,9 +458,7 @@ def _generate_from_file_instances(
@staticmethod
def _generate_last_saved_instance(element: Question) -> bool:
- """
- True if a last-saved instance should be generated, false otherwise.
- """
+ """True if a last-saved instance should be generated, false otherwise."""
if element.default and has_pyxform_reference_with_last_saved(element.default):
return True
if element.choice_filter and has_pyxform_reference_with_last_saved(
@@ -594,9 +584,7 @@ def get_element_instances():
seen[i.name] = i
def xml_model_bindings(self) -> Generator[DetachableElement | None, None, None]:
- """
- Yield bindings (bind or action elements) for this node and all its descendants.
- """
+ """Yield bindings (bind or action elements) for this node and all its descendants."""
for e in self.iter_descendants(
condition=lambda i: not isinstance(i, Option | Tag)
):
@@ -606,9 +594,7 @@ def xml_model_bindings(self) -> Generator[DetachableElement | None, None, None]:
yield from e.xml_actions(survey=self, in_repeat=False)
def xml_model(self):
- """
- Generate the xform element
- """
+ """Generate the xform element."""
self._setup_translations()
self._setup_media()
self._add_empty_translations()
@@ -739,8 +725,8 @@ def _redirect_is_search_itext(self, element: MultipleChoiceQuestion) -> bool:
def _setup_translations(self):
"""
- set up the self._translations dict which will be referenced in the
- setup media and itext functions
+ Set up the self._translations dict which will be referenced in the
+ setup media and itext functions.
"""
def get_choice_content(name, idx, choice):
@@ -912,7 +898,7 @@ def itext(self) -> DetachableElement:
This function creates the survey's itext nodes from _translations
@see _setup_media _setup_translations
itext nodes are localized images/audio/video/text
- @see http://code.google.com/p/opendatakit/wiki/XFormDesignGuidelines
+ @see `http://code.google.com/p/opendatakit/wiki/XFormDesignGuidelines`.
"""
result = []
for lang, translation in self._translations.items():
@@ -1033,17 +1019,15 @@ def _var_repl_function(
Given a dictionary of xpaths, return a function we can use to
replace ${varname} with the xpath to varname.
"""
-
name = matchobj.group("ncname")
last_saved = matchobj.group("last_saved") is not None
is_indexed_repeat = matchobj.string.find("indexed-repeat(") > -1
def _in_secondary_instance_predicate() -> bool:
"""
- check if ${} expression represented by matchobj
- is in a predicate for a path expression for a secondary instance
+ Check if ${} expression represented by matchobj
+ is in a predicate for a path expression for a secondary instance.
"""
-
if RE_INSTANCE.search(matchobj.string) is not None:
bracket_regex_match_iter = RE_BRACKET.finditer(matchobj.string)
# Check whether current ${varname} is in the correct bracket_regex_match
diff --git a/pyxform/survey_element.py b/pyxform/survey_element.py
index 668dba60c..c7bcf6fc0 100644
--- a/pyxform/survey_element.py
+++ b/pyxform/survey_element.py
@@ -1,6 +1,4 @@
-"""
-Survey Element base class for all survey elements.
-"""
+"""Survey Element base class for all survey elements."""
import json
import warnings
@@ -193,9 +191,7 @@ def iter_ancestors(
def lowest_common_ancestor(
self, other: "SurveyElement", group_type: str | None = None
) -> tuple[str, int | None, int | None, Optional["SurveyElement"]]:
- """
- Get the relation type, steps from self, steps from other, and the common ancestor.
- """
+ """Get the relation type, steps from self, steps from other, and the common ancestor."""
# Filtering
if group_type:
type_filter = {group_type}
@@ -238,9 +234,7 @@ def lowest_common_ancestor(
return "Common Ancestor", self_ancestors[lca], other_ancestors[lca], lca
def get_xpath(self, relative_to: Optional["SurveyElement"] = None) -> str:
- """
- Return the xpath of this survey element.
- """
+ """Return the xpath of this survey element."""
# Imported here to avoid circular references.
from pyxform.survey import Survey
@@ -279,7 +273,7 @@ def stop_before(e):
def _delete_keys_from_dict(self, dictionary: dict, keys: Iterable[str]):
"""
Deletes a list of keys from a dictionary.
- Credits: https://stackoverflow.com/a/49723101
+ Credits: `https://stackoverflow.com/a/49723101`.
"""
for key in keys:
dictionary.pop(key, None)
@@ -294,7 +288,7 @@ def copy(self) -> dict[str, Any]:
def to_json_dict(self, delete_keys: Iterable[str] | None = None) -> dict:
"""
Create a dict copy of this survey element by removing inappropriate
- attributes and converting its children to dicts
+ attributes and converting its children to dicts.
"""
self.validate()
result = self.copy()
@@ -359,7 +353,7 @@ def _translation_path(self, display_element: str) -> str:
def get_translations(self, default_language):
"""
Returns translations used by this element so they can be included in
- the block. @see survey._setup_translations
+ the block. @see survey._setup_translations.
"""
bind_dict = self.bind
if bind_dict and isinstance(bind_dict, dict):
@@ -525,9 +519,7 @@ def xml_label_and_hint(self, survey: "Survey") -> list["DetachableElement"]:
def xml_bindings(
self, survey: "Survey"
) -> Generator[DetachableElement | None, None, None]:
- """
- Return the binding(s) for this survey element.
- """
+ """Return the binding(s) for this survey element."""
if not hasattr(self, "bind") or self.get("bind") is None:
return None
if hasattr(self, "flat") and self.get("flat"):
diff --git a/pyxform/translator.py b/pyxform/translator.py
index bfd99720b..9936d4f7a 100644
--- a/pyxform/translator.py
+++ b/pyxform/translator.py
@@ -1,6 +1,4 @@
-"""
-Translator class module.
-"""
+"""Translator class module."""
from collections import defaultdict
@@ -39,7 +37,7 @@ class Translator:
def __init__(self):
"""
I'm being super lazy dictionary has to have the form:
- {'yes' : {'English' : {'French' : 'oui'}}}
+ `{'yes' : {'English' : {'French' : 'oui'}}}`.
"""
self._dict = infinite_dict()
self._languages = []
diff --git a/pyxform/util/enum.py b/pyxform/util/enum.py
index 41bec2aef..980f2ab77 100644
--- a/pyxform/util/enum.py
+++ b/pyxform/util/enum.py
@@ -1,32 +1,9 @@
-from enum import Enum
+from enum import StrEnum as StrEnumBase
-class StrEnum(str, Enum):
+class StrEnum(StrEnumBase):
"""Base Enum class with common helper function."""
- # Copied from Python 3.11 enum.py. In many cases can use members as strings, but
- # sometimes need to deref with ".value" property e.g. `EnumClass.MEMBERNAME.value`.
- def __new__(cls, *values):
- "values must already be of type `str`"
- if len(values) > 3:
- raise TypeError(f"too many arguments for str(): {values!r}")
- if len(values) == 1:
- # it must be a string
- if not isinstance(values[0], str):
- raise TypeError(f"{values[0]!r} is not a string")
- if len(values) >= 2:
- # check that encoding argument is a string
- if not isinstance(values[1], str):
- raise TypeError(f"encoding must be a string, not {values[1]!r}")
- if len(values) == 3:
- # check that errors argument is a string
- if not isinstance(values[2], str):
- raise TypeError(f"errors must be a string, not {values[2]!r}")
- value = str(*values)
- member = str.__new__(cls, value)
- member._value_ = value
- return member
-
@classmethod
def value_list(cls) -> list:
return list(cls.__members__.values())
diff --git a/pyxform/utils.py b/pyxform/utils.py
index 6b16f870a..6cc730713 100644
--- a/pyxform/utils.py
+++ b/pyxform/utils.py
@@ -1,6 +1,4 @@
-"""
-pyxform utils module.
-"""
+"""pyxform utils module."""
import copy
import csv
@@ -155,8 +153,8 @@ def get_pyobj_from_json(str_or_path):
def print_pyobj_to_json(pyobj, path=None):
"""
- dump a python nested array/dict structure to the specified file
- or stdout if no file is specified
+ Dump a python nested array/dict structure to the specified file
+ or stdout if no file is specified.
"""
if path:
with open(path, mode="w", encoding="utf-8") as fp:
@@ -199,9 +197,7 @@ def external_choices_to_csv(
def has_external_choices(json_struct):
- """
- Returns true if a select one external prompt is used in the survey.
- """
+ """Returns true if a select one external prompt is used in the survey."""
if isinstance(json_struct, dict):
for k, v in json_struct.items():
if (
diff --git a/pyxform/validators/enketo_validate/__init__.py b/pyxform/validators/enketo_validate/__init__.py
index becdeb91b..5c4a9e59e 100644
--- a/pyxform/validators/enketo_validate/__init__.py
+++ b/pyxform/validators/enketo_validate/__init__.py
@@ -1,6 +1,4 @@
-"""
-Validate XForms using Enketo validator.
-"""
+"""Validate XForms using Enketo validator."""
import os
from typing import TYPE_CHECKING
@@ -26,9 +24,7 @@ class EnketoValidateError(Exception):
def install_exists():
- """
- Check if Enketo-validate is installed.
- """
+ """Check if Enketo-validate is installed."""
return os.path.exists(ENKETO_VALIDATE_PATH)
@@ -37,9 +33,7 @@ def _call_validator(path_to_xform, bin_file_path=ENKETO_VALIDATE_PATH) -> "Popen
def install_ok(bin_file_path=ENKETO_VALIDATE_PATH):
- """
- Check if Enketo-validate functions as expected.
- """
+ """Check if Enketo-validate functions as expected."""
check_readable(file_path=XFORM_SPEC_PATH)
return_code, _, _, _ = _call_validator(
path_to_xform=XFORM_SPEC_PATH, bin_file_path=bin_file_path
diff --git a/pyxform/validators/error_cleaner.py b/pyxform/validators/error_cleaner.py
index 642645e40..dd83bdc07 100644
--- a/pyxform/validators/error_cleaner.py
+++ b/pyxform/validators/error_cleaner.py
@@ -1,10 +1,10 @@
-"""
-Cleans up error messages from the validators.
-"""
+"""Cleans up error messages from the validators."""
import re
-ERROR_MESSAGE_REGEX = re.compile(r"(/[a-z0-9\-_]+(?:/[a-z0-9\-_]+)+)", flags=re.I)
+ERROR_MESSAGE_REGEX = re.compile(
+ r"(/[a-z0-9\-_]+(?:/[a-z0-9\-_]+)+)", flags=re.IGNORECASE
+)
class ErrorCleaner:
diff --git a/pyxform/validators/odk_validate/__init__.py b/pyxform/validators/odk_validate/__init__.py
index 14251a443..953154de1 100644
--- a/pyxform/validators/odk_validate/__init__.py
+++ b/pyxform/validators/odk_validate/__init__.py
@@ -1,7 +1,4 @@
-"""
-odk_validate.py
-A python wrapper around ODK Validate
-"""
+"""A python wrapper around ODK Validate."""
import logging
import os
@@ -40,9 +37,7 @@ def _call_validator(path_to_xform, bin_file_path=ODK_VALIDATE_PATH) -> "PopenRes
def install_ok(bin_file_path=ODK_VALIDATE_PATH):
- """
- Check if ODK Validate functions as expected.
- """
+ """Check if ODK Validate functions as expected."""
check_readable(file_path=XFORM_SPEC_PATH)
result = _call_validator(
path_to_xform=XFORM_SPEC_PATH,
@@ -55,9 +50,7 @@ def install_ok(bin_file_path=ODK_VALIDATE_PATH):
def check_java_available():
- """
- Check if 'which java' returncode is 0. If not, raise an error since java is required.
- """
+ """Check if 'which java' returncode is 0. If not, raise an error since java is required."""
java_path = shutil.which(cmd="java")
if java_path is not None:
return
diff --git a/pyxform/validators/pyxform/iana_subtags/validation.py b/pyxform/validators/pyxform/iana_subtags/validation.py
index a65481a1b..a968dd567 100644
--- a/pyxform/validators/pyxform/iana_subtags/validation.py
+++ b/pyxform/validators/pyxform/iana_subtags/validation.py
@@ -16,9 +16,7 @@ def read_tags(file_name: str) -> set[str]:
def get_languages_with_bad_tags(languages):
- """
- Returns languages with invalid or missing IANA subtags.
- """
+ """Returns languages with invalid or missing IANA subtags."""
languages_with_bad_tags = []
for lang in languages:
# Minimum matchable lang code attempt requires 3 characters e.g. "a()".
diff --git a/pyxform/validators/pyxform/parameters.py b/pyxform/validators/pyxform/parameters.py
index 5bae8dacd..249b520f6 100644
--- a/pyxform/validators/pyxform/parameters.py
+++ b/pyxform/validators/pyxform/parameters.py
@@ -11,9 +11,7 @@ def validate(
accepted: type[StrEnum],
row_number: int,
) -> None:
- """
- Raise an error if 'parameters' includes any keys not named in 'accepted'.
- """
+ """Raise an error if 'parameters' includes any keys not named in 'accepted'."""
extras = set(parameters) - accepted.value_set()
if 0 < len(extras):
raise PyXFormError(
diff --git a/pyxform/validators/pyxform/pyxform_reference.py b/pyxform/validators/pyxform/pyxform_reference.py
index dc474e39c..484a81fef 100644
--- a/pyxform/validators/pyxform/pyxform_reference.py
+++ b/pyxform/validators/pyxform/pyxform_reference.py
@@ -37,7 +37,7 @@ def __init__(self, name: str, last_saved: bool = False):
def is_pyxform_reference_candidate(value: str) -> bool:
"""
- Does the string look like a pyxform reference?
+ Check if the string looks like a pyxform reference.
Needs 2 characters for "${", plus at least 1 more for a name inside. Does not look
for closing brace because full parsing will try to detect malformed references. This
@@ -107,7 +107,7 @@ def _parse(
@lru_cache(maxsize=128)
def is_pyxform_reference(value: str) -> bool:
"""
- Does the input string contain only a valid Pyxform reference? e.g. `${my_question}`
+ Does the input string contain only a valid Pyxform reference? e.g. `${my_question}`.
:param value: The string to inspect.
"""
@@ -120,7 +120,7 @@ def is_pyxform_reference(value: str) -> bool:
@lru_cache(maxsize=128)
def has_pyxform_reference(value: str) -> bool:
"""
- Does the input string contain a valid Pyxform reference? e.g. `hi ${name}`
+ Does the input string contain a valid Pyxform reference? e.g. `hi ${name}`.
:param value: The string to inspect.
"""
@@ -133,7 +133,7 @@ def has_pyxform_reference(value: str) -> bool:
@lru_cache(maxsize=128)
def has_pyxform_reference_with_last_saved(value: str) -> bool:
"""
- Does the input string contain a valid '#last-saved' reference? e.g. `${last-saved#my_question}`
+ Does the input string contain a valid '#last-saved'? e.g. `${last-saved#my_question}`.
Needs 14 characters for "${last-saved#}", plus a name inside. This pre-check can help
avoid more expensive full parsing.
diff --git a/pyxform/validators/pyxform/question_types/__init__.py b/pyxform/validators/pyxform/question_types/__init__.py
index 8e24eca46..ac6fa10a5 100644
--- a/pyxform/validators/pyxform/question_types/__init__.py
+++ b/pyxform/validators/pyxform/question_types/__init__.py
@@ -1,6 +1,4 @@
-"""
-Validations for question types.
-"""
+"""Validations for question types."""
from collections.abc import Collection, Iterable
diff --git a/pyxform/validators/updater.py b/pyxform/validators/updater.py
index 66b2a964d..4172e777c 100644
--- a/pyxform/validators/updater.py
+++ b/pyxform/validators/updater.py
@@ -1,6 +1,4 @@
-"""
-pyxform_validator_update - command to update XForm validators.
-"""
+"""pyxform_validator_update - command to update XForm validators."""
import argparse
import fnmatch
@@ -23,9 +21,7 @@
class _UpdateInfo:
- """
- Data class for Updater info.
- """
+ """Data class for Updater info."""
def __init__(
self,
@@ -78,9 +74,7 @@ class _UpdateHandler:
@staticmethod
def _request_latest_json(url):
- """
- Get the GitHub API JSON response doc for the latest release from URL.
- """
+ """Get the GitHub API JSON response doc for the latest release from URL."""
content = request_get(url=url)
return json.loads(content.decode("utf-8"))
@@ -93,27 +87,21 @@ def _check_path(file_path):
@staticmethod
def _read_json(file_path):
- """
- Read the JSON file to a string.
- """
+ """Read the JSON file to a string."""
_UpdateHandler._check_path(file_path=file_path)
with open(file_path, encoding="utf-8") as in_file:
return json.load(in_file)
@staticmethod
def _write_json(file_path, content):
- """
- Save the JSON data to a file.
- """
+ """Save the JSON data to a file."""
with open(file_path, mode="w", encoding="utf-8", newline="\n") as out_file:
data = json.dumps(content, indent=2, sort_keys=True)
out_file.write(str(data))
@staticmethod
def _read_last_check(file_path):
- """
- Read the .last_check file.
- """
+ """Read the .last_check file."""
_UpdateHandler._check_path(file_path=file_path)
with open(file_path, encoding="utf-8") as in_file:
first_line = in_file.readline()
@@ -126,17 +114,13 @@ def _read_last_check(file_path):
@staticmethod
def _write_last_check(file_path, content):
- """
- Write the .last_check file.
- """
+ """Write the .last_check file."""
with open(file_path, mode="w", encoding="utf-8", newline="\n") as out_file:
out_file.write(str(content.strftime(UTC_FMT)))
@staticmethod
def _check_necessary(update_info, utc_now):
- """
- Determine whether a check for the latest version is necessary.
- """
+ """Determine whether a check for the latest version is necessary."""
if not os.path.exists(update_info.last_check_path):
return True
elif not os.path.exists(update_info.latest_path):
@@ -156,9 +140,7 @@ def _check_necessary(update_info, utc_now):
@staticmethod
def _get_latest(update_info):
- """
- Get the latest release info, either from GitHub or a recent file copy.
- """
+ """Get the latest release info, either from GitHub or a recent file copy."""
utc_now = datetime.utcnow()
if _UpdateHandler._check_necessary(update_info=update_info, utc_now=utc_now):
latest = _UpdateHandler._request_latest_json(url=update_info.api_url)
@@ -212,9 +194,7 @@ def list(update_info):
@staticmethod
def _find_download_url(update_info, json_data, file_name):
- """
- Find the download URL for the file in the GitHub API JSON response doc.
- """
+ """Find the download URL for the file in the GitHub API JSON response doc."""
rel_name = json_data["tag_name"]
files = json_data["assets"]
@@ -241,9 +221,7 @@ def _find_download_url(update_info, json_data, file_name):
@staticmethod
def _download_file(url, file_path):
- """
- Save response content from the URL to a binary file at the file path.
- """
+ """Save response content from the URL to a binary file at the file path."""
with open(file_path, mode="wb") as out_file:
file_data = request_get(url=url)
out_file.write(file_data)
@@ -327,9 +305,7 @@ def _unzip_extract_file(open_zip_file, zip_item, file_out_path):
@staticmethod
def _unzip(update_info, file_path, out_path):
- """
- Unzip the contents of a zip file to an existing output path.
- """
+ """Unzip the contents of a zip file to an existing output path."""
_UpdateHandler._check_path(file_path=file_path)
_UpdateHandler._check_path(file_path=out_path)
bin_paths = _UpdateHandler._get_bin_paths(
@@ -349,9 +325,7 @@ def _unzip(update_info, file_path, out_path):
@staticmethod
def _install(update_info, file_name):
- """
- Install the latest release.
- """
+ """Install the latest release."""
try:
latest = _UpdateHandler._get_latest(update_info=update_info)
file_path = os.path.join(update_info.bin_new_path, file_name)
@@ -581,9 +555,7 @@ def _build_validator_menu(main_subparser, validator_name, updater_instance):
def _create_parser():
- """
- Parse command line arguments.
- """
+ """Parse command line arguments."""
main_title = "pyxform validator updater"
epilog = (
"------------------------------------------------------\n"
diff --git a/pyxform/validators/util.py b/pyxform/validators/util.py
index 6798dcc8b..5c91121c8 100644
--- a/pyxform/validators/util.py
+++ b/pyxform/validators/util.py
@@ -1,6 +1,4 @@
-"""
-The validators utility functions.
-"""
+"""The validators utility functions."""
import logging
import os
@@ -22,7 +20,7 @@
class PopenResult:
- """Result data for run_popen_with_timeout"""
+ """Result data for run_popen_with_timeout."""
def __init__(
self, return_code: int, timeout: bool, stdout: bytes, stderr: bytes
@@ -39,7 +37,7 @@ def run_popen_with_timeout(command, timeout) -> "PopenResult":
"""
Run a sub-program in subprocess.Popen, pass it the input_data,
kill it if the specified timeout has passed.
- returns a tuple of resultcode, timeout, stdout, stderr
+ returns a tuple of resultcode, timeout, stdout, stderr.
"""
kill_check = threading.Event()
@@ -84,7 +82,7 @@ def _kill_process_after_a_timeout(pid):
def decode_stream(stream):
- """
+ r"""
Decode a stream, e.g. stdout or stderr.
On Windows, stderr may be latin-1; in which case utf-8 decode will fail.
@@ -103,9 +101,7 @@ def decode_stream(stream):
def request_get(url):
- """
- Get the response content from URL.
- """
+ """Get the response content from URL."""
try:
if not url.startswith(("http:", "https:")):
raise ValueError("URL must start with 'http:' or 'https:'")
diff --git a/pyxform/xform2json.py b/pyxform/xform2json.py
index edf2576bd..bb4d0b347 100644
--- a/pyxform/xform2json.py
+++ b/pyxform/xform2json.py
@@ -1,6 +1,4 @@
-"""
-xform2json module - Transform an XForm to a JSON dictionary.
-"""
+"""xform2json module - Transform an XForm to a JSON dictionary."""
import copy
import json
@@ -32,9 +30,7 @@
# {{{ http://code.activestate.com/recipes/573463/ (r7)
class XmlDictObject(dict):
- """
- Adds object like functionality to the standard dictionary.
- """
+ """Adds object like functionality to the standard dictionary."""
def __init__(self, initdict=None):
if initdict is None:
@@ -55,10 +51,7 @@ def __str__(self):
@staticmethod
def wrap(x):
- """
- Static method to wrap a dictionary recursively as an XmlDictObject
- """
-
+ """Static method to wrap a dictionary recursively as an XmlDictObject."""
if isinstance(x, dict):
return XmlDictObject((k, XmlDictObject.Wrap(v)) for (k, v) in iter(x.items()))
elif isinstance(x, list):
@@ -80,7 +73,6 @@ def un_wrap(self):
Recursively converts an XmlDictObject to a standard dictionary
and returns the result.
"""
-
return XmlDictObject._un_wrap(self)
@@ -107,10 +99,7 @@ def _convert_dict_to_xml_recurse(parent, dictitem):
def convert_dict_to_xml(xmldict):
- """
- Converts a dictionary to an XML ElementTree Element
- """
-
+ """Converts a dictionary to an XML ElementTree Element."""
roottag = xmldict.keys()[0]
root = Element(roottag)
_convert_dict_to_xml_recurse(root, xmldict[roottag])
@@ -160,9 +149,7 @@ def _convert_xml_to_dict_recurse(node, dictclass):
def convert_xml_to_dict(root, dictclass=XmlDictObject):
- """
- Converts an XML file or ElementTree Element to a dictionary
- """
+ """Converts an XML file or ElementTree Element to a dictionary."""
# If a string is passed in, try to open it as a file
if isinstance(root, str):
root = _try_parse(root)
@@ -176,9 +163,7 @@ def convert_xml_to_dict(root, dictclass=XmlDictObject):
def _try_parse(root, parser=None):
- """
- Try to parse the root from a string or a file/file-like object.
- """
+ """Try to parse the root from a string or a file/file-like object."""
root = root.encode("UTF-8")
try:
parsed_root = fromstring(root, parser)
@@ -211,7 +196,7 @@ def create_survey_element_from_xml(xml_file):
class XFormToDictBuilder:
- """Experimental XFORM xml to XFORM JSON"""
+ """Experimental XFORM xml to XFORM JSON."""
def __init__(self, xml_file):
doc_as_dict = XFormToDict(xml_file).get_dict()
@@ -654,19 +639,17 @@ def _get_text_from_translation(self, ref, key="label"):
def _get_bracketed_name(self, ref):
name = self._get_name_from_ref(ref)
- return "".join(["${", name.strip(), "}"])
+ return f"${{{name.strip()}}}"
def _get_constraint_msg(self, constraint_msg):
if isinstance(constraint_msg, str):
if constraint_msg.find(":jr:constraintMsg") != -1:
ref = constraint_msg.replace("jr:itext('", "").replace("')", "")
- k, constraint_msg = self._get_text_from_translation(ref)
+ _, constraint_msg = self._get_text_from_translation(ref)
return constraint_msg
def _get_choices(self) -> dict[str, Any]:
- """
- Get all form choices, using the model/instance and model/itext.
- """
+ """Get all form choices, using the model/instance and model/itext."""
choices = {}
for instance in self.secondary_instances:
items = []
@@ -683,8 +666,8 @@ def _get_choices(self) -> dict[str, Any]:
@staticmethod
def _get_name_from_ref(ref):
- """given /xlsform_spec_test/launch,
- return the string after the last occurance of the character '/'
+ """Given /xlsform_spec_test/launch,
+ return the string after the last occurance of the character '/'.
"""
pos = ref.rfind("/")
if pos == -1:
@@ -707,9 +690,9 @@ def replace_function(match):
# moving re flags into compile for python 2.6 compat
pattern = "( /[a-z0-9-_]+(?:/[a-z0-9-_]+)+ )"
- text = re.compile(pattern, flags=re.I).sub(replace_function, text)
+ text = re.compile(pattern, flags=re.IGNORECASE).sub(replace_function, text)
pattern = "(/[a-z0-9-_]+(?:/[a-z0-9-_]+)+)"
- text = re.compile(pattern, flags=re.I).sub(replace_function, text)
+ text = re.compile(pattern, flags=re.IGNORECASE).sub(replace_function, text)
return text
diff --git a/pyxform/xform_instance_parser.py b/pyxform/xform_instance_parser.py
index 427d64c98..19b6f63dc 100644
--- a/pyxform/xform_instance_parser.py
+++ b/pyxform/xform_instance_parser.py
@@ -1,6 +1,4 @@
-"""
-XFormInstanceParser class module - parses an instance XML.
-"""
+"""XFormInstanceParser class module - parses an instance XML."""
# todo: this has been copied from xform_manager, we need to figure out
# where this code is actually going to live.
@@ -44,9 +42,7 @@ def _xml_node_to_dict(node):
def _flatten_dict(d, prefix):
- """
- Return a list of XPath, value pairs.
- """
+ """Return a list of XPath, value pairs."""
if not isinstance(d, dict):
raise PyXFormError("""Invalid value for `d`.""")
if not isinstance(prefix, list):
@@ -75,9 +71,7 @@ def _flatten_dict(d, prefix):
def _get_all_attributes(node):
- """
- Go through an XML document returning all the attributes we see.
- """
+ """Go through an XML document returning all the attributes we see."""
if hasattr(node, "hasAttributes") and node.hasAttributes():
for key in node.attributes.keys():
yield key, node.getAttribute(key)
diff --git a/pyxform/xls2json.py b/pyxform/xls2json.py
index bc091098d..db2373e56 100644
--- a/pyxform/xls2json.py
+++ b/pyxform/xls2json.py
@@ -1,11 +1,10 @@
-"""
-A Python script to convert excel files into JSON.
-"""
+"""A Python script to convert excel files into JSON."""
import os
import re
import sys
from collections import Counter
+from pathlib import Path
from typing import IO, Any
from pyxform import aliases, constants
@@ -111,7 +110,7 @@ def add_flat_annotations(prompt_list, parent_relevant="", name_prefix=""):
(However, there could be namespace collisions now.)
- "and"s group relevance formulas onto that of their children.
- Adds a flat property to groups
- The flat property is used in the json2xform code
+ The flat property is used in the json2xform code.
"""
for prompt in prompt_list:
prompt_relevant = prompt.get("bind", {}).get("relevant", "")
@@ -215,7 +214,7 @@ def workbook_to_json(
default language.
If the default language is used as a suffix for media/labels/hints,
then the suffixless version will be overwritten.
- warnings -- an optional list which warnings will be appended to
+ warnings -- an optional list which warnings will be appended to.
returns a nested dictionary equivalent to the format specified in the
json form spec.
@@ -1409,9 +1408,7 @@ def parse_file_to_json(
warnings: list[str] | None = None,
file_object: IO | None = None,
) -> dict[str, Any]:
- """
- A wrapper for workbook_to_json
- """
+ """A wrapper for workbook_to_json."""
if warnings is None:
warnings = []
workbook_dict = get_xlsform(xlsform=coalesce(path, file_object))
@@ -1481,9 +1478,13 @@ def print_warning_log(self, warn_out_file):
# convert that file to json, then print it
if len(sys.argv) < 2:
# print "You must supply a file argument."
- _filename = "xlsform_spec_test.xls"
- _path = "/home/user/python-dev/xlsform/pyxform/tests/example_xls/"
- _path += _filename
+ _path = str(
+ Path(__file__).parent.parent
+ / "tests"
+ / "fixtures"
+ / "example_forms"
+ / "xlsform_spec_test.xlsx"
+ )
else:
_path = sys.argv[1]
diff --git a/pyxform/xls2json_backends.py b/pyxform/xls2json_backends.py
index 306d53e0f..cecaa5c25 100644
--- a/pyxform/xls2json_backends.py
+++ b/pyxform/xls2json_backends.py
@@ -1,6 +1,4 @@
-"""
-XLS-to-dict and csv-to-dict are essentially backends for xls2json.
-"""
+"""XLS-to-dict and csv-to-dict are essentially backends for xls2json."""
import csv
import datetime
@@ -19,17 +17,10 @@
from openpyxl.reader.excel import ExcelReader
from openpyxl.workbook import Workbook as pyxlWorkbook
from openpyxl.worksheet.worksheet import Worksheet as pyxlWorksheet
-from xlrd import XL_CELL_BOOLEAN, XL_CELL_DATE, XL_CELL_NUMBER, XLRDError
-from xlrd import open_workbook as xlrd_open
-from xlrd.book import Book as xlrdBook
-from xlrd.sheet import Cell as xlrdCell
-from xlrd.sheet import Sheet as xlrdSheet
-from xlrd.xldate import XLDateAmbiguous, xldate_as_tuple
from pyxform import constants
from pyxform.errors import PyXFormError, PyXFormReadError
-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"
@@ -62,7 +53,7 @@ class DefinitionData:
def _list_to_dict_list(list_items):
"""
Takes a list and creates a dict with the list values as keys.
- Returns a list of the created dict or an empty list
+ Returns a list of the created dict or an empty list.
"""
if list_items:
return [{str(i): None for i in list_items}]
@@ -70,9 +61,7 @@ def _list_to_dict_list(list_items):
def trim_trailing_empty(a_list: list, n_empty: int) -> list:
- """
- Trim trailing empty columns or rows. Avoids `[:-0] == []`, and unnecessary list copy.
- """
+ """Trim trailing empty columns or rows. Avoids `[:-0] == []`, and unnecessary list copy."""
if 0 < n_empty:
offset = len(a_list) - n_empty
a_list = a_list[:offset]
@@ -106,8 +95,8 @@ def get_excel_column_headers(first_row: Iterable[str | None]) -> list[str | None
def get_excel_rows(
headers: Iterable[str | None],
- rows: Iterable[tuple[aCell, ...]],
- cell_func: Callable[[aCell, int, str], Any],
+ rows: Iterable[tuple[pyxlCell, ...]],
+ cell_func: Callable[[pyxlCell, int, 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
@@ -141,114 +130,6 @@ def get_excel_rows(
return trim_trailing_empty(result_rows, adjacent_empty_rows)
-def xls_to_dict(path_or_file):
- """
- Return a Python dictionary with a key for each worksheet
- name. For each sheet there is a list of dictionaries, each
- dictionary corresponds to a single row in the worksheet. A
- dictionary has keys taken from the column headers and values
- equal to the cell value for that row and column.
- All the keys and leaf elements are unicode text.
- """
-
- def xls_clean_cell(
- wb: xlrdBook, wb_sheet: xlrdSheet, cell: xlrdCell, row_n: int, col_key: str
- ) -> str | None:
- value = cell.value
- if isinstance(value, str):
- value = value.strip()
- if not is_empty(value):
- try:
- return xls_value_to_unicode(value, cell.ctype, wb.datemode)
- except XLDateAmbiguous as date_err:
- raise PyXFormError(
- XL_DATE_AMBIGOUS_MSG % (wb_sheet.name, col_key, row_n)
- ) from date_err
-
- return None
-
- def xls_to_dict_normal_sheet(wb: xlrdBook, wb_sheet: xlrdSheet):
- # XLS format: max cols 256, max rows 65536
- first_row = (c.value for c in next(wb_sheet.get_rows(), []))
- headers = get_excel_column_headers(first_row=first_row)
- row_iter = (
- tuple(wb_sheet.cell(r, c) for c in range(len(headers)))
- for r in range(1, wb_sheet.nrows)
- )
-
- # Inject wb/sheet as closure since functools.partial isn't typing friendly.
- 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
- )
-
- rows = get_excel_rows(headers=headers, rows=row_iter, cell_func=clean_func)
- column_header_list = [key for key in headers if key is not None]
- return rows, _list_to_dict_list(column_header_list)
-
- def process_workbook(wb: xlrdBook):
- result_book = {"sheet_names": []}
- for wb_sheet in wb.sheets():
- # Note original in sheet_names for spelling check.
- result_book["sheet_names"].append(wb_sheet.name)
- sheet_name = wb_sheet.name.lower()
- # Do not process sheets that have nothing to do with XLSForm.
- if sheet_name not in constants.SUPPORTED_SHEET_NAMES:
- if len(wb.sheets()) == 1:
- (
- result_book[constants.SURVEY],
- result_book[f"{constants.SURVEY}_header"],
- ) = xls_to_dict_normal_sheet(wb=wb, wb_sheet=wb_sheet)
- else:
- continue
- else:
- (
- result_book[sheet_name],
- result_book[f"{sheet_name}_header"],
- ) = xls_to_dict_normal_sheet(wb=wb, wb_sheet=wb_sheet)
- return result_book
-
- try:
- wb_file = get_definition_data(definition=path_or_file)
- workbook = xlrd_open(file_contents=wb_file.data.getvalue())
- try:
- return process_workbook(wb=workbook)
- finally:
- workbook.release_resources()
- except (AttributeError, TypeError, 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:
- """
- Take a xls formatted value and try to make a unicode string representation.
- """
- if value_type == XL_CELL_BOOLEAN:
- return "TRUE" if value else "FALSE"
- elif value_type == XL_CELL_NUMBER:
- # Try to display as an int if possible.
- int_value = int(value)
- if int_value == value:
- return str(int_value)
- else:
- return str(value)
- elif value_type is XL_CELL_DATE:
- # Warn that it is better to single quote as a string.
- # error_location = cellFormatString % (ss_row_idx, ss_col_idx)
- # raise Exception(
- # "Cannot handle excel formatted date at " + error_location)
- datetime_or_time_only = xldate_as_tuple(value, datemode)
- if datetime_or_time_only[:3] == (0, 0, 0):
- # must be time only
- return str(datetime.time(*datetime_or_time_only[3:]))
- return str(datetime.datetime(*datetime_or_time_only))
- else:
- # ensure unicode and replace nbsp spaces with normal ones
- # to avoid this issue:
- # https://github.com/modilabs/pyxform/issues/83
- return str(value).replace(chr(160), " ")
-
-
def xlsx_to_dict(path_or_file):
"""
Return a Python dictionary with a key for each worksheet
@@ -313,9 +194,7 @@ def process_workbook(wb: pyxlWorkbook):
def xlsx_value_to_str(value) -> str:
- """
- Take a xls formatted value and try to make a string representation.
- """
+ """Take a xls formatted value and try to make a string representation."""
if value is True:
return "TRUE"
elif value is False:
@@ -346,38 +225,6 @@ def is_empty(value):
return False
-def get_cascading_json(sheet_list, prefix, level):
- return_list = []
- for row in sheet_list:
- if "stopper" in row:
- if row["stopper"] == level:
- # last element's name IS the prefix; doesn't need level
- return_list[-1]["name"] = prefix
- return return_list
- else:
- continue
- elif "lambda" in row:
-
- def replace_prefix(d, prefix):
- for k, v in d.items():
- if isinstance(v, str):
- d[k] = v.replace("$PREFIX$", prefix)
- elif isinstance(v, dict):
- d[k] = replace_prefix(v, prefix)
- elif isinstance(v, list):
- d[k] = (replace_prefix(x, prefix) for x in v)
- return d
-
- return_list.append(replace_prefix(row["lambda"], prefix))
- raise PyXFormError(
- "Found a cascading_select "
- + level
- + ", but could not find "
- + level
- + "in cascades sheet."
- )
-
-
def csv_to_dict(path_or_file):
def first_column_as_sheet_name(row):
if len(row) == 0:
@@ -446,15 +293,18 @@ def process_csv_data(rd):
def convert_file_to_csv_string(path):
"""
- This will open a csv or xls file and return a CSV in the format:
- sheet_name1
- ,col1,col2
- ,r1c1,r1c2
- ,r2c1,r2c2
- sheet_name2
- ,col1,col2
- ,r1c1,r1c2
- ,r2c1,r2c2
+ This will open a csv or xlsx file and return a CSV in the format.
+
+ ```
+ sheet_name1
+ ,col1,col2
+ ,r1c1,r1c2
+ ,r2c1,r2c2
+ sheet_name2
+ ,col1,col2
+ ,r1c1,r1c2
+ ,r2c1,r2c2
+ ```
Currently, it processes csv files and xls files to ensure consistent
csv delimiters, etc. for tests.
@@ -462,7 +312,7 @@ def convert_file_to_csv_string(path):
if path.endswith(".csv"):
imported_sheets = csv_to_dict(path)
else:
- imported_sheets = xls_to_dict(path)
+ imported_sheets = xlsx_to_dict(path)
foo = StringIO(newline="")
writer = csv.writer(foo, delimiter=",", quotechar='"', quoting=csv.QUOTE_MINIMAL)
for sheet_name, rows in imported_sheets.items():
@@ -486,39 +336,7 @@ def convert_file_to_csv_string(path):
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_path)
- try:
- sheet = wb.sheet_by_name(sheet_name)
- except 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
+ return xlsx_sheet_to_csv(workbook_path, csv_path, sheet_name)
def xlsx_sheet_to_csv(workbook_path, csv_path, sheet_name):
@@ -631,9 +449,7 @@ def process_md_data(md_: str):
def md_table_to_workbook(mdstr: str) -> pyxlWorkbook:
- """
- Convert Markdown table string to an openpyxl.Workbook. Call wb.save() to persist.
- """
+ """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.items():
@@ -691,7 +507,6 @@ def is_csv(data: str) -> bool:
class SupportedFileTypes(Enum):
xlsx = ".xlsx"
xlsm = ".xlsm"
- xls = ".xls"
md = ".md"
csv = ".csv"
@@ -700,7 +515,6 @@ def get_processors():
return {
SupportedFileTypes.xlsx: xlsx_to_dict,
SupportedFileTypes.xlsm: xlsx_to_dict,
- SupportedFileTypes.xls: xls_to_dict,
SupportedFileTypes.md: md_to_dict,
SupportedFileTypes.csv: csv_to_dict,
}
@@ -742,7 +556,7 @@ def definition_to_dict(
return DefinitionData(
fallback_form_name=definition.file_path_stem, **func(definition)
)
- except PyXFormReadError: # noqa: PERF203
+ except PyXFormReadError:
continue
raise PyXFormError(
diff --git a/pyxform/xls2xform.py b/pyxform/xls2xform.py
index a768764a2..0cda374a8 100644
--- a/pyxform/xls2xform.py
+++ b/pyxform/xls2xform.py
@@ -33,7 +33,7 @@
def get_xml_path(path):
"""
- Returns the xform file path
+ Returns the xform file path.
Generates an output path for the xform file from the given
xlsx input file path.
@@ -150,9 +150,7 @@ def xls2xform_convert(
def _create_parser():
- """
- Parse command line arguments.
- """
+ """Parse command line arguments."""
parser = argparse.ArgumentParser()
parser.add_argument(
"path_to_XLSForm",
diff --git a/tests/bug_example_xls/badly_named_choices_sheet.xls b/tests/bug_example_xls/badly_named_choices_sheet.xls
deleted file mode 100644
index 9b9cf43b0..000000000
Binary files a/tests/bug_example_xls/badly_named_choices_sheet.xls and /dev/null differ
diff --git a/tests/bug_example_xls/blank_second_row.xls b/tests/bug_example_xls/blank_second_row.xls
deleted file mode 100644
index 41dbd0c5a..000000000
Binary files a/tests/bug_example_xls/blank_second_row.xls and /dev/null differ
diff --git a/tests/bug_example_xls/calculate_without_calculation.xls b/tests/bug_example_xls/calculate_without_calculation.xls
deleted file mode 100644
index 3592c1a0a..000000000
Binary files a/tests/bug_example_xls/calculate_without_calculation.xls and /dev/null differ
diff --git a/tests/bug_example_xls/extra_columns.xls b/tests/bug_example_xls/extra_columns.xls
deleted file mode 100644
index d05366933..000000000
Binary files a/tests/bug_example_xls/extra_columns.xls and /dev/null differ
diff --git a/tests/bug_example_xls/group_name_test.xls b/tests/bug_example_xls/group_name_test.xls
deleted file mode 100644
index 0469e4433..000000000
Binary files a/tests/bug_example_xls/group_name_test.xls and /dev/null differ
diff --git a/tests/bug_example_xls/ict_survey_fails.xls b/tests/bug_example_xls/ict_survey_fails.xls
deleted file mode 100644
index f979fecfd..000000000
Binary files a/tests/bug_example_xls/ict_survey_fails.xls and /dev/null differ
diff --git a/tests/bug_example_xls/spaces_in_choices_header.xls b/tests/bug_example_xls/spaces_in_choices_header.xls
deleted file mode 100644
index 987ba5ff2..000000000
Binary files a/tests/bug_example_xls/spaces_in_choices_header.xls and /dev/null differ
diff --git a/tests/entities/test_create_repeat.py b/tests/entities/test_create_repeat.py
index 226e0c110..00093b9df 100644
--- a/tests/entities/test_create_repeat.py
+++ b/tests/entities/test_create_repeat.py
@@ -5,7 +5,7 @@
class TestEntitiesCreateRepeat(PyxformTestCase):
- """Test entity create specs for entities declared in a repeat"""
+ """Test entity create specs for entities declared in a repeat."""
def test_other_controls_before__ok(self):
"""Should find that having other control types before the entity repeat is OK."""
diff --git a/tests/entities/test_entities.py b/tests/entities/test_entities.py
index 926178067..2a6055a39 100644
--- a/tests/entities/test_entities.py
+++ b/tests/entities/test_entities.py
@@ -1,5 +1,5 @@
"""
-## Entity feature traceability test suite
+## Entity feature traceability test suite.
Each entities test should reference one (or more) requirements from these lists.
diff --git a/tests/example_xls/README.rst b/tests/example_xls/README.rst
deleted file mode 100644
index 961a4a44b..000000000
--- a/tests/example_xls/README.rst
+++ /dev/null
@@ -1,6 +0,0 @@
-======================
-Example Questionnaires
-======================
-
-The Microsoft Excel files in this directory demonstrate the xls2xform
-syntax. To get a good overview of the syntax look at tutorial.xls.
diff --git a/tests/example_xls/allow_comment_rows_test.xls b/tests/example_xls/allow_comment_rows_test.xls
deleted file mode 100644
index 6e51ada37..000000000
Binary files a/tests/example_xls/allow_comment_rows_test.xls and /dev/null differ
diff --git a/tests/example_xls/calculate.xls b/tests/example_xls/calculate.xls
deleted file mode 100644
index 9aa840fda..000000000
Binary files a/tests/example_xls/calculate.xls and /dev/null differ
diff --git a/tests/example_xls/cascading_select_test_equivalent.xls b/tests/example_xls/cascading_select_test_equivalent.xls
deleted file mode 100644
index a8e63f901..000000000
Binary files a/tests/example_xls/cascading_select_test_equivalent.xls and /dev/null differ
diff --git a/tests/example_xls/case_insensitivity.xls b/tests/example_xls/case_insensitivity.xls
deleted file mode 100644
index 853b759db..000000000
Binary files a/tests/example_xls/case_insensitivity.xls and /dev/null differ
diff --git a/tests/example_xls/choice_name_as_type.xls b/tests/example_xls/choice_name_as_type.xls
deleted file mode 100644
index 906ac31f7..000000000
Binary files a/tests/example_xls/choice_name_as_type.xls and /dev/null differ
diff --git a/tests/example_xls/choice_name_same_as_select_name.xls b/tests/example_xls/choice_name_same_as_select_name.xls
deleted file mode 100644
index 48a05abe2..000000000
Binary files a/tests/example_xls/choice_name_same_as_select_name.xls and /dev/null differ
diff --git a/tests/example_xls/default_time_demo.xls b/tests/example_xls/default_time_demo.xls
deleted file mode 100644
index f52133a75..000000000
Binary files a/tests/example_xls/default_time_demo.xls and /dev/null differ
diff --git a/tests/example_xls/gps.csv b/tests/example_xls/gps.csv
deleted file mode 100644
index a4b60bcef..000000000
--- a/tests/example_xls/gps.csv
+++ /dev/null
@@ -1,5 +0,0 @@
-"survey",,
-,"type","name"
-,"gps","location"
-"choices",,
-,"list name","value","text:english"
diff --git a/tests/example_xls/gps.xls b/tests/example_xls/gps.xls
deleted file mode 100644
index c8be86c54..000000000
Binary files a/tests/example_xls/gps.xls and /dev/null differ
diff --git a/tests/example_xls/group.xls b/tests/example_xls/group.xls
deleted file mode 100644
index 7b89251ba..000000000
Binary files a/tests/example_xls/group.xls and /dev/null differ
diff --git a/tests/example_xls/hidden.xls b/tests/example_xls/hidden.xls
deleted file mode 100644
index 99808f589..000000000
Binary files a/tests/example_xls/hidden.xls and /dev/null differ
diff --git a/tests/example_xls/include.csv b/tests/example_xls/include.csv
deleted file mode 100644
index d8478aa0b..000000000
--- a/tests/example_xls/include.csv
+++ /dev/null
@@ -1,6 +0,0 @@
-survey,,,
-,type,name,label:English
-,text,name,What's your name?
-,include,yes_or_no_question,Yes or no question section
-choices,,,
-,list name,name,label:english
diff --git a/tests/example_xls/include.md b/tests/example_xls/include.md
deleted file mode 100644
index b83d34d78..000000000
--- a/tests/example_xls/include.md
+++ /dev/null
@@ -1,6 +0,0 @@
-| survey |
-| | type | name | label:English |
-| | text | name | What's your name? |
-| | include | yes_or_no_question | Yes or no question section |
-| choices |
-| | list name | name | label:english |
diff --git a/tests/example_xls/include.xls b/tests/example_xls/include.xls
deleted file mode 100644
index f8612f759..000000000
Binary files a/tests/example_xls/include.xls and /dev/null differ
diff --git a/tests/example_xls/include.xlsx b/tests/example_xls/include.xlsx
deleted file mode 100644
index b2a9cee70..000000000
Binary files a/tests/example_xls/include.xlsx and /dev/null differ
diff --git a/tests/example_xls/include_json.csv b/tests/example_xls/include_json.csv
deleted file mode 100644
index e8627ba58..000000000
--- a/tests/example_xls/include_json.csv
+++ /dev/null
@@ -1,5 +0,0 @@
-"survey",,,
-,"type","name","label:English"
-,"include","how_old_are_you",
-"choices",,,
-,list name,name,label:english
diff --git a/tests/example_xls/include_json.md b/tests/example_xls/include_json.md
deleted file mode 100644
index 5adc5396e..000000000
--- a/tests/example_xls/include_json.md
+++ /dev/null
@@ -1,5 +0,0 @@
-| survey |
-| | type | name | label:English |
-| | include | how_old_are_you | |
-| choices |
-| | list name | name | label:english |
diff --git a/tests/example_xls/include_json.xls b/tests/example_xls/include_json.xls
deleted file mode 100644
index adc3b65e2..000000000
Binary files a/tests/example_xls/include_json.xls and /dev/null differ
diff --git a/tests/example_xls/include_json.xlsx b/tests/example_xls/include_json.xlsx
deleted file mode 100644
index 5a7647fee..000000000
Binary files a/tests/example_xls/include_json.xlsx and /dev/null differ
diff --git a/tests/example_xls/loop.xls b/tests/example_xls/loop.xls
deleted file mode 100644
index 85a5ae1bd..000000000
Binary files a/tests/example_xls/loop.xls and /dev/null differ
diff --git a/tests/example_xls/repeat_date_test.xls b/tests/example_xls/repeat_date_test.xls
deleted file mode 100644
index 43edec6e1..000000000
Binary files a/tests/example_xls/repeat_date_test.xls and /dev/null differ
diff --git a/tests/example_xls/simple_loop.xls b/tests/example_xls/simple_loop.xls
deleted file mode 100644
index 19597ecbc..000000000
Binary files a/tests/example_xls/simple_loop.xls and /dev/null differ
diff --git a/tests/example_xls/sms_info.xls b/tests/example_xls/sms_info.xls
deleted file mode 100644
index a1a00312b..000000000
Binary files a/tests/example_xls/sms_info.xls and /dev/null differ
diff --git a/tests/example_xls/specify_other.csv b/tests/example_xls/specify_other.csv
deleted file mode 100644
index 593a1506c..000000000
--- a/tests/example_xls/specify_other.csv
+++ /dev/null
@@ -1,8 +0,0 @@
-"survey",,,
-,"type","name","label:English"
-,"select one from sexes or specify other","sex","What sex are you?"
-,,,
-"choices",,,
-,"list name","name","label:English"
-,"sexes","male","Male"
-,"sexes","female","Female"
diff --git a/tests/example_xls/specify_other.md b/tests/example_xls/specify_other.md
deleted file mode 100644
index 0d888ff9f..000000000
--- a/tests/example_xls/specify_other.md
+++ /dev/null
@@ -1,7 +0,0 @@
-| survey |
-| | type | name | label:English |
-| | select one from sexes or specify other | sex | What sex are you? |
-| choices |
-| | list name | name | label:English |
-| | sexes | male | Male |
-| | sexes | female | Female |
diff --git a/tests/example_xls/specify_other.xls b/tests/example_xls/specify_other.xls
deleted file mode 100644
index 5d403ff98..000000000
Binary files a/tests/example_xls/specify_other.xls and /dev/null differ
diff --git a/tests/example_xls/specify_other.xlsx b/tests/example_xls/specify_other.xlsx
deleted file mode 100644
index 9f9735596..000000000
Binary files a/tests/example_xls/specify_other.xlsx and /dev/null differ
diff --git a/tests/example_xls/style_settings.xls b/tests/example_xls/style_settings.xls
deleted file mode 100644
index 933720644..000000000
Binary files a/tests/example_xls/style_settings.xls and /dev/null differ
diff --git a/tests/example_xls/table-list.xls b/tests/example_xls/table-list.xls
deleted file mode 100644
index 2a37fbf34..000000000
Binary files a/tests/example_xls/table-list.xls and /dev/null differ
diff --git a/tests/example_xls/text_and_integer.xls b/tests/example_xls/text_and_integer.xls
deleted file mode 100644
index 4d708e8af..000000000
Binary files a/tests/example_xls/text_and_integer.xls and /dev/null differ
diff --git a/tests/example_xls/tutorial.xls b/tests/example_xls/tutorial.xls
deleted file mode 100755
index 247f08177..000000000
Binary files a/tests/example_xls/tutorial.xls and /dev/null differ
diff --git a/tests/example_xls/unknown_question_type.xls b/tests/example_xls/unknown_question_type.xls
deleted file mode 100644
index 5289ed91d..000000000
Binary files a/tests/example_xls/unknown_question_type.xls and /dev/null differ
diff --git a/tests/example_xls/widgets.xls b/tests/example_xls/widgets.xls
deleted file mode 100644
index 601696688..000000000
Binary files a/tests/example_xls/widgets.xls and /dev/null differ
diff --git a/tests/example_xls/xml_escaping.xls b/tests/example_xls/xml_escaping.xls
deleted file mode 100644
index fb7d6b3ee..000000000
Binary files a/tests/example_xls/xml_escaping.xls and /dev/null differ
diff --git a/tests/example_xls/yes_or_no_question.xls b/tests/example_xls/yes_or_no_question.xls
deleted file mode 100644
index 3984f3f48..000000000
Binary files a/tests/example_xls/yes_or_no_question.xls and /dev/null differ
diff --git a/tests/fixtures/__init__.py b/tests/fixtures/__init__.py
new file mode 100644
index 000000000..e69de29bb
diff --git a/tests/bug_example_xls/ODKValidateWarnings.xlsx b/tests/fixtures/bug_example_forms/ODKValidateWarnings.xlsx
similarity index 100%
rename from tests/bug_example_xls/ODKValidateWarnings.xlsx
rename to tests/fixtures/bug_example_forms/ODKValidateWarnings.xlsx
diff --git a/tests/bug_example_xls/UCL_Biomass_Plot_Form.xlsx b/tests/fixtures/bug_example_forms/UCL_Biomass_Plot_Form.xlsx
similarity index 100%
rename from tests/bug_example_xls/UCL_Biomass_Plot_Form.xlsx
rename to tests/fixtures/bug_example_forms/UCL_Biomass_Plot_Form.xlsx
diff --git a/tests/bug_example_xls/__init__.py b/tests/fixtures/bug_example_forms/__init__.py
similarity index 100%
rename from tests/bug_example_xls/__init__.py
rename to tests/fixtures/bug_example_forms/__init__.py
diff --git a/tests/bug_example_xls/bad_calc.xlsx b/tests/fixtures/bug_example_forms/bad_calc.xlsx
similarity index 100%
rename from tests/bug_example_xls/bad_calc.xlsx
rename to tests/fixtures/bug_example_forms/bad_calc.xlsx
diff --git a/tests/bug_example_xls/duplicate_columns.xlsx b/tests/fixtures/bug_example_forms/duplicate_columns.xlsx
similarity index 100%
rename from tests/bug_example_xls/duplicate_columns.xlsx
rename to tests/fixtures/bug_example_forms/duplicate_columns.xlsx
diff --git a/tests/bug_example_xls/excel_with_macros.xlsm b/tests/fixtures/bug_example_forms/excel_with_macros.xlsm
similarity index 100%
rename from tests/bug_example_xls/excel_with_macros.xlsm
rename to tests/fixtures/bug_example_forms/excel_with_macros.xlsm
diff --git a/tests/fixtures/bug_example_forms/extra_columns.xlsx b/tests/fixtures/bug_example_forms/extra_columns.xlsx
new file mode 100644
index 000000000..1c861e4a1
Binary files /dev/null and b/tests/fixtures/bug_example_forms/extra_columns.xlsx differ
diff --git a/tests/bug_example_xls/xl_date_ambiguous.xlsx b/tests/fixtures/bug_example_forms/xl_date_ambiguous.xlsx
similarity index 100%
rename from tests/bug_example_xls/xl_date_ambiguous.xlsx
rename to tests/fixtures/bug_example_forms/xl_date_ambiguous.xlsx
diff --git a/tests/bug_example_xls/xl_date_ambiguous_v1.xlsx b/tests/fixtures/bug_example_forms/xl_date_ambiguous_v1.xlsx
similarity index 100%
rename from tests/bug_example_xls/xl_date_ambiguous_v1.xlsx
rename to tests/fixtures/bug_example_forms/xl_date_ambiguous_v1.xlsx
diff --git a/tests/example_xls/__init__.py b/tests/fixtures/example_forms/__init__.py
similarity index 100%
rename from tests/example_xls/__init__.py
rename to tests/fixtures/example_forms/__init__.py
diff --git a/tests/example_xls/attribute_columns_test.xlsx b/tests/fixtures/example_forms/attribute_columns_test.xlsx
similarity index 100%
rename from tests/example_xls/attribute_columns_test.xlsx
rename to tests/fixtures/example_forms/attribute_columns_test.xlsx
diff --git a/tests/example_xls/bad_calc.xlsx b/tests/fixtures/example_forms/bad_calc.xlsx
similarity index 100%
rename from tests/example_xls/bad_calc.xlsx
rename to tests/fixtures/example_forms/bad_calc.xlsx
diff --git a/tests/example_xls/case_insensitivity.csv b/tests/fixtures/example_forms/case_insensitivity.csv
similarity index 100%
rename from tests/example_xls/case_insensitivity.csv
rename to tests/fixtures/example_forms/case_insensitivity.csv
diff --git a/tests/example_xls/case_insensitivity.md b/tests/fixtures/example_forms/case_insensitivity.md
similarity index 100%
rename from tests/example_xls/case_insensitivity.md
rename to tests/fixtures/example_forms/case_insensitivity.md
diff --git a/tests/example_xls/case_insensitivity.xlsx b/tests/fixtures/example_forms/case_insensitivity.xlsx
similarity index 100%
rename from tests/example_xls/case_insensitivity.xlsx
rename to tests/fixtures/example_forms/case_insensitivity.xlsx
diff --git a/tests/example_xls/choice_filter_test.xlsx b/tests/fixtures/example_forms/choice_filter_test.xlsx
similarity index 100%
rename from tests/example_xls/choice_filter_test.xlsx
rename to tests/fixtures/example_forms/choice_filter_test.xlsx
diff --git a/tests/example_xls/extra_columns.xlsx b/tests/fixtures/example_forms/extra_columns.xlsx
similarity index 100%
rename from tests/example_xls/extra_columns.xlsx
rename to tests/fixtures/example_forms/extra_columns.xlsx
diff --git a/tests/example_xls/extra_sheet_names.xlsx b/tests/fixtures/example_forms/extra_sheet_names.xlsx
similarity index 100%
rename from tests/example_xls/extra_sheet_names.xlsx
rename to tests/fixtures/example_forms/extra_sheet_names.xlsx
diff --git a/tests/example_xls/field-list.xlsx b/tests/fixtures/example_forms/field-list.xlsx
similarity index 100%
rename from tests/example_xls/field-list.xlsx
rename to tests/fixtures/example_forms/field-list.xlsx
diff --git a/tests/example_xls/flat_xlsform_test.xlsx b/tests/fixtures/example_forms/flat_xlsform_test.xlsx
similarity index 100%
rename from tests/example_xls/flat_xlsform_test.xlsx
rename to tests/fixtures/example_forms/flat_xlsform_test.xlsx
diff --git a/tests/example_xls/fruits.csv b/tests/fixtures/example_forms/fruits.csv
similarity index 100%
rename from tests/example_xls/fruits.csv
rename to tests/fixtures/example_forms/fruits.csv
diff --git a/tests/example_xls/group.csv b/tests/fixtures/example_forms/group.csv
similarity index 100%
rename from tests/example_xls/group.csv
rename to tests/fixtures/example_forms/group.csv
diff --git a/tests/example_xls/group.md b/tests/fixtures/example_forms/group.md
similarity index 100%
rename from tests/example_xls/group.md
rename to tests/fixtures/example_forms/group.md
diff --git a/tests/example_xls/group.xlsx b/tests/fixtures/example_forms/group.xlsx
similarity index 100%
rename from tests/example_xls/group.xlsx
rename to tests/fixtures/example_forms/group.xlsx
diff --git a/tests/example_xls/loop.csv b/tests/fixtures/example_forms/loop.csv
similarity index 100%
rename from tests/example_xls/loop.csv
rename to tests/fixtures/example_forms/loop.csv
diff --git a/tests/example_xls/loop.md b/tests/fixtures/example_forms/loop.md
similarity index 100%
rename from tests/example_xls/loop.md
rename to tests/fixtures/example_forms/loop.md
diff --git a/tests/example_xls/loop.xlsx b/tests/fixtures/example_forms/loop.xlsx
similarity index 100%
rename from tests/example_xls/loop.xlsx
rename to tests/fixtures/example_forms/loop.xlsx
diff --git a/tests/example_xls/or_other.xlsx b/tests/fixtures/example_forms/or_other.xlsx
similarity index 100%
rename from tests/example_xls/or_other.xlsx
rename to tests/fixtures/example_forms/or_other.xlsx
diff --git a/tests/example_xls/pull_data.xlsx b/tests/fixtures/example_forms/pull_data.xlsx
similarity index 100%
rename from tests/example_xls/pull_data.xlsx
rename to tests/fixtures/example_forms/pull_data.xlsx
diff --git a/tests/fixtures/example_forms/repeat_date_test.xlsx b/tests/fixtures/example_forms/repeat_date_test.xlsx
new file mode 100644
index 000000000..239c7c8f9
Binary files /dev/null and b/tests/fixtures/example_forms/repeat_date_test.xlsx differ
diff --git a/tests/example_xls/simple_loop.csv b/tests/fixtures/example_forms/simple_loop.csv
similarity index 100%
rename from tests/example_xls/simple_loop.csv
rename to tests/fixtures/example_forms/simple_loop.csv
diff --git a/tests/example_xls/spec_test_expected_output.xml b/tests/fixtures/example_forms/spec_test_expected_output.xml
similarity index 100%
rename from tests/example_xls/spec_test_expected_output.xml
rename to tests/fixtures/example_forms/spec_test_expected_output.xml
diff --git a/tests/example_xls/survey_no_name.xlsx b/tests/fixtures/example_forms/survey_no_name.xlsx
similarity index 100%
rename from tests/example_xls/survey_no_name.xlsx
rename to tests/fixtures/example_forms/survey_no_name.xlsx
diff --git a/tests/example_xls/text_and_integer.csv b/tests/fixtures/example_forms/text_and_integer.csv
similarity index 100%
rename from tests/example_xls/text_and_integer.csv
rename to tests/fixtures/example_forms/text_and_integer.csv
diff --git a/tests/example_xls/text_and_integer.md b/tests/fixtures/example_forms/text_and_integer.md
similarity index 100%
rename from tests/example_xls/text_and_integer.md
rename to tests/fixtures/example_forms/text_and_integer.md
diff --git a/tests/example_xls/text_and_integer.xlsx b/tests/fixtures/example_forms/text_and_integer.xlsx
similarity index 100%
rename from tests/example_xls/text_and_integer.xlsx
rename to tests/fixtures/example_forms/text_and_integer.xlsx
diff --git a/tests/example_xls/utf_csv.csv b/tests/fixtures/example_forms/utf_csv.csv
similarity index 100%
rename from tests/example_xls/utf_csv.csv
rename to tests/fixtures/example_forms/utf_csv.csv
diff --git a/tests/example_xls/widgets-media/a.jpg b/tests/fixtures/example_forms/widgets-media/a.jpg
old mode 100755
new mode 100644
similarity index 100%
rename from tests/example_xls/widgets-media/a.jpg
rename to tests/fixtures/example_forms/widgets-media/a.jpg
diff --git a/tests/example_xls/widgets-media/b.jpg b/tests/fixtures/example_forms/widgets-media/b.jpg
old mode 100755
new mode 100644
similarity index 100%
rename from tests/example_xls/widgets-media/b.jpg
rename to tests/fixtures/example_forms/widgets-media/b.jpg
diff --git a/tests/example_xls/widgets-media/happy.jpg b/tests/fixtures/example_forms/widgets-media/happy.jpg
old mode 100755
new mode 100644
similarity index 100%
rename from tests/example_xls/widgets-media/happy.jpg
rename to tests/fixtures/example_forms/widgets-media/happy.jpg
diff --git a/tests/example_xls/widgets-media/img_test.jpg b/tests/fixtures/example_forms/widgets-media/img_test.jpg
old mode 100755
new mode 100644
similarity index 100%
rename from tests/example_xls/widgets-media/img_test.jpg
rename to tests/fixtures/example_forms/widgets-media/img_test.jpg
diff --git a/tests/example_xls/widgets-media/sad.jpg b/tests/fixtures/example_forms/widgets-media/sad.jpg
old mode 100755
new mode 100644
similarity index 100%
rename from tests/example_xls/widgets-media/sad.jpg
rename to tests/fixtures/example_forms/widgets-media/sad.jpg
diff --git a/tests/example_xls/widgets.csv b/tests/fixtures/example_forms/widgets.csv
similarity index 100%
rename from tests/example_xls/widgets.csv
rename to tests/fixtures/example_forms/widgets.csv
diff --git a/tests/fixtures/example_forms/widgets.xlsx b/tests/fixtures/example_forms/widgets.xlsx
new file mode 100644
index 000000000..e9f6dc5a5
Binary files /dev/null and b/tests/fixtures/example_forms/widgets.xlsx differ
diff --git a/tests/example_xls/widgets.xml b/tests/fixtures/example_forms/widgets.xml
similarity index 100%
rename from tests/example_xls/widgets.xml
rename to tests/fixtures/example_forms/widgets.xml
diff --git a/tests/example_xls/xlsform_spec_test.xlsx b/tests/fixtures/example_forms/xlsform_spec_test.xlsx
similarity index 100%
rename from tests/example_xls/xlsform_spec_test.xlsx
rename to tests/fixtures/example_forms/xlsform_spec_test.xlsx
diff --git a/tests/example_xls/yes_or_no_question.csv b/tests/fixtures/example_forms/yes_or_no_question.csv
similarity index 100%
rename from tests/example_xls/yes_or_no_question.csv
rename to tests/fixtures/example_forms/yes_or_no_question.csv
diff --git a/tests/example_xls/yes_or_no_question.md b/tests/fixtures/example_forms/yes_or_no_question.md
similarity index 100%
rename from tests/example_xls/yes_or_no_question.md
rename to tests/fixtures/example_forms/yes_or_no_question.md
diff --git a/tests/example_xls/yes_or_no_question.xlsx b/tests/fixtures/example_forms/yes_or_no_question.xlsx
similarity index 100%
rename from tests/example_xls/yes_or_no_question.xlsx
rename to tests/fixtures/example_forms/yes_or_no_question.xlsx
diff --git a/tests/pyxform_test_case.py b/tests/pyxform_test_case.py
index ea3ed09f3..c786583f7 100644
--- a/tests/pyxform_test_case.py
+++ b/tests/pyxform_test_case.py
@@ -1,6 +1,4 @@
-"""
-PyxformTestCase base class using markdown to define the XLSForm.
-"""
+"""PyxformTestCase base class using markdown to define the XLSForm."""
import logging
import os
@@ -112,7 +110,7 @@ def assertPyxformXform(
cases where testing whitespace and cells' type is important.
:param survey: easy for reuse within a test
# Note: XLS is not implemented at this time. You can use builder to create a
- pyxform Survey object
+ pyxform Survey object.
One or more XForm assertions:
:param xml__xpath_exact: A list of tuples where the first tuple element is an
@@ -367,7 +365,7 @@ def _assert_contains(content, text, msg_prefix):
def assertContains(self, content, text, count=None, msg_prefix=""):
"""
- FROM: django source- testcases.py
+ FROM: django source- testcases.py.
Asserts that ``text`` occurs ``count`` times in the content string.
If ``count`` is None, the count doesn't matter - the assertion is
diff --git a/tests/question_types/__init__.py b/tests/question_types/__init__.py
new file mode 100644
index 000000000..e69de29bb
diff --git a/tests/question_types/test_hidden.py b/tests/question_types/test_hidden.py
new file mode 100644
index 000000000..a737683eb
--- /dev/null
+++ b/tests/question_types/test_hidden.py
@@ -0,0 +1,19 @@
+from tests.pyxform_test_case import PyxformTestCase
+from tests.xpath_helpers.questions import xpq
+
+
+class TestHiddenOutput(PyxformTestCase):
+ def test_hidden(self):
+ """Should find that the hidden type is recognised and output as a string."""
+ md = """
+ | survey |
+ | | type | name | label |
+ | | hidden | q1 | Q1 |
+ """
+ self.assertPyxformXform(
+ md=md,
+ xml__xpath_match=[
+ xpq.model_instance_item("q1"),
+ xpq.model_instance_bind("q1", "string"),
+ ],
+ )
diff --git a/tests/question_types/test_include.py b/tests/question_types/test_include.py
new file mode 100644
index 000000000..5ce9f7109
--- /dev/null
+++ b/tests/question_types/test_include.py
@@ -0,0 +1,104 @@
+from pathlib import Path
+
+from pyxform.builder import create_survey_from_path
+from pyxform.errors import PyXFormError
+from pyxform.xls2xform import convert
+
+from tests.pyxform_test_case import PyxformTestCase
+from tests.utils import get_temp_dir
+from tests.xpath_helpers.questions import xpq
+
+
+class TestIncludeParsing(PyxformTestCase):
+ def test_include_file_missing__error(self):
+ """Should raise an error if the include 'name' does not match an adjacent file."""
+ md = """
+ | survey |
+ | | type | name | label |
+ | | begin_group | g1 | G1 |
+ | | include | included | Included |
+ | | end_group | g1 | |
+ """
+ with get_temp_dir() as tmp, self.assertRaises(PyXFormError) as err:
+ self.assertFalse((Path(tmp) / "included.md").is_file())
+ (Path(tmp) / "test_name.md").write_text(md)
+ create_survey_from_path(
+ path=str(Path(tmp) / "test_name.md"),
+ include_directory=True,
+ )
+
+ self.assertEqual("This section has not been included.", str(err.exception))
+
+ def test_include_via_pyxformtestcase__error(self):
+ """Should raise an error if 'include' is used via PyxformTestCase."""
+ # Loading of additional files is only available via `create_survey_from_path`.
+ md = """
+ | survey |
+ | | type | name | label |
+ | | begin_group | g1 | G1 |
+ | | include | included | Included |
+ | | end_group | g1 | |
+ """
+ self.assertPyxformXform(
+ md=md, errored=True, error__contains=["This section has not been included."]
+ )
+
+ def test_include_via_convert__error(self):
+ """Should raise an error if 'include' is used via `convert`."""
+ # Loading of additional files is only available via `create_survey_from_path`.
+ md = """
+ | survey |
+ | | type | name | label |
+ | | begin_group | g1 | G1 |
+ | | include | included | Included |
+ | | end_group | g1 | |
+ """
+ included = """
+ | survey |
+ | | type | name | label |
+ | | text | q1 | Q1 |
+ """
+ with get_temp_dir() as tmp, self.assertRaises(PyXFormError) as err:
+ (Path(tmp) / "included.md").write_text(included)
+ (Path(tmp) / "test_name.md").write_text(md)
+ convert(xlsform=Path(tmp) / "test_name.md")
+
+ self.assertEqual("This section has not been included.", str(err.exception))
+
+
+class TestIncludeOutput(PyxformTestCase):
+ def test_include(self):
+ """Should find that the 'include' type loads adjacent named files into the form."""
+ # The included file has the "meta" item so it has to be wrapped in a group.
+ md = """
+ | survey |
+ | | type | name | label |
+ | | begin_group | g1 | G1 |
+ | | include | included | Included |
+ | | end_group | g1 | |
+ """
+ included = """
+ | survey |
+ | | type | name | label |
+ | | text | q1 | Q1 |
+ """
+ with get_temp_dir() as tmp:
+ # The include item "name" value is used to match the file name.
+ (Path(tmp) / "included.md").write_text(included)
+ # Uses "test_name" to match the default XPath helper instance name.
+ (Path(tmp) / "test_name.md").write_text(md)
+ # The feature is only implemented and reachable via this function.
+ survey = create_survey_from_path(
+ path=str(Path(tmp) / "test_name.md"),
+ include_directory=True,
+ )
+
+ self.assertPyxformXform(
+ survey=survey,
+ xml__xpath_match=[
+ xpq.model_instance_item("g1/x:q1"),
+ xpq.model_instance_bind("g1/q1", "string"),
+ # The question item directive "included" is in the output.
+ """/h:html/h:head/x:model/x:instance[not(.//x:included)]""",
+ ],
+ )
diff --git a/tests/question_types/test_select_or_other.py b/tests/question_types/test_select_or_other.py
new file mode 100644
index 000000000..21511b509
--- /dev/null
+++ b/tests/question_types/test_select_or_other.py
@@ -0,0 +1,136 @@
+from collections.abc import Iterable
+from unittest import expectedFailure
+
+from pyxform.aliases import select, select_multiple, select_one
+
+from tests.pyxform_test_case import PyxformTestCase
+from tests.xpath_helpers.choices import xpc
+from tests.xpath_helpers.questions import xpq
+
+
+class TestSelectOrOtherOutput(PyxformTestCase):
+ @staticmethod
+ def get_cases(
+ commands: Iterable[str], control: str, list_name: str = "c1"
+ ) -> tuple[tuple[str, str], ...]:
+ return tuple(
+ (f"{s} {list_name} {o}", control)
+ for s in commands
+ for o in ("or specify other", "or_other", "or other")
+ )
+
+ def test_aliases__select_one__ok(self):
+ """Should find that all supported aliases result in the same output."""
+ md = """
+ | survey |
+ | | type | name | label |
+ | | {} | q1 | Q1 |
+
+ | choices |
+ | | list_name | name | label |
+ | | c1 | n1 | N1 |
+ """
+ for case in self.get_cases(select_one, "select1"):
+ with self.subTest(msg=case):
+ self.assertPyxformXform(
+ md=md.format(case[0]),
+ xml__xpath_match=[
+ xpc.model_instance_choices_label(
+ "c1", (("n1", "N1"), ("other", "Other"))
+ ),
+ xpq.model_instance_item("q1"),
+ xpq.model_instance_bind("q1", "string"),
+ xpq.body_control("q1", case[1]),
+ xpq.model_instance_item("q1_other"),
+ xpq.model_instance_bind("q1_other", "string"),
+ xpq.body_control("q1_other", "input"),
+ ],
+ )
+
+ def test_aliases__select_multiple__ok(self):
+ """Should find that all supported aliases result in the same output."""
+ md = """
+ | survey |
+ | | type | name | label |
+ | | {} | q1 | Q1 |
+
+ | choices |
+ | | list_name | name | label |
+ | | c1 | n1 | N1 |
+ """
+ for case in self.get_cases(select_multiple, "select"):
+ with self.subTest(msg=case):
+ self.assertPyxformXform(
+ md=md.format(case[0]),
+ xml__xpath_match=[
+ xpc.model_instance_choices_label(
+ "c1", (("n1", "N1"), ("other", "Other"))
+ ),
+ xpq.model_instance_item("q1"),
+ xpq.model_instance_bind("q1", "string"),
+ xpq.body_control("q1", case[1]),
+ xpq.model_instance_item("q1_other"),
+ xpq.model_instance_bind("q1_other", "string"),
+ xpq.body_control("q1_other", "input"),
+ ],
+ )
+
+ def test_aliases__select_from_file__error(self):
+ """Should raise an error for unsupported select types used with or_other."""
+ md = """
+ | survey |
+ | | type | name | label |
+ | | {} | q1 | Q1 |
+
+ | choices |
+ | | list_name | name | label |
+ | | c1 | n1 | N1 |
+ """
+ for case in self.get_cases(select_multiple, "select", "c1.csv"):
+ with self.subTest(msg=case):
+ self.assertPyxformXform(
+ md=md.format(case[0]),
+ errored=True,
+ error__contains=[
+ "[row : 2] Please specify choices for this 'or other' question."
+ ],
+ )
+
+ # Does not raise an error, just outputs a form with `q1` but no `q1_other`.
+ @expectedFailure
+ def test_aliases__rank__error(self):
+ """Should raise an error for unsupported select types used with or_other."""
+ md = """
+ | survey |
+ | | type | name | label |
+ | | rank c1 | q1 | Q1 |
+
+ | choices |
+ | | list_name | name | label |
+ | | c1 | n1 | N1 |
+ """
+ for case in self.get_cases(("rank",), "odk:rank"):
+ with self.subTest(msg=case):
+ self.assertPyxformXform(
+ md=md.format(case[0]),
+ errored=True,
+ )
+
+ # Does not raise an error, just outputs a form with `q1` but no `q1_other`.
+ @expectedFailure
+ def test_aliases__select_one_external__error(self):
+ """Should raise an error for unsupported select types used with or_other."""
+ md = """
+ | survey |
+ | | type | name | label | choice_filter |
+ | | select_one_external c1 | q1 | Q1 | false() |
+
+ | external_choices |
+ | | list_name | name | label |
+ | | c1 | n1 | N1 |
+ """
+ self.assertIn("select_one_external", select)
+ self.assertPyxformXform(
+ md=md,
+ errored=True,
+ )
diff --git a/tests/test_area.py b/tests/test_area.py
index 58ebf58fb..088145d27 100644
--- a/tests/test_area.py
+++ b/tests/test_area.py
@@ -1,14 +1,10 @@
-"""
-AreaTest - test enclosed-area(geo_shape) calculation.
-"""
+"""AreaTest - test enclosed-area(geo_shape) calculation."""
from tests.pyxform_test_case import PyxformTestCase
class AreaTest(PyxformTestCase):
- """
- AreaTest - test enclosed-area(geo_shape) calculation.
- """
+ """AreaTest - test enclosed-area(geo_shape) calculation."""
def test_area(self):
d = (
diff --git a/tests/test_audit.py b/tests/test_audit.py
index 651bb5e71..7e8c2cc10 100644
--- a/tests/test_audit.py
+++ b/tests/test_audit.py
@@ -1,14 +1,10 @@
-"""
-AuditTest - test audit question type.
-"""
+"""AuditTest - test audit question type."""
from tests.pyxform_test_case import PyxformTestCase
class AuditTest(PyxformTestCase):
- """
- AuditTest - test audit question type.
- """
+ """AuditTest - test audit question type."""
def test_audit(self):
self.assertPyxformXform(
diff --git a/tests/test_bind_conversions.py b/tests/test_bind_conversions.py
index 2ff3d1110..9c62d6c46 100644
--- a/tests/test_bind_conversions.py
+++ b/tests/test_bind_conversions.py
@@ -1,14 +1,10 @@
-"""
-BindConversionsTest - test bind conversions.
-"""
+"""BindConversionsTest - test bind conversions."""
from tests.pyxform_test_case import PyxformTestCase
class BindConversionsTest(PyxformTestCase):
- """
- BindConversionsTest - test bind conversions
- """
+ """BindConversionsTest - test bind conversions."""
def test_bind_readonly_conversion(self):
self.assertPyxformXform(
diff --git a/tests/test_bug_round_calculation.py b/tests/test_bug_round_calculation.py
deleted file mode 100644
index 706e0138f..000000000
--- a/tests/test_bug_round_calculation.py
+++ /dev/null
@@ -1,19 +0,0 @@
-"""
-Test round(number, precision) calculation.
-"""
-
-from tests.pyxform_test_case import PyxformTestCase
-
-
-class RoundCalculationTest(PyxformTestCase):
- def test_non_existent_itext_reference(self):
- self.assertPyxformXform(
- name="ecsv",
- md="""
- | survey | | | | |
- | | type | name | label | calculation |
- | | decimal | amount | Counter | |
- | | calculate | rounded | Rounded | round(${amount}, 0) |
- """,
- xml__contains=[""""""],
- )
diff --git a/tests/test_builder.py b/tests/test_builder.py
index de0e069de..8d041c563 100644
--- a/tests/test_builder.py
+++ b/tests/test_builder.py
@@ -1,13 +1,9 @@
-"""
-Test builder module functionality.
-"""
+"""Test builder module functionality."""
import os
-import re
from pathlib import Path
from unittest import TestCase
-import defusedxml.ElementTree as ETree
from pyxform import InputQuestion, Survey
from pyxform.builder import SurveyElementBuilder, create_survey_from_xls
from pyxform.errors import ErrorCode, PyXFormError
@@ -15,22 +11,10 @@
from tests import utils
-FIXTURE_FILETYPE = "xls"
-
class BuilderTests(TestCase):
maxDiff = None
- def test_unknown_question_type(self):
- with self.assertRaises(PyXFormError) as err:
- utils.build_survey("unknown_question_type.xls")
- self.assertEqual(
- ErrorCode.HEADER_002.value.format(
- sheet_name="survey", other="bind:relevant", header="relevance"
- ),
- err.exception.args[0],
- )
-
def setUp(self):
self.this_directory = os.path.dirname(__file__)
survey_out = Survey(name="age", sms_keyword="age", type="survey")
@@ -43,7 +27,7 @@ def setUp(self):
@staticmethod
def test_create_from_file_object():
- path = utils.path_to_text_fixture("yes_or_no_question.xls")
+ path = utils.path_to_text_fixture("yes_or_no_question.xlsx")
with open(path, "rb") as f:
create_survey_from_xls(f)
@@ -104,104 +88,8 @@ def test_create_table_from_dict(self):
}
self.assertEqual(expected_dict, g.to_json_dict())
- def test_specify_other(self):
- survey = utils.create_survey_from_fixture(
- "specify_other", filetype=FIXTURE_FILETYPE
- )
- expected_dict = {
- "name": "specify_other",
- "type": "survey",
- "title": "specify_other",
- "default_language": "default",
- "id_string": "specify_other",
- "sms_keyword": "specify_other",
- "choices": {
- "sexes": [
- {"label": {"English": "Male"}, "name": "male"},
- {"label": {"English": "Female"}, "name": "female"},
- {"label": {"English": "Other"}, "name": "other"},
- ]
- },
- "children": [
- {
- "name": "sex",
- "label": {"English": "What sex are you?"},
- "type": "select one",
- "list_name": "sexes",
- "itemset": "sexes",
- "children": [
- # TODO Change to choices (there is stuff in the
- # json2xform half that will need to change)
- {"name": "male", "label": {"English": "Male"}},
- {"name": "female", "label": {"English": "Female"}},
- {"name": "other", "label": {"English": "Other"}},
- ],
- },
- {
- "name": "sex_other",
- "bind": {"relevant": "selected(../sex, 'other')"},
- "label": "Specify other.",
- "type": "text",
- },
- {
- "children": [
- {
- "bind": {"jr:preload": "uid", "readonly": "true()"},
- "name": "instanceID",
- "type": "calculate",
- }
- ],
- "control": {"bodyless": True},
- "name": "meta",
- "type": "group",
- },
- ],
- }
- self.assertEqual(survey.to_json_dict(), expected_dict)
-
- def test_select_one_question_with_identical_choice_name(self):
- """
- testing to make sure that select ones whose choice names are the same
- as the name of the select one get compiled.
- """
- survey = utils.create_survey_from_fixture(
- "choice_name_same_as_select_name", filetype=FIXTURE_FILETYPE
- )
- expected_dict = {
- "name": "choice_name_same_as_select_name",
- "title": "choice_name_same_as_select_name",
- "sms_keyword": "choice_name_same_as_select_name",
- "default_language": "default",
- "id_string": "choice_name_same_as_select_name",
- "type": "survey",
- "choices": {"zone": [{"label": "Zone", "name": "zone"}]},
- "children": [
- {
- "children": [{"name": "zone", "label": "Zone"}],
- "type": "select one",
- "name": "zone",
- "label": "Zone",
- "list_name": "zone",
- "itemset": "zone",
- },
- {
- "children": [
- {
- "bind": {"jr:preload": "uid", "readonly": "true()"},
- "name": "instanceID",
- "type": "calculate",
- }
- ],
- "control": {"bodyless": True},
- "name": "meta",
- "type": "group",
- },
- ],
- }
- self.assertEqual(expected_dict, survey.to_json_dict())
-
def test_loop(self):
- survey = utils.create_survey_from_fixture("loop", filetype=FIXTURE_FILETYPE)
+ survey = utils.create_survey_from_fixture("loop", filetype="xlsx")
expected_dict = {
"name": "loop",
"id_string": "loop",
@@ -339,221 +227,6 @@ def test_loop(self):
}
self.assertEqual(expected_dict, survey.to_json_dict())
- def test_sms_columns(self):
- survey = utils.create_survey_from_fixture("sms_info", filetype=FIXTURE_FILETYPE)
- expected_dict = {
- "children": [
- {
- "children": [
- {
- "label": "How old are you?",
- "name": "age",
- "sms_field": "q1",
- "type": "integer",
- },
- {
- "children": [
- {"label": "no", "name": "0", "sms_option": "n"},
- {"label": "yes", "name": "1", "sms_option": "y"},
- ],
- "label": "Do you have any children?",
- "list_name": "yes_no",
- "itemset": "yes_no",
- "name": "has_children",
- "sms_field": "q2",
- "type": "select one",
- },
- {
- "label": "What's your birth day?",
- "name": "bday",
- "sms_field": "q3",
- "type": "date",
- },
- {
- "label": "What is your name?",
- "name": "name",
- "sms_field": "q4",
- "type": "text",
- },
- ],
- "name": "section1",
- "sms_field": "a",
- "type": "group",
- },
- {
- "children": [
- {
- "label": "May I take your picture?",
- "name": "picture",
- "type": "photo",
- },
- {
- "label": "Record your GPS coordinates.",
- "name": "gps",
- "type": "geopoint",
- },
- ],
- "name": "medias",
- "sms_field": "c",
- "type": "group",
- },
- {
- "children": [
- {
- "children": [
- {
- "label": "Mozilla Firefox",
- "name": "firefox",
- "sms_option": "ff",
- },
- {
- "label": "Google Chrome",
- "name": "chrome",
- "sms_option": "gc",
- },
- {
- "label": "Internet Explorer",
- "name": "ie",
- "sms_option": "ie",
- },
- {
- "label": "Safari",
- "name": "safari",
- "sms_option": "saf",
- },
- ],
- "label": "What web browsers do you use?",
- "list_name": "browsers",
- "itemset": "browsers",
- "name": "web_browsers",
- "sms_field": "q5",
- "type": "select all that apply",
- }
- ],
- "name": "browsers",
- "sms_field": "b",
- "type": "group",
- },
- {
- "children": [
- {
- "label": "Phone Number",
- "name": "phone",
- "type": "phonenumber",
- },
- {"label": "Start DT", "name": "start", "type": "start"},
- {"label": "End DT", "name": "end", "type": "end"},
- {"label": "Send Day", "name": "today", "type": "today"},
- {"label": "IMEI", "name": "imei", "type": "deviceid"},
- {"label": "Hey!", "name": "nope", "type": "note"},
- ],
- "name": "metadata",
- "sms_field": "meta",
- "type": "group",
- },
- {
- "children": [
- {
- "bind": {"jr:preload": "uid", "readonly": "true()"},
- "name": "instanceID",
- "type": "calculate",
- }
- ],
- "control": {"bodyless": True},
- "name": "meta",
- "type": "group",
- },
- ],
- "default_language": "default",
- "id_string": "sms_info_form",
- "name": "sms_info",
- "sms_allow_media": "TRUE",
- "sms_date_format": "%Y-%m-%d",
- "sms_datetime_format": "%Y-%m-%d-%H:%M",
- "sms_keyword": "inf",
- "sms_separator": "+",
- "title": "SMS Example",
- "type": "survey",
- "choices": {
- "browsers": [
- {"label": "Mozilla Firefox", "name": "firefox", "sms_option": "ff"},
- {"label": "Google Chrome", "name": "chrome", "sms_option": "gc"},
- {"label": "Internet Explorer", "name": "ie", "sms_option": "ie"},
- {"label": "Safari", "name": "safari", "sms_option": "saf"},
- ],
- "yes_no": [
- {"label": "no", "name": "0", "sms_option": "n"},
- {"label": "yes", "name": "1", "sms_option": "y"},
- ],
- },
- }
- self.assertEqual(expected_dict, survey.to_json_dict())
-
- def test_style_column(self):
- survey = utils.create_survey_from_fixture(
- "style_settings", filetype=FIXTURE_FILETYPE
- )
- expected_dict = {
- "children": [
- {
- "label": {"english": "What is your name?"},
- "name": "your_name",
- "type": "text",
- },
- {
- "label": {"english": "How many years old are you?"},
- "name": "your_age",
- "type": "integer",
- },
- {
- "children": [
- {
- "bind": {"jr:preload": "uid", "readonly": "true()"},
- "name": "instanceID",
- "type": "calculate",
- }
- ],
- "control": {"bodyless": True},
- "name": "meta",
- "type": "group",
- },
- ],
- "default_language": "default",
- "id_string": "new_id",
- "name": "style_settings",
- "sms_keyword": "new_id",
- "style": "ltr",
- "title": "My Survey",
- "type": "survey",
- }
- self.assertEqual(expected_dict, survey.to_json_dict())
-
- STRIP_NS_FROM_TAG_RE = re.compile(r"\{.+\}")
-
- 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(pretty_print=False)
- # find the body tag
- root_elm = ETree.fromstring(xml.encode("utf-8"))
- body_elms = [
- c for c in root_elm if self.STRIP_NS_FROM_TAG_RE.sub("", c.tag) == "body"
- ]
- self.assertEqual(len(body_elms), 1)
- self.assertIsNone(body_elms[0].get("class"))
-
- def test_style_added_to_body_if_present(self):
- survey = utils.create_survey_from_fixture(
- "style_settings", filetype=FIXTURE_FILETYPE
- )
- xml = survey.to_xml()
- # find the body tag
- root_elm = ETree.fromstring(xml.encode("utf-8"))
- body_elms = [
- c for c in root_elm if self.STRIP_NS_FROM_TAG_RE.sub("", c.tag) == "body"
- ]
- self.assertEqual(len(body_elms), 1)
- self.assertEqual(body_elms[0].get("class"), "ltr")
-
def test_trigger_data_wrong_type__error(self):
"""Should raise an error if a trigger is truthy and something other than tuple."""
# Should only happen if the builder is used incorrectly, rather than any user
diff --git a/tests/test_typed_calculates.py b/tests/test_calculate.py
similarity index 66%
rename from tests/test_typed_calculates.py
rename to tests/test_calculate.py
index 9daef9065..85e26e412 100644
--- a/tests/test_typed_calculates.py
+++ b/tests/test_calculate.py
@@ -4,6 +4,7 @@
"""
from tests.pyxform_test_case import PyxformTestCase
+from tests.xpath_helpers.questions import xpq
class TypedCalculatesTest(PyxformTestCase):
@@ -138,6 +139,19 @@ def test_row_without_label_or_calculation_throws_error(self):
error__contains=["The survey element named 'a' has no label or hint."],
)
+ def test_calculate_without_calculation__error(self):
+ """Should find an error is raised when a calculate question has no calculation."""
+ md = """
+ | survey |
+ | | type | name | label | calculation |
+ | | calculate | q1 | Q1 | |
+ """
+ self.assertPyxformXform(
+ md=md,
+ errored=True,
+ error__contains=["[row : 2] Missing calculation."],
+ )
+
def test_calculate_without_calculation_without_default(self):
self.assertPyxformXform(
name="calculate-without-calculation-without-default",
@@ -172,3 +186,68 @@ def test_calculate_without_calculation_with_dynamic_default(self):
""",
instance__contains=[""],
)
+
+ def test_round_function__ok(self):
+ """Should find that using the round() function does not raise an ODK Validate error."""
+ md = """
+ | survey |
+ | | type | name | label | calculation |
+ | | decimal | q1 | Q1 | |
+ | | calculate | q2 | Q2 | round(${q1}, 0) |
+ """
+ self.assertPyxformXform(md=md)
+
+ def test_cascading_select_via_calculate__ok(self):
+ """Should find that cascading selects can be done via calculate without error."""
+ # Easier with choice_filter, but it could be done this way.
+ md = """
+ | survey |
+ | | type | name | label | relevant | calculation |
+ | | select_one c1 | q1 | Q1 | | |
+ | | select_one c2 | q2 | Q2 | ${q1}='n1' | |
+ | | select_one c3 | q3 | Q3 | ${q1}='n2' | |
+ | | calculate | q4 | Q4 | | if(${q1}='n1', ${q2}, if(${q1}='n2', ${q3}, 'Error')) |
+ | | select_one c4 | q5 | Q5 | ${q4}='n3' | |
+ | | select_one c5 | q6 | Q6 | ${q4}='n4' | |
+ | | select_one c6 | q7 | Q7 | ${q4}='n5' | |
+ | | select_one c7 | q8 | Q8 | ${q4}='n6' | |
+ | | calculate | q9 | Q9 | | if(${q4}='n3', ${q5}, if(${q4}='n4', ${q6}, if(${q4}='n5', ${q7}, if(${q4}='n6', ${q8}, 'Error')))) |
+
+ | choices |
+ | | list_name | name | label |
+ | | c1 | n1 | N1 |
+ | | c1 | n2 | N2 |
+ | | c2 | n3 | N3 |
+ | | c2 | n4 | N4 |
+ | | c3 | n5 | N5 |
+ | | c3 | n6 | N6 |
+ | | c4 | n7 | N7 |
+ | | c4 | n8 | N8 |
+ | | c5 | n9 | N9 |
+ | | c5 | n10 | N10 |
+ | | c6 | n11 | N11 |
+ | | c6 | n12 | N12 |
+ | | c7 | n13 | N13 |
+ | | c7 | n14 | N14 |
+ """
+ self.assertPyxformXform(
+ md=md,
+ xml__xpath_match=[
+ xpq.model_instance_bind_attr("q2", "relevant", " /test_name/q1 ='n1'"),
+ xpq.model_instance_bind_attr("q3", "relevant", " /test_name/q1 ='n2'"),
+ xpq.model_instance_bind_attr(
+ "q4",
+ "calculate",
+ "if( /test_name/q1 ='n1', /test_name/q2 , if( /test_name/q1 ='n2', /test_name/q3 , 'Error'))",
+ ),
+ xpq.model_instance_bind_attr("q5", "relevant", " /test_name/q4 ='n3'"),
+ xpq.model_instance_bind_attr("q6", "relevant", " /test_name/q4 ='n4'"),
+ xpq.model_instance_bind_attr("q7", "relevant", " /test_name/q4 ='n5'"),
+ xpq.model_instance_bind_attr("q8", "relevant", " /test_name/q4 ='n6'"),
+ xpq.model_instance_bind_attr(
+ "q9",
+ "calculate",
+ "if( /test_name/q4 ='n3', /test_name/q5 , if( /test_name/q4 ='n4', /test_name/q6 , if( /test_name/q4 ='n5', /test_name/q7 , if( /test_name/q4 ='n6', /test_name/q8 , 'Error'))))",
+ ),
+ ],
+ )
diff --git a/tests/test_choices_sheet.py b/tests/test_choices_sheet.py
index 610b680dd..0e0facb81 100644
--- a/tests/test_choices_sheet.py
+++ b/tests/test_choices_sheet.py
@@ -7,9 +7,7 @@
class TestChoicesSheet(PyxformTestCase):
def test_numeric_choice_names__for_static_selects__allowed(self):
- """
- Test numeric choice names for static selects.
- """
+ """Test numeric choice names for static selects."""
self.assertPyxformXform(
md="""
| survey | | | |
@@ -27,9 +25,7 @@ def test_numeric_choice_names__for_static_selects__allowed(self):
)
def test_numeric_choice_names__for_dynamic_selects__allowed(self):
- """
- Test numeric choice names for dynamic selects.
- """
+ """Test numeric choice names for dynamic selects."""
self.assertPyxformXform(
md="""
| survey | | | | |
@@ -609,3 +605,40 @@ def test_name_not_validated_as_xml_name(self):
md=md,
xml__xpath_match=[xpc.model_instance_choices_label("c1", ((".n", "N1"),))],
)
+
+ def test_choice_name_is_type__ok(self):
+ """Should find that a choice name can be 'type' without error."""
+ md = """
+ | survey |
+ | | type | name | label |
+ | | select_one c1 | q1 | Q1 |
+
+ | choices |
+ | | list_name | name | label |
+ | | c1 | type | N1 |
+ """
+ self.assertPyxformXform(
+ md=md,
+ xml__xpath_match=[xpc.model_instance_choices_label("c1", (("type", "N1"),))],
+ )
+
+ def test_choice_name_same_as_select_name__ok(self):
+ """Should find that the select and choice list/name can be the same without error."""
+ md = """
+ | survey |
+ | | type | name | label |
+ | | select_one q1 | q1 | Q1 |
+
+ | choices |
+ | | list_name | name | label |
+ | | q1 | q1 | Q1 |
+ """
+ self.assertPyxformXform(
+ md=md,
+ xml__xpath_match=[
+ xpq.model_instance_item("q1"),
+ xpc.model_instance_choices_label("q1", (("q1", "Q1"),)),
+ xpq.model_instance_bind("q1", "string"),
+ xpq.body_control("q1", "select1"),
+ ],
+ )
diff --git a/tests/test_dump_and_load.py b/tests/test_dump_and_load.py
index 45702e513..8eafb8461 100644
--- a/tests/test_dump_and_load.py
+++ b/tests/test_dump_and_load.py
@@ -1,6 +1,4 @@
-"""
-Test multiple XLSForm can be generated successfully.
-"""
+"""Test multiple XLSForm can be generated successfully."""
import os
from pathlib import Path
@@ -14,18 +12,10 @@
class DumpAndLoadTests(TestCase):
def setUp(self):
self.excel_files = [
- "gps.xls",
- # "include.xls",
- "specify_other.xls",
- "group.xls",
- "loop.xls",
- "text_and_integer.xls",
- # todo: this is looking for json that is created (and
- # deleted) by another test, is should just add that json
- # to the directory.
- # "include_json.xls",
- "simple_loop.xls",
- "yes_or_no_question.xls",
+ "group.xlsx",
+ "loop.xlsx",
+ "text_and_integer.xlsx",
+ "yes_or_no_question.xlsx",
]
self.surveys = {}
self.this_directory = os.path.dirname(__file__)
diff --git a/tests/test_dynamic_default.py b/tests/test_dynamic_default.py
index 13ed2b982..fc6db9cb7 100644
--- a/tests/test_dynamic_default.py
+++ b/tests/test_dynamic_default.py
@@ -1,6 +1,4 @@
-"""
-Test handling dynamic default in forms
-"""
+"""Test handling dynamic default in forms."""
from dataclasses import dataclass
from os import getpid
@@ -39,9 +37,7 @@ class Case:
class XPathHelper:
- """
- XPath expressions for dynamic defaults assertions.
- """
+ """XPath expressions for dynamic defaults assertions."""
@staticmethod
def model_setvalue(q_num: int):
@@ -150,9 +146,7 @@ def body_select1(q_num: int, choices: tuple[tuple[str, str], ...]):
class TestDynamicDefault(PyxformTestCase):
- """
- Handling dynamic defaults.
- """
+ """Handling dynamic defaults."""
def test_static_default_in_repeat(self):
"""Should use instance repeat template and first row for static default inside a repeat."""
@@ -924,7 +918,7 @@ def test_dynamic_default_performance__time(self):
process = Process(getpid())
for count in (500, 1000, 2000, 5000, 10000):
questions = "\n".join(question.format(i=i) for i in range(count))
- md = "".join((survey_header, questions))
+ md = f"{survey_header}{questions}"
def run(name, case):
runs = 0
@@ -964,7 +958,7 @@ def test_dynamic_default_performance__memory(self):
| | text | q{i} | Q{i} | if(../t2 = 'test', 1, 2) + 15 - int(1.2) |
"""
questions = "\n".join(question.format(i=i) for i in range(1, 2000))
- md = "".join((survey_header, questions))
+ md = f"{survey_header}{questions}"
process = Process(getpid())
pre_mem = process.memory_info().rss
self.assertPyxformXform(md=md)
diff --git a/tests/test_expected_output/default_time_demo.xml b/tests/test_expected_output/default_time_demo.xml
deleted file mode 100644
index 89a9373ee..000000000
--- a/tests/test_expected_output/default_time_demo.xml
+++ /dev/null
@@ -1,23 +0,0 @@
-
-
-
- Default TIme Demo
-
-
-
- 09:30:00
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
diff --git a/tests/test_expected_output/table-list.xml b/tests/test_expected_output/table-list.xml
deleted file mode 100644
index 26b30a7c2..000000000
--- a/tests/test_expected_output/table-list.xml
+++ /dev/null
@@ -1,147 +0,0 @@
-
-
-
- table-list
-
-
-
-
- jr://images/sad.jpg
-
-
- jr://images/happy.jpg
-
-
- jr://images/happy.jpg
-
-
- jr://images/sad.jpg
-
-
- jr://images/sad.jpg
-
-
- jr://images/happy.jpg
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- This is a user-defined hint for the 1st row
-
-
-
-
-
- yes
-
-
-
- no
-
-
-
-
- This is a user-defined hint for the 2nd row
-
-
- yes
-
-
-
- no
-
-
-
-
- This is a user-defined hint for the 3rd row
-
-
- yes
-
-
-
- no
-
-
-
-
-
-
- This is a user-defined hint for the 1st row
-
-
-
-
-
-
-
diff --git a/tests/test_expected_output/xml_escaping.xml b/tests/test_expected_output/xml_escaping.xml
deleted file mode 100644
index f74eb5885..000000000
--- a/tests/test_expected_output/xml_escaping.xml
+++ /dev/null
@@ -1,139 +0,0 @@
-
-
-
- xml_escaping
-
-
-
-
- jr://images/a.jpg
-
-
- jr://images/b.jpg
-
-
- jr://images/happy.jpg
-
-
- jr://images/sad.jpg
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- a
-
-
-
- b
-
-
-
- c
-
-
-
- d
-
-
-
-
-
-
-
- 1
-
-
-
- 2
-
-
-
- 3
-
-
-
- 4
-
-
-
- 5
-
-
-
- 6
-
-
-
- 7
-
-
-
- 8
-
-
-
-
-
-
-
- yes
-
-
-
- no
-
-
-
-
-
-
-
- a_b-0
- a
-
-
- a_b-1
- b
-
-
-
-
-
-
- happy_sad-0
- happy
-
-
- happy_sad-1
- sad
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
diff --git a/tests/test_external_instances.py b/tests/test_external_instances.py
index f9bb5abfe..7c866a0bd 100644
--- a/tests/test_external_instances.py
+++ b/tests/test_external_instances.py
@@ -13,9 +13,7 @@
class ExternalInstanceTests(PyxformTestCase):
- """
- External Instance Tests
- """
+ """External Instance Tests."""
def test_can__output_single_external_xml_item(self):
"""Simplest possible example to include an external instance."""
@@ -259,9 +257,7 @@ def test_cannot__use_different_src_same_id__select_then_internal(self):
)
def test_cannot__use_different_src_same_id__external_then_pulldata(self):
- """
- Duplicate instance from pulldata after xml-external raises an error.
- """
+ """Duplicate instance from pulldata after xml-external raises an error."""
md = """
| survey | | | | |
| | type | name | label | calculation |
@@ -285,9 +281,7 @@ def test_cannot__use_different_src_same_id__external_then_pulldata(self):
)
def test_cannot__use_different_src_same_id__pulldata_then_external(self):
- """
- Duplicate instance from xml-external after pulldata raises an error.
- """
+ """Duplicate instance from xml-external after pulldata raises an error."""
md = """
| survey | | | | |
| | type | name | label | calculation |
@@ -407,7 +401,7 @@ def test_can__reuse_xml__external_then_selects(self):
def test_external_instance_pulldata_constraint(self):
"""
Checks if instance node for pulldata function is added
- when pulldata occurs in column with constraint title
+ when pulldata occurs in column with constraint title.
"""
md = """
| survey | | | | |
@@ -507,7 +501,7 @@ def test_pulldata_calculate_single_line_expression__multiple_calls(self):
def test_external_instance_pulldata_readonly(self):
"""
Checks if instance node for pulldata function is added
- when pulldata occurs in column with readonly title
+ when pulldata occurs in column with readonly title.
"""
md = """
| survey | | | | |
@@ -525,7 +519,7 @@ def test_external_instance_pulldata_readonly(self):
def test_external_instance_pulldata_required(self):
"""
Checks if instance node for pulldata function is added
- when pulldata occurs in column with required title
+ when pulldata occurs in column with required title.
"""
md = """
| survey | | | | |
@@ -542,7 +536,7 @@ def test_external_instance_pulldata_required(self):
def test_external_instance_pulldata_relevant(self):
"""
Checks if instance node for pulldata function is added
- when pulldata occurs in column with relevant title
+ when pulldata occurs in column with relevant title.
"""
md = """
| survey | | | | |
@@ -585,7 +579,7 @@ def test_external_instance_pulldata(self):
"""
Checks that only one instance node for pulldata is created
if pulldata function is present in at least one columns with
- the titles: constraint, relevant, required, readonly
+ the titles: constraint, relevant, required, readonly.
"""
md = """
| survey | | | | | | |
@@ -610,7 +604,7 @@ def test_external_instances_multiple_diff_pulldatas(self):
Checks that all instances for pulldata that needs creation
are created
The situation is if pulldata is present in 2 or more
- columns but pulling data from different csv files
+ columns but pulling data from different csv files.
"""
md = """
| survey | | | | | |
diff --git a/tests/test_external_instances_for_selects.py b/tests/test_external_instances_for_selects.py
index 782330152..1e6b970d8 100644
--- a/tests/test_external_instances_for_selects.py
+++ b/tests/test_external_instances_for_selects.py
@@ -1,5 +1,5 @@
"""
-Test external instance syntax
+Test external instance syntax.
See also test_external_instances
"""
@@ -21,9 +21,7 @@
@dataclass(slots=True)
class XPathHelperSelectFromFile:
- """
- XPath expressions for translations-related assertions.
- """
+ """XPath expressions for translations-related assertions."""
q_type: str
q_name: str
@@ -145,7 +143,7 @@ def test_with_params_no_filters(self):
)
def test_no_params_with_filters(self):
- """Should find that choice_filter adds a predicate to the itemset's instance ref"""
+ """Should find that choice_filter adds a predicate to the itemset's instance ref."""
md = """
| survey | | | | |
| | type | name | label | choice_filter |
@@ -289,9 +287,7 @@ def test_expected_error_message(self):
class TestSelectOneExternal(PyxformTestCase):
- """
- select_one_external question type, where external_choices are converted to a CSV.
- """
+ """select_one_external question type, where external_choices are converted to a CSV."""
all_choices = """
| choices | | | |
@@ -307,7 +303,7 @@ class TestSelectOneExternal(PyxformTestCase):
"""
def test_no_params_no_filters(self):
- """Should find that Pyxform errors out, not a supported use case as per #488"""
+ """Should find that Pyxform errors out, not a supported use case as per #488."""
md = """
| survey | | | |
| | type | name | label |
@@ -346,7 +342,7 @@ def test_with_params_no_filters(self):
)
def test_no_params_with_filters(self):
- """Should find that choice_filter generates input()s with refs to external itemsets"""
+ """Should find that choice_filter generates input()s with refs to external itemsets."""
md = """
| survey | | | | |
| | type | name | label | choice_filter |
@@ -512,3 +508,26 @@ def test_external_choices_with_only_header__errors(self):
self.assertContains(
str(e), "should be an external_choices sheet in this xlsform"
)
+
+ def test_choice_name_is_type__ok(self):
+ """Should find that a choice name can be 'type' without error."""
+ md = """
+ | survey |
+ | | type | name | label | choice_filter |
+ | | select_one_external c1 | q1 | Q1 | true() |
+
+ | external_choices |
+ | | list_name | name | label |
+ | | c1 | type | N1 |
+ """
+ self.assertPyxformXform(
+ md=md,
+ xml__xpath_match=[
+ """
+ /h:html/h:body/x:input[
+ @ref='/test_name/q1'
+ and @query="instance('c1')/root/item[true()]"
+ ]
+ """
+ ],
+ )
diff --git a/tests/test_fields.py b/tests/test_fields.py
index d904807db..292aeb7d4 100644
--- a/tests/test_fields.py
+++ b/tests/test_fields.py
@@ -1,6 +1,4 @@
-"""
-Test duplicate survey question field name.
-"""
+"""Test duplicate survey question field name."""
from pyxform import constants as co
from pyxform.errors import ErrorCode
@@ -9,9 +7,7 @@
class TestQuestionParsing(PyxformTestCase):
- """
- Test XLSForm Fields
- """
+ """Test XLSForm Fields."""
def test_names__question_basic_case__ok(self):
"""Should find that a single unique question name is ok."""
diff --git a/tests/test_file.py b/tests/test_file.py
index 847f34f2b..c1609299e 100644
--- a/tests/test_file.py
+++ b/tests/test_file.py
@@ -1,19 +1,13 @@
-"""
-Test file question type.
-"""
+"""Test file question type."""
from tests.pyxform_test_case import PyxformTestCase
class FileWidgetTest(PyxformTestCase):
- """
- Test file widget class.
- """
+ """Test file widget class."""
def test_file_type(self):
- """
- Test file question type.
- """
+ """Test file question type."""
self.assertPyxformXform(
name="data",
md="""
diff --git a/tests/test_file_utils.py b/tests/test_file_utils.py
deleted file mode 100644
index 8266235d1..000000000
--- a/tests/test_file_utils.py
+++ /dev/null
@@ -1,18 +0,0 @@
-"""
-Test xls2json_backends util functions.
-"""
-
-from unittest import TestCase
-
-from pyxform.xls2json_backends import convert_file_to_csv_string
-
-from tests import utils
-
-
-class BackendUtilsTests(TestCase):
- def test_xls_to_csv(self):
- specify_other_xls = utils.path_to_text_fixture("specify_other.xls")
- converted_xls = convert_file_to_csv_string(specify_other_xls)
- specify_other_csv = utils.path_to_text_fixture("specify_other.csv")
- converted_csv = convert_file_to_csv_string(specify_other_csv)
- self.assertEqual(converted_csv, converted_xls)
diff --git a/tests/test_form_name.py b/tests/test_form_name.py
index e3f395307..8b10e596a 100644
--- a/tests/test_form_name.py
+++ b/tests/test_form_name.py
@@ -1,6 +1,4 @@
-"""
-Test setting form name to data.
-"""
+"""Test setting form name to data."""
from tests.pyxform_test_case import PyxformTestCase
@@ -24,9 +22,7 @@ def test_default_to_data_when_no_name(self):
)
def test_default_to_data(self):
- """
- Test using data as the name of the form which will generate .
- """
+ """Test using data as the name of the form which will generate ."""
self.assertPyxformXform(
md="""
| survey | | | |
@@ -44,10 +40,7 @@ def test_default_to_data(self):
)
def test_default_form_name_to_superclass_definition(self):
- """
- Test no form_name and setting name field, should use name field.
- """
-
+ """Test no form_name and setting name field, should use name field."""
self.assertPyxformXform(
md="""
| survey | | | |
diff --git a/tests/test_geo.py b/tests/test_geo.py
index 6121ca1fc..bc022c9bf 100644
--- a/tests/test_geo.py
+++ b/tests/test_geo.py
@@ -1,5 +1,5 @@
"""
-## Geo control traceability
+## Geo control traceability.
Each test should reference one (or more) requirements from these lists.
@@ -61,9 +61,7 @@ def test_gps_alias(self):
)
def test_geo_widgets_types(self):
- """
- this test could be broken into multiple smaller tests.
- """
+ """This test could be broken into multiple smaller tests."""
self.assertPyxformXform(
name="geos",
md="""
diff --git a/tests/test_group.py b/tests/test_group.py
index 6730abbe7..1051834c6 100644
--- a/tests/test_group.py
+++ b/tests/test_group.py
@@ -1,6 +1,4 @@
-"""
-Test groups.
-"""
+"""Test groups."""
from unittest import TestCase
@@ -10,12 +8,11 @@
from tests.pyxform_test_case import PyxformTestCase
from tests.xpath_helpers.group import xpg
+from tests.xpath_helpers.questions import xpq
class TestGroupOutput(PyxformTestCase):
- """
- Test output for groups.
- """
+ """Test output for groups."""
def test_group_type(self):
self.assertPyxformXform(
@@ -80,37 +77,79 @@ def test_group_relevant_included_in_bind(self):
)
def test_table_list_appearance(self):
+ """Should find that the table-list shortcut applies field-list and list-nolabel."""
md = """
| survey |
- | | type | name | label | hint | appearance |
- | | begin_group | tablelist1 | Table_Y_N | | table-list minimal |
- | | select_one yes_no | options1a | Q1 | first row! | |
- | | select_one yes_no | options1b | Q2 | | |
- | | end_group | | | | |
+ | | type | name | label | hint | appearance |
+ | | begin_group | g1 | G1 | | table-list |
+ | | select_one c1 | q1 | Q1 | first row! | |
+ | | select_one c1 | q2 | Q2 | | |
+ | | end_group | | | | |
+
+ | choices |
+ | | list_name | name | label |
+ | | c1 | n1 | N1 |
+ """
+ self.assertPyxformXform(
+ md=md,
+ xml__xpath_match=[
+ # Model instance for specified + generated items.
+ xpq.model_instance_item("g1"),
+ xpq.model_instance_item("g1/x:generated_table_list_label_2"),
+ xpq.model_instance_item("g1/x:reserved_name_for_field_list_labels_3"),
+ xpq.model_instance_item("g1/x:q1"),
+ xpq.model_instance_item("g1/x:q2"),
+ # Model bind for specified + generated items.
+ xpq.model_instance_bind("g1/generated_table_list_label_2", "string"),
+ xpq.model_instance_bind(
+ "g1/reserved_name_for_field_list_labels_3", "string"
+ ),
+ xpq.model_instance_bind("g1/q1", "string"),
+ xpq.model_instance_bind("g1/q2", "string"),
+ # Body control group and selects are assigned appearances.
+ xpg.group_no_label_appearance("/test_name/g1", "field-list"),
+ xpq.body_label_inline(
+ "group/x:input", "g1/generated_table_list_label_2", "G1"
+ ),
+ xpq.body_group_select1_itemset(
+ "g1", "reserved_name_for_field_list_labels_3", "label"
+ ),
+ xpq.body_label_inline(
+ "group/x:select1", "g1/reserved_name_for_field_list_labels_3", " "
+ ),
+ xpq.body_group_select1_itemset("g1", "q1", "list-nolabel"),
+ xpq.body_label_inline("group/x:select1", "g1/q1", "Q1"),
+ """
+ /h:html/h:body/x:group/x:select1[@ref='/test_name/g1/q1']
+ /x:hint[text()='first row!']
+ """,
+ xpq.body_group_select1_itemset("g1", "q2", "list-nolabel"),
+ xpq.body_label_inline("group/x:select1", "g1/q2", "Q2"),
+ ],
+ )
+
+ def test_table_list_appearance__preserve_additional_appearances(self):
+ """Should find that the table-list shortcut keeps any extra appearances."""
+ # Currently the documented / supported appearances for groups are 'table-list' and
+ # 'field-list', so an extra 'fake' appearance is added to check that anything else
+ # will be preserved (without adding confusion as to what is supported).
+ md = """
+ | survey |
+ | | type | name | label | hint | appearance |
+ | | begin_group | g1 | G1 | | table-list fake |
+ | | select_one c1 | q1 | Q1 | first row! | |
+ | | select_one c1 | q2 | Q2 | | |
+ | | end_group | | | | |
+
| choices |
| | list_name | name | label |
- | | yes_no | yes | Yes |
- """
- xml_contains = """
-
-
-
-
-
-
-
-
-
-
-
-
-
- first row!
-""".strip()
- self.assertPyxformXform(
- name="table-list-appearance-mod",
- md=md,
- xml__contains=[xml_contains],
+ | | c1 | n1 | N1 |
+ """
+ self.assertPyxformXform(
+ md=md,
+ xml__xpath_match=[
+ xpg.group_no_label_appearance("/test_name/g1", "field-list fake"),
+ ],
)
def test_group__label__ok(self):
diff --git a/tests/test_guidance_hint.py b/tests/test_guidance_hint.py
index 896a6953e..f87177e82 100644
--- a/tests/test_guidance_hint.py
+++ b/tests/test_guidance_hint.py
@@ -1,6 +1,4 @@
-"""
-Guidance hint test module.
-"""
+"""Guidance hint test module."""
from tests.pyxform_test_case import PyxformTestCase
@@ -21,7 +19,7 @@ def test_hint_only(self):
)
def test_guidance_hint_and_label(self):
- """Test guidance_hint with label"""
+ """Test guidance_hint with label."""
self.assertPyxformXform(
name="data",
md="""
diff --git a/tests/test_image_app_parameter.py b/tests/test_image_app_parameter.py
index b6fad7da5..1bfe93c86 100644
--- a/tests/test_image_app_parameter.py
+++ b/tests/test_image_app_parameter.py
@@ -1,6 +1,4 @@
-"""
-Test image max-pixels and app parameters.
-"""
+"""Test image max-pixels and app parameters."""
from pyxform import constants as co
from pyxform.errors import ErrorCode
diff --git a/tests/test_j2x_creation.py b/tests/test_j2x_creation.py
deleted file mode 100644
index 7bb2f1733..000000000
--- a/tests/test_j2x_creation.py
+++ /dev/null
@@ -1,71 +0,0 @@
-"""
-Testing creation of Surveys using verbose methods
-"""
-
-from unittest import TestCase
-
-from pyxform import MultipleChoiceQuestion, Survey, create_survey_from_xls
-
-from tests import utils
-
-
-class Json2XformVerboseSurveyCreationTests(TestCase):
- def test_survey_can_be_created_in_a_slightly_less_verbose_manner(self):
- choices = {
- "test": [
- {"name": "red", "label": "Red"},
- {"name": "blue", "label": "Blue"},
- ]
- }
- s = Survey(name="Roses_are_Red", choices=choices)
- q = MultipleChoiceQuestion(
- name="Favorite_Color",
- type="select one",
- list_name="test",
- )
- s.add_child(q)
-
- expected_dict = {
- "name": "Roses_are_Red",
- "type": "survey",
- "children": [
- {"name": "Favorite_Color", "type": "select one", "list_name": "test"}
- ],
- "choices": choices,
- }
-
- self.assertEqual(expected_dict, s.to_json_dict())
-
- def test_allow_surveys_with_comment_rows(self):
- """assume that a survey with rows that don't have name, type, or label
- headings raise warning only"""
- path = utils.path_to_text_fixture("allow_comment_rows_test.xls")
- survey = create_survey_from_xls(path)
- expected_dict = {
- "children": [
- {
- "label": {"English": "First and last name of farmer"},
- "name": "farmer_name",
- "type": "text",
- },
- {
- "children": [
- {
- "bind": {"jr:preload": "uid", "readonly": "true()"},
- "name": "instanceID",
- "type": "calculate",
- }
- ],
- "control": {"bodyless": True},
- "name": "meta",
- "type": "group",
- },
- ],
- "default_language": "default",
- "id_string": "allow_comment_rows_test",
- "name": "data",
- "sms_keyword": "allow_comment_rows_test",
- "title": "allow_comment_rows_test",
- "type": "survey",
- }
- self.assertEqual(expected_dict, survey.to_json_dict())
diff --git a/tests/test_j2x_instantiation.py b/tests/test_j2x_instantiation.py
index d7f928bcf..3af7bb8aa 100644
--- a/tests/test_j2x_instantiation.py
+++ b/tests/test_j2x_instantiation.py
@@ -1,10 +1,8 @@
-"""
-Testing the instance object for pyxform.
-"""
+"""Testing the instance object for pyxform."""
from unittest import TestCase
-from pyxform import Survey, SurveyInstance
+from pyxform import MultipleChoiceQuestion, Survey, SurveyInstance
from pyxform.builder import create_survey_element_from_dict
from tests.utils import prep_class_config
@@ -97,3 +95,29 @@ def test_simple_registration_xml(self):
self.cls_name, "test_simple_registration_xml"
).format(reg_xform.id_string)
self.assertEqual(rx, expected_xml)
+
+ def test_survey_can_be_created_in_a_slightly_less_verbose_manner(self):
+ choices = {
+ "test": [
+ {"name": "red", "label": "Red"},
+ {"name": "blue", "label": "Blue"},
+ ]
+ }
+ s = Survey(name="Roses_are_Red", choices=choices)
+ q = MultipleChoiceQuestion(
+ name="Favorite_Color",
+ type="select one",
+ list_name="test",
+ )
+ s.add_child(q)
+
+ expected_dict = {
+ "name": "Roses_are_Red",
+ "type": "survey",
+ "children": [
+ {"name": "Favorite_Color", "type": "select one", "list_name": "test"}
+ ],
+ "choices": choices,
+ }
+
+ self.assertEqual(expected_dict, s.to_json_dict())
diff --git a/tests/test_j2x_question.py b/tests/test_j2x_question.py
index ed114b98a..3a1b4fc72 100644
--- a/tests/test_j2x_question.py
+++ b/tests/test_j2x_question.py
@@ -1,6 +1,4 @@
-"""
-Testing creation of Surveys using verbose methods
-"""
+"""Testing creation of Surveys using verbose methods."""
from collections.abc import Generator
@@ -15,8 +13,8 @@
def ctw(control):
"""
- ctw stands for control_test_wrap, but ctw is shorter and easier. using
- begin_str and end_str to take out the wrap that xml gives us
+ Ctw stands for control_test_wrap, but ctw is shorter and easier. using
+ begin_str and end_str to take out the wrap that xml gives us.
"""
if isinstance(control, list) and len(control) == 1:
control = control[0]
@@ -145,9 +143,7 @@ def test_select_one_question_multilingual__common_choices(self):
)
def test_simple_integer_question_type_multilingual(self):
- """
- not sure how integer questions should show up.
- """
+ """Not sure how integer questions should show up."""
simple_integer_question = {
"label": {"f": "fc", "e": "ec"},
"type": "integer",
@@ -171,9 +167,7 @@ def test_simple_integer_question_type_multilingual(self):
self.assertEqual(ctw(q.xml_bindings(survey=self.s)), expected_integer_binding_xml)
def test_simple_date_question_type_multilingual(self):
- """
- not sure how date questions should show up.
- """
+ """Not sure how date questions should show up."""
simple_date_question = {
"label": {"f": "fd", "e": "ed"},
"type": "date",
@@ -195,9 +189,7 @@ def test_simple_date_question_type_multilingual(self):
self.assertEqual(ctw(q.xml_bindings(survey=self.s)), expected_date_binding_xml)
def test_simple_phone_number_question_type_multilingual(self):
- """
- not sure how phone number questions should show up.
- """
+ """Not sure how phone number questions should show up."""
simple_phone_number_question = {
"label": {"f": "fe", "e": "ee"},
"type": "phone number",
@@ -232,9 +224,7 @@ def test_simple_phone_number_question_type_multilingual(self):
self.assertDictEqual(expected, observed)
def test_simple_select_all_question_multilingual(self):
- """
- not sure how select all questions should show up...
- """
+ """Not sure how select all questions should show up..."""
survey = {
"type": "survey",
"name": "test_name",
@@ -272,9 +262,7 @@ def test_simple_select_all_question_multilingual(self):
)
def test_simple_decimal_question_multilingual(self):
- """
- not sure how decimal should show up.
- """
+ """Not sure how decimal should show up."""
simple_decimal_question = {
"label": {"f": "f text", "e": "e text"},
"type": "decimal",
diff --git a/tests/test_js2x_import_from_json.py b/tests/test_js2x_import_from_json.py
index bc639e64f..4ace585dd 100644
--- a/tests/test_js2x_import_from_json.py
+++ b/tests/test_js2x_import_from_json.py
@@ -1,6 +1,4 @@
-"""
-Testing our ability to import from a JSON text file.
-"""
+"""Testing our ability to import from a JSON text file."""
from unittest import TestCase
diff --git a/tests/test_json2xform.py b/tests/test_json2xform.py
index b06bfe735..eb6869ab9 100644
--- a/tests/test_json2xform.py
+++ b/tests/test_json2xform.py
@@ -1,6 +1,4 @@
-"""
-Testing simple cases for pyxform
-"""
+"""Testing simple cases for pyxform."""
from unittest import TestCase
@@ -15,7 +13,7 @@
class BasicJson2XFormTests(TestCase):
def test_survey_can_have_to_xml_called_twice(self):
"""
- Test: Survey can have "to_xml" called multiple times
+ Test: Survey can have "to_xml" called multiple times.
(This was not being allowed before.)
diff --git a/tests/test_language_warnings.py b/tests/test_language_warnings.py
index 347122eec..270d3810e 100644
--- a/tests/test_language_warnings.py
+++ b/tests/test_language_warnings.py
@@ -1,14 +1,10 @@
-"""
-Test language warnings.
-"""
+"""Test language warnings."""
from tests.pyxform_test_case import PyxformTestCase
class LanguageWarningTest(PyxformTestCase):
- """
- Test language warnings.
- """
+ """Test language warnings."""
def test_label_with_valid_subtag_should_not_warn(self):
self.assertPyxformXform(
diff --git a/tests/test_last_saved.py b/tests/test_last_saved.py
index 5e0f6edb6..84c3c8031 100644
--- a/tests/test_last_saved.py
+++ b/tests/test_last_saved.py
@@ -1,6 +1,4 @@
-"""
-The last-saved virtual instance can be queried to get values from the last saved instance of the form being authored.
-"""
+"""The last-saved virtual instance can be queried to get values from the last saved instance of the form being authored."""
from pyxform.errors import ErrorCode
diff --git a/tests/test_loop.py b/tests/test_loop.py
index 48c61401a..22c043324 100644
--- a/tests/test_loop.py
+++ b/tests/test_loop.py
@@ -1,6 +1,4 @@
-"""
-Test loop syntax.
-"""
+"""Test loop syntax."""
from unittest import TestCase
diff --git a/tests/test_metadata.py b/tests/test_metadata.py
index 25c025eb9..8b74ec33b 100644
--- a/tests/test_metadata.py
+++ b/tests/test_metadata.py
@@ -1,6 +1,4 @@
-"""
-Test language warnings.
-"""
+"""Test language warnings."""
from pyxform.errors import ErrorCode
@@ -8,9 +6,7 @@
class TestMetadata(PyxformTestCase):
- """
- Test metadata and related warnings.
- """
+ """Test metadata and related warnings."""
def test_metadata_bindings(self):
self.assertPyxformXform(
diff --git a/tests/test_notes.py b/tests/test_notes.py
index ba9fc95ce..7da0570ae 100644
--- a/tests/test_notes.py
+++ b/tests/test_notes.py
@@ -1,6 +1,4 @@
-"""
-Test the "note" question type.
-"""
+"""Test the "note" question type."""
from dataclasses import dataclass, field
@@ -10,9 +8,7 @@
@dataclass(slots=True)
class Case:
- """
- A test case spec for note output scenarios.
- """
+ """A test case spec for note output scenarios."""
label: str
match: set[str]
diff --git a/tests/test_osm.py b/tests/test_osm.py
index 1f52019a5..a6f363865 100644
--- a/tests/test_osm.py
+++ b/tests/test_osm.py
@@ -1,6 +1,4 @@
-"""
-Test OSM widgets.
-"""
+"""Test OSM widgets."""
from tests.pyxform_test_case import PyxformTestCase
from tests.xpath_helpers.choices import xpc
@@ -19,9 +17,7 @@
class OSMWidgetsTest(PyxformTestCase):
- """
- Test OSM widgets.
- """
+ """Test OSM widgets."""
def test_osm_type(self):
self.assertPyxformXform(
diff --git a/tests/test_parameters_rows.py b/tests/test_parameters_rows.py
index 943cc3fea..2eed427f5 100644
--- a/tests/test_parameters_rows.py
+++ b/tests/test_parameters_rows.py
@@ -1,6 +1,4 @@
-"""
-Test text rows parameter.
-"""
+"""Test text rows parameter."""
from tests.pyxform_test_case import PyxformTestCase
diff --git a/tests/test_pyxformtestcase.py b/tests/test_pyxformtestcase.py
index 89b4b3ea5..d467920f7 100644
--- a/tests/test_pyxformtestcase.py
+++ b/tests/test_pyxformtestcase.py
@@ -3,14 +3,14 @@
internal conversions correctly.
"""
-from tests.pyxform_test_case import PyxformTestCase
+from tests.pyxform_test_case import PyxformTestCase, PyxformTestError
from tests.xpath_helpers.settings import xps
class PyxformTestCaseNonMarkdownSurveyAlternatives(PyxformTestCase):
def test_tainted_vanilla_survey_failure(self):
"""
- the _invalid_ss_structure structure should fail to compile
+ The _invalid_ss_structure structure should fail to compile
because the note has no label.
if "errored" parameter is not set to False, it should
@@ -20,7 +20,7 @@ def test_tainted_vanilla_survey_failure(self):
def _no_valid_flag():
"""
- when the 'errored' flag is set to false (default) and the survey
+ When the 'errored' flag is set to false (default) and the survey
fails to compile, the test should raise an exception.
"""
self.assertPyxformXform(
@@ -28,7 +28,7 @@ def _no_valid_flag():
errored=False, # errored=False by default
)
- self.assertRaises(Exception, _no_valid_flag)
+ self.assertRaises(PyxformTestError, _no_valid_flag)
# however when errored=True is present,
self.assertPyxformXform(
@@ -39,7 +39,7 @@ def _no_valid_flag():
def test_vanilla_survey(self):
"""
- testing that a survey can be passed as a _spreadsheet structure_ named
+ Testing that a survey can be passed as a _spreadsheet structure_ named
'ss_structure'.
this will be helpful when testing whitespace constraints and
diff --git a/tests/test_randomize_itemsets.py b/tests/test_randomize_itemsets.py
index e2d97e350..e9dbf25a8 100644
--- a/tests/test_randomize_itemsets.py
+++ b/tests/test_randomize_itemsets.py
@@ -1,6 +1,4 @@
-"""
-Test randomize itemsets.
-"""
+"""Test randomize itemsets."""
from pyxform import constants as co
from pyxform.errors import ErrorCode
diff --git a/tests/test_range.py b/tests/test_range.py
index 0f0b74b74..b586adab8 100644
--- a/tests/test_range.py
+++ b/tests/test_range.py
@@ -1,5 +1,5 @@
"""
-## Range control traceability
+## Range control traceability.
Each test should reference one (or more) requirements from these lists.
diff --git a/tests/test_rank.py b/tests/test_rank.py
index f0ed2b896..d55b8ef72 100644
--- a/tests/test_rank.py
+++ b/tests/test_rank.py
@@ -1,6 +1,4 @@
-"""
-Test rank widget.
-"""
+"""Test rank widget."""
from tests.pyxform_test_case import PyxformTestCase
from tests.xpath_helpers.choices import xpc
diff --git a/tests/test_repeat.py b/tests/test_repeat.py
index 4c41f6096..463330892 100644
--- a/tests/test_repeat.py
+++ b/tests/test_repeat.py
@@ -1,6 +1,4 @@
-"""
-Test repeat structure.
-"""
+"""Test repeat structure."""
from os import getpid
from time import perf_counter
@@ -16,14 +14,10 @@
class TestRepeatOutput(PyxformTestCase):
- """
- Test output for repeats.
- """
+ """Test output for repeats."""
def test_repeat_relative_reference(self):
- """
- Test relative reference in repeats.
- """
+ """Test relative reference in repeats."""
self.assertPyxformXform(
md="""
| survey | | | | |
@@ -350,7 +344,7 @@ def test_choice_from_previous_repeat_answers_not_name(self):
)
def test_choice_from_previous_repeat_answers_with_choice_filter(self):
- """Select one choices from previous repeat answers with choice filter"""
+ """Select one choices from previous repeat answers with choice filter."""
xlsform_md = """
| survey | | | | |
| | type | name | label | choice_filter |
@@ -377,9 +371,7 @@ def test_choice_from_previous_repeat_answers_with_choice_filter(self):
)
def test_choice_from_previous_repeat_answers_in_child_repeat(self):
- """
- Select one choice from previous repeat answers when within a child of a repeat
- """
+ """Select one choice from previous repeat answers when within a child of a repeat."""
xlsform_md = """
| survey | | | | |
| | type | name | label | choice_filter |
@@ -399,7 +391,7 @@ def test_choice_from_previous_repeat_answers_in_child_repeat(self):
)
def test_choice_from_previous_repeat_answers_in_nested_repeat(self):
- """Select one choices from previous repeat answers within a nested repeat"""
+ """Select one choices from previous repeat answers within a nested repeat."""
xlsform_md = """
| survey | | | | |
| | type | name | label | choice_filter |
@@ -419,9 +411,7 @@ def test_choice_from_previous_repeat_answers_in_nested_repeat(self):
)
def test_choice_from_previous_repeat_answers_in_nested_repeat_uses_current(self):
- """
- Select one choices from previous repeat answers within a nested repeat should use current if a sibling node of a select is used
- """
+ """Select one choices from previous repeat answers within a nested repeat should use current if a sibling node of a select is used."""
xlsform_md = """
| survey | | | | |
| | type | name | label | choice_filter |
@@ -446,7 +436,7 @@ def test_choice_from_previous_repeat_answers_in_nested_repeat_uses_current(self)
)
def test_choice_from_previous_repeat_in_current_repeat_parents_out_to_repeat(self):
- """Test choice from previous repeat in current repeat produces the correct reference"""
+ """Test choice from previous repeat in current repeat produces the correct reference."""
xlsform_md = """
| survey | | | | | | | |
| | type | name | label | choice_filter | appearance | relevant | calculation |
@@ -678,9 +668,7 @@ def test_indexed_repeat_math_expression_with_double_variable_in_nested_repeat_re
def test_repeat_using_select_with_reference_path_in_predicate_uses_current(
self,
):
- """
- Test relative path expansion using current if reference path is inside a predicate in a survey with select choice list
- """
+ """Test relative path expansion using current if reference path is inside a predicate in a survey with select choice list."""
xlsform_md = """
| survey | | | | | |
| | type | name | label | choice_filter | calculation |
@@ -708,9 +696,7 @@ def test_repeat_using_select_with_reference_path_in_predicate_uses_current(
def test_repeat_using_select_uses_current_with_reference_path_in_predicate_and_instance_is_not_first_expression(
self,
):
- """
- Test relative path expansion using current if reference path is inside a predicate and instance is not first expression in a survey with select choice list
- """
+ """Test relative path expansion using current if reference path is inside a predicate and instance is not first expression in a survey with select choice list."""
xlsform_md = """
| survey | | | | | |
| | type | name | label | choice_filter | calculation |
@@ -738,9 +724,7 @@ def test_repeat_using_select_uses_current_with_reference_path_in_predicate_and_i
def test_repeat_and_group_with_reference_path_in_predicate_uses_current(
self,
):
- """
- Test relative path expansion using current if reference path is inside a predicate in a survey with group
- """
+ """Test relative path expansion using current if reference path is inside a predicate in a survey with group."""
xlsform_md = """
| survey | | | | |
| | type | name | label | calculation |
@@ -763,9 +747,7 @@ def test_repeat_and_group_with_reference_path_in_predicate_uses_current(
def test_repeat_with_reference_path_in_predicate_uses_current(
self,
):
- """
- Test relative path expansion using current if reference path is inside a predicate
- """
+ """Test relative path expansion using current if reference path is inside a predicate."""
xlsform_md = """
| survey | | | | |
| | type | name | label | calculation |
@@ -786,9 +768,7 @@ def test_repeat_with_reference_path_in_predicate_uses_current(
def test_repeat_with_reference_path_with_spaces_in_predicate_uses_current(
self,
):
- """
- Test relative path expansion using current if reference path (with whitespaces before/after an operator of ${name}) is inside a predicate
- """
+ """Test relative path expansion using current if reference path (with whitespaces before/after an operator of ${name}) is inside a predicate."""
xlsform_md = """
| survey | | | | |
| | type | name | label | calculation |
@@ -813,9 +793,7 @@ def test_repeat_with_reference_path_with_spaces_in_predicate_uses_current(
def test_repeat_with_reference_path_in_a_method_with_spaces_in_predicate_uses_current(
self,
):
- """
- Test relative path expansion using current if reference path in a method (with whitespaces before/after an operator of ${name}) is inside apredicate
- """
+ """Test relative path expansion using current if reference path in a method (with whitespaces before/after an operator of ${name}) is inside apredicate."""
xlsform_md = """
| survey | | | | |
| | type | name | label | calculation |
@@ -836,9 +814,7 @@ def test_repeat_with_reference_path_in_a_method_with_spaces_in_predicate_uses_cu
def test_repeat_with_reference_path_with_spaces_in_predicate_with_parenthesis_uses_current(
self,
):
- """
- Test relative path expansion using current if reference path (with whitespaces before/after an operator of ${name}) is inside a predicate with parenthesis
- """
+ """Test relative path expansion using current if reference path (with whitespaces before/after an operator of ${name}) is inside a predicate with parenthesis."""
xlsform_md = """
| survey | | | | |
| | type | name | label | calculation |
@@ -859,9 +835,7 @@ def test_repeat_with_reference_path_with_spaces_in_predicate_with_parenthesis_us
def test_relative_path_expansion_not_using_current_if_reference_path_is_predicate_but_not_in_a_repeat(
self,
):
- """
- Test relative path expansion using xpath without current() if reference path is inside a predicate and not inside a repeat
- """
+ """Test relative path expansion using xpath without current() if reference path is inside a predicate and not inside a repeat."""
xlsform_md = """
| survey | | | | |
| | type | name | label | calculation |
@@ -879,9 +853,7 @@ def test_relative_path_expansion_not_using_current_if_reference_path_is_predicat
def test_relative_path_expansion_not_using_current_if_reference_path_is_predicate_but_not_part_of_primary_instance(
self,
):
- """
- Test relative path expansion using xpath without current() if reference path is inside a predicate but not part of the primary instance
- """
+ """Test relative path expansion using xpath without current() if reference path is inside a predicate but not part of the primary instance."""
xlsform_md = """
| survey | | | | |
| | type | name | label | calculation |
@@ -901,9 +873,7 @@ def test_relative_path_expansion_not_using_current_if_reference_path_is_predicat
def test_repeat_with_reference_path_in_multiple_predicate_uses_current(
self,
):
- """
- Test relative path expansion using current if reference path is in multiple predicate
- """
+ """Test relative path expansion using current if reference path is in multiple predicate."""
xlsform_md = """
| survey | | | | |
| | type | name | label | calculation |
@@ -926,7 +896,7 @@ def test_repeat_with_reference_path_in_multiple_complex_predicate_uses_current(
):
"""
Test relative path expansion using current if reference path is in multiple predicate, one of it is a complex one
- ${pos1} is not using current because it is not in repeat
+ ${pos1} is not using current because it is not in repeat.
"""
xlsform_md = """
| survey | | | | |
@@ -953,7 +923,7 @@ def test_repeat_with_reference_path_after_instance_in_predicate_uses_current(
Test relative reference path expansion uses current if reference path is in predicate
even if the reference path is found after the instance() expression
${pos5} that is in 'index =${pos5}'' uses current because it is in a predicate
- ${pos5} that is in 'position()=${pos5}'' uses current because it is in a predicate (eventhough not in an instance)
+ ${pos5} that is in 'position()=${pos5}'' uses current because it is in a predicate (eventhough not in an instance).
"""
xlsform_md = """
| survey | | | | |
@@ -978,7 +948,7 @@ def test_repeat_with_reference_path_after_instance_not_in_predicate_not_using_cu
Test relative reference path expansion not using current if reference path is not in predicate
and the reference path is found after the instance() expression
${pos5} that is in 'index =${pos5} uses current because it is in a predicate
- ${pos5} that is in '${pos5} + 1'' not using current because it is not in a predicate (regardless in an instance or not)
+ ${pos5} that is in '${pos5} + 1'' not using current because it is not in a predicate (regardless in an instance or not).
"""
xlsform_md = """
| survey | | | | |
@@ -1026,7 +996,7 @@ def test_check_performance__relative_reference(self):
process = Process(getpid())
for count in (500, 1000, 2000, 5000, 10000):
questions = "\n".join(question.format(i=i) for i in range(count))
- md = "".join((survey_header, questions, survey_footer))
+ md = f"{survey_header}{questions}{survey_footer}"
def run(name, case):
runs = 0
@@ -1072,9 +1042,7 @@ def test_calculation_using_node_from_nested_repeat_has_relative_reference(self):
)
def test_repeat_adding_template_and_instance(self):
- """
- Repeat should add template and instances
- """
+ """Repeat should add template and instances."""
self.assertPyxformXform(
md="""
| survey | | | |
@@ -1116,9 +1084,7 @@ def test_repeat_adding_template_and_instance(self):
)
def test_repeat_adding_template_and_instance_with_group(self):
- """
- Repeat should add template and instance even when they are inside grouping
- """
+ """Repeat should add template and instance even when they are inside grouping."""
self.assertPyxformXform(
md="""
| survey | | | |
@@ -1498,9 +1464,7 @@ def test_unlabeled_repeat_fieldlist_alternate_syntax(self):
class TestRepeatCount(PyxformTestCase):
- """
- Test usages of the survey repeat_count column.
- """
+ """Test usages of the survey repeat_count column."""
def test_single_reference__generated_element_same_name__ok(self):
"""Should not have a name clash, the referenced item should be used directly."""
diff --git a/tests/test_search_function.py b/tests/test_search_function.py
index 984f81296..396e4db10 100644
--- a/tests/test_search_function.py
+++ b/tests/test_search_function.py
@@ -64,7 +64,7 @@ def setUpClass(cls) -> None:
cls.run_odk_validate = True
def test_shared_choice_list(self):
- """Should include translation for search() items, when sharing the choice list"""
+ """Should include translation for search() items, when sharing the choice list."""
md = """
| survey | | | | | |
| | type | name | label::en | label::fr | appearance |
@@ -91,7 +91,7 @@ def test_shared_choice_list(self):
)
def test_usage_with_other_selects(self):
- """Should include translation for search() items, when used with other selects"""
+ """Should include translation for search() items, when used with other selects."""
md = """
| survey | | | | | |
| | type | name | label::en | label::fr | appearance |
@@ -138,7 +138,7 @@ def test_usage_with_other_selects__invalid_list_reuse_by_non_search_question(sel
)
def test_single_question_usage(self):
- """Should include translation for search() items, edge case of single question"""
+ """Should include translation for search() items, edge case of single question."""
md = """
| survey | | | | | |
| | type | name | label::en | label::fr | appearance |
@@ -162,7 +162,7 @@ def test_single_question_usage(self):
)
def test_additional_static_choices(self):
- """Should include translation for search() items, when adding static choices"""
+ """Should include translation for search() items, when adding static choices."""
md = """
| survey | | | | | |
| | type | name | label::en | label::fr | appearance |
@@ -212,7 +212,7 @@ def test_name_clashes(self):
)
def test_search_and_select_xlsx(self):
- """Test to replace the old XLSX-based test fixture, 'search_and_select.xlsx'"""
+ """Test to replace the old XLSX-based test fixture, 'search_and_select.xlsx'."""
md = """
| survey | | | | |
| | type | name | label | appearance |
@@ -245,9 +245,7 @@ def test_search_and_select_xlsx(self):
class TestSecondaryInstances(PyxformTestCase):
- """
- Test behaviour of the search() appearance with other sources of secondary instances.
- """
+ """Test behaviour of the search() appearance with other sources of secondary instances."""
@classmethod
def setUpClass(cls) -> None:
diff --git a/tests/test_secondary_instance_translations.py b/tests/test_secondary_instance_translations.py
index df83879d6..35858614b 100644
--- a/tests/test_secondary_instance_translations.py
+++ b/tests/test_secondary_instance_translations.py
@@ -1,6 +1,4 @@
-"""
-Testing inlining translation when no translation is specified.
-"""
+"""Testing inlining translation when no translation is specified."""
from tests.pyxform_test_case import PyxformTestCase
from tests.xpath_helpers.choices import xpc
@@ -42,9 +40,7 @@ def test_inline_translations(self):
)
def test_multiple_translations(self):
- """
- Dynamic choice with potential translation should generate itext fields.
- """
+ """Dynamic choice with potential translation should generate itext fields."""
self.assertPyxformXform(
md="""
| survey | | | | |
@@ -76,9 +72,7 @@ def test_multiple_translations(self):
def test_select_with_media_and_choice_filter_and_no_translations_generates_media(
self,
):
- """
- Selects with media and choice filter should generate itext fields for the media.
- """
+ """Selects with media and choice filter should generate itext fields for the media."""
md = """
| survey | | | | |
| | type | name | label | choice_filter |
@@ -118,9 +112,7 @@ def test_select_with_media_and_choice_filter_and_no_translations_generates_media
def test_select_with_choice_filter_and_translations_generates_single_translation(
self,
):
- """
- Selects with choice filter and translations should only have a single itext entry.
- """
+ """Selects with choice filter and translations should only have a single itext entry."""
xform_md = """
| survey | | | | |
| | type | name | label | choice_filter |
@@ -148,9 +140,7 @@ def test_select_with_choice_filter_and_translations_generates_single_translation
def test_select_with_dynamic_option_label__and_choice_filter__and_no_translations__generates_itext(
self,
):
- """
- A select with a choice filter and no translations in which the first option label is dynamic should generate itext for choice labels.
- """
+ """A select with a choice filter and no translations in which the first option label is dynamic should generate itext for choice labels."""
xform_md = """
| survey | | | | | |
| | type | name | label | choice_filter | default |
@@ -177,9 +167,7 @@ def test_select_with_dynamic_option_label__and_choice_filter__and_no_translation
def test_select_with_dynamic_option_label_for_second_choice__and_choice_filter__and_no_translations__generates_itext(
self,
):
- """
- A select with a choice filter and no translations in which the second option label is dynamic should generate itext for choice labels.
- """
+ """A select with a choice filter and no translations in which the second option label is dynamic should generate itext for choice labels."""
xform_md = """
| survey | | | | | |
| | type | name | label | choice_filter | default |
@@ -211,9 +199,7 @@ def test_select_with_dynamic_option_label_for_second_choice__and_choice_filter__
def test_select_with_dynamic_option_label__and_choice_filter__and_no_translations__maintains_additional_columns(
self,
):
- """
- A select with a choice filter and no translations in which the first option label is dynamic should maintain data columns.
- """
+ """A select with a choice filter and no translations in which the first option label is dynamic should maintain data columns."""
xform_md = """
| survey | | | | | |
| | type | name | label | choice_filter | default |
@@ -232,9 +218,7 @@ def test_select_with_dynamic_option_label__and_choice_filter__and_no_translation
def test_select_with_dynamic_option_label__and_no_choice_filter__and_no_translations__inlines_output(
self,
):
- """
- A select without a choice filter and no translations in which the first option label is dynamic should not use itext.
- """
+ """A select without a choice filter and no translations in which the first option label is dynamic should not use itext."""
md = """
| survey | | | |
| | type | name | label |
diff --git a/tests/test_set_geopoint.py b/tests/test_set_geopoint.py
index c52196ed1..68469c6ee 100644
--- a/tests/test_set_geopoint.py
+++ b/tests/test_set_geopoint.py
@@ -1,6 +1,4 @@
-"""
-Test setgeopoint widget.
-"""
+"""Test setgeopoint widget."""
from tests.pyxform_test_case import PyxformTestCase
diff --git a/tests/test_settings.py b/tests/test_settings.py
index c954901c7..403524201 100644
--- a/tests/test_settings.py
+++ b/tests/test_settings.py
@@ -356,11 +356,53 @@ def test_instance_id__can_be_used_as_reference_variable__error(self):
],
)
+ def test_style__no_default_output(self):
+ """Should find that no default style is set."""
+ md = """
+ | survey |
+ | | type | name | label |
+ | | text | q1 | Q1 |
+ """
+ self.assertPyxformXform(
+ md=md,
+ xml__xpath_match=["""/h:html/h:body[not(@class)]"""],
+ )
+
+ def test_style__output_body_class_attribute(self):
+ """Should find that the 'style' setting is output as a body 'class' attribute."""
+ md = """
+ | settings |
+ | | style |
+ | | theme-grid |
+
+ | survey |
+ | | type | name | label |
+ | | text | q1 | Q1 |
+ """
+ self.assertPyxformXform(
+ md=md,
+ xml__xpath_match=["""/h:html/h:body[@class='theme-grid']"""],
+ )
+
+ def test_style__output_body_class_attribute_verbatim(self):
+ """Should find that the 'style' setting has no processing or validation."""
+ md = r"""
+ | settings |
+ | | style |
+ | | theme-grids\n\n |
+
+ | survey |
+ | | type | name | label |
+ | | text | q1 | Q1 |
+ """
+ self.assertPyxformXform(
+ md=md,
+ xml__xpath_match=[r"""/h:html/h:body[@class='theme-grids\n\n']"""],
+ )
+
class TestNamespaces(PyxformTestCase):
- """
- Test namespaces, for the XForm and in relation to settings that can be namespaced.
- """
+ """Test namespaces, for the XForm and in relation to settings that can be namespaced."""
def test_standard_namespaces(self):
"""Should find the standard namespaces in the XForm output."""
diff --git a/tests/test_settings_auto_send_delete.py b/tests/test_settings_auto_send_delete.py
index eec4a7c75..58e8e13cb 100644
--- a/tests/test_settings_auto_send_delete.py
+++ b/tests/test_settings_auto_send_delete.py
@@ -1,6 +1,4 @@
-"""
-Test settins auto settings.
-"""
+"""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 8f6d7ff82..064f3ef66 100644
--- a/tests/test_sheet_columns.py
+++ b/tests/test_sheet_columns.py
@@ -1,6 +1,4 @@
-"""
-Test XLSForm sheet names.
-"""
+"""Test XLSForm sheet names."""
from collections.abc import Container
from dataclasses import dataclass
@@ -65,14 +63,10 @@ def test_form_id_variant(self):
class TestSurveyColumns(PyxformTestCase):
- """
- Invalid survey column tests
- """
+ """Invalid survey column tests."""
def test_missing_name(self):
- """
- every question needs a name (or alias of name)
- """
+ """Every question needs a name (or alias of name)."""
self.assertPyxformXform(
name="invalidcols",
ss_structure={"survey": [{"type": "text", "label": "label"}]},
@@ -144,9 +138,7 @@ def test_media_column__is_ignored(self):
)
def test_column_case(self):
- """
- Ensure that column name is case insensitive
- """
+ """Ensure that column name is case insensitive."""
self.assertPyxformXform(
md="""
| Survey | | | |
@@ -162,7 +154,6 @@ def test_label_caps_alternatives(self):
re: https://github.com/SEL-Columbia/pyxform/issues/76
Capitalization of 'label' column can lead to confusing errors.
"""
-
self.assertPyxformXform(
md="""
| survey | | | |
@@ -203,18 +194,59 @@ def test_missing_survey_headers(self):
],
)
+ def test_empty_bind_attribute__via_md__ignored(self):
+ """Should find that if provided as md data, a LF ignored entirely."""
+ md = """
+ | survey |
+ | | type | name | label | relevant |
+ | | text | q1 | Q1 | \n |
+ """
+ self.assertPyxformXform(
+ md=md,
+ xml__xpath_match=[
+ """/h:html/h:head/x:model/x:bind[@nodeset='/test_name/q1' and not(@relevant)]"""
+ ],
+ )
+
+ def test_empty_bind_attribute__via_dict__empty_string(self):
+ """Should find that if provided as dict data, a LF is stripped back to an empty string."""
+ self.assertPyxformXform(
+ ss_structure={
+ "survey": [
+ {"type": "text", "name": "q1", "label": "Q1", "relevant": "\n"}
+ ]
+ },
+ xml__xpath_match=[
+ """/h:html/h:head/x:model/x:bind[@nodeset='/test_name/q1' and @relevant='']"""
+ ],
+ odk_validate_error__contains=(
+ "Encountered a problem with display condition for node [${q1}] at line: , "
+ "Bad node: org.javarosa.xpath.parser.ast.ASTNodeAbstractExpr"
+ ),
+ )
+
+ def test_ignore_additional_columns_or_rows(self):
+ """Should find that additional columns or rows (such as for comments) are ignored."""
+ md = """
+ | survey |
+ | | _comment | type | name | label |
+ | | looks good | | | |
+ | | | text | q1 | Q1 |
+ """
+ self.assertPyxformXform(
+ md=md,
+ xml__xpath_match=[
+ """/h:html/h:head/x:model/x:bind[@nodeset='/test_name/q1' and not(@_comment)]"""
+ ],
+ )
+
class TestChoicesColumns(PyxformTestCase):
- """
- Invalid choice sheet column tests
- """
+ """Invalid choice sheet column tests."""
@staticmethod
def _simple_choice_ss(choice_sheet=None):
- """
- Return simple choices sheet
- """
-
+ """Return simple choices sheet."""
if choice_sheet is None:
choice_sheet = []
return {
@@ -229,10 +261,7 @@ def _simple_choice_ss(choice_sheet=None):
}
def test_valid_choices_sheet_passes(self):
- """
- Test invalid choices sheet passes
- """
-
+ """Test invalid choices sheet passes."""
self.assertPyxformXform(
name="valid_choices",
ss_structure=self._simple_choice_ss(
@@ -244,10 +273,7 @@ def test_valid_choices_sheet_passes(self):
)
def test_invalid_choices_sheet_fails(self):
- """
- Test invalid choices sheet fails
- """
-
+ """Test invalid choices sheet fails."""
self.assertPyxformXform(
name="missing_name",
ss_structure=self._simple_choice_ss(
@@ -263,10 +289,7 @@ def test_invalid_choices_sheet_fails(self):
)
def test_missing_list_name(self):
- """
- Test missing sheet name
- """
-
+ """Test missing sheet name."""
self.assertPyxformXform(
name="missing_list_name",
ss_structure=self._simple_choice_ss(
@@ -298,14 +321,10 @@ def test_missing_choice_headers(self):
class TestColumnAliases(PyxformTestCase):
- """
- Aliases Tests
- """
+ """Aliases Tests."""
def test_value_and_name(self):
- """
- confirm that both 'name' and 'value' columns of choice list work
- """
+ """Confirm that both 'name' and 'value' columns of choice list work."""
md = """
| survey | | | |
| | type | name | label |
diff --git a/tests/test_sms.py b/tests/test_sms.py
index 6bfa434be..b241bed69 100644
--- a/tests/test_sms.py
+++ b/tests/test_sms.py
@@ -1,6 +1,4 @@
-"""
-Test sms syntax.
-"""
+"""Test sms syntax."""
from tests.pyxform_test_case import PyxformTestCase
@@ -64,3 +62,69 @@ def test_sms_tag(self):
"",
],
)
+
+ def test_sms_info(self):
+ md = """
+ | settings |
+ | | form_title | form_id | sms_keyword | sms_separator | sms_allow_media | sms_date_format | sms_datetime_format |
+ | | SMS | sms_info | inf | + | 1 | %Y-%m-%d | %Y-%m-%d-%H:%M |
+
+ | survey |
+ | | type | name | sms_field | label |
+ | | begin_group | section1 | a | |
+ | | integer | age | q1 | How old are you? |
+ | | select_one yes_no | has_children | q2 | Do you have any children? |
+ | | end_group | | | |
+ | | begin_group | medias | c | |
+ | | image | picture | | May I take your picture? |
+ | | geopoint | gps | | Record your GPS coordinates. |
+ | | end_group | | | |
+ | | begin_group | browsers | b | |
+ | | select_multiple browsers | web_browsers | q5 | What web browsers do you use? |
+ | | end_group | | | |
+
+ | choices |
+ | | list_name | name | sms_option | label |
+ | | yes_no | n | n | no |
+ | | yes_no | y | y | yes |
+ | | browsers | firefox | ff | Mozilla Firefox |
+ | | browsers | chrome | gc | Google Chrome |
+ """
+
+ def get_cases(value) -> tuple[tuple[str, int], ...]:
+ """No element text and no attribute values match the input 'value'."""
+ return (
+ (f"""/h:html//*[text()='{value}']""", 0),
+ (f"""/h:html//*[@*='{value}']""", 0),
+ )
+
+ sms_values = (
+ "inf",
+ "+",
+ "1",
+ "%Y-%m-%d",
+ "%Y-%m-%d-%H:%M",
+ "a",
+ "q1",
+ "q2",
+ "b",
+ "c",
+ )
+
+ self.assertPyxformXform(
+ md=md,
+ xml__xpath_count=[
+ # The sms_* settings and sms_field values do not affect the xform output.
+ case
+ for val in sms_values
+ for case in get_cases(val)
+ ],
+ xml__xpath_match=[
+ # Model instance for choices contains sms_option values. Currently occurs
+ # due to passing through all choices data, not specific to sms_option.
+ """/h:html/h:head/x:model/x:instance[@id='yes_no']/x:root/x:item[./x:name='n' and ./x:sms_option='n']""",
+ """/h:html/h:head/x:model/x:instance[@id='yes_no']/x:root/x:item[./x:name='y' and ./x:sms_option='y']""",
+ """/h:html/h:head/x:model/x:instance[@id='browsers']/x:root/x:item[./x:name='firefox' and ./x:sms_option='ff']""",
+ """/h:html/h:head/x:model/x:instance[@id='browsers']/x:root/x:item[./x:name='chrome' and ./x:sms_option='gc']""",
+ ],
+ )
diff --git a/tests/test_static_defaults.py b/tests/test_static_defaults.py
index 987642257..79e3fbfcc 100644
--- a/tests/test_static_defaults.py
+++ b/tests/test_static_defaults.py
@@ -1,20 +1,29 @@
from tests.pyxform_test_case import PyxformTestCase
+from tests.xpath_helpers.questions import xpq
class StaticDefaultTests(PyxformTestCase):
def test_static_defaults(self):
+ """Should find that static defaults are placed in the model with no setvalue nodes."""
+ md = """
+ | survey |
+ | | type | name | label | default |
+ | | integer | numba | Foo | foo |
+ | | begin repeat | repeat | | |
+ | | integer | bar | Bar | 12 |
+ | | end repeat | repeat | | |
+ """
self.assertPyxformXform(
- name="static",
- md="""
- | survey | | | | |
- | | type | name | label | default |
- | | integer | numba | Foo | foo |
- | | begin repeat | repeat | | |
- | | integer | bar | Bar | 12 |
- | | end repeat | repeat | | |
- """,
- model__contains=["foo", "12"],
- model__excludes=["setvalue", ""],
+ md=md,
+ xml__xpath_match=[
+ xpq.model_instance_item("numba", "foo"),
+ xpq.model_instance_item("repeat[@jr:template='']/x:bar", "12"),
+ xpq.model_instance_item("repeat[not(@jr:template)]/x:bar", "12"),
+ ],
+ xml__xpath_count=[
+ ("""/h:html/h:head/x:model/x:setvalue""", 0),
+ ("""/h:html/h:body//x:setvalue""", 0),
+ ],
)
def test_static_image_defaults(self):
diff --git a/tests/test_survey.py b/tests/test_survey.py
index d19911318..6ac7af3ff 100644
--- a/tests/test_survey.py
+++ b/tests/test_survey.py
@@ -11,9 +11,7 @@
class TestSurvey(PyxformTestCase):
- """
- Tests for the Survey class.
- """
+ """Tests for the Survey class."""
def test_many_xpath_references_do_not_hit_64_recursion_limit__one_to_one(self):
"""Should be able to pipe a question into one note more than 64 times."""
@@ -195,9 +193,7 @@ def build_survey_from_path_spec(
class TestGetPathRelativeToLCAR(TestCase):
- """
- Tests of pyxform.survey.get_path_relative_to_lcar
- """
+ """Tests of `pyxform.survey.get_path_relative_to_lcar`."""
def assert_relative_path(
self,
diff --git a/tests/test_translations.py b/tests/test_translations.py
index 879ee5827..d0d354eda 100644
--- a/tests/test_translations.py
+++ b/tests/test_translations.py
@@ -1,6 +1,4 @@
-"""
-Test translations syntax.
-"""
+"""Test translations syntax."""
from dataclasses import dataclass
from os import getpid
@@ -26,9 +24,7 @@
@dataclass(slots=True)
class XPathHelper:
- """
- XPath expressions for translations-related assertions.
- """
+ """XPath expressions for translations-related assertions."""
question_type: str
question_name: str
@@ -224,7 +220,7 @@ def test_spaces_adjacent_to_translation_delimiter(self):
)
def test_missing_media_itext(self):
- """Test missing media itext translation
+ """Test missing media itext translation.
Fix for https://github.com/XLSForm/pyxform/issues/32
"""
@@ -409,7 +405,7 @@ def test_missing_translations_check_performance(self):
for count in (500, 1000, 2000, 5000, 10000):
questions = "\n".join(question.format(i=i) for i in range(count))
choice_lists = "\n".join(choice_list.format(i=i) for i in range(count))
- md = "".join((survey_header, questions, choices_header, choice_lists))
+ md = f"{survey_header}{questions}{choices_header}{choice_lists}"
def run(name, case):
runs = 0
diff --git a/tests/test_trigger.py b/tests/test_trigger.py
index 1e5d17163..b02c91584 100644
--- a/tests/test_trigger.py
+++ b/tests/test_trigger.py
@@ -1,6 +1,4 @@
-"""
-Test handling setvalue of 'trigger' column in forms
-"""
+"""Test handling setvalue of 'trigger' column in forms."""
from itertools import product
diff --git a/tests/test_tutorial_xls.py b/tests/test_tutorial_xls.py
deleted file mode 100644
index f57264d8b..000000000
--- a/tests/test_tutorial_xls.py
+++ /dev/null
@@ -1,16 +0,0 @@
-"""
-Test tutorial XLSForm.
-"""
-
-from unittest import TestCase
-
-from pyxform.builder import create_survey_from_path
-
-from tests import utils
-
-
-class TutorialTests(TestCase):
- @staticmethod
- def test_create_from_path():
- path = utils.path_to_text_fixture("tutorial.xls")
- create_survey_from_path(path)
diff --git a/tests/test_unicode_rtl.py b/tests/test_unicode_rtl.py
index 664159bc6..b7771d089 100644
--- a/tests/test_unicode_rtl.py
+++ b/tests/test_unicode_rtl.py
@@ -1,6 +1,4 @@
-"""
-Test unicode rtl in XLSForms.
-"""
+"""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 92d0708c0..623daaf7a 100644
--- a/tests/test_upload_question.py
+++ b/tests/test_upload_question.py
@@ -1,6 +1,4 @@
-"""
-Test upload (image, audio, file) question types in XLSForm
-"""
+"""Test upload (image, audio, file) question types in XLSForm."""
from tests.pyxform_test_case import PyxformTestCase
diff --git a/tests/test_validate_unicode_exception.py b/tests/test_validate_unicode_exception.py
index 4648f0cd4..d71b106a3 100644
--- a/tests/test_validate_unicode_exception.py
+++ b/tests/test_validate_unicode_exception.py
@@ -1,6 +1,4 @@
-"""
-Test unicode characters in validate error messages.
-"""
+"""Test unicode characters in validate error messages."""
from tests.pyxform_test_case import PyxformTestCase
diff --git a/tests/test_validator_update.py b/tests/test_validator_update.py
index 5aeb0283b..a1be1cf5a 100644
--- a/tests/test_validator_update.py
+++ b/tests/test_validator_update.py
@@ -1,6 +1,4 @@
-"""
-Test validator update cli command.
-"""
+"""Test validator update cli command."""
import os
import platform
diff --git a/tests/test_validator_util.py b/tests/test_validator_util.py
index 550ab0c02..31fb7711e 100644
--- a/tests/test_validator_util.py
+++ b/tests/test_validator_util.py
@@ -1,6 +1,4 @@
-"""
-Test pyxform.validators.utils module.
-"""
+"""Test pyxform.validators.utils module."""
import os
from unittest import TestCase
diff --git a/tests/test_validators.py b/tests/test_validators.py
index 868bb449f..58328ee00 100644
--- a/tests/test_validators.py
+++ b/tests/test_validators.py
@@ -1,6 +1,4 @@
-"""
-Test validators.
-"""
+"""Test validators."""
from unittest import TestCase
from unittest.mock import patch
@@ -12,7 +10,7 @@
class TestValidatorsUtil(TestCase):
- """Test validators.util"""
+ """Test validators.util."""
def test_check_java_available__found(self):
"""Should not raise an error when Java is found."""
diff --git a/tests/test_warnings.py b/tests/test_warnings.py
index 470efd534..a099ead39 100644
--- a/tests/test_warnings.py
+++ b/tests/test_warnings.py
@@ -1,6 +1,4 @@
-"""
-Test warnings.
-"""
+"""Test warnings."""
from tests.pyxform_test_case import PyxformTestCase
diff --git a/tests/test_whitespace.py b/tests/test_whitespace.py
index 29750455d..09cba33ea 100644
--- a/tests/test_whitespace.py
+++ b/tests/test_whitespace.py
@@ -1,6 +1,4 @@
-"""
-Test whitespace around output variables in XForms.
-"""
+"""Test whitespace around output variables in XForms."""
from tests.pyxform_test_case import PyxformTestCase
@@ -32,6 +30,7 @@ def test_whitespace_output_permutations(self):
| | text | G1 | Wrap {0} in text |
| | text | H1 | Wrap {0} in {0} text |
| | text | I1 | Wrap {0} in {0} |
+ | | text | J1 | Encode {0} < {0} |
"""
xp = "/h:html/h:body/x:input[@ref='/test_name/{}']/x:label"
test_cases = ("A", "B1")
@@ -89,6 +88,12 @@ def test_whitespace_output_permutations(self):
f""""""
},
),
+ (
+ xp.format("J1"),
+ {
+ f""""""
+ },
+ ),
],
)
diff --git a/tests/test_xform2json.py b/tests/test_xform2json.py
index 19b353f47..1f6d4e07f 100644
--- a/tests/test_xform2json.py
+++ b/tests/test_xform2json.py
@@ -1,6 +1,4 @@
-"""
-Test xform2json module.
-"""
+"""Test xform2json module."""
import json
import os
@@ -22,22 +20,13 @@ class DumpAndLoadXForm2JsonTests(XFormTestCase):
def setUp(self):
self.excel_files = [
- "gps.xls",
- # "include.xls",
"choice_filter_test.xlsx",
- "specify_other.xls",
- "loop.xls",
- "text_and_integer.xls",
- # todo: this is looking for json that is created (and
- # deleted) by another test, is should just add that json
- # to the directory.
- # "include_json.xls",
- "simple_loop.xls",
- "yes_or_no_question.xls",
+ "loop.xlsx",
+ "text_and_integer.xlsx",
+ "yes_or_no_question.xlsx",
"xlsform_spec_test.xlsx",
"field-list.xlsx",
- "table-list.xls",
- "group.xls",
+ "group.xlsx",
]
self.surveys = {}
self.this_directory = os.path.dirname(__file__)
@@ -105,14 +94,12 @@ def test_try_parse_with_bad_file(self):
class TestXForm2JSON(PyxformTestCase):
- """
- Test xform2json module
- """
+ """Test xform2json module."""
def test_convert_toJSON_multi_language(self):
"""
Test that it's possible to convert XLSForms with multiple languages
- to JSON and back into XML without losing any of the required information
+ to JSON and back into XML without losing any of the required information.
"""
md = """
| survey |
diff --git a/tests/test_xls2json.py b/tests/test_xls2json.py
index 302f9d63f..aa8398b0c 100644
--- a/tests/test_xls2json.py
+++ b/tests/test_xls2json.py
@@ -4,7 +4,8 @@
from pyxform.xls2json_backends import get_xlsform, md_table_to_workbook
from pyxform.xls2xform import get_xml_path, xls2xform_convert
-from tests import example_xls, test_output
+from tests import test_output
+from tests.fixtures import example_forms
from tests.pyxform_test_case import PyxformTestCase
from tests.utils import get_temp_dir
@@ -563,7 +564,7 @@ def test_workbook_to_json__optional_sheets_ok(self):
)
def test_xls2xform_convert__e2e_row_with_no_column_value(self):
- """Programmatically-created XLSX files may have rows without column values"""
+ """Programmatically-created XLSX files may have rows without column values."""
md = """
| survey | | | | |
| | type | name | label | hint |
@@ -587,7 +588,7 @@ def test_xls2xform_convert__e2e_with_settings_misspelling(self):
"""Should warn about settings misspelling when running full pipeline."""
file_name = "extra_sheet_names"
warnings = xls2xform_convert(
- xlsform_path=os.path.join(example_xls.PATH, file_name + ".xlsx"),
+ xlsform_path=os.path.join(example_forms.PATH, file_name + ".xlsx"),
xform_path=os.path.join(test_output.PATH, file_name + ".xml"),
validate=False,
pretty_print=False,
@@ -602,11 +603,11 @@ def test_xls2xform_convert__e2e_with_settings_misspelling(self):
def test_xls2xform_convert__e2e_with_extra_columns__does_not_use_excessive_memory(
self,
):
- """Degenerate form with many blank columns"""
+ """Degenerate form with many blank columns."""
process = psutil.Process(os.getpid())
pre_mem = process.memory_info().rss
xls2xform_convert(
- xlsform_path=os.path.join(example_xls.PATH, "extra_columns.xlsx"),
+ xlsform_path=os.path.join(example_forms.PATH, "extra_columns.xlsx"),
xform_path=os.path.join(test_output.PATH, "extra_columns.xml"),
)
post_mem = process.memory_info().rss
@@ -615,7 +616,7 @@ def test_xls2xform_convert__e2e_with_extra_columns__does_not_use_excessive_memor
def test_xlsx_to_dict__extra_sheet_names_are_returned_by_parser(self):
"""Should return all sheet names so that later steps can do spellcheck."""
- d = get_xlsform(os.path.join(example_xls.PATH, "extra_sheet_names.xlsx"))
+ d = get_xlsform(os.path.join(example_forms.PATH, "extra_sheet_names.xlsx"))
self.assertIn("survey", d.sheet_names)
self.assertIn("my_sheet", d.sheet_names)
self.assertIn("stettings", d.sheet_names)
diff --git a/tests/test_xls2json_backends.py b/tests/test_xls2json_backends.py
index 743f84224..df62f4e6a 100644
--- a/tests/test_xls2json_backends.py
+++ b/tests/test_xls2json_backends.py
@@ -1,25 +1,25 @@
-"""
-Test xls2json_backends module functionality.
-"""
+"""Test xls2json_backends module functionality."""
import datetime
import os
+from pathlib import Path
+from unittest import expectedFailure
import openpyxl
-import xlrd
from pyxform.builder import create_survey_element_from_dict
from pyxform.xls2json import workbook_to_json
from pyxform.xls2json_backends import (
+ convert_file_to_csv_string,
csv_to_dict,
get_xlsform,
+ md_table_to_workbook,
md_to_dict,
- xls_to_dict,
- xls_value_to_unicode,
xlsx_to_dict,
xlsx_value_to_str,
)
-from tests import bug_example_xls, utils
+from tests import utils
+from tests.fixtures import bug_example_forms
from tests.pyxform_test_case import PyxformTestCase
from tests.xpath_helpers.choices import xpc
from tests.xpath_helpers.entities import xpe
@@ -28,32 +28,10 @@
class TestXLS2JSONBackends(PyxformTestCase):
- """
- Test xls2json_backends module.
- """
+ """Test xls2json_backends module."""
maxDiff = None
- def test_xls_value_to_unicode(self):
- """
- Test external choices sheet with numeric values is processed successfully.
-
- The test ensures that the integer values within the external choices sheet
- are returned as they were initially received.
- """
- value = 32.0
- value_type = xlrd.XL_CELL_NUMBER
- datemode = 1
- csv_data = xls_value_to_unicode(value, value_type, datemode)
- expected_output = "32"
- self.assertEqual(csv_data, expected_output)
-
- # Test that the decimal value is not changed during conversion.
- value = 46.9
- csv_data = xls_value_to_unicode(value, value_type, datemode)
- expected_output = "46.9"
- self.assertEqual(csv_data, expected_output)
-
def test_xlsx_value_to_str(self):
value = 32.0
csv_data = xlsx_value_to_str(value)
@@ -103,7 +81,7 @@ def test_case_insensitivity(self):
# osm
xpq.body_upload_tags("q3", (("n1-o", "l1-o"), ("n2-o", "l2-o"))),
]
- file_types = [".xlsx", ".xls", ".csv", ".md"]
+ file_types = [".xlsx", ".csv", ".md"]
for file_type in file_types:
with self.subTest(msg=file_type):
data = get_xlsform(
@@ -159,53 +137,49 @@ def test_equivalency(self):
"""Should get the same data from equivalent files using each file type reader."""
equivalent_fixtures = [
"group",
- "include",
- "include_json",
"loop",
- "specify_other",
"text_and_integer",
"yes_or_no_question",
]
for fixture in equivalent_fixtures:
xlsx_inp = xlsx_to_dict(utils.path_to_text_fixture(f"{fixture}.xlsx"))
- xls_inp = xls_to_dict(utils.path_to_text_fixture(f"{fixture}.xls"))
csv_inp = csv_to_dict(utils.path_to_text_fixture(f"{fixture}.csv"))
md_inp = md_to_dict(utils.path_to_text_fixture(f"{fixture}.md"))
- self.assertEqual(xlsx_inp, xls_inp)
self.assertEqual(xlsx_inp, csv_inp)
self.assertEqual(xlsx_inp, md_inp)
- def test_xls_with_many_empty_cells(self):
+ def test_form_with_many_empty_cells(self):
"""Should quickly produce expected data, and find large input sheet dimensions."""
# Test fixture produced by adding data at cells IV1 and A19999.
- xls_path = os.path.join(bug_example_xls.PATH, "extra_columns.xls")
- before = datetime.datetime.now(datetime.timezone.utc)
- xls_data = xls_to_dict(xls_path)
- after = datetime.datetime.now(datetime.timezone.utc)
+ xlsx_path = os.path.join(bug_example_forms.PATH, "extra_columns.xlsx")
+ before = datetime.datetime.now(datetime.UTC)
+ xlsx_data = xlsx_to_dict(xlsx_path)
+ after = datetime.datetime.now(datetime.UTC)
self.assertLess((after - before).total_seconds(), 5)
- wb = xlrd.open_workbook(filename=xls_path)
+ wb = openpyxl.open(filename=xlsx_path, read_only=True, data_only=True)
survey_headers = [
"type",
"name",
"label",
]
- self.assertEqual(survey_headers, list(xls_data["survey_header"][0].keys()))
- self.assertEqual(3, len(xls_data["survey"]))
- self.assertEqual("b", xls_data["survey"][2]["name"])
+ self.assertEqual(survey_headers, list(xlsx_data["survey_header"][0].keys()))
+ self.assertEqual(3, len(xlsx_data["survey"]))
+ self.assertEqual("b", xlsx_data["survey"][2]["name"])
survey = wb["survey"]
- self.assertTupleEqual((19999, 256), (survey.nrows, survey.ncols))
+ self.assertEqual(19999, survey.max_row)
+ self.assertEqual(256, survey.max_column)
- wb.release_resources()
+ wb.close()
- def test_xlsx_with_many_empty_cells(self):
+ def test_xlsx_with_many_empty_rows(self):
"""Should quickly produce expected data, and find large input sheet dimensions."""
# Test fixture produced (presumably) by a LibreOffice serialisation bug.
- xlsx_path = os.path.join(bug_example_xls.PATH, "UCL_Biomass_Plot_Form.xlsx")
- before = datetime.datetime.now(datetime.timezone.utc)
+ xlsx_path = os.path.join(bug_example_forms.PATH, "UCL_Biomass_Plot_Form.xlsx")
+ before = datetime.datetime.now(datetime.UTC)
xlsx_data = xlsx_to_dict(xlsx_path)
- after = datetime.datetime.now(datetime.timezone.utc)
+ after = datetime.datetime.now(datetime.UTC)
self.assertLess((after - before).total_seconds(), 5)
wb = openpyxl.open(filename=xlsx_path, read_only=True, data_only=True)
@@ -257,3 +231,63 @@ def test_xlsx_with_many_empty_cells(self):
self.assertTupleEqual((2, 2), (settings.max_row, settings.max_column))
wb.close()
+
+ def test_convert_file_to_csv_string(self):
+ """Should find that the same data read from xlsx or csv results in the same csv."""
+ md = """
+ | survey |
+ | | type | name | label |
+ | | text | q1 | Q1 |
+ """
+ csv = "survey\n,type,name,label\n,text,q1,Q1"
+ wb = md_table_to_workbook(md)
+ with utils.get_temp_dir() as tmp:
+ form_path = Path(tmp) / "test_name.xlsx"
+ wb.save(form_path)
+ wb.close()
+ converted_form = convert_file_to_csv_string(str(form_path))
+ csv_path = Path(tmp) / "test_name.csv"
+ csv_path.write_text(csv)
+ converted_csv = convert_file_to_csv_string(str(csv_path))
+ self.assertEqual(converted_csv, converted_form)
+
+ @expectedFailure
+ def test_convert_file_to_csv_string__column_order_bug(self):
+ """Should find that the header order is preserved when writing to csv."""
+ md = """
+ | survey |
+ | | type | name | label | hint | constraint | relevant |
+ | | text | q1 | Q1 | Hmm | | true() |
+ | | text | q2 | Q2 | | . != '0' | |
+ """
+ csv = (
+ "survey"
+ "\n,type,name,label,hint,constraint,relevant"
+ "\n,text,q1,Q1,Hmm,,true()"
+ "\n,text,q2,Q2,,\". != '0'\","
+ )
+ wb = md_table_to_workbook(md)
+ with utils.get_temp_dir() as tmp:
+ form_path = Path(tmp) / "test_name.xlsx"
+ wb.save(form_path)
+ wb.close()
+ converted_form = convert_file_to_csv_string(str(form_path))
+ csv_path = Path(tmp) / "test_name.csv"
+ csv_path.write_text(csv)
+ converted_csv = convert_file_to_csv_string(str(csv_path))
+ self.assertEqual(converted_csv, converted_form)
+
+ # Bug seems to occur because convert_file_to_csv collects the headers from the
+ # rows rather than using the "survey_header" (or similar). In this case the first
+ # row has no constraint, but the second row does, so constraint is put last.
+ expected_header = ",type,name,label,hint,constraint,relevant"
+ bugged_header = ",type,name,label,hint,relevant,constraint"
+ # Expected header is there but only in the the "survey_header" dict.
+ self.assertEqual(1, converted_csv.count(expected_header))
+ self.assertEqual(1, converted_form.count(expected_header))
+ # Bugged header appears in the "survey" dict.
+ self.assertEqual(1, converted_csv.count(bugged_header))
+ self.assertEqual(1, converted_form.count(bugged_header))
+ # Fail the test since the "survey" data should retain column order.
+ self.assertEqual(2, converted_csv.count(expected_header))
+ self.assertEqual(2, converted_form.count(expected_header))
diff --git a/tests/test_xls2json_xls.py b/tests/test_xls2json_xls.py
index 121ea2a26..eec81db1e 100644
--- a/tests/test_xls2json_xls.py
+++ b/tests/test_xls2json_xls.py
@@ -1,6 +1,4 @@
-"""
-Testing simple cases for Xls2Json
-"""
+"""Testing simple cases for Xls2Json."""
import json
from pathlib import Path
@@ -10,15 +8,16 @@
from pyxform.xls2json_backends import csv_to_dict, xlsx_to_dict
from pyxform.xls2xform import convert
-from tests import example_xls, test_expected_output, utils
+from tests import test_expected_output, utils
+from tests.fixtures import example_forms
class BasicXls2JsonApiTests(TestCase):
maxDiff = None
def test_simple_yes_or_no_question(self):
- filename = "yes_or_no_question.xls"
- path_to_excel_file = Path(example_xls.PATH) / filename
+ filename = "yes_or_no_question.xlsx"
+ path_to_excel_file = Path(example_forms.PATH) / filename
expected_output_path = Path(test_expected_output.PATH) / (
path_to_excel_file.stem + ".json"
)
@@ -28,51 +27,9 @@ def test_simple_yes_or_no_question(self):
with open(expected_output_path, encoding="utf-8") as expected:
self.assertEqual(json.load(expected), result._pyxform)
- def test_hidden(self):
- x = SurveyReader(utils.path_to_text_fixture("hidden.xls"), default_name="hidden")
- x_results = x.to_json_dict()
-
- expected_dict = [
- {"type": "hidden", "name": "hidden_test"},
- {
- "children": [
- {
- "bind": {"jr:preload": "uid", "readonly": "true()"},
- "name": "instanceID",
- "type": "calculate",
- }
- ],
- "control": {"bodyless": True},
- "name": "meta",
- "type": "group",
- },
- ]
- self.assertEqual(x_results["children"], expected_dict)
-
- def test_gps(self):
- x = SurveyReader(utils.path_to_text_fixture("gps.xls"), default_name="gps")
-
- expected_dict = [
- {"type": "gps", "name": "location", "label": "GPS"},
- {
- "children": [
- {
- "bind": {"jr:preload": "uid", "readonly": "true()"},
- "name": "instanceID",
- "type": "calculate",
- }
- ],
- "control": {"bodyless": True},
- "name": "meta",
- "type": "group",
- },
- ]
-
- self.assertEqual(x.to_json_dict()["children"], expected_dict)
-
def test_text_and_integer(self):
x = SurveyReader(
- utils.path_to_text_fixture("text_and_integer.xls"),
+ utils.path_to_text_fixture("text_and_integer.xlsx"),
default_name="text_and_integer",
)
@@ -104,9 +61,7 @@ def test_text_and_integer(self):
self.assertEqual(x.to_json_dict()["children"], expected_dict)
def test_choice_filter_choice_fields(self):
- """
- Test that the choice filter fields appear on children field of json
- """
+ """Test that the choice filter fields appear on children field of json."""
choice_filter_survey = SurveyReader(
utils.path_to_text_fixture("choice_filter_test.xlsx"),
default_name="choice_filter_test",
@@ -179,7 +134,7 @@ class UnicodeCsvTest(TestCase):
def test_a_unicode_csv_works(self):
"""
Simply tests that xls2json_backends.csv_to_dict does not have a problem
- with a csv with unicode characters
+ with a csv with unicode characters.
"""
utf_csv_path = utils.path_to_text_fixture("utf_csv.csv")
dict_value = csv_to_dict(utf_csv_path)
diff --git a/tests/test_xls2xform.py b/tests/test_xls2xform.py
index aa725a363..e5c83d20d 100644
--- a/tests/test_xls2xform.py
+++ b/tests/test_xls2xform.py
@@ -1,6 +1,4 @@
-"""
-Test xls2xform module.
-"""
+"""Test xls2xform module."""
# The Django application xls2xform uses the function
# pyxform.create_survey. We have a test here to make sure no one
@@ -23,7 +21,7 @@
xls2xform_convert,
)
-from tests import example_xls
+from tests.fixtures import example_forms
from tests.utils import get_temp_dir, get_temp_file, path_to_text_fixture
@@ -36,7 +34,7 @@ def test_create_parser_without_args(self):
def test_create_parser_optional_output_path(self):
"""
Should run fine for a single argument i.e. that is the
- path to the xlsx file path, while the output path is left out
+ path to the xlsx file path, while the output path is left out.
"""
try:
_create_parser().parse_args(["/some/path/tofile.xlsx"])
@@ -136,7 +134,7 @@ def test_validator_args_logic_odk_and_enketo(self):
self.assertEqual(True, args.enketo_validate)
def test_validator_args_logic_skip_validate_override(self):
- """Should deactivate both validators"""
+ """Should deactivate both validators."""
raw_args = _create_parser().parse_args(
[
"xlsform.xlsx",
@@ -166,7 +164,7 @@ def test_validator_args_logic_skip_validate_override(self):
def test_xls2form_convert_parameters(self, converter_mock, parser_mock_args):
"""
Checks that xls2xform_convert is given the right arguments, when the
- output-path is not given
+ output-path is not given.
"""
converter_mock.return_value = "{}"
main_cli()
@@ -195,7 +193,7 @@ def test_xls2xform_convert_params_with_flags(self, converter_mock, parser_mock_a
"""
Should call xlsform_convert with the correct input for output
path where only the xlsform input path and json flag were provided, since
- the xlsform-convert can be called if json flag was set or when not
+ the xlsform-convert can be called if json flag was set or when not.
"""
converter_mock.return_value = "{}"
main_cli()
@@ -220,16 +218,14 @@ def test_xls2xform_convert_params_with_flags(self, converter_mock, parser_mock_a
),
)
def test_xls2xform_convert_throwing_odk_error(self, parser_mock_args):
- """
- Parse and validate bad_calc.xlsx
- """
+ """Parse and validate bad_calc.xlsx."""
logger = logging.getLogger("pyxform.xls2xform")
with mock.patch.object(logger, "error") as mock_debug:
main_cli()
self.assertEqual(mock_debug.call_count, 1)
def test_get_xml_path_function(self):
- """Should return an xml path in the same directory as the xlsx file"""
+ """Should return an xml path in the same directory as the xlsx file."""
xlsx_path = "/home/user/Desktop/xlsform.xlsx"
expected = "/home/user/Desktop/xlsform.xml"
self.assertEqual(expected, get_xml_path(xlsx_path))
@@ -240,18 +236,14 @@ def test_get_xml_path_function(self):
class TestXLS2XFormConvert(TestCase):
- """
- Tests for `xls2xform_convert`.
- """
+ """Tests for `xls2xform_convert`."""
def test_xls2xform_convert__ok(self):
"""Should find the expected output files for the conversion."""
xlsforms = (
- Path(example_xls.PATH) / "group.xlsx",
- Path(example_xls.PATH) / "group.xls",
- Path(example_xls.PATH) / "group.csv",
- Path(example_xls.PATH) / "group.md",
- Path(example_xls.PATH) / "choice_name_as_type.xls", # has external choices
+ Path(example_forms.PATH) / "group.xlsx",
+ Path(example_forms.PATH) / "group.csv",
+ Path(example_forms.PATH) / "group.md",
)
kwargs = (
("validate", (True, False)),
@@ -269,16 +261,10 @@ def test_xls2xform_convert__ok(self):
self.assertIsInstance(observed, list)
self.assertEqual(len(observed), 0)
self.assertGreater(len(Path(xform).read_text()), 0)
- if x.name == "choice_name_as_type.xls":
- self.assertTrue(
- (Path(xform).parent / "itemsets.csv").is_file()
- )
class TestXLS2XFormConvertAPI(TestCase):
- """
- Tests for the `convert` library API entrypoint (not xls2xform_convert).
- """
+ """Tests for the `convert` library API entrypoint (not xls2xform_convert)."""
@staticmethod
def with_xlsform_path_str(**kwargs):
@@ -317,14 +303,9 @@ def test_args_combinations__ok(self):
("str (data)", self.with_xlsform_data_str), # Only for .csv, .md.
]
xlsforms = (
- (Path(example_xls.PATH) / "group.xlsx", funcs[:4]),
- (Path(example_xls.PATH) / "group.xls", funcs[:4]),
- (Path(example_xls.PATH) / "group.csv", funcs),
- (Path(example_xls.PATH) / "group.md", funcs),
- (
- Path(example_xls.PATH) / "choice_name_as_type.xls",
- funcs[:4],
- ), # has external choices
+ (Path(example_forms.PATH) / "group.xlsx", funcs[:4]),
+ (Path(example_forms.PATH) / "group.csv", funcs),
+ (Path(example_forms.PATH) / "group.md", funcs),
)
# Not including validate here because it's slow, the test is more about input,
# and these same forms are checked with validate=True above via xls2xform_convert.
@@ -349,8 +330,6 @@ def test_invalid_input_raises(self):
msg = "Argument 'definition' was not recognized as a supported type"
with get_temp_file() as empty, get_temp_dir() as td:
- bad_xls = Path(td) / "bad.xls"
- bad_xls.write_text("bad")
bad_xlsx = Path(td) / "bad.xlsx"
bad_xlsx.write_text("bad")
bad_type = Path(td) / "bad.txt"
@@ -362,7 +341,6 @@ def test_invalid_input_raises(self):
"ok",
b"ok",
empty,
- bad_xls,
bad_xlsx,
bad_type,
)
diff --git a/tests/utils.py b/tests/utils.py
index 7aa6c313e..7ddf1c26a 100644
--- a/tests/utils.py
+++ b/tests/utils.py
@@ -1,6 +1,4 @@
-"""
-The tests utils module functionality.
-"""
+"""The tests utils module functionality."""
import configparser
import os
@@ -13,11 +11,11 @@
from pyxform import file_utils
from pyxform.builder import create_survey, create_survey_from_path
-from tests import example_xls
+from tests.fixtures import example_forms
def path_to_text_fixture(filename):
- return os.path.join(example_xls.PATH, filename)
+ return os.path.join(example_forms.PATH, filename)
def build_survey(filename):
@@ -25,12 +23,12 @@ def build_survey(filename):
return create_survey_from_path(path)
-def create_survey_from_fixture(fixture_name, filetype="xls", include_directory=False):
+def create_survey_from_fixture(fixture_name, filetype="xlsx", include_directory=False):
fixture_path = path_to_text_fixture(f"{fixture_name}.{filetype}")
- noop, section_dict = file_utils.load_file_to_dict(fixture_path)
+ _, section_dict = file_utils.load_file_to_dict(fixture_path)
pkg = {"main_section": section_dict}
if include_directory:
- directory, noop = os.path.split(fixture_path)
+ directory, _ = os.path.split(fixture_path)
pkg["sections"] = file_utils.collect_compatible_files_in_directory(directory)
return create_survey(**pkg)
@@ -79,9 +77,7 @@ def get_temp_dir():
def truncate_temp_files(temp_dir):
- """
- Truncate files in a folder, recursing into directories.
- """
+ """Truncate files in a folder, recursing into directories."""
# If we can't delete, at least the files can be truncated,
# so that they don't take up disk space until next cleanup.
# Seems to be a Windows-specific error for newly-created files.
@@ -97,9 +93,7 @@ def truncate_temp_files(temp_dir):
def cleanup_pyxform_temp_files(prefix: str):
- """
- Try to clean up temp pyxform files from previous test runs.
- """
+ """Try to clean up temp pyxform files from previous test runs."""
temp_root = tempfile.gettempdir()
if os.path.exists(temp_root):
for f in os.scandir(temp_root):
diff --git a/tests/xform_test_case/base.py b/tests/xform_test_case/base.py
index b99dd0314..4959c33c2 100644
--- a/tests/xform_test_case/base.py
+++ b/tests/xform_test_case/base.py
@@ -4,7 +4,8 @@
from defusedxml.ElementTree import fromstring
from formencode.doctest_xml_compare import xml_compare
-from tests import example_xls, test_output
+from tests import test_output
+from tests.fixtures import example_forms
# Do not use this class for new tests. Use PyxformTestCase instead.
@@ -21,7 +22,7 @@ class XFormTestCase(TestCase):
"""
def get_file_path(self, filename):
- self.path_to_excel_file = os.path.join(example_xls.PATH, filename)
+ self.path_to_excel_file = os.path.join(example_forms.PATH, filename)
# Get the xform output path:
self.root_filename, self.ext = os.path.splitext(filename)
diff --git a/tests/xform_test_case/test_bugs.py b/tests/xform_test_case/test_bugs.py
index 83f326c93..a88539a4e 100644
--- a/tests/xform_test_case/test_bugs.py
+++ b/tests/xform_test_case/test_bugs.py
@@ -1,20 +1,18 @@
-"""
-Some tests for the new (v0.9) spec is properly implemented.
-"""
+"""Some tests for the new (v0.9) spec is properly implemented."""
import os
from pathlib import Path
from unittest import TestCase
import pyxform
-from pyxform.errors import ErrorCode, PyXFormError
-from pyxform.utils import has_external_choices
+from pyxform.errors import PyXFormError
from pyxform.validators.odk_validate import ODKValidateError, check_xform
from pyxform.xls2json import SurveyReader
from pyxform.xls2json_backends import DefinitionData, get_xlsform, xlsx_to_dict
from pyxform.xls2xform import convert
-from tests import bug_example_xls, example_xls, test_output
+from tests import test_output
+from tests.fixtures import bug_example_forms
class TestXFormConversion(TestCase):
@@ -22,15 +20,11 @@ class TestXFormConversion(TestCase):
def test_conversion_raises(self):
"""Should find that conversion results in an error being raised by pyxform."""
- cases = (
- ("group_name_test.xls", "[row : 3] Question or group with no name."),
- ("duplicate_columns.xlsx", "Duplicate column header: label"),
- ("calculate_without_calculation.xls", "[row : 34] Missing calculation."),
- )
+ cases = (("duplicate_columns.xlsx", "Duplicate column header: label"),)
for i, (case, err_msg) in enumerate(cases):
with self.subTest(msg=f"{i}: {case}"):
with self.assertRaises(PyXFormError) as err:
- convert(xlsform=Path(bug_example_xls.PATH) / case, warnings=[])
+ convert(xlsform=Path(bug_example_forms.PATH) / case, warnings=[])
self.assertIn(err_msg, err.exception.args[0])
@@ -40,9 +34,9 @@ class ValidateWrapper(TestCase):
@staticmethod
def test_conversion():
filename = "ODKValidateWarnings.xlsx"
- path_to_excel_file = os.path.join(bug_example_xls.PATH, filename)
+ path_to_excel_file = os.path.join(bug_example_forms.PATH, filename)
# Get the xform output path:
- root_filename, ext = os.path.splitext(filename)
+ root_filename, _ = os.path.splitext(filename)
output_path = os.path.join(test_output.PATH, root_filename + ".xml")
# Do the conversion:
warnings = []
@@ -53,106 +47,40 @@ def test_conversion():
survey.print_xform_to_file(output_path, warnings=warnings)
-class EmptyStringOnRelevantColumnTest(TestCase):
- def test_conversion(self):
- filename = "ict_survey_fails.xls"
- workbook_dict = get_xlsform(xlsform=os.path.join(bug_example_xls.PATH, filename))
- with self.assertRaises(KeyError):
- # bind:relevant should not be part of workbook_dict
- workbook_dict.survey[0]["bind: relevant"].strip()
-
-
-class BadChoicesSheetHeaders(TestCase):
- def test_conversion(self):
- filename = "spaces_in_choices_header.xls"
- path_to_excel_file = os.path.join(bug_example_xls.PATH, filename)
- warnings = []
- pyxform.xls2json.parse_file_to_json(
- path_to_excel_file,
- default_name="spaces_in_choices_header",
- warnings=warnings,
- )
- # The "column with no header" warning is probably not reachable since XLS/X
- # pre-processing ignores any columns without a header.
- observed = [
- w
- for w in warnings
- if w == ErrorCode.HEADER_004.value.format(column="header with spaces")
- ]
- self.assertEqual(1, len(observed), warnings)
-
- def test_values_with_spaces_are_cleaned(self):
- """
- Test that values with leading and trailing whitespaces are processed.
-
- This test checks that the submission_url provided is cleaned
- of leading and trailing whitespaces.
- """
- filename = "spaces_in_choices_header.xls"
- path_to_excel_file = os.path.join(bug_example_xls.PATH, filename)
- survey_reader = SurveyReader(
- path_to_excel_file, default_name="spaces_in_choices_header"
- )
- result = survey_reader.to_json_dict()
-
- self.assertEqual(
- result["submission_url"], "https://odk.ona.io/random_person/submission"
- )
-
-
-class TestChoiceNameAsType(TestCase):
- def test_choice_name_as_type(self):
- filename = "choice_name_as_type.xls"
- path_to_excel_file = os.path.join(example_xls.PATH, filename)
- xls_reader = SurveyReader(path_to_excel_file, default_name="choice_name_as_type")
- survey_dict = xls_reader.to_json_dict()
- self.assertTrue(has_external_choices(survey_dict))
-
-
-class TestBlankSecondRow(TestCase):
- def test_blank_second_row(self):
- filename = "blank_second_row.xls"
- path_to_excel_file = os.path.join(bug_example_xls.PATH, filename)
- xls_reader = SurveyReader(path_to_excel_file, default_name="blank_second_row")
- survey_dict = xls_reader.to_json_dict()
- self.assertTrue(len(survey_dict) > 0)
-
-
class TestXLDateAmbigous(TestCase):
"""Test non standard sheet with exception is processed successfully."""
def test_xl_date_ambigous(self):
"""Test non standard sheet with exception is processed successfully."""
filename = "xl_date_ambiguous.xlsx"
- path_to_excel_file = os.path.join(bug_example_xls.PATH, filename)
+ path_to_excel_file = os.path.join(bug_example_forms.PATH, filename)
xls_reader = SurveyReader(path_to_excel_file, default_name="xl_date_ambiguous")
survey_dict = xls_reader.to_json_dict()
self.assertTrue(len(survey_dict) > 0)
class TestXLDateAmbigousNoException(TestCase):
- """Test date values that exceed the workbook datemode value.
- (This would cause an exception with xlrd, but openpyxl handles it)."""
+ """Test date values that exceed the workbook datemode value."""
def test_xl_date_ambigous_no_exception(self):
"""Test standard sheet is processed successfully."""
filename = "xl_date_ambiguous_v1.xlsx"
- path_to_excel_file = os.path.join(bug_example_xls.PATH, filename)
+ path_to_excel_file = os.path.join(bug_example_forms.PATH, filename)
survey_dict = xlsx_to_dict(path_to_excel_file)
self.assertEqual(survey_dict["survey"][4]["default"], "1900-01-01 00:00:00")
class TestSpreadSheetFilesWithMacrosAreAllowed(TestCase):
- """Test that spreadsheets with .xlsm extension are allowed"""
+ """Test that spreadsheets with .xlsm extension are allowed."""
def test_xlsm_files_are_allowed(self):
filename = "excel_with_macros.xlsm"
- result = get_xlsform(xlsform=os.path.join(bug_example_xls.PATH, filename))
+ result = get_xlsform(xlsform=os.path.join(bug_example_forms.PATH, filename))
self.assertIsInstance(result, DefinitionData)
class TestBadCalculation(TestCase):
- """Bad calculation should not kill the application"""
+ """Bad calculation should not kill the application."""
def test_bad_calculate_javarosa_error(self):
filename = "bad_calc.xml"
diff --git a/tests/xform_test_case/test_xform_conversion.py b/tests/xform_test_case/test_xform_conversion.py
index 8e67451e7..3319dacfb 100644
--- a/tests/xform_test_case/test_xform_conversion.py
+++ b/tests/xform_test_case/test_xform_conversion.py
@@ -1,6 +1,4 @@
-"""
-Some tests for the new (v0.9) spec is properly implemented.
-"""
+"""Some tests for the new (v0.9) spec is properly implemented."""
from pathlib import Path
@@ -20,12 +18,10 @@ def test_conversion_vs_expected(self):
("flat_xlsform_test.xlsx", True),
("or_other.xlsx", True),
("pull_data.xlsx", True),
- ("repeat_date_test.xls", True),
+ ("repeat_date_test.xlsx", True),
("survey_no_name.xlsx", False),
- ("widgets.xls", True),
+ ("widgets.xlsx", True),
("xlsform_spec_test.xlsx", True),
- ("xml_escaping.xls", True),
- ("default_time_demo.xls", True),
)
for i, (case, set_name) in enumerate(cases):
with self.subTest(msg=f"{i}: {case}"):
diff --git a/tests/xform_test_case/test_xml.py b/tests/xform_test_case/test_xml.py
index c7374bfae..9483450e1 100644
--- a/tests/xform_test_case/test_xml.py
+++ b/tests/xform_test_case/test_xml.py
@@ -1,6 +1,4 @@
-"""
-Test XForm XML syntax.
-"""
+"""Test XForm XML syntax."""
from sys import version_info
from unittest import TestCase
@@ -18,7 +16,7 @@ class XMLTests(XFormTestCase):
def setUp(self):
self.survey = create_survey_from_xls(
- path_to_text_fixture("yes_or_no_question.xls"), "yes_or_no_question"
+ path_to_text_fixture("yes_or_no_question.xlsx"), "yes_or_no_question"
)
def test_to_xml(self):
diff --git a/tests/xpath_helpers/choices.py b/tests/xpath_helpers/choices.py
index b39907140..66bb4ccb5 100644
--- a/tests/xpath_helpers/choices.py
+++ b/tests/xpath_helpers/choices.py
@@ -7,9 +7,7 @@
class XPathHelper:
- """
- XPath expressions for choices assertions.
- """
+ """XPath expressions for choices assertions."""
@staticmethod
def model_instance_choices_label(cname: str, choices: tuple[tuple[str, str], ...]):
diff --git a/tests/xpath_helpers/entities.py b/tests/xpath_helpers/entities.py
index 08c5966a6..a96c2522c 100644
--- a/tests/xpath_helpers/entities.py
+++ b/tests/xpath_helpers/entities.py
@@ -1,7 +1,5 @@
class XPathHelper:
- """
- XPath expressions for entities assertions.
- """
+ """XPath expressions for entities assertions."""
@staticmethod
def model_entities_version(version: str):
diff --git a/tests/xpath_helpers/group.py b/tests/xpath_helpers/group.py
index a72929527..db6373817 100644
--- a/tests/xpath_helpers/group.py
+++ b/tests/xpath_helpers/group.py
@@ -1,7 +1,5 @@
class XPathHelper:
- """
- XPath expressions for settings assertions.
- """
+ """XPath expressions for settings assertions."""
@staticmethod
def group_no_label(ref: str) -> str:
diff --git a/tests/xpath_helpers/questions.py b/tests/xpath_helpers/questions.py
index c1a666113..d140a512f 100644
--- a/tests/xpath_helpers/questions.py
+++ b/tests/xpath_helpers/questions.py
@@ -2,9 +2,7 @@
class XPathHelper:
- """
- XPath expressions for questions assertions.
- """
+ """XPath expressions for questions assertions."""
@staticmethod
def model_instance_exists(i_id: str) -> str:
@@ -14,10 +12,13 @@ def model_instance_exists(i_id: str) -> str:
"""
@staticmethod
- def model_instance_item(q_name: str) -> str:
+ def model_instance_item(q_name: str, value: str | None = None) -> str:
"""Model instance contains the question item."""
+ pred = ""
+ if value is not None:
+ pred = f"[. = '{value}']"
return rf"""
- /h:html/h:head/x:model/x:instance/x:test_name/x:{q_name}
+ /h:html/h:head/x:model/x:instance/x:test_name/x:{q_name}{pred}
"""
@staticmethod
@@ -137,13 +138,19 @@ def body_select1_itemset(q_name: str) -> str:
"""
@staticmethod
- def body_group_select1_itemset(g_name: str, q_name: str) -> str:
+ def body_group_select1_itemset(
+ g_name: str, q_name: str, appearance: str | None = None
+ ) -> str:
"""Body has a select1 with an itemset, and no inline items."""
+ appearance_expr = ""
+ if appearance is not None:
+ appearance_expr = f"and @appearance='{appearance}'"
return rf"""
/h:html/h:body/x:group[@ref='/test_name/{g_name}']/x:select1[
@ref = '/test_name/{g_name}/{q_name}'
and ./x:itemset
and not(./x:item)
+ {appearance_expr}
]
"""
diff --git a/tests/xpath_helpers/settings.py b/tests/xpath_helpers/settings.py
index 498fe413e..e42de686c 100644
--- a/tests/xpath_helpers/settings.py
+++ b/tests/xpath_helpers/settings.py
@@ -1,7 +1,5 @@
class XPathHelper:
- """
- XPath expressions for settings assertions.
- """
+ """XPath expressions for settings assertions."""
@staticmethod
def form_title(value: str) -> str: