├── .github
├── CODE_OF_CONDUCT.md
├── ISSUE_TEMPLATE.md
├── ISSUE_TEMPLATE
│ └── config.yml
├── PULL_REQUEST_TEMPLATE.md
└── workflows
│ ├── release.yml
│ └── verify.yml
├── .gitignore
├── LICENSE.md
├── README.md
├── SECURITY.md
├── docs
├── assets
│ ├── extra_css.css
│ ├── favicon.ico
│ └── logo.svg
├── client.md
├── entities.md
├── entity_lists.md
├── examples
│ ├── 2022-10-pyodk-webinar.ipynb
│ ├── README.md
│ ├── app_user_provisioner
│ │ ├── Roboto-Regular.license
│ │ ├── Roboto-Regular.ttf
│ │ ├── app_user_provisioner.py
│ │ └── requirements.txt
│ ├── basic-analysis-pandas.ipynb
│ ├── beyond-library-methods.ipynb
│ ├── create_entities_from_submissions
│ │ ├── create_entities_from_submissions.py
│ │ └── imported_answers.csv
│ ├── create_or_update_form
│ │ └── create_or_update_form.py
│ ├── docs-form-version-xlsx.png
│ ├── fav_color.xlsx
│ ├── mail_merge
│ │ ├── mail_merge.py
│ │ ├── merged
│ │ │ ├── Pedro.docx
│ │ │ └── Sally.docx
│ │ ├── requirements.txt
│ │ └── template.docx
│ ├── requirements.txt
│ ├── simple_repeat.xlsx
│ └── working-with-repeats.ipynb
├── forms.md
├── http-methods.md
├── index.md
├── overrides
│ └── main.html
├── projects.md
└── submissions.md
├── mkdocs.yml
├── pyodk
├── __init__.py
├── __version__.py
├── _endpoints
│ ├── __init__.py
│ ├── auth.py
│ ├── bases.py
│ ├── comments.py
│ ├── entities.py
│ ├── entity_list_properties.py
│ ├── entity_lists.py
│ ├── form_assignments.py
│ ├── form_draft_attachments.py
│ ├── form_drafts.py
│ ├── forms.py
│ ├── project_app_users.py
│ ├── projects.py
│ ├── submission_attachments.py
│ └── submissions.py
├── _utils
│ ├── __init__.py
│ ├── config.py
│ ├── session.py
│ ├── utils.py
│ └── validators.py
├── client.py
└── errors.py
├── pyproject.toml
├── runtime.txt
└── tests
├── __init__.py
├── endpoints
├── __init__.py
├── test_auth.py
├── test_comments.py
├── test_entities.py
├── test_entity_lists.py
├── test_forms.py
├── test_projects.py
└── test_submissions.py
├── resources
├── .pyodk_cache.toml
├── .pyodk_config.toml
├── __init__.py
├── comments_data.py
├── entities_data.py
├── entity_lists_data.py
├── forms
│ ├── fruits.csv
│ └── range_draft.xml
├── forms_data.py
├── projects_data.py
└── submissions_data.py
├── test_client.py
├── test_config.py
├── test_session.py
├── test_validators.py
└── utils
├── __init__.py
├── entity_lists.py
├── forms.py
├── md_table.py
├── submissions.py
└── utils.py
/.github/CODE_OF_CONDUCT.md:
--------------------------------------------------------------------------------
1 | Please refer to the project-wide [ODK Code of Conduct](https://github.com/getodk/governance/blob/master/CODE-OF-CONDUCT.md).
--------------------------------------------------------------------------------
/.github/ISSUE_TEMPLATE.md:
--------------------------------------------------------------------------------
1 |
12 |
13 | #### Software and hardware versions
14 | pyodk v1.x.x, Python v
15 |
16 | #### Problem description
17 |
18 | #### Steps to reproduce the problem
19 |
20 | #### Expected behavior
21 |
22 | #### Other information
23 | Things you tried, stack traces, related issues, suggestions on how to fix it...
--------------------------------------------------------------------------------
/.github/ISSUE_TEMPLATE/config.yml:
--------------------------------------------------------------------------------
1 | blank_issues_enabled: true
2 | contact_links:
3 | - name: "Report an issue"
4 | about: "For when pyodk is behaving in an unexpected way"
5 | url: "https://forum.getodk.org/c/support/6"
6 | - name: "Request a feature"
7 | about: "For when pyodk is missing functionality"
8 | url: "https://forum.getodk.org/c/features/9"
9 | - name: "Everything else"
10 | about: "For everything else"
11 | url: "https://forum.getodk.org/c/support/6"
12 |
--------------------------------------------------------------------------------
/.github/PULL_REQUEST_TEMPLATE.md:
--------------------------------------------------------------------------------
1 | Closes #
2 |
3 |
9 |
10 | #### What has been done to verify that this works as intended?
11 |
12 | #### Why is this the best possible solution? Were any other approaches considered?
13 |
14 | #### How does this change affect users? Describe intentional changes to behavior and behavior that could have accidentally been affected by code changes. In other words, what are the regression risks?
15 |
16 | #### Do we need any specific form for testing your changes? If so, please attach one.
17 |
18 | #### Does this change require updates to documentation? If so, please file an issue [here]( https://github.com/getodk/docs/issues/new) and include the link below.
19 |
20 | #### Before submitting this PR, please make sure you have:
21 | - [ ] included test cases for core behavior and edge cases in `tests`
22 | - [ ] run `python -m unittest` and verified all tests pass
23 | - [ ] run `ruff format pyodk tests` and `ruff check pyodk tests` to lint code
24 | - [ ] verified that any code or assets from external sources are properly credited in comments
25 |
--------------------------------------------------------------------------------
/.github/workflows/release.yml:
--------------------------------------------------------------------------------
1 | name: Release
2 |
3 | on:
4 | release:
5 | types: [published]
6 |
7 | jobs:
8 | release:
9 | runs-on: ${{ matrix.os }}
10 | strategy:
11 | matrix:
12 | python: ['3.12']
13 | os: [ubuntu-latest]
14 | steps:
15 | - uses: actions/checkout@v4
16 | - name: Set up Python
17 | uses: actions/setup-python@v5
18 | with:
19 | python-version: ${{ matrix.python }}
20 |
21 | # Install dependencies.
22 | - uses: actions/cache@v4
23 | name: Python cache with dependencies.
24 | id: python-cache
25 | with:
26 | path: ${{ env.pythonLocation }}
27 | key: ${{ env.pythonLocation }}-${{ matrix.os }}-${{ matrix.python }}-${{ hashFiles('pyproject.toml') }}
28 | - name: Install dependencies.
29 | run: |
30 | python -m pip install --upgrade pip
31 | pip install -e .[docs]
32 | pip list
33 |
34 | # Build and publish.
35 | - name: Publish release to PyPI
36 | if: success()
37 | run: |
38 | pip install flit==3.9.0
39 | flit --debug publish --no-use-vcs
40 | env:
41 | FLIT_USERNAME: __token__
42 | FLIT_PASSWORD: ${{ secrets.PYPI_API_TOKEN }}
43 |
44 | # Publish docs.
45 | - name: Publish docs to gh-pages
46 | run: mkdocs gh-deploy --force
47 | env:
48 | # Silences platformdirs DeprecationWarning for jupyter v6 (not released yet).
49 | JUPYTER_PLATFORM_DIRS: 1
50 |
--------------------------------------------------------------------------------
/.github/workflows/verify.yml:
--------------------------------------------------------------------------------
1 | name: Verify
2 |
3 | on: [push, pull_request]
4 |
5 | jobs:
6 | lint:
7 | runs-on: ${{ matrix.os }}
8 | strategy:
9 | matrix:
10 | python: ['3.12']
11 | os: [ubuntu-latest]
12 | steps:
13 | - uses: actions/checkout@v4
14 | - name: Set up Python
15 | uses: actions/setup-python@v5
16 | with:
17 | python-version: ${{ matrix.python }}
18 |
19 | # Install dependencies.
20 | - uses: actions/cache@v4
21 | name: Python cache with dependencies.
22 | id: python-cache
23 | with:
24 | path: ${{ env.pythonLocation }}
25 | key: ${{ env.pythonLocation }}-${{ matrix.os }}-${{ matrix.python }}-${{ hashFiles('pyproject.toml') }}
26 | - name: Install dependencies.
27 | run: |
28 | python -m pip install --upgrade pip
29 | pip install -e .[dev]
30 | pip list
31 |
32 | # Linter.
33 | - run: ruff check pyodk tests docs --no-fix
34 | - run: ruff format pyodk tests docs --diff
35 |
36 | test:
37 | runs-on: ${{ matrix.os }}
38 | strategy:
39 | # Run all matrix jobs even if one of them fails.
40 | fail-fast: false
41 | matrix:
42 | python: ['3.10', '3.11', '3.12', '3.13']
43 | os: [ubuntu-latest, macos-latest, windows-latest]
44 | pydantic: ['2.11.7']
45 | # Test pydantic at lower boundary of requirement compatibility spec.
46 | include:
47 | - python: '3.12'
48 | os: ubuntu-latest
49 | pydantic: '2.6.4'
50 | steps:
51 | - uses: actions/checkout@v4
52 | - name: Set up Python
53 | uses: actions/setup-python@v5
54 | with:
55 | python-version: ${{ matrix.python }}
56 |
57 | # Install dependencies.
58 | - uses: actions/cache@v4
59 | name: Python cache with dependencies.
60 | id: python-cache
61 | with:
62 | path: ${{ env.pythonLocation }}
63 | key: ${{ env.pythonLocation }}-${{ matrix.os }}-${{ matrix.python }}-${{ hashFiles('pyproject.toml') }}
64 | - name: Install dependencies.
65 | run: |
66 | python -m pip install --upgrade pip
67 | pip install pydantic==${{ matrix.pydantic }}
68 | pip install -e .[dev,docs]
69 | pip list
70 |
71 | # Tests.
72 | - name: Run tests
73 | run: python -m unittest --verbose
74 |
75 | # Build and Upload.
76 | - name: Build sdist and wheel.
77 | if: success()
78 | run: |
79 | pip install flit==3.9.0
80 | flit --debug build --no-use-vcs
81 | - name: Upload sdist and wheel.
82 | if: success()
83 | uses: actions/upload-artifact@v4
84 | with:
85 | name: pyodk--on-${{ matrix.os }}--py${{ matrix.python }}--pydantic${{ matrix.pydantic }}
86 | path: ${{ github.workspace }}/dist/pyodk*
87 |
88 | # Check docs.
89 | - name: Check docs
90 | run: mkdocs build --strict
91 | env:
92 | # Silences platformdirs DeprecationWarning for jupyter v6 (not released yet).
93 | JUPYTER_PLATFORM_DIRS: 1
94 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | # Common editing files.
2 | *.pyc
3 | *.swp
4 | .DS_Store
5 | .idea
6 | *.iml
7 | .vscode
8 | venv/*
9 |
10 | # Folders created by setuptools.
11 | build
12 | dist
13 | pyodk.egg-info
14 | pip-wheel-metadata/*
15 |
16 | # Pypi manifest.
17 | MANIFEST
18 |
19 | # Jupyter
20 | .ipynb_checkpoints
21 | */.ipynb_checkpoints/*
22 |
23 | # Mkdocs
24 | site/*
25 |
26 | # MS Office temp files
27 | ~$*
--------------------------------------------------------------------------------
/LICENSE.md:
--------------------------------------------------------------------------------
1 | Apache License
2 | ==============
3 |
4 | _Version 2.0, January 2004_
5 | _<>_
6 |
7 | ### Terms and Conditions for use, reproduction, and distribution
8 |
9 | #### 1. Definitions
10 |
11 | “License” shall mean the terms and conditions for use, reproduction, and
12 | distribution as defined by Sections 1 through 9 of this document.
13 |
14 | “Licensor” shall mean the copyright owner or entity authorized by the copyright
15 | owner that is granting the License.
16 |
17 | “Legal Entity” shall mean the union of the acting entity and all other entities
18 | that control, are controlled by, or are under common control with that entity.
19 | For the purposes of this definition, “control” means **(i)** the power, direct or
20 | indirect, to cause the direction or management of such entity, whether by
21 | contract or otherwise, or **(ii)** ownership of fifty percent (50%) or more of the
22 | outstanding shares, or **(iii)** beneficial ownership of such entity.
23 |
24 | “You” (or “Your”) shall mean an individual or Legal Entity exercising
25 | permissions granted by this License.
26 |
27 | “Source” form shall mean the preferred form for making modifications, including
28 | but not limited to software source code, documentation source, and configuration
29 | files.
30 |
31 | “Object” form shall mean any form resulting from mechanical transformation or
32 | translation of a Source form, including but not limited to compiled object code,
33 | generated documentation, and conversions to other media types.
34 |
35 | “Work” shall mean the work of authorship, whether in Source or Object form, made
36 | available under the License, as indicated by a copyright notice that is included
37 | in or attached to the work (an example is provided in the Appendix below).
38 |
39 | “Derivative Works” shall mean any work, whether in Source or Object form, that
40 | is based on (or derived from) the Work and for which the editorial revisions,
41 | annotations, elaborations, or other modifications represent, as a whole, an
42 | original work of authorship. For the purposes of this License, Derivative Works
43 | shall not include works that remain separable from, or merely link (or bind by
44 | name) to the interfaces of, the Work and Derivative Works thereof.
45 |
46 | “Contribution” shall mean any work of authorship, including the original version
47 | of the Work and any modifications or additions to that Work or Derivative Works
48 | thereof, that is intentionally submitted to Licensor for inclusion in the Work
49 | by the copyright owner or by an individual or Legal Entity authorized to submit
50 | on behalf of the copyright owner. For the purposes of this definition,
51 | “submitted” means any form of electronic, verbal, or written communication sent
52 | to the Licensor or its representatives, including but not limited to
53 | communication on electronic mailing lists, source code control systems, and
54 | issue tracking systems that are managed by, or on behalf of, the Licensor for
55 | the purpose of discussing and improving the Work, but excluding communication
56 | that is conspicuously marked or otherwise designated in writing by the copyright
57 | owner as “Not a Contribution.”
58 |
59 | “Contributor” shall mean Licensor and any individual or Legal Entity on behalf
60 | of whom a Contribution has been received by Licensor and subsequently
61 | incorporated within the Work.
62 |
63 | #### 2. Grant of Copyright License
64 |
65 | Subject to the terms and conditions of this License, each Contributor hereby
66 | grants to You a perpetual, worldwide, non-exclusive, no-charge, royalty-free,
67 | irrevocable copyright license to reproduce, prepare Derivative Works of,
68 | publicly display, publicly perform, sublicense, and distribute the Work and such
69 | Derivative Works in Source or Object form.
70 |
71 | #### 3. Grant of Patent License
72 |
73 | Subject to the terms and conditions of this License, each Contributor hereby
74 | grants to You a perpetual, worldwide, non-exclusive, no-charge, royalty-free,
75 | irrevocable (except as stated in this section) patent license to make, have
76 | made, use, offer to sell, sell, import, and otherwise transfer the Work, where
77 | such license applies only to those patent claims licensable by such Contributor
78 | that are necessarily infringed by their Contribution(s) alone or by combination
79 | of their Contribution(s) with the Work to which such Contribution(s) was
80 | submitted. If You institute patent litigation against any entity (including a
81 | cross-claim or counterclaim in a lawsuit) alleging that the Work or a
82 | Contribution incorporated within the Work constitutes direct or contributory
83 | patent infringement, then any patent licenses granted to You under this License
84 | for that Work shall terminate as of the date such litigation is filed.
85 |
86 | #### 4. Redistribution
87 |
88 | You may reproduce and distribute copies of the Work or Derivative Works thereof
89 | in any medium, with or without modifications, and in Source or Object form,
90 | provided that You meet the following conditions:
91 |
92 | * **(a)** You must give any other recipients of the Work or Derivative Works a copy of
93 | this License; and
94 | * **(b)** You must cause any modified files to carry prominent notices stating that You
95 | changed the files; and
96 | * **(c)** You must retain, in the Source form of any Derivative Works that You distribute,
97 | all copyright, patent, trademark, and attribution notices from the Source form
98 | of the Work, excluding those notices that do not pertain to any part of the
99 | Derivative Works; and
100 | * **(d)** If the Work includes a “NOTICE” text file as part of its distribution, then any
101 | Derivative Works that You distribute must include a readable copy of the
102 | attribution notices contained within such NOTICE file, excluding those notices
103 | that do not pertain to any part of the Derivative Works, in at least one of the
104 | following places: within a NOTICE text file distributed as part of the
105 | Derivative Works; within the Source form or documentation, if provided along
106 | with the Derivative Works; or, within a display generated by the Derivative
107 | Works, if and wherever such third-party notices normally appear. The contents of
108 | the NOTICE file are for informational purposes only and do not modify the
109 | License. You may add Your own attribution notices within Derivative Works that
110 | You distribute, alongside or as an addendum to the NOTICE text from the Work,
111 | provided that such additional attribution notices cannot be construed as
112 | modifying the License.
113 |
114 | You may add Your own copyright statement to Your modifications and may provide
115 | additional or different license terms and conditions for use, reproduction, or
116 | distribution of Your modifications, or for any such Derivative Works as a whole,
117 | provided Your use, reproduction, and distribution of the Work otherwise complies
118 | with the conditions stated in this License.
119 |
120 | #### 5. Submission of Contributions
121 |
122 | Unless You explicitly state otherwise, any Contribution intentionally submitted
123 | for inclusion in the Work by You to the Licensor shall be under the terms and
124 | conditions of this License, without any additional terms or conditions.
125 | Notwithstanding the above, nothing herein shall supersede or modify the terms of
126 | any separate license agreement you may have executed with Licensor regarding
127 | such Contributions.
128 |
129 | #### 6. Trademarks
130 |
131 | This License does not grant permission to use the trade names, trademarks,
132 | service marks, or product names of the Licensor, except as required for
133 | reasonable and customary use in describing the origin of the Work and
134 | reproducing the content of the NOTICE file.
135 |
136 | #### 7. Disclaimer of Warranty
137 |
138 | Unless required by applicable law or agreed to in writing, Licensor provides the
139 | Work (and each Contributor provides its Contributions) on an “AS IS” BASIS,
140 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied,
141 | including, without limitation, any warranties or conditions of TITLE,
142 | NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A PARTICULAR PURPOSE. You are
143 | solely responsible for determining the appropriateness of using or
144 | redistributing the Work and assume any risks associated with Your exercise of
145 | permissions under this License.
146 |
147 | #### 8. Limitation of Liability
148 |
149 | In no event and under no legal theory, whether in tort (including negligence),
150 | contract, or otherwise, unless required by applicable law (such as deliberate
151 | and grossly negligent acts) or agreed to in writing, shall any Contributor be
152 | liable to You for damages, including any direct, indirect, special, incidental,
153 | or consequential damages of any character arising as a result of this License or
154 | out of the use or inability to use the Work (including but not limited to
155 | damages for loss of goodwill, work stoppage, computer failure or malfunction, or
156 | any and all other commercial damages or losses), even if such Contributor has
157 | been advised of the possibility of such damages.
158 |
159 | #### 9. Accepting Warranty or Additional Liability
160 |
161 | While redistributing the Work or Derivative Works thereof, You may choose to
162 | offer, and charge a fee for, acceptance of support, warranty, indemnity, or
163 | other liability obligations and/or rights consistent with this License. However,
164 | in accepting such obligations, You may act only on Your own behalf and on Your
165 | sole responsibility, not on behalf of any other Contributor, and only if You
166 | agree to indemnify, defend, and hold each Contributor harmless for any liability
167 | incurred by, or claims asserted against, such Contributor by reason of your
168 | accepting any such warranty or additional liability.
169 |
170 | _END OF TERMS AND CONDITIONS_
171 |
172 | ### APPENDIX: How to apply the Apache License to your work
173 |
174 | To apply the Apache License to your work, attach the following boilerplate
175 | notice, with the fields enclosed by brackets `[]` replaced with your own
176 | identifying information. (Don't include the brackets!) The text should be
177 | enclosed in the appropriate comment syntax for the file format. We also
178 | recommend that a file or class name and description of purpose be included on
179 | the same “printed page” as the copyright notice for easier identification within
180 | third-party archives.
181 |
182 | Copyright [yyyy] [name of copyright owner]
183 |
184 | Licensed under the Apache License, Version 2.0 (the "License");
185 | you may not use this file except in compliance with the License.
186 | You may obtain a copy of the License at
187 |
188 | http://www.apache.org/licenses/LICENSE-2.0
189 |
190 | Unless required by applicable law or agreed to in writing, software
191 | distributed under the License is distributed on an "AS IS" BASIS,
192 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
193 | See the License for the specific language governing permissions and
194 | limitations under the License.
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # pyODK
2 | [](https://pypi.python.org/pypi/pyodk)
3 |
4 | An API client for the [ODK Central API](https://docs.getodk.org/central-api). Use it to interact with your data and automate common tasks from Python.
5 |
6 | This library aims to make common data analysis and workflow automation tasks as simple as possible by providing clear method names, types, and examples. It also provides convenient access to the full API using [HTTP verb methods](https://getodk.github.io/pyodk/http-methods/).
7 |
8 | ## Install
9 |
10 | The currently supported Python versions for `pyodk` 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`](https://github.com/pyenv/pyenv) to manage multiple versions of Python.
11 |
12 | The currently supported Central version is v2025.1.4. Newer or older Central versions will likely work too, but convenience (non-HTTP) methods assume this version. If you see a 404 error or another server error, please verify the version of your Central server.
13 |
14 | ### From pip
15 |
16 | ```bash
17 | pip install pyodk
18 | ```
19 |
20 | ### From source
21 |
22 | ```bash
23 | # Get a copy of the repository.
24 | mkdir -P ~/repos/pyodk
25 | cd ~/repos/pyodk
26 | git clone https://github.com/getodk/pyodk.git repo
27 |
28 | # Create and activate a virtual environment for the install.
29 | python -m venv venv
30 | source venv/bin/activate
31 |
32 | # Install pyodk and its production dependencies.
33 | cd ~/repos/pyodk/repo
34 | pip install -e .
35 |
36 | # Leave the virtualenv.
37 | deactivate
38 | ```
39 |
40 | ## Configure
41 |
42 | The configuration file uses the TOML format. The default file name is `.pyodk_config.toml`, and the default location is the user home directory. The file name and location can be customised by setting the environment variable `PYODK_CONFIG_FILE` to some other file path, or by passing the path at init with `Client(config_path="my_config.toml")`. The expected file structure is as follows:
43 |
44 | ```toml
45 | [central]
46 | base_url = "https://www.example.com"
47 | username = "my_user"
48 | password = "my_password"
49 | default_project_id = 123
50 | ```
51 |
52 | ### Custom configuration file paths
53 |
54 | The `Client` is specific to a configuration and cache file. These approximately correspond to the session which the `Client` represents; it also encourages segregating credentials. These paths can be set by:
55 |
56 | - Setting environment variables `PYODK_CONFIG_FILE` and `PYODK_CACHE_FILE`
57 | - Init arguments: `Client(config_path="my_config.toml", cache_path="my_cache.toml")`.
58 |
59 | ### Default project
60 |
61 | The `Client` is not specific to a project, but a default `project_id` can be set by:
62 |
63 | - A `default_project_id` in the configuration file.
64 | - An init argument: `Client(project_id=1)`.
65 | - A property on the client: `client.project_id = 1`.
66 |
67 | *Default Identifiers*
68 |
69 | For each endpoint, a default can be set for key identifiers, so these identifiers are optional in most methods. When the identifier is required, validation ensures that either a default value is set, or a value is specified. E.g.
70 |
71 | ```python
72 | client.projects.default_project_id = 1
73 | client.forms.default_form_id = "my_form"
74 | client.submissions.default_form_id = "my_form"
75 | client.entities.default_entity_list_name = "my_list"
76 | client.entities.default_project_id = 1
77 | ```
78 |
79 | ### Session cache file
80 |
81 | The session cache file uses the TOML format. The default file name is `.pyodk_cache.toml`, and the default location is the user home directory. The file name and location can be customised by setting the environment variable `PYODK_CACHE_FILE` to some other file path, or by passing the path at init with `Client(config_path="my_cache.toml")`. This file should not be pre-created as it is used to store a session token after login.
82 |
83 | ## Use
84 |
85 | To get started with `pyODK`, build a `Client`:
86 |
87 | ```python
88 | from pyodk.client import Client
89 |
90 | client = Client()
91 | ```
92 |
93 | Authentication is triggered by the first API call on the `Client`, or by explicitly using `Client.open()`.
94 |
95 | Use `Client.close()` to clean up a client session. Clean up is recommended for long-running scripts, e.g. web apps, etc.
96 |
97 | You can also use the Client as a context manager to manage authentication and clean up:
98 |
99 | ```python
100 | with Client() as client:
101 | print(client.projects.list())
102 | ```
103 |
104 | Learn more [in the documentation](https://getodk.github.io/pyodk/).
105 |
106 | ### Examples
107 |
108 | **👉 See detailed tutorials in [the documentation](https://getodk.github.io/pyodk/examples/).**
109 |
110 | ```python
111 | from pyodk.client import Client
112 |
113 | client = Client()
114 | projects = client.projects.list()
115 | forms = client.forms.list()
116 | submissions = client.submissions.list(form_id=next(forms).xmlFormId)
117 | form_data = client.submissions.get_table(form_id="birds", project_id=8)
118 | comments = client.submissions.list_comments(form_id=next(forms).xmlFormId, instance_id="uuid:...")
119 | client.forms.update(
120 | form_id="my_xlsform",
121 | definition="my_xlsform.xlsx",
122 | attachments=["fruits.csv", "vegetables.png"],
123 | )
124 | client.close()
125 | ```
126 |
127 | ### Session customization
128 | If Session behaviour needs to be customised, for example to set alternative timeouts or retry strategies, etc., then subclass the `pyodk.session.Session` and provide an instance to the `Client` constructor, e.g. `Client(session=my_session)`.
129 |
130 |
131 | ### Logging
132 | Errors raised by pyODK and other messages are logged using the `logging` standard library. The logger is in the `pyodk` namespace / hierarchy (e.g `pyodk.config`, `pyodk.endpoints.auth`, etc.). The logs can be manipulated from your script / app as follows.
133 |
134 | ```python
135 | import logging
136 |
137 | # Initialise an example basic logging config (writes to stdout/stderr).
138 | logging.basicConfig()
139 | logging.getLogger().setLevel(logging.DEBUG)
140 |
141 | # Get a reference to the pyodk logger.
142 | pyodk_log = logging.getLogger("pyodk")
143 |
144 | # Receive everything DEBUG level and higher.
145 | pyodk_log.setLevel(logging.DEBUG)
146 | pyodk_log.propagate = True
147 |
148 | # Ignore everything below FATAL level.
149 | pyodk_log.setLevel(logging.FATAL)
150 | pyodk_log.propagate = False
151 | ```
152 |
153 | ### Errors raised by pyODK
154 | Error types raised by pyODK are found in `errors.py`, which currently is only the `PyODKError`. In general this error is raised when:
155 |
156 | - The pyODK configuration is invalid (missing file, missing fields, etc).
157 | - The client method arguments are invalid (missing, wrong type, etc.).
158 | - The response from ODK Central indicated and error (HTTP >=400, <600).
159 | - The data returned from ODK Central does not have the expected fields or types.
160 |
161 | Note that pyODK does not attempt to wrap every possible error condition, so if needed, broader exception handling should be included in your script / app.
162 |
163 | ## Design considerations
164 | Our goal with pyODK is to support the most common Central API functionality in an easy-to-use, high-level way. Because we expose [HTTP verb methods](https://getodk.github.io/pyodk/http-methods/), we don't feel the need to add explicit support for the whole Central API.
165 |
166 | Here are some points to think about when considering adding new methods to pyODK:
167 |
168 | * Is it common enough to warrant a designed method or can we show examples using the HTTP methods if needed?
169 | * For example, we currently consider manipulating form drafts directly out of scope but `client.forms.update` implicitly creates and publishes a draft
170 | * Do people take this action independently or is it always part of reaching some broader goal?
171 | * What pyODK class does it best fit in?
172 | * For example, form versions are subresources on Central backend but in this library, methods that deal with form versions can be in the `forms` class directly since we’re not going to expose many of them
173 | * How do people talk about the action that’s being performed? How do ODK docs and Central frontend talk about it?
174 | * For example, documentation has the concept of "submission edits" so use `client.submissions.edit` rather than update
175 | * Value expressiveness and consistency with ODK concepts over pyODK internal consistency
176 | * What actually needs to be commonly configured? Starting by exposing a subset of parameters and naming/typing them carefully is ideal.
177 |
178 | ## Contribute
179 |
180 | See issues for additions to `pyodk` that are under consideration. Please file new issues for any functionality you are missing.
181 |
182 | ## Develop
183 |
184 | Install the source files as described above, then:
185 |
186 | ```bash
187 | pip install -e .[dev]
188 | ```
189 |
190 | You can run tests with:
191 |
192 | ```bash
193 | python -m unittest
194 | ```
195 |
196 | ### Testing
197 |
198 | When adding or updating pyODK functionality, at a minimum add or update corresponding unit tests. The unit tests are filed in `tests/endpoints` or `tests`. These tests focus on pyODK functionality, such as ensuring that data de/serialisation works as expected, and that method logic results in the expected call patterns. The unit tests use mocks and static data, which are stored in `tests/resources`. These data are obtained by making an API call and saving the Python dict returned by `response.json()` as text.
199 |
200 | For interactive testing, debugging, or sanity checking workflows, end-to-end tests are stored in `tests/test_client.py`. These tests are not run by default because they require access to a live Central server. The ODK team use the Central staging instance https://staging.getodk.cloud/ which is already configured for testing. Below are the steps to set up a new project in Central to be able to run these tests.
201 |
202 | 1. Create a test project in Central.
203 | 2. Create a test user in Central. It can be a site-wide Administrator. If it is not an Administrator, assign the user to the project with "Project Manager" privileges, so that forms and submissions in the test project can be uploaded and modified.
204 | 3. Save the user's credentials and the project ID in a `.pyodk_config.toml` (or equivalent) as described in the above section titled "Configure".
205 | 4. When the tests in `test_client.py` are run, the test setup method should automatically create a few fixtures for testing with. At a minimum these allow the tests to pass, but can also be used to interactively test or debug.
206 |
207 |
208 | ## Release
209 |
210 | 1. Run all linting and tests.
211 | 1. Draft a new GitHub release with the list of merged PRs.
212 | 1. Check out a release branch from latest upstream master.
213 | 1. Update `pyproject.toml` and `pyodk/__version__.py` with the new release version number.
214 | 1. Update the Central version in the README to reflect the version we test against.
215 | 1. Commit, push the branch, and initiate a pull request. Wait for tests to pass, then merge the PR.
216 | 1. Tag the release and it will automatically be published (see `release.yml` actions file).
217 |
--------------------------------------------------------------------------------
/SECURITY.md:
--------------------------------------------------------------------------------
1 | # Security Policy
2 |
3 | ## Reporting a Vulnerability
4 |
5 | See our [Vulnerability Disclosure Policy](https://getodk.org/legal/vulnerability.html).
6 |
--------------------------------------------------------------------------------
/docs/assets/extra_css.css:
--------------------------------------------------------------------------------
1 | @font-face {
2 | font-family:-apple-system, BlinkMacSystemFont, 'Segoe UI', Helvetica, Arial, sans-serif, 'Apple Color Emoji', 'Segoe UI Emoji', 'Segoe UI Symbol';
3 | }
4 |
5 | :root {
6 | --md-text-font: "-apple-system, BlinkMacSystemFont, 'Segoe UI', Helvetica, Arial, sans-serif, 'Apple Color Emoji', 'Segoe UI Emoji', 'Segoe UI Symbol'";
7 | --md-accent-fg-color:#009ecc;
8 | }
9 |
10 | .md-content,.md-sidebar,.md-header {
11 | --md-typeset-a-color:#009ecc;
12 | }
--------------------------------------------------------------------------------
/docs/assets/favicon.ico:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/getodk/pyodk/9ba3b59770df034b01b5b0cd10db82ea4fe3b223/docs/assets/favicon.ico
--------------------------------------------------------------------------------
/docs/assets/logo.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/docs/client.md:
--------------------------------------------------------------------------------
1 | # Client
2 |
3 | ::: pyodk.client.Client
--------------------------------------------------------------------------------
/docs/entities.md:
--------------------------------------------------------------------------------
1 | # Entities
2 |
3 | ::: pyodk._endpoints.entities.EntityService
--------------------------------------------------------------------------------
/docs/entity_lists.md:
--------------------------------------------------------------------------------
1 | # Entity Lists
2 |
3 | ::: pyodk._endpoints.entity_lists.EntityListService
--------------------------------------------------------------------------------
/docs/examples/README.md:
--------------------------------------------------------------------------------
1 | # Examples
2 |
3 | Examples of solutions to different types of problems.
4 |
5 | Please help us expand this resource! Contribute an example by [opening a pull request](https://github.com/getodk/pyodk/pulls). You can also share what you've done or what you'd like to see in [an issue](https://github.com/getodk/pyodk/issues).
6 |
7 | ## [Using `pyodk` and `pandas` for basic analysis](basic-analysis-pandas.ipynb)
8 |
9 | A standalone Jupyter notebook intended to introduce `pyodk` and show how it can be used with `pandas` and `geopandas` to perform basic analysis. Shows building a client and fetching data for a form. You can try this on your server using [this form definition](fav_color.xlsx).
10 |
11 | ## [Going beyond `pyodk`'s library methods](beyond-library-methods.ipynb)
12 |
13 | A standalone Jupyter notebook that shows how the raw HTTP method access and the [API docs](https://docs.getodk.org/central-api) can be used together to make use of the full ODK Central API.
14 |
15 | ## [Working with repeats](working-with-repeats.ipynb)
16 |
17 | A Jupyter notebook demonstrating some options for working with repeats.
18 |
19 | ## [App User provisioning script](app_user_provisioner/app_user_provisioner.py)
20 |
21 | A script that reads names from a CSV and creates an App User for each one that isn't currently used by an active App User on the server. Also creates customized QR codes for each new App User.
22 |
23 | ## [Create Entities from CSV data](create_entities_from_submissions/create_entities_from_submissions.py)
24 |
25 | A script that reads data from a CSV and creates Entities.
26 |
27 | ## [Mail merge script](mail_merge/mail_merge.py)
28 |
29 | A script that uses mail merge to create personalized Word documents with data from Central.
30 |
31 | ## [October 2022 webinar materials](2022-10-pyodk-webinar.ipynb)
32 |
33 | A Jupyter notebook companion to an October 2022 webinar by Hélène Martin introducing `pyodk`. Includes link to the session recording.
34 |
35 | ## [Create or Update Form script](create_or_update_form/create_or_update_form.py)
36 |
37 | A script to create or update a form, optionally with attachments.
38 |
--------------------------------------------------------------------------------
/docs/examples/app_user_provisioner/Roboto-Regular.license:
--------------------------------------------------------------------------------
1 | Apache License
2 | Version 2.0, January 2004
3 | http://www.apache.org/licenses/
4 |
5 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
6 |
7 | 1. Definitions.
8 |
9 | "License" shall mean the terms and conditions for use, reproduction,
10 | and distribution as defined by Sections 1 through 9 of this document.
11 |
12 | "Licensor" shall mean the copyright owner or entity authorized by
13 | the copyright owner that is granting the License.
14 |
15 | "Legal Entity" shall mean the union of the acting entity and all
16 | other entities that control, are controlled by, or are under common
17 | control with that entity. For the purposes of this definition,
18 | "control" means (i) the power, direct or indirect, to cause the
19 | direction or management of such entity, whether by contract or
20 | otherwise, or (ii) ownership of fifty percent (50%) or more of the
21 | outstanding shares, or (iii) beneficial ownership of such entity.
22 |
23 | "You" (or "Your") shall mean an individual or Legal Entity
24 | exercising permissions granted by this License.
25 |
26 | "Source" form shall mean the preferred form for making modifications,
27 | including but not limited to software source code, documentation
28 | source, and configuration files.
29 |
30 | "Object" form shall mean any form resulting from mechanical
31 | transformation or translation of a Source form, including but
32 | not limited to compiled object code, generated documentation,
33 | and conversions to other media types.
34 |
35 | "Work" shall mean the work of authorship, whether in Source or
36 | Object form, made available under the License, as indicated by a
37 | copyright notice that is included in or attached to the work
38 | (an example is provided in the Appendix below).
39 |
40 | "Derivative Works" shall mean any work, whether in Source or Object
41 | form, that is based on (or derived from) the Work and for which the
42 | editorial revisions, annotations, elaborations, or other modifications
43 | represent, as a whole, an original work of authorship. For the purposes
44 | of this License, Derivative Works shall not include works that remain
45 | separable from, or merely link (or bind by name) to the interfaces of,
46 | the Work and Derivative Works thereof.
47 |
48 | "Contribution" shall mean any work of authorship, including
49 | the original version of the Work and any modifications or additions
50 | to that Work or Derivative Works thereof, that is intentionally
51 | submitted to Licensor for inclusion in the Work by the copyright owner
52 | or by an individual or Legal Entity authorized to submit on behalf of
53 | the copyright owner. For the purposes of this definition, "submitted"
54 | means any form of electronic, verbal, or written communication sent
55 | to the Licensor or its representatives, including but not limited to
56 | communication on electronic mailing lists, source code control systems,
57 | and issue tracking systems that are managed by, or on behalf of, the
58 | Licensor for the purpose of discussing and improving the Work, but
59 | excluding communication that is conspicuously marked or otherwise
60 | designated in writing by the copyright owner as "Not a Contribution."
61 |
62 | "Contributor" shall mean Licensor and any individual or Legal Entity
63 | on behalf of whom a Contribution has been received by Licensor and
64 | subsequently incorporated within the Work.
65 |
66 | 2. Grant of Copyright License. Subject to the terms and conditions of
67 | this License, each Contributor hereby grants to You a perpetual,
68 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable
69 | copyright license to reproduce, prepare Derivative Works of,
70 | publicly display, publicly perform, sublicense, and distribute the
71 | Work and such Derivative Works in Source or Object form.
72 |
73 | 3. Grant of Patent License. Subject to the terms and conditions of
74 | this License, each Contributor hereby grants to You a perpetual,
75 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable
76 | (except as stated in this section) patent license to make, have made,
77 | use, offer to sell, sell, import, and otherwise transfer the Work,
78 | where such license applies only to those patent claims licensable
79 | by such Contributor that are necessarily infringed by their
80 | Contribution(s) alone or by combination of their Contribution(s)
81 | with the Work to which such Contribution(s) was submitted. If You
82 | institute patent litigation against any entity (including a
83 | cross-claim or counterclaim in a lawsuit) alleging that the Work
84 | or a Contribution incorporated within the Work constitutes direct
85 | or contributory patent infringement, then any patent licenses
86 | granted to You under this License for that Work shall terminate
87 | as of the date such litigation is filed.
88 |
89 | 4. Redistribution. You may reproduce and distribute copies of the
90 | Work or Derivative Works thereof in any medium, with or without
91 | modifications, and in Source or Object form, provided that You
92 | meet the following conditions:
93 |
94 | (a) You must give any other recipients of the Work or
95 | Derivative Works a copy of this License; and
96 |
97 | (b) You must cause any modified files to carry prominent notices
98 | stating that You changed the files; and
99 |
100 | (c) You must retain, in the Source form of any Derivative Works
101 | that You distribute, all copyright, patent, trademark, and
102 | attribution notices from the Source form of the Work,
103 | excluding those notices that do not pertain to any part of
104 | the Derivative Works; and
105 |
106 | (d) If the Work includes a "NOTICE" text file as part of its
107 | distribution, then any Derivative Works that You distribute must
108 | include a readable copy of the attribution notices contained
109 | within such NOTICE file, excluding those notices that do not
110 | pertain to any part of the Derivative Works, in at least one
111 | of the following places: within a NOTICE text file distributed
112 | as part of the Derivative Works; within the Source form or
113 | documentation, if provided along with the Derivative Works; or,
114 | within a display generated by the Derivative Works, if and
115 | wherever such third-party notices normally appear. The contents
116 | of the NOTICE file are for informational purposes only and
117 | do not modify the License. You may add Your own attribution
118 | notices within Derivative Works that You distribute, alongside
119 | or as an addendum to the NOTICE text from the Work, provided
120 | that such additional attribution notices cannot be construed
121 | as modifying the License.
122 |
123 | You may add Your own copyright statement to Your modifications and
124 | may provide additional or different license terms and conditions
125 | for use, reproduction, or distribution of Your modifications, or
126 | for any such Derivative Works as a whole, provided Your use,
127 | reproduction, and distribution of the Work otherwise complies with
128 | the conditions stated in this License.
129 |
130 | 5. Submission of Contributions. Unless You explicitly state otherwise,
131 | any Contribution intentionally submitted for inclusion in the Work
132 | by You to the Licensor shall be under the terms and conditions of
133 | this License, without any additional terms or conditions.
134 | Notwithstanding the above, nothing herein shall supersede or modify
135 | the terms of any separate license agreement you may have executed
136 | with Licensor regarding such Contributions.
137 |
138 | 6. Trademarks. This License does not grant permission to use the trade
139 | names, trademarks, service marks, or product names of the Licensor,
140 | except as required for reasonable and customary use in describing the
141 | origin of the Work and reproducing the content of the NOTICE file.
142 |
143 | 7. Disclaimer of Warranty. Unless required by applicable law or
144 | agreed to in writing, Licensor provides the Work (and each
145 | Contributor provides its Contributions) on an "AS IS" BASIS,
146 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
147 | implied, including, without limitation, any warranties or conditions
148 | of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
149 | PARTICULAR PURPOSE. You are solely responsible for determining the
150 | appropriateness of using or redistributing the Work and assume any
151 | risks associated with Your exercise of permissions under this License.
152 |
153 | 8. Limitation of Liability. In no event and under no legal theory,
154 | whether in tort (including negligence), contract, or otherwise,
155 | unless required by applicable law (such as deliberate and grossly
156 | negligent acts) or agreed to in writing, shall any Contributor be
157 | liable to You for damages, including any direct, indirect, special,
158 | incidental, or consequential damages of any character arising as a
159 | result of this License or out of the use or inability to use the
160 | Work (including but not limited to damages for loss of goodwill,
161 | work stoppage, computer failure or malfunction, or any and all
162 | other commercial damages or losses), even if such Contributor
163 | has been advised of the possibility of such damages.
164 |
165 | 9. Accepting Warranty or Additional Liability. While redistributing
166 | the Work or Derivative Works thereof, You may choose to offer,
167 | and charge a fee for, acceptance of support, warranty, indemnity,
168 | or other liability obligations and/or rights consistent with this
169 | License. However, in accepting such obligations, You may act only
170 | on Your own behalf and on Your sole responsibility, not on behalf
171 | of any other Contributor, and only if You agree to indemnify,
172 | defend, and hold each Contributor harmless for any liability
173 | incurred by, or claims asserted against, such Contributor by reason
174 | of your accepting any such warranty or additional liability.
175 |
176 | END OF TERMS AND CONDITIONS
177 |
178 | APPENDIX: How to apply the Apache License to your work.
179 |
180 | To apply the Apache License to your work, attach the following
181 | boilerplate notice, with the fields enclosed by brackets "[]"
182 | replaced with your own identifying information. (Don't include
183 | the brackets!) The text should be enclosed in the appropriate
184 | comment syntax for the file format. We also recommend that a
185 | file or class name and description of purpose be included on the
186 | same "printed page" as the copyright notice for easier
187 | identification within third-party archives.
188 |
189 | Copyright [yyyy] [name of copyright owner]
190 |
191 | Licensed under the Apache License, Version 2.0 (the "License");
192 | you may not use this file except in compliance with the License.
193 | You may obtain a copy of the License at
194 |
195 | http://www.apache.org/licenses/LICENSE-2.0
196 |
197 | Unless required by applicable law or agreed to in writing, software
198 | distributed under the License is distributed on an "AS IS" BASIS,
199 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
200 | See the License for the specific language governing permissions and
201 | limitations under the License.
202 |
--------------------------------------------------------------------------------
/docs/examples/app_user_provisioner/Roboto-Regular.ttf:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/getodk/pyodk/9ba3b59770df034b01b5b0cd10db82ea4fe3b223/docs/examples/app_user_provisioner/Roboto-Regular.ttf
--------------------------------------------------------------------------------
/docs/examples/app_user_provisioner/app_user_provisioner.py:
--------------------------------------------------------------------------------
1 | """
2 | App User Provisioner
3 |
4 | Put a series of user names (one on each line) in a file named `users.csv` in the same
5 | directory as this script. The script will create App Users for each user, using the
6 | project, forms, and other configurations set below. The outputs are one PNG for each
7 | provisioned App User, and a `users.pdf` file with all the App User PNGs in the folder.
8 |
9 | Install requirements for this script in `requirements.txt`. The specified versions are
10 | those that were current when the script was last updated, though it should work with
11 | more recent versions. Install these with `pip install -r requirements.txt`.
12 |
13 | To run the script, use `python app_user_provisioner.py`.
14 | """
15 |
16 | import base64
17 | import glob
18 | import json
19 | import zlib
20 | from typing import Any
21 |
22 | import segno
23 | from PIL import Image, ImageDraw, ImageFont, ImageOps
24 | from pyodk.client import Client
25 |
26 | # Customise these settings to your environment.
27 | PROJECT_ID = 149
28 | PROJECT_NAME = "My Cool Project"
29 | FORMS_TO_ACCESS = ["all-widgets", "afp-knowledge"]
30 | ADMIN_PASSWORD = "s00p3rs3cr3t" # noqa: S105
31 |
32 |
33 | def get_settings(server_url: str, project_name: str, username: str) -> dict[str, Any]:
34 | """Template for the settings to encode in the QR image. Customise as needed."""
35 | return {
36 | "general": {
37 | "form_update_mode": "match_exactly",
38 | "autosend": "wifi_and_cellular",
39 | "delete_send": True,
40 | "server_url": server_url,
41 | "username": username,
42 | },
43 | "admin": {
44 | "admin_pw": ADMIN_PASSWORD,
45 | "change_server": False,
46 | "automatic_update": False,
47 | "change_autosend": False,
48 | },
49 | "project": {"name": project_name, "color": "#ffeb3b", "icon": "💥"},
50 | }
51 |
52 |
53 | # Check that the Roboto font used for the QR images is available (e.g. on Linux / Win).
54 | try:
55 | ImageFont.truetype("Roboto-Regular.ttf", 24)
56 | except OSError:
57 | print(
58 | "Font file 'Roboto-Regular.ttf' not found. This can be downloaded "
59 | "from Google, or copied from the Examples directory. "
60 | "Source: https://fonts.google.com/specimen/Roboto/about"
61 | )
62 |
63 |
64 | # Provision the App Users.
65 | with open("users.csv", newline="") as f:
66 | desired_users = f.readlines()
67 | desired_users = [user.rstrip() for user in desired_users]
68 |
69 | client = Client()
70 | provisioned_users = client.projects.create_app_users(
71 | display_names=desired_users, forms=FORMS_TO_ACCESS, project_id=PROJECT_ID
72 | )
73 |
74 | # Generate the QR codes.
75 | for user in provisioned_users:
76 | collect_settings = get_settings(
77 | server_url=f"{client.session.base_url}key/{user.token}/projects/{PROJECT_ID}",
78 | project_name=f"{PROJECT_NAME}: {user.displayName}",
79 | username=user.displayName,
80 | )
81 | qr_data = base64.b64encode(
82 | zlib.compress(json.dumps(collect_settings).encode("utf-8"))
83 | )
84 |
85 | code = segno.make(qr_data, micro=False)
86 | code.save("settings.png", scale=4)
87 |
88 | png = Image.open("settings.png")
89 | png = png.convert("RGB")
90 | text_anchor = png.height
91 | png = ImageOps.expand(png, border=(0, 0, 0, 30), fill=(255, 255, 255))
92 | draw = ImageDraw.Draw(png)
93 | font = ImageFont.truetype("Roboto-Regular.ttf", 24)
94 | draw.text((20, text_anchor - 10), user.displayName, font=font, fill=(0, 0, 0))
95 | png.save(f"settings-{user.displayName}.png", format="PNG")
96 |
97 | # Concatenate the user images into a PDF.
98 | images = [Image.open(f) for f in sorted(glob.glob("./settings-*.png"))]
99 | if 0 < len(images):
100 | img = iter(images)
101 | next(img).save(
102 | "users.pdf", format="PDF", resolution=100, save_all=True, append_images=img
103 | )
104 |
--------------------------------------------------------------------------------
/docs/examples/app_user_provisioner/requirements.txt:
--------------------------------------------------------------------------------
1 | segno==1.6.1
2 | Pillow==10.3.0
3 |
--------------------------------------------------------------------------------
/docs/examples/create_entities_from_submissions/create_entities_from_submissions.py:
--------------------------------------------------------------------------------
1 | """
2 | A script that uses CSV data to create an entity list and populate it with entities.
3 | """
4 |
5 | from csv import DictReader
6 | from pathlib import Path
7 | from uuid import uuid4
8 |
9 | from pyodk import Client
10 |
11 | project_id = 1
12 | entity_label_field = "first_name"
13 | entity_properties = ("age", "location")
14 | csv_path = Path("./imported_answers.csv")
15 |
16 |
17 | def create_one_at_a_time():
18 | with Client(project_id=project_id) as client, open(csv_path) as csv_file:
19 | # Create the entity list.
20 | entity_list = client.entity_lists.create(
21 | entity_list_name=f"previous_survey_{uuid4()}"
22 | )
23 | for prop in entity_properties:
24 | client.entity_lists.add_property(name=prop, entity_list_name=entity_list.name)
25 |
26 | # Create the entities from the CSV data.
27 | for row in DictReader(csv_file):
28 | client.entities.create(
29 | label=row[entity_label_field],
30 | data={k: str(v) for k, v in row.items() if k in entity_properties},
31 | entity_list_name=entity_list.name,
32 | )
33 |
34 |
35 | def create_with_merge():
36 | with Client(project_id=project_id) as client, open(csv_path) as csv_file:
37 | client.entity_lists.default_entity_list_name = f"previous_survey_{uuid4()}"
38 | entity_list = client.entity_lists.create()
39 | client.entities.merge(
40 | data=DictReader(csv_file),
41 | entity_list_name=entity_list.name,
42 | source_label_key=entity_label_field,
43 | source_keys=(entity_label_field, *entity_properties),
44 | )
45 |
46 |
47 | if __name__ == "__main__":
48 | # create_one_at_a_time()
49 | create_with_merge()
50 |
--------------------------------------------------------------------------------
/docs/examples/create_entities_from_submissions/imported_answers.csv:
--------------------------------------------------------------------------------
1 | first_name,age,favorite_color,favorite_color_other,location
2 | John,30,r,,37.7749 -122.4194 0 10
3 | Alice,25,y,,-33.8651 151.2099 0 5
4 | Bob,35,o,orange,51.5074 -0.1278 0 15
--------------------------------------------------------------------------------
/docs/examples/create_or_update_form/create_or_update_form.py:
--------------------------------------------------------------------------------
1 | """
2 | A script to create or update a form, optionally with attachments.
3 |
4 | Either use as a CLI script, or import create_or_update into another module.
5 |
6 | If provided, all files in the [attachments_dir] path will be uploaded with the form.
7 | """
8 |
9 | import sys
10 | from os import PathLike
11 | from pathlib import Path
12 |
13 | from pyodk.client import Client
14 | from pyodk.errors import PyODKError
15 |
16 |
17 | def create_ignore_duplicate_error(client: Client, definition: PathLike | str | bytes):
18 | """Create the form; ignore the error raised if it exists (409.3)."""
19 | try:
20 | client.forms.create(definition=definition)
21 | except PyODKError as err:
22 | if not err.is_central_error(code=409.3):
23 | raise
24 |
25 |
26 | def create_or_update(form_id: str, definition: str, attachments: str | None):
27 | """Create (and publish) the form, optionally with attachments."""
28 | with Client() as client:
29 | create_ignore_duplicate_error(client=client, definition=definition)
30 | attach = None
31 | if attachments is not None:
32 | attach = Path(attachments).iterdir()
33 | client.forms.update(
34 | definition=definition,
35 | form_id=form_id,
36 | attachments=attach,
37 | )
38 |
39 |
40 | if __name__ == "__main__":
41 | usage = """
42 | Usage:
43 |
44 | python create_or_update_form.py form_id definition.xlsx
45 | python create_or_update_form.py form_id definition.xlsx attachments_dir
46 | """
47 | if len(sys.argv) < 3:
48 | print(usage)
49 | sys.exit(1)
50 | fid = sys.argv[1]
51 | def_path = sys.argv[2]
52 | attach_path = None
53 | if len(sys.argv) == 4:
54 | attach_path = sys.argv[3]
55 | create_or_update(form_id=fid, definition=def_path, attachments=attach_path)
56 |
--------------------------------------------------------------------------------
/docs/examples/docs-form-version-xlsx.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/getodk/pyodk/9ba3b59770df034b01b5b0cd10db82ea4fe3b223/docs/examples/docs-form-version-xlsx.png
--------------------------------------------------------------------------------
/docs/examples/fav_color.xlsx:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/getodk/pyodk/9ba3b59770df034b01b5b0cd10db82ea4fe3b223/docs/examples/fav_color.xlsx
--------------------------------------------------------------------------------
/docs/examples/mail_merge/mail_merge.py:
--------------------------------------------------------------------------------
1 | """
2 | Mail Merge
3 |
4 | This script will use mail merge to create personalized Word documents with
5 | data from Central. In this example, only data from approved submissions
6 | are included.
7 |
8 | For a tutorial on how to populate Word templates with Python, see:
9 | https://pbpython.com/python-word-template.html
10 |
11 | For more examples, see:
12 | https://github.com/iulica/docx-mailmerge/tree/master/tests
13 |
14 | Install requirements for this script in `requirements.txt`. The specified
15 | versions are those that were current when the script was last updated,
16 | though it should work with more recent versions.
17 | Install with `pip install -r requirements.txt`.
18 |
19 | To run the script, use `python mail_merge.py`.
20 | """
21 |
22 | import os
23 | from datetime import datetime
24 |
25 | from mailmerge import MailMerge
26 | from pyodk.client import Client
27 |
28 | # customize these settings to your environment
29 | PROJECT_ID = 1
30 | FORM_ID = "my_form"
31 | TEMPLATE_DOCUMENT = "template.docx"
32 | OUTPUT_FOLDER = "merged"
33 |
34 | with Client(project_id=PROJECT_ID) as client:
35 | submissions = client.submissions.get_table(form_id=FORM_ID)
36 | for submission in submissions["value"]:
37 | # only include approved submissions
38 | if submission["__system"]["reviewState"] == "approved":
39 | with MailMerge(TEMPLATE_DOCUMENT) as document:
40 | coordinates = submission["age_location"]["location"]["coordinates"]
41 | location = f"{coordinates[1]}, {coordinates[0]}"
42 | generation_date = datetime.now().strftime("%m-%d-%Y %H:%M:%S.%f")
43 | document.merge(
44 | first_name=submission["first_name"],
45 | age=submission["age_location"]["age"],
46 | location=location,
47 | instance_id=submission["meta"]["instanceID"],
48 | submission_date=submission["__system"]["submissionDate"],
49 | generation_date=generation_date,
50 | )
51 | # warning: choose variables with unique values to prevent overwritten files
52 | output_path = os.path.join(
53 | OUTPUT_FOLDER, f"{submission['first_name']}.docx"
54 | )
55 | document.write(output_path)
56 |
--------------------------------------------------------------------------------
/docs/examples/mail_merge/merged/Pedro.docx:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/getodk/pyodk/9ba3b59770df034b01b5b0cd10db82ea4fe3b223/docs/examples/mail_merge/merged/Pedro.docx
--------------------------------------------------------------------------------
/docs/examples/mail_merge/merged/Sally.docx:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/getodk/pyodk/9ba3b59770df034b01b5b0cd10db82ea4fe3b223/docs/examples/mail_merge/merged/Sally.docx
--------------------------------------------------------------------------------
/docs/examples/mail_merge/requirements.txt:
--------------------------------------------------------------------------------
1 | docx-mailmerge2==0.8.0
2 |
--------------------------------------------------------------------------------
/docs/examples/mail_merge/template.docx:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/getodk/pyodk/9ba3b59770df034b01b5b0cd10db82ea4fe3b223/docs/examples/mail_merge/template.docx
--------------------------------------------------------------------------------
/docs/examples/requirements.txt:
--------------------------------------------------------------------------------
1 | geopandas
2 | pandas
3 | flatten_json
--------------------------------------------------------------------------------
/docs/examples/simple_repeat.xlsx:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/getodk/pyodk/9ba3b59770df034b01b5b0cd10db82ea4fe3b223/docs/examples/simple_repeat.xlsx
--------------------------------------------------------------------------------
/docs/forms.md:
--------------------------------------------------------------------------------
1 | # Forms
2 |
3 | ::: pyodk._endpoints.forms.FormService
--------------------------------------------------------------------------------
/docs/http-methods.md:
--------------------------------------------------------------------------------
1 | # HTTP verb methods
2 |
3 | For interacting with parts of the ODK Central API ([docs](https://docs.getodk.org/central-api)) that have not been implemented in `pyodk`, use HTTP verb methods exposed on the `Client`:
4 |
5 | ```python
6 | client.get("projects/8")
7 | client.post("projects/7/app-users", json={"displayName": "Lab Tech"})
8 | ```
9 |
10 | These methods provide convenient access to `Client.session`, which is a [`requests.Session`](https://requests.readthedocs.io/en/latest/user/advanced/#session-objects) object subclass. The `Session` has customised to prefix request URLs with the `base_url` from the pyodk config. For example with a base_url `https://www.example.com`, a call to `client.session.get("projects/8")` gets the details of `project_id=8`, using the full url `https://www.example.com/v1/projects/8`.
11 |
12 | Learn more in [this example](https://getodk.github.io/pyodk/examples/beyond-library-methods/).
13 |
--------------------------------------------------------------------------------
/docs/index.md:
--------------------------------------------------------------------------------
1 | --8<-- "README.md"
--------------------------------------------------------------------------------
/docs/overrides/main.html:
--------------------------------------------------------------------------------
1 | {% extends "base.html" %}
2 |
3 | {% block scripts %}
4 | {{ super() }}
5 |
6 | {% endblock %}
--------------------------------------------------------------------------------
/docs/projects.md:
--------------------------------------------------------------------------------
1 | # Projects
2 |
3 | ::: pyodk._endpoints.projects.ProjectService
--------------------------------------------------------------------------------
/docs/submissions.md:
--------------------------------------------------------------------------------
1 | # Submissions
2 |
3 | ::: pyodk._endpoints.submissions.SubmissionService
--------------------------------------------------------------------------------
/mkdocs.yml:
--------------------------------------------------------------------------------
1 | site_name: pyODK
2 | edit_uri: https://github.com/getodk/pyodk/tree/master/docs
3 | repo_url: https://github.com/getodk/pyodk
4 | repo_name: getodk/pyodk
5 | docs_dir: docs
6 | watch:
7 | - ./README.md
8 | - ./docs
9 | - ./pyodk
10 |
11 | nav:
12 | - Overview: index.md
13 | - Client: client.md
14 | - .entities: entities.md
15 | - .entity_lists: entity_lists.md
16 | - .forms: forms.md
17 | - .projects: projects.md
18 | - .submissions: submissions.md
19 | - HTTP methods: http-methods.md
20 | - Examples: examples/README.md
21 |
22 | theme:
23 | name: material
24 | logo: assets/logo.svg
25 | favicon: assets/favicon.ico
26 | custom_dir: docs/overrides
27 | features:
28 | - content.action.edit
29 | - content.action.view
30 | - content.code.copy
31 | - navigation.expand
32 | - navigation.instant
33 | - navigation.top
34 | - navigation.tracking
35 | - toc.follow
36 | icon:
37 | view: material/file-eye-outline
38 | edit: material/file-edit-outline
39 | palette:
40 | - media: "(prefers-color-scheme: light)"
41 | scheme: default
42 | primary: white
43 | toggle:
44 | icon: material/weather-sunny
45 | name: Switch to dark mode
46 | - media: "(prefers-color-scheme: dark)"
47 | scheme: slate
48 | primary: black
49 | toggle:
50 | icon: material/weather-night
51 | name: Switch to light mode
52 |
53 | extra_css:
54 | - assets/extra_css.css
55 |
56 | plugins:
57 | - search
58 | - mkdocstrings:
59 | enabled: !ENV [ENABLE_MKDOCSTRINGS, true]
60 | default_handler: python
61 | handlers:
62 | python:
63 | options:
64 | docstring_style: sphinx
65 | members_order: alphabetical
66 | show_bases: false
67 | show_root_toc_entry: false
68 | show_source: false
69 | - mkdocs-jupyter
70 |
71 | markdown_extensions:
72 | - pymdownx.highlight:
73 | anchor_linenums: true
74 | - pymdownx.inlinehilite
75 | - pymdownx.snippets
76 | - pymdownx.superfences
77 | - toc:
78 | permalink: true
79 |
--------------------------------------------------------------------------------
/pyodk/__init__.py:
--------------------------------------------------------------------------------
1 | import logging
2 |
3 | from pyodk import errors
4 | from pyodk.client import Client
5 |
6 | __all__ = (
7 | "Client",
8 | "errors",
9 | )
10 |
11 |
12 | logging.getLogger(__name__).addHandler(logging.NullHandler())
13 |
--------------------------------------------------------------------------------
/pyodk/__version__.py:
--------------------------------------------------------------------------------
1 | __version__ = "1.2.1"
2 |
--------------------------------------------------------------------------------
/pyodk/_endpoints/__init__.py:
--------------------------------------------------------------------------------
1 | __all__ = ()
2 |
--------------------------------------------------------------------------------
/pyodk/_endpoints/auth.py:
--------------------------------------------------------------------------------
1 | import logging
2 | from typing import TYPE_CHECKING
3 |
4 | from pyodk._utils import config
5 | from pyodk.errors import PyODKError
6 |
7 | if TYPE_CHECKING:
8 | from pyodk._utils.session import Session
9 |
10 | log = logging.getLogger(__name__)
11 |
12 |
13 | class AuthService:
14 | def __init__(self, session: "Session", cache_path: str | None = None) -> None:
15 | self.session: Session = session
16 | self.cache_path: str = cache_path
17 |
18 | def verify_token(self, token: str) -> str:
19 | """
20 | Check with Central that a token is valid.
21 |
22 | :param token: The token to check.
23 | :return:
24 | """
25 | response = self.session.get(
26 | url="users/current",
27 | headers={
28 | "Content-Type": "application/json",
29 | "Authorization": f"Bearer {token}",
30 | },
31 | )
32 | if response.status_code == 200:
33 | return token
34 | else:
35 | msg = (
36 | f"The token verification request failed."
37 | f" Status: {response.status_code}, content: {response.content}"
38 | )
39 | err = PyODKError(msg)
40 | log.error(err, exc_info=True)
41 | raise err
42 |
43 | def get_new_token(self, username: str, password: str) -> str:
44 | """
45 | Get a new token from Central by creating a new session.
46 |
47 | https://docs.getodk.org/central-api-authentication/#logging-in
48 |
49 | :param username: The username of the Web User to auth with.
50 | :param password: The Web User's password.
51 | :return: The session token.
52 | """
53 | response = self.session.post(
54 | url="sessions",
55 | json={"email": username, "password": password},
56 | headers={"Content-Type": "application/json"},
57 | )
58 | if response.status_code == 200:
59 | data = response.json()
60 | if "token" not in data:
61 | msg = "The login request was OK but there was no token in the response."
62 | err = PyODKError(msg)
63 | log.error(err, exc_info=True)
64 | raise err
65 | else:
66 | return data["token"]
67 | else:
68 | msg = (
69 | f"The login request failed."
70 | f" Status: {response.status_code}, content: {response.content}"
71 | )
72 | err = PyODKError(msg, response)
73 | log.error(err, exc_info=True)
74 | raise err
75 |
76 | def get_token(self, username: str, password: str) -> str:
77 | """
78 | Get a verified session token with the provided credential.
79 |
80 | Tries to verify token in cache_file, or requests a new session.
81 |
82 | :param username: The username of the Web User to auth with.
83 | :param password: The Web User's password.
84 | :return: The session token or None if anything has gone wrong
85 | """
86 | try:
87 | token = config.read_cache_token(cache_path=self.cache_path)
88 | return self.verify_token(token=token)
89 | except PyODKError:
90 | # Couldn't read the token, or it wasn't valid.
91 | pass
92 |
93 | token = self.get_new_token(username=username, password=password)
94 | config.write_cache(key="token", value=token, cache_path=self.cache_path)
95 | return token
96 |
--------------------------------------------------------------------------------
/pyodk/_endpoints/bases.py:
--------------------------------------------------------------------------------
1 | from pydantic import BaseModel, ConfigDict
2 |
3 | from pyodk._utils.session import Session
4 |
5 |
6 | class Model(BaseModel):
7 | """Base configuration for data model classes."""
8 |
9 | model_config = ConfigDict(arbitrary_types_allowed=True, validate_assignment=True)
10 |
11 |
12 | class Manager:
13 | """Base for managers of data model classes."""
14 |
15 | __slots__ = ("__weakref__",)
16 |
17 | @classmethod
18 | def from_dict(cls, session: Session, project_id: int, data: dict) -> Model:
19 | raise NotImplementedError()
20 |
21 |
22 | class Service:
23 | """Base for services interacting with the ODK Central API over HTTP."""
24 |
25 | __slots__ = ("__weakref__",)
26 |
--------------------------------------------------------------------------------
/pyodk/_endpoints/comments.py:
--------------------------------------------------------------------------------
1 | import logging
2 | from dataclasses import dataclass
3 | from datetime import datetime
4 |
5 | from pyodk._endpoints.bases import Model, Service
6 | from pyodk._utils import validators as pv
7 | from pyodk._utils.session import Session
8 | from pyodk.errors import PyODKError
9 |
10 | log = logging.getLogger(__name__)
11 |
12 |
13 | class Comment(Model):
14 | body: str
15 | actorId: int
16 | createdAt: datetime
17 |
18 |
19 | @dataclass(frozen=True, slots=True)
20 | class URLs:
21 | list: str = "projects/{project_id}/forms/{form_id}/submissions/{instance_id}/comments"
22 | post: str = "projects/{project_id}/forms/{form_id}/submissions/{instance_id}/comments"
23 |
24 |
25 | class CommentService(Service):
26 | __slots__ = (
27 | "default_form_id",
28 | "default_instance_id",
29 | "default_project_id",
30 | "session",
31 | "urls",
32 | )
33 |
34 | def __init__(
35 | self,
36 | session: Session,
37 | default_project_id: int | None = None,
38 | default_form_id: str | None = None,
39 | default_instance_id: str | None = None,
40 | urls: URLs = None,
41 | ):
42 | self.urls: URLs = urls if urls is not None else URLs()
43 | self.session: Session = session
44 | self.default_project_id: int | None = default_project_id
45 | self.default_form_id: str | None = default_form_id
46 | self.default_instance_id: str | None = default_instance_id
47 |
48 | def list(
49 | self,
50 | form_id: str | None = None,
51 | project_id: int | None = None,
52 | instance_id: str | None = None,
53 | ) -> list[Comment]:
54 | """
55 | Read all Comment details.
56 |
57 | :param form_id: The xmlFormId of the Form being referenced.
58 | :param project_id: The id of the project the Submissions belong to.
59 | :param instance_id: The instanceId of the Submission being referenced.
60 | """
61 | try:
62 | pid = pv.validate_project_id(project_id, self.default_project_id)
63 | fid = pv.validate_form_id(form_id, self.default_form_id)
64 | iid = pv.validate_instance_id(instance_id, self.default_instance_id)
65 | except PyODKError as err:
66 | log.error(err, exc_info=True)
67 | raise
68 |
69 | response = self.session.response_or_error(
70 | method="GET",
71 | url=self.session.urlformat(
72 | self.urls.list, project_id=pid, form_id=fid, instance_id=iid
73 | ),
74 | logger=log,
75 | )
76 | data = response.json()
77 | return [Comment(**r) for r in data]
78 |
79 | def post(
80 | self,
81 | comment: str,
82 | project_id: int | None = None,
83 | form_id: str | None = None,
84 | instance_id: str | None = None,
85 | ) -> Comment:
86 | """
87 | Create a Comment.
88 |
89 | :param comment: The text of the comment.
90 | :param project_id: The id of the project this form belongs to.
91 | :param form_id: The xmlFormId of the Form being referenced.
92 | :param instance_id: The instanceId of the Submission being referenced.
93 | """
94 | try:
95 | pid = pv.validate_project_id(project_id, self.default_project_id)
96 | fid = pv.validate_form_id(form_id, self.default_form_id)
97 | iid = pv.validate_instance_id(instance_id, self.default_instance_id)
98 | comment = pv.validate_str(comment, key="comment")
99 | json = {"body": comment}
100 | except PyODKError as err:
101 | log.error(err, exc_info=True)
102 | raise
103 |
104 | response = self.session.response_or_error(
105 | method="POST",
106 | url=self.session.urlformat(
107 | self.urls.post, project_id=pid, form_id=fid, instance_id=iid
108 | ),
109 | logger=log,
110 | json=json,
111 | )
112 | data = response.json()
113 | return Comment(**data)
114 |
--------------------------------------------------------------------------------
/pyodk/_endpoints/entity_list_properties.py:
--------------------------------------------------------------------------------
1 | import logging
2 | from dataclasses import dataclass
3 | from datetime import datetime
4 |
5 | from pyodk._endpoints.bases import Model, Service
6 | from pyodk._utils import validators as pv
7 | from pyodk._utils.session import Session
8 | from pyodk.errors import PyODKError
9 |
10 | log = logging.getLogger(__name__)
11 |
12 |
13 | class EntityListProperty(Model):
14 | name: str
15 | odataName: str
16 | publishedAt: datetime
17 | forms: list[str]
18 |
19 |
20 | @dataclass(frozen=True, slots=True)
21 | class URLs:
22 | post: str = "projects/{project_id}/datasets/{entity_list_name}/properties"
23 |
24 |
25 | class EntityListPropertyService(Service):
26 | __slots__ = (
27 | "default_entity_list_name",
28 | "default_project_id",
29 | "session",
30 | "urls",
31 | )
32 |
33 | def __init__(
34 | self,
35 | session: Session,
36 | default_project_id: int | None = None,
37 | default_entity_list_name: str | None = None,
38 | urls: URLs = None,
39 | ):
40 | self.urls: URLs = urls if urls is not None else URLs()
41 | self.session: Session = session
42 | self.default_project_id: int | None = default_project_id
43 | self.default_entity_list_name: str | None = default_entity_list_name
44 |
45 | def create(
46 | self,
47 | name: str,
48 | entity_list_name: str | None = None,
49 | project_id: int | None = None,
50 | ) -> bool:
51 | """
52 | Create an Entity List Property.
53 |
54 | :param name: The name of the Property. Property names follow the same rules as
55 | form field names (valid XML identifiers) and cannot use the reserved names of
56 | name or label, or begin with the reserved prefix __.
57 | :param entity_list_name: The name of the Entity List (Dataset) being referenced.
58 | :param project_id: The id of the project this Entity List belongs to.
59 | """
60 | try:
61 | pid = pv.validate_project_id(project_id, self.default_project_id)
62 | eln = pv.validate_entity_list_name(
63 | entity_list_name, self.default_entity_list_name
64 | )
65 | req_data = {"name": pv.validate_str(name, key="name")}
66 | except PyODKError as err:
67 | log.error(err, exc_info=True)
68 | raise
69 |
70 | response = self.session.response_or_error(
71 | method="POST",
72 | url=self.session.urlformat(
73 | self.urls.post,
74 | project_id=pid,
75 | entity_list_name=eln,
76 | ),
77 | logger=log,
78 | json=req_data,
79 | )
80 | data = response.json()
81 | return data["success"]
82 |
--------------------------------------------------------------------------------
/pyodk/_endpoints/entity_lists.py:
--------------------------------------------------------------------------------
1 | import logging
2 | from dataclasses import dataclass
3 | from datetime import datetime
4 | from typing import Any
5 |
6 | from pyodk._endpoints.bases import Model, Service
7 | from pyodk._endpoints.entity_list_properties import (
8 | EntityListProperty,
9 | EntityListPropertyService,
10 | )
11 | from pyodk._utils import validators as pv
12 | from pyodk._utils.session import Session
13 | from pyodk.errors import PyODKError
14 |
15 | log = logging.getLogger(__name__)
16 |
17 |
18 | class EntityList(Model):
19 | name: str
20 | projectId: int
21 | createdAt: datetime
22 | approvalRequired: bool
23 | properties: list[EntityListProperty] | None = None
24 |
25 |
26 | @dataclass(frozen=True, slots=True)
27 | class URLs:
28 | _entity_list = "projects/{project_id}/datasets"
29 | list: str = _entity_list
30 | post: str = _entity_list
31 | get: str = f"{_entity_list}/{{entity_list_name}}"
32 |
33 |
34 | class EntityListService(Service):
35 | """
36 | Entity List-related functionality is accessed through `client.entity_lists`.
37 |
38 | For example:
39 |
40 | ```python
41 | from pyodk.client import Client
42 |
43 | client = Client()
44 | data = client.entity_lists.list()
45 | ```
46 |
47 | Conceptually, an EntityList's parent object is a Project. Each Project may have
48 | multiple EntityLists.
49 | """
50 |
51 | __slots__ = (
52 | "_default_entity_list_name",
53 | "_default_project_id",
54 | "_property_service",
55 | "add_property",
56 | "session",
57 | "urls",
58 | )
59 |
60 | def __init__(
61 | self,
62 | session: Session,
63 | default_project_id: int | None = None,
64 | default_entity_list_name: str | None = None,
65 | urls: URLs = None,
66 | ):
67 | self.urls: URLs = urls if urls is not None else URLs()
68 | self.session: Session = session
69 | self._property_service = EntityListPropertyService(
70 | session=self.session,
71 | default_project_id=default_project_id,
72 | default_entity_list_name=default_entity_list_name,
73 | )
74 | self.add_property = self._property_service.create
75 |
76 | self._default_project_id: int | None = None
77 | self.default_project_id = default_project_id
78 | self._default_entity_list_name: str | None = None
79 | self.default_entity_list_name = default_entity_list_name
80 |
81 | def _default_kw(self) -> dict[str, Any]:
82 | return {
83 | "default_project_id": self.default_project_id,
84 | "default_entity_list_name": self.default_entity_list_name,
85 | }
86 |
87 | @property
88 | def default_project_id(self) -> int | None:
89 | return self._default_project_id
90 |
91 | @default_project_id.setter
92 | def default_project_id(self, v) -> None:
93 | self._default_project_id = v
94 | self._property_service.default_project_id = v
95 |
96 | @property
97 | def default_entity_list_name(self) -> str | None:
98 | return self._default_entity_list_name
99 |
100 | @default_entity_list_name.setter
101 | def default_entity_list_name(self, v) -> None:
102 | self._default_entity_list_name = v
103 | self._property_service.default_entity_list_name = v
104 |
105 | def list(self, project_id: int | None = None) -> list[EntityList]:
106 | """
107 | Read all Entity List details.
108 |
109 | :param project_id: The id of the project the Entity List belongs to.
110 |
111 | :return: A list of the object representation of all Entity Lists' details.
112 | """
113 | try:
114 | pid = pv.validate_project_id(project_id, self.default_project_id)
115 | except PyODKError as err:
116 | log.error(err, exc_info=True)
117 | raise
118 |
119 | response = self.session.response_or_error(
120 | method="GET",
121 | url=self.session.urlformat(self.urls.list, project_id=pid),
122 | logger=log,
123 | )
124 | data = response.json()
125 | return [EntityList(**r) for r in data]
126 |
127 | def get(
128 | self,
129 | entity_list_name: str | None = None,
130 | project_id: int | None = None,
131 | ) -> EntityList:
132 | """
133 | Read Entity List details.
134 |
135 | :param project_id: The id of the project the Entity List belongs to.
136 | :param entity_list_name: The name of the Entity List (Dataset) being referenced.
137 |
138 | :return: An object representation of all Entity Lists' details.
139 | """
140 | try:
141 | pid = pv.validate_project_id(project_id, self.default_project_id)
142 | eln = pv.validate_entity_list_name(
143 | entity_list_name, self.default_entity_list_name
144 | )
145 | except PyODKError as err:
146 | log.error(err, exc_info=True)
147 | raise
148 |
149 | response = self.session.response_or_error(
150 | method="GET",
151 | url=self.session.urlformat(
152 | self.urls.get, project_id=pid, entity_list_name=eln
153 | ),
154 | logger=log,
155 | )
156 | data = response.json()
157 | return EntityList(**data)
158 |
159 | def create(
160 | self,
161 | approval_required: bool | None = False,
162 | entity_list_name: str | None = None,
163 | project_id: int | None = None,
164 | ) -> EntityList:
165 | """
166 | Create an Entity List.
167 |
168 | :param approval_required: If False, create Entities as soon as Submissions are
169 | received by Central. If True, create Entities when Submissions are marked as
170 | Approved in Central.
171 | :param entity_list_name: The name of the Entity List (Dataset) being referenced.
172 | :param project_id: The id of the project this Entity List belongs to.
173 | """
174 | try:
175 | pid = pv.validate_project_id(project_id, self.default_project_id)
176 | req_data = {
177 | "name": pv.validate_entity_list_name(
178 | entity_list_name, self.default_entity_list_name
179 | ),
180 | "approvalRequired": pv.validate_bool(
181 | approval_required, key="approval_required"
182 | ),
183 | }
184 | except PyODKError as err:
185 | log.error(err, exc_info=True)
186 | raise
187 |
188 | response = self.session.response_or_error(
189 | method="POST",
190 | url=self.session.urlformat(self.urls.post, project_id=pid),
191 | logger=log,
192 | json=req_data,
193 | )
194 | data = response.json()
195 | return EntityList(**data)
196 |
--------------------------------------------------------------------------------
/pyodk/_endpoints/form_assignments.py:
--------------------------------------------------------------------------------
1 | import logging
2 | from dataclasses import dataclass
3 |
4 | from pyodk._endpoints.bases import Service
5 | from pyodk._utils import validators as pv
6 | from pyodk._utils.session import Session
7 | from pyodk.errors import PyODKError
8 |
9 | log = logging.getLogger(__name__)
10 |
11 |
12 | @dataclass(frozen=True, slots=True)
13 | class URLs:
14 | _form: str = "projects/{project_id}/forms/{form_id}"
15 | post: str = f"{_form}/assignments/{{role_id}}/{{user_id}}"
16 |
17 |
18 | class FormAssignmentService(Service):
19 | __slots__ = ("default_form_id", "default_project_id", "session", "urls")
20 |
21 | def __init__(
22 | self,
23 | session: Session,
24 | default_project_id: int | None = None,
25 | default_form_id: str | None = None,
26 | urls: URLs = None,
27 | ):
28 | self.urls: URLs = urls if urls is not None else URLs()
29 | self.session: Session = session
30 | self.default_project_id: int | None = default_project_id
31 | self.default_form_id: str | None = default_form_id
32 |
33 | def assign(
34 | self,
35 | role_id: int,
36 | user_id: int,
37 | form_id: str | None = None,
38 | project_id: int | None = None,
39 | ) -> bool:
40 | """
41 | Assign a user to a role for a form.
42 |
43 | :param role_id: The id of the role to assign the user to.
44 | :param user_id: The id of the user to assign to the role.
45 | :param form_id: The xmlFormId of the Form being referenced.
46 | :param project_id: The id of the project this form belongs to.
47 | """
48 | try:
49 | pid = pv.validate_project_id(project_id, self.default_project_id)
50 | fid = pv.validate_form_id(form_id, self.default_form_id)
51 | rid = pv.validate_int(role_id, key="role_id")
52 | uid = pv.validate_int(user_id, key="user_id")
53 | except PyODKError as err:
54 | log.error(err, exc_info=True)
55 | raise
56 |
57 | response = self.session.response_or_error(
58 | method="POST",
59 | url=self.session.urlformat(
60 | self.urls.post, project_id=pid, form_id=fid, role_id=rid, user_id=uid
61 | ),
62 | logger=log,
63 | )
64 |
65 | data = response.json()
66 | return data["success"]
67 |
--------------------------------------------------------------------------------
/pyodk/_endpoints/form_draft_attachments.py:
--------------------------------------------------------------------------------
1 | import logging
2 | import mimetypes
3 | from dataclasses import dataclass
4 | from datetime import datetime
5 | from os import PathLike
6 |
7 | from pyodk._endpoints.bases import Model, Service
8 | from pyodk._utils import validators as pv
9 | from pyodk._utils.session import Session
10 | from pyodk.errors import PyODKError
11 |
12 | log = logging.getLogger(__name__)
13 |
14 |
15 | class FormAttachment(Model):
16 | name: str
17 | type: str # image | audio | video | file
18 | hash: str
19 | exists: bool # Either blobExists or dataExists is True
20 | blobExists: bool # Server has the file
21 | datasetExists: bool # File is linked to a Dataset
22 | updatedAt: datetime # When the file was created or deleted
23 |
24 |
25 | @dataclass(frozen=True, slots=True)
26 | class URLs:
27 | _form: str = "projects/{project_id}/forms/{form_id}"
28 | post: str = f"{_form}/draft/attachments/{{fname}}"
29 |
30 |
31 | class FormDraftAttachmentService(Service):
32 | __slots__ = ("default_form_id", "default_project_id", "session", "urls")
33 |
34 | def __init__(
35 | self,
36 | session: Session,
37 | default_project_id: int | None = None,
38 | default_form_id: str | None = None,
39 | urls: URLs = None,
40 | ):
41 | self.urls: URLs = urls if urls is not None else URLs()
42 | self.session: Session = session
43 | self.default_project_id: int | None = default_project_id
44 | self.default_form_id: str | None = default_form_id
45 |
46 | def upload(
47 | self,
48 | file_path: PathLike | str,
49 | file_name: str | None = None,
50 | form_id: str | None = None,
51 | project_id: int | None = None,
52 | ) -> bool:
53 | """
54 | Upload a Form Draft Attachment.
55 |
56 | :param file_path: The path to the file to upload.
57 | :param file_name: A name for the file, otherwise the name in file_path is used.
58 | :param form_id: The xmlFormId of the Form being referenced.
59 | :param project_id: The id of the project this form belongs to.
60 | """
61 | try:
62 | pid = pv.validate_project_id(project_id, self.default_project_id)
63 | fid = pv.validate_form_id(form_id, self.default_form_id)
64 | file_path = pv.validate_file_path(file_path)
65 | if file_name is None:
66 | file_name = pv.validate_str(file_path.name, key="file_name")
67 | guess_type, guess_encoding = mimetypes.guess_type(file_name)
68 | headers = {
69 | "Transfer-Encoding": "chunked",
70 | "Content-Type": guess_type or "application/octet-stream",
71 | }
72 | if guess_encoding: # associated compression type, if any.
73 | headers["Content-Encoding"] = guess_encoding
74 | except PyODKError as err:
75 | log.error(err, exc_info=True)
76 | raise
77 |
78 | def file_stream():
79 | # Generator forces requests to read/send in chunks instead of all at once.
80 | with open(file_path, "rb") as f:
81 | while chunk := f.read(self.session.blocksize):
82 | yield chunk
83 |
84 | response = self.session.response_or_error(
85 | method="POST",
86 | url=self.session.urlformat(
87 | self.urls.post,
88 | project_id=pid,
89 | form_id=fid,
90 | fname=file_name,
91 | ),
92 | logger=log,
93 | headers=headers,
94 | data=file_stream(),
95 | )
96 | data = response.json()
97 | try:
98 | # Response format prior to Central v2025.1 is constant `{"success": True}`.
99 | return data["success"]
100 | except KeyError:
101 | # Response introduced in Central v2025.1. Model details currently not used.
102 | return FormAttachment(**data).exists
103 |
--------------------------------------------------------------------------------
/pyodk/_endpoints/form_drafts.py:
--------------------------------------------------------------------------------
1 | import logging
2 | from dataclasses import dataclass
3 | from io import BytesIO
4 | from os import PathLike
5 | from zipfile import is_zipfile
6 |
7 | from pyodk._endpoints.bases import Service
8 | from pyodk._utils import validators as pv
9 | from pyodk._utils.session import Session
10 | from pyodk.errors import PyODKError
11 |
12 | log = logging.getLogger(__name__)
13 | CONTENT_TYPES = {
14 | ".xlsx": "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
15 | ".xls": "application/vnd.ms-excel",
16 | ".xml": "application/xml",
17 | }
18 |
19 |
20 | def is_xls_file(buf: bytes) -> bool:
21 | """
22 | Implements the Microsoft Excel (Office 97-2003) document type matcher.
23 |
24 | From h2non/filetype v1.2.0, MIT License, Copyright (c) 2016 Tomás Aparicio
25 |
26 | :param buf: buffer to match against.
27 | """
28 | if len(buf) > 520 and buf[0:8] == b"\xd0\xcf\x11\xe0\xa1\xb1\x1a\xe1":
29 | if buf[512:516] == b"\xfd\xff\xff\xff" and (buf[518] == 0x00 or buf[518] == 0x02):
30 | return True
31 | if buf[512:520] == b"\x09\x08\x10\x00\x00\x06\x05\x00":
32 | return True
33 | if (
34 | len(buf) > 2095
35 | and b"\xe2\x00\x00\x00\x5c\x00\x70\x00\x04\x00\x00Calc" in buf[1568:2095]
36 | ):
37 | return True
38 |
39 | return False
40 |
41 |
42 | def get_definition_data(
43 | definition: PathLike | str | bytes | None,
44 | ) -> (bytes, str, str | None):
45 | """
46 | Get the form definition data from a path or bytes.
47 |
48 | :param definition: The path to the file to upload (string or PathLike), or the
49 | form definition in memory (string (XML) or bytes (XLS/XLSX)).
50 | :return: definition_data, content_type, file_path_stem (if any).
51 | """
52 | definition_data = None
53 | content_type = None
54 | file_path_stem = None
55 | if (
56 | isinstance(definition, str)
57 | and """http://www.w3.org/2002/xforms""" in definition[:1000]
58 | ):
59 | content_type = CONTENT_TYPES[".xml"]
60 | definition_data = definition.encode("utf-8")
61 | elif isinstance(definition, str | PathLike):
62 | file_path = pv.validate_file_path(definition)
63 | file_path_stem = file_path.stem
64 | definition_data = file_path.read_bytes()
65 | if file_path.suffix not in CONTENT_TYPES:
66 | raise PyODKError(
67 | "Parameter 'definition' file name has an unexpected file extension, "
68 | "expected one of '.xlsx', '.xls', '.xml'."
69 | )
70 | content_type = CONTENT_TYPES[file_path.suffix]
71 | elif isinstance(definition, bytes):
72 | definition_data = definition
73 | if is_zipfile(BytesIO(definition)):
74 | content_type = CONTENT_TYPES[".xlsx"]
75 | elif is_xls_file(definition):
76 | content_type = CONTENT_TYPES[".xls"]
77 | if definition_data is None or content_type is None:
78 | raise PyODKError(
79 | "Parameter 'definition' has an unexpected file type, "
80 | "expected one of '.xlsx', '.xls', '.xml'."
81 | )
82 | return definition_data, content_type, file_path_stem
83 |
84 |
85 | @dataclass(frozen=True, slots=True)
86 | class URLs:
87 | _form: str = "projects/{project_id}/forms/{form_id}"
88 | post: str = f"{_form}/draft"
89 | post_publish: str = f"{_form}/draft/publish"
90 |
91 |
92 | class FormDraftService(Service):
93 | __slots__ = ("default_form_id", "default_project_id", "session", "urls")
94 |
95 | def __init__(
96 | self,
97 | session: Session,
98 | default_project_id: int | None = None,
99 | default_form_id: str | None = None,
100 | urls: URLs = None,
101 | ):
102 | self.urls: URLs = urls if urls is not None else URLs()
103 | self.session: Session = session
104 | self.default_project_id: int | None = default_project_id
105 | self.default_form_id: str | None = default_form_id
106 |
107 | def _prep_form_post(
108 | self,
109 | definition: PathLike | str | bytes | None = None,
110 | ignore_warnings: bool | None = True,
111 | form_id: str | None = None,
112 | project_id: int | None = None,
113 | ) -> (str, str, dict, dict, bytes | None):
114 | """
115 | Prepare / validate input arguments for POSTing a new form definition or version.
116 |
117 | :param definition: The path to the file to upload (string or PathLike), or the
118 | form definition in memory (string (XML) or bytes (XLS/XLSX)).
119 | :param form_id: The xmlFormId of the Form being referenced.
120 | :param project_id: The id of the project this form belongs to.
121 | :param ignore_warnings: If True, create the form if there are XLSForm warnings.
122 | :return: project_id, form_id, headers, params
123 | """
124 | try:
125 | pid = pv.validate_project_id(project_id, self.default_project_id)
126 | headers = {}
127 | params = {}
128 | definition_data = None
129 | file_path_stem = None
130 | if definition is not None:
131 | definition_data, content_type, file_path_stem = get_definition_data(
132 | definition=definition
133 | )
134 | headers["Content-Type"] = content_type
135 | fid = pv.validate_form_id(
136 | form_id,
137 | self.default_form_id,
138 | file_path_stem,
139 | self.session.get_xform_uuid(),
140 | )
141 | if definition is not None:
142 | if ignore_warnings is not None:
143 | key = "ignore_warnings"
144 | params["ignoreWarnings"] = pv.validate_bool(ignore_warnings, key=key)
145 | headers["X-XlsForm-FormId-Fallback"] = self.session.urlquote(fid)
146 | except PyODKError as err:
147 | log.error(err, exc_info=True)
148 | raise
149 |
150 | return pid, fid, headers, params, definition_data
151 |
152 | def create(
153 | self,
154 | definition: PathLike | str | bytes | None = None,
155 | ignore_warnings: bool | None = True,
156 | form_id: str | None = None,
157 | project_id: int | None = None,
158 | ) -> bool:
159 | """
160 | Create a Form Draft.
161 |
162 | :param definition: The path to the file to upload (string or PathLike), or the
163 | form definition in memory (string (XML) or bytes (XLS/XLSX)).
164 | :param form_id: The xmlFormId of the Form being referenced.
165 | :param project_id: The id of the project this form belongs to.
166 | :param ignore_warnings: If True, create the form if there are XLSForm warnings.
167 | """
168 | pid, fid, headers, params, form_def = self._prep_form_post(
169 | definition=definition,
170 | ignore_warnings=ignore_warnings,
171 | form_id=form_id,
172 | project_id=project_id,
173 | )
174 | response = self.session.response_or_error(
175 | method="POST",
176 | url=self.session.urlformat(self.urls.post, project_id=pid, form_id=fid),
177 | logger=log,
178 | headers=headers,
179 | params=params,
180 | data=form_def,
181 | )
182 | data = response.json()
183 | return data["success"]
184 |
185 | def publish(
186 | self,
187 | form_id: str | None = None,
188 | project_id: int | None = None,
189 | version: str | None = None,
190 | ) -> bool:
191 | """
192 | Publish a Form Draft.
193 |
194 | :param form_id: The xmlFormId of the Form being referenced.
195 | :param project_id: The id of the project this form belongs to.
196 | :param version: The version to be associated with the Draft once it's published.
197 | """
198 | try:
199 | pid = pv.validate_project_id(project_id, self.default_project_id)
200 | fid = pv.validate_form_id(form_id, self.default_form_id)
201 | params = {}
202 | if version is not None:
203 | key = "version"
204 | params[key] = pv.validate_str(version, key=key)
205 | except PyODKError as err:
206 | log.error(err, exc_info=True)
207 | raise
208 |
209 | response = self.session.response_or_error(
210 | method="POST",
211 | url=self.session.urlformat(
212 | self.urls.post_publish, project_id=pid, form_id=fid
213 | ),
214 | logger=log,
215 | params=params,
216 | )
217 | data = response.json()
218 | return data["success"]
219 |
--------------------------------------------------------------------------------
/pyodk/_endpoints/forms.py:
--------------------------------------------------------------------------------
1 | import logging
2 | from collections.abc import Callable, Iterable
3 | from dataclasses import dataclass
4 | from datetime import datetime
5 | from os import PathLike
6 | from typing import Any
7 |
8 | from pyodk._endpoints.bases import Model, Service
9 | from pyodk._endpoints.form_draft_attachments import FormDraftAttachmentService
10 | from pyodk._endpoints.form_drafts import FormDraftService
11 | from pyodk._utils import validators as pv
12 | from pyodk._utils.session import Session
13 | from pyodk.errors import PyODKError
14 |
15 | log = logging.getLogger(__name__)
16 |
17 |
18 | # TODO: actual response has undocumented fields: enketoOnceId, sha, sha256, draftToken
19 |
20 |
21 | class Form(Model):
22 | projectId: int
23 | xmlFormId: str
24 | version: str
25 | hash: str
26 | state: str # open, closing, closed
27 | createdAt: datetime
28 | name: str | None # Null if Central couldn't parse the XForm title, or it was blank.
29 | enketoId: str | None # Null if Enketo not being used with Central.
30 | keyId: int | None
31 | updatedAt: datetime | None
32 | publishedAt: datetime | None
33 |
34 |
35 | @dataclass(frozen=True, slots=True)
36 | class URLs:
37 | forms: str = "projects/{project_id}/forms"
38 | get: str = f"{forms}/{{form_id}}"
39 |
40 |
41 | class FormService(Service):
42 | """
43 | Form-related functionality is accessed through `client.forms`. For example:
44 |
45 | ```python
46 | from pyodk.client import Client
47 |
48 | client = Client()
49 | forms = client.forms.list()
50 | ```
51 | """
52 |
53 | __slots__ = ("default_form_id", "default_project_id", "session", "urls")
54 |
55 | def __init__(
56 | self,
57 | session: Session,
58 | default_project_id: int | None = None,
59 | default_form_id: str | None = None,
60 | urls: URLs = None,
61 | ):
62 | self.urls: URLs = urls if urls is not None else URLs()
63 | self.session: Session = session
64 | self.default_project_id: int | None = default_project_id
65 | self.default_form_id: str | None = default_form_id
66 |
67 | def _default_kw(self) -> dict[str, Any]:
68 | return {
69 | "default_project_id": self.default_project_id,
70 | "default_form_id": self.default_form_id,
71 | }
72 |
73 | def list(self, project_id: int | None = None) -> list[Form]:
74 | """
75 | Read all Form details.
76 |
77 | :param project_id: The id of the project the forms belong to.
78 |
79 | :return: A list of object representations of all Forms' metadata.
80 | """
81 | try:
82 | pid = pv.validate_project_id(project_id, self.default_project_id)
83 | except PyODKError as err:
84 | log.error(err, exc_info=True)
85 | raise
86 | else:
87 | response = self.session.response_or_error(
88 | method="GET",
89 | url=self.session.urlformat(self.urls.forms, project_id=pid),
90 | logger=log,
91 | )
92 | data = response.json()
93 | return [Form(**r) for r in data]
94 |
95 | def get(
96 | self,
97 | form_id: str,
98 | project_id: int | None = None,
99 | ) -> Form:
100 | """
101 | Read Form details.
102 |
103 | :param form_id: The id of this form as given in its XForms XML definition.
104 | :param project_id: The id of the project this form belongs to.
105 |
106 | :return: An object representation of the Form's metadata.
107 | """
108 | try:
109 | pid = pv.validate_project_id(project_id, self.default_project_id)
110 | fid = pv.validate_form_id(form_id, self.default_form_id)
111 | except PyODKError as err:
112 | log.error(err, exc_info=True)
113 | raise
114 | else:
115 | response = self.session.response_or_error(
116 | method="GET",
117 | url=self.session.urlformat(self.urls.get, project_id=pid, form_id=fid),
118 | logger=log,
119 | )
120 | data = response.json()
121 | return Form(**data)
122 |
123 | def create(
124 | self,
125 | definition: PathLike | str | bytes,
126 | attachments: Iterable[PathLike | str] | None = None,
127 | ignore_warnings: bool | None = True,
128 | form_id: str | None = None,
129 | project_id: int | None = None,
130 | ) -> Form:
131 | """
132 | Create a form.
133 |
134 | :param definition: The path to the file to upload (string or PathLike), or the
135 | form definition in memory (string (XML) or bytes (XLS/XLSX)).
136 | :param attachments: The paths of the form attachment file(s) to upload.
137 | :param ignore_warnings: If True, create the form if there are XLSForm warnings.
138 | :param form_id: The xmlFormId of the Form being referenced.
139 | :param project_id: The id of the project this form belongs to.
140 | :return: An object representation of the Form's metadata.
141 | """
142 | fd = FormDraftService(session=self.session, **self._default_kw())
143 | pid, fid, headers, params, form_def = fd._prep_form_post(
144 | definition=definition,
145 | ignore_warnings=ignore_warnings,
146 | form_id=form_id,
147 | project_id=project_id,
148 | )
149 |
150 | # Create the new Form definition, in draft state.
151 | params["publish"] = False
152 | response = self.session.response_or_error(
153 | method="POST",
154 | url=self.session.urlformat(self.urls.forms, project_id=pid),
155 | logger=log,
156 | headers=headers,
157 | params=params,
158 | data=form_def,
159 | )
160 | data = response.json()
161 |
162 | # In case the form_id parameter was None, use the (maybe generated) response value.
163 | form = Form(**data)
164 | fp_ids = {"form_id": form.xmlFormId, "project_id": project_id}
165 |
166 | # Upload the attachments, if any.
167 | if attachments is not None:
168 | fda = FormDraftAttachmentService(session=self.session, **self._default_kw())
169 | for attach in attachments:
170 | if not fda.upload(file_path=attach, **fp_ids):
171 | raise PyODKError("Form create (attachment upload) failed.")
172 |
173 | # Publish the draft.
174 | if not fd.publish(**fp_ids):
175 | raise PyODKError("Form create (draft publish) failed.")
176 |
177 | return form
178 |
179 | def update(
180 | self,
181 | form_id: str,
182 | project_id: int | None = None,
183 | definition: PathLike | str | bytes | None = None,
184 | attachments: Iterable[PathLike | str] | None = None,
185 | version_updater: Callable[[str], str] | None = None,
186 | ) -> None:
187 | """
188 | Update an existing Form. Must specify definition, attachments or both.
189 |
190 | Accepted call patterns:
191 |
192 | * form definition only
193 | * form definition with attachments
194 | * form attachments only
195 | * form attachments with `version_updater`
196 |
197 | If a definition is provided, the new version name must be specified in the
198 | definition. If no definition is provided, a default version will be set using
199 | the current datetime is ISO format.
200 |
201 | The default datetime version can be overridden by providing a `version_updater`
202 | function. The function will be passed the current version name as a string, and
203 | must return a string with the new version name. For example:
204 |
205 | * Parse then increment a version number: `version_updater=lambda v: int(v) + 1`
206 | * Disregard the input and return a string: `version_updater=lambda v: "v2.0"`.
207 |
208 | :param form_id: The xmlFormId of the Form being referenced.
209 | :param project_id: The id of the project this form belongs to.
210 | :param definition: The path to the file to upload (string or PathLike), or the
211 | form definition in memory (string (XML) or bytes (XLS/XLSX)). The form
212 | definition must include an updated version string.
213 | :param attachments: The paths of the form attachment file(s) to upload.
214 | :param version_updater: A function that accepts a version name string and returns
215 | a version name string, which is used for the new form version. Not allowed if a
216 | form definition is specified.
217 | """
218 | if definition is None and attachments is None:
219 | raise PyODKError("Must specify a form definition and/or attachments.")
220 |
221 | if definition is not None and version_updater is not None:
222 | raise PyODKError("Must not specify both a definition and version_updater.")
223 |
224 | # Start a new draft - with a new definition, if provided.
225 | fp_ids = {"form_id": form_id, "project_id": project_id}
226 | fd = FormDraftService(session=self.session, **self._default_kw())
227 | if not fd.create(definition=definition, **fp_ids):
228 | raise PyODKError("Form update (form draft create) failed.")
229 |
230 | # Upload the attachments, if any.
231 | if attachments is not None:
232 | fda = FormDraftAttachmentService(session=self.session, **self._default_kw())
233 | for attach in attachments:
234 | if not fda.upload(file_path=attach, **fp_ids):
235 | raise PyODKError("Form update (attachment upload) failed.")
236 |
237 | new_version = None
238 | if definition is None:
239 | # Get a new version - using either a timestamp or the callback.
240 | if version_updater is None:
241 | new_version = datetime.now().isoformat()
242 | else:
243 | new_version = version_updater(self.get(**fp_ids).version)
244 |
245 | # Publish the draft.
246 | if not fd.publish(version=new_version, **fp_ids):
247 | raise PyODKError("Form update (draft publish) failed.")
248 |
--------------------------------------------------------------------------------
/pyodk/_endpoints/project_app_users.py:
--------------------------------------------------------------------------------
1 | import logging
2 | from dataclasses import dataclass
3 | from datetime import datetime
4 |
5 | from pyodk._endpoints.bases import Model, Service
6 | from pyodk._utils import validators as pv
7 | from pyodk._utils.session import Session
8 | from pyodk.errors import PyODKError
9 |
10 | log = logging.getLogger(__name__)
11 |
12 |
13 | class ProjectAppUser(Model):
14 | projectId: int
15 | id: int
16 | displayName: str
17 | createdAt: datetime
18 | type: str | None # user, field_key, public_link, singleUse
19 | token: str | None
20 | updatedAt: datetime | None
21 | deletedAt: datetime | None
22 |
23 |
24 | @dataclass(frozen=True, slots=True)
25 | class URLs:
26 | list: str = "projects/{project_id}/app-users"
27 | post: str = "projects/{project_id}/app-users"
28 |
29 |
30 | class ProjectAppUserService(Service):
31 | __slots__ = (
32 | "default_project_id",
33 | "session",
34 | "urls",
35 | )
36 |
37 | def __init__(
38 | self,
39 | session: Session,
40 | default_project_id: int | None = None,
41 | urls: URLs = None,
42 | ):
43 | self.urls: URLs = urls if urls is not None else URLs()
44 | self.session: Session = session
45 | self.default_project_id: int | None = default_project_id
46 |
47 | def list(
48 | self,
49 | project_id: int | None = None,
50 | ) -> list[ProjectAppUser]:
51 | """
52 | Read all ProjectAppUser details.
53 |
54 | :param project_id: The project_id the ProjectAppUsers are assigned to.
55 | """
56 | try:
57 | pid = pv.validate_project_id(project_id, self.default_project_id)
58 | except PyODKError as err:
59 | log.error(err, exc_info=True)
60 | raise
61 |
62 | response = self.session.response_or_error(
63 | method="GET",
64 | url=self.session.urlformat(self.urls.list, project_id=pid),
65 | logger=log,
66 | )
67 | data = response.json()
68 | return [ProjectAppUser(**r) for r in data]
69 |
70 | def create(
71 | self,
72 | display_name: str,
73 | project_id: int | None = None,
74 | ) -> ProjectAppUser:
75 | """
76 | Create a ProjectAppUser.
77 |
78 | :param display_name: The friendly nickname of the App User to be created.
79 | :param project_id: The project_id the ProjectAppUser should be assigned to.
80 | """
81 | try:
82 | pid = pv.validate_project_id(project_id, self.default_project_id)
83 | display_name = pv.validate_str(display_name, key="display_name")
84 | json = {"displayName": display_name}
85 | except PyODKError as err:
86 | log.error(err, exc_info=True)
87 | raise
88 |
89 | response = self.session.response_or_error(
90 | method="POST",
91 | url=self.session.urlformat(self.urls.post, project_id=pid),
92 | logger=log,
93 | json=json,
94 | )
95 | data = response.json()
96 | return ProjectAppUser(**data)
97 |
--------------------------------------------------------------------------------
/pyodk/_endpoints/projects.py:
--------------------------------------------------------------------------------
1 | import logging
2 | from collections.abc import Iterable
3 | from dataclasses import dataclass
4 | from datetime import datetime
5 | from typing import Any
6 |
7 | from pyodk._endpoints.bases import Model, Service
8 | from pyodk._endpoints.form_assignments import FormAssignmentService
9 | from pyodk._endpoints.project_app_users import ProjectAppUser, ProjectAppUserService
10 | from pyodk._utils import validators as pv
11 | from pyodk._utils.session import Session
12 | from pyodk.errors import PyODKError
13 |
14 | log = logging.getLogger(__name__)
15 |
16 |
17 | class Project(Model):
18 | id: int
19 | name: str
20 | createdAt: datetime
21 | description: str | None = None
22 | archived: bool | None = None
23 | keyId: int | None = None
24 | appUsers: int | None = None
25 | forms: int | None = None
26 | lastSubmission: str | None = None
27 | updatedAt: datetime | None = None
28 | deletedAt: datetime | None = None
29 |
30 |
31 | @dataclass(frozen=True, slots=True)
32 | class URLs:
33 | list: str = "projects"
34 | get: str = "projects/{project_id}"
35 | get_data: str = "projects/{project_id}/forms/{form_id}.svc/{table_name}"
36 | post_app_users: str = "projects/{project_id}/app-users"
37 |
38 |
39 | class ProjectService(Service):
40 | """
41 | Project-related functionality is accessed through `client.projects`. For example:
42 |
43 | ```python
44 | from pyodk.client import Client
45 |
46 | client = Client()
47 | projects = client.projects.list()
48 | ```
49 | """
50 |
51 | __slots__ = ("default_project_id", "session", "urls")
52 |
53 | def __init__(
54 | self,
55 | session: Session,
56 | default_project_id: int | None = None,
57 | urls: URLs = None,
58 | ):
59 | self.urls: URLs = urls if urls is not None else URLs()
60 | self.session: Session = session
61 | self.default_project_id: int | None = default_project_id
62 |
63 | def _default_kw(self) -> dict[str, Any]:
64 | return {
65 | "default_project_id": self.default_project_id,
66 | }
67 |
68 | def list(self) -> list[Project]:
69 | """
70 | Read Project details.
71 |
72 | :return: An list of object representations of the Projects' metadata.
73 | """
74 | response = self.session.response_or_error(
75 | method="GET",
76 | url=self.urls.list,
77 | logger=log,
78 | )
79 | data = response.json()
80 | return [Project(**r) for r in data]
81 |
82 | def get(self, project_id: int | None = None) -> Project:
83 | """
84 | Read all Project details.
85 |
86 | :param project_id: The id of the project to read.
87 |
88 | :return: An object representation of the Project's metadata.
89 | """
90 | try:
91 | pid = pv.validate_project_id(project_id, self.default_project_id)
92 | except PyODKError as err:
93 | log.error(err, exc_info=True)
94 | raise
95 | else:
96 | response = self.session.response_or_error(
97 | method="GET",
98 | url=self.session.urlformat(self.urls.get, project_id=pid),
99 | logger=log,
100 | )
101 | data = response.json()
102 | return Project(**data)
103 |
104 | def create_app_users(
105 | self,
106 | display_names: Iterable[str],
107 | forms: Iterable[str] | None = None,
108 | project_id: int | None = None,
109 | ) -> Iterable[ProjectAppUser]:
110 | """
111 | Create new project app users and optionally assign forms to them.
112 |
113 | :param display_names: The friendly nicknames of the app users to be created.
114 | :param forms: The xmlFormIds of the forms to assign the app users to.
115 | :param project_id: The id of the project this form belongs to.
116 | """
117 | if display_names is None:
118 | raise PyODKError("Must specify display_names.")
119 |
120 | pid = {"project_id": project_id}
121 | pau = ProjectAppUserService(session=self.session, **self._default_kw())
122 | fa = FormAssignmentService(session=self.session, **self._default_kw())
123 |
124 | current = {u.displayName for u in pau.list(**pid) if u.token is not None}
125 | to_create = (user for user in display_names if user not in current)
126 | users = [pau.create(display_name=n, **pid) for n in to_create]
127 | # The "App User" role_id should always be "2", so no need to look it up by name.
128 | # Ref: "https://github.com/getodk/central-backend/blob/9db0d792cf4640ec7329722984
129 | # cebdee3687e479/lib/model/migrations/20181212-01-add-roles.js"
130 | # See also roles data in `tests/resources/projects_data.py`.
131 | if forms is not None:
132 | for user in users:
133 | for form_id in forms:
134 | if not fa.assign(role_id=2, user_id=user.id, form_id=form_id, **pid):
135 | raise PyODKError("Role assignment failed.")
136 |
137 | return users
138 |
--------------------------------------------------------------------------------
/pyodk/_endpoints/submission_attachments.py:
--------------------------------------------------------------------------------
1 | import logging
2 | import mimetypes
3 | from dataclasses import dataclass
4 | from os import PathLike
5 |
6 | from pyodk._endpoints.bases import Model, Service
7 | from pyodk._utils import validators as pv
8 | from pyodk._utils.session import Session
9 | from pyodk.errors import PyODKError
10 |
11 | log = logging.getLogger(__name__)
12 |
13 |
14 | class SubmissionAttachment(Model):
15 | name: str
16 | exists: bool
17 |
18 |
19 | @dataclass(frozen=True, slots=True)
20 | class URLs:
21 | _submission: str = "projects/{project_id}/forms/{form_id}/submissions/{instance_id}"
22 | list: str = f"{_submission}/attachments"
23 | post: str = f"{_submission}/attachments/{{fname}}"
24 |
25 |
26 | class SubmissionAttachmentService(Service):
27 | __slots__ = (
28 | "default_form_id",
29 | "default_instance_id",
30 | "default_project_id",
31 | "session",
32 | "urls",
33 | )
34 |
35 | def __init__(
36 | self,
37 | session: Session,
38 | default_project_id: int | None = None,
39 | default_form_id: str | None = None,
40 | default_instance_id: str | None = None,
41 | urls: URLs = None,
42 | ):
43 | self.urls: URLs = urls if urls is not None else URLs()
44 | self.session: Session = session
45 | self.default_project_id: int | None = default_project_id
46 | self.default_form_id: str | None = default_form_id
47 | self.default_instance_id: str | None = default_instance_id
48 |
49 | def list(
50 | self,
51 | form_id: str | None = None,
52 | project_id: int | None = None,
53 | instance_id: str | None = None,
54 | ) -> list[SubmissionAttachment]:
55 | """
56 | Read all Submission Attachment details.
57 |
58 | :param form_id: The xmlFormId of the Form being referenced.
59 | :param project_id: The id of the project the Submissions belong to.
60 | :param instance_id: The instanceId of the Submission being referenced.
61 | """
62 | try:
63 | pid = pv.validate_project_id(project_id, self.default_project_id)
64 | fid = pv.validate_form_id(form_id, self.default_form_id)
65 | iid = pv.validate_instance_id(instance_id, self.default_instance_id)
66 | except PyODKError as err:
67 | log.error(err, exc_info=True)
68 | raise
69 |
70 | response = self.session.response_or_error(
71 | method="GET",
72 | url=self.session.urlformat(
73 | self.urls.list, project_id=pid, form_id=fid, instance_id=iid
74 | ),
75 | logger=log,
76 | )
77 | data = response.json()
78 | return [SubmissionAttachment(**r) for r in data]
79 |
80 | def upload(
81 | self,
82 | file_path: PathLike | str,
83 | file_name: str | None = None,
84 | project_id: int | None = None,
85 | form_id: str | None = None,
86 | instance_id: str | None = None,
87 | ) -> bool:
88 | """
89 | Upload a Submission Attachment.
90 |
91 | :param file_path: The path to the file to upload.
92 | :param file_name: A name for the file, otherwise the name in file_path is used.
93 | :param project_id: The id of the project this form belongs to.
94 | :param form_id: The xmlFormId of the Form being referenced.
95 | :param instance_id: The instanceId of the Submission being referenced.
96 | """
97 | try:
98 | pid = pv.validate_project_id(project_id, self.default_project_id)
99 | fid = pv.validate_form_id(form_id, self.default_form_id)
100 | iid = pv.validate_instance_id(instance_id, self.default_instance_id)
101 | file_path = pv.validate_file_path(file_path)
102 | if file_name is None:
103 | file_name = pv.validate_str(file_path.name, key="file_name")
104 | guess_type, guess_encoding = mimetypes.guess_type(file_name)
105 | headers = {
106 | "Transfer-Encoding": "chunked",
107 | "Content-Type": guess_type or "application/octet-stream",
108 | }
109 | if guess_encoding: # associated compression type, if any.
110 | headers["Content-Encoding"] = guess_encoding
111 | except PyODKError as err:
112 | log.error(err, exc_info=True)
113 | raise
114 |
115 | def file_stream():
116 | # Generator forces requests to read/send in chunks instead of all at once.
117 | with open(file_path, "rb") as f:
118 | while chunk := f.read(self.session.blocksize):
119 | yield chunk
120 |
121 | response = self.session.response_or_error(
122 | method="POST",
123 | url=self.session.urlformat(
124 | self.urls.post,
125 | project_id=pid,
126 | form_id=fid,
127 | instance_id=iid,
128 | fname=file_name,
129 | ),
130 | logger=log,
131 | headers=headers,
132 | data=file_stream(),
133 | )
134 | data = response.json()
135 | return data["success"]
136 |
--------------------------------------------------------------------------------
/pyodk/_utils/__init__.py:
--------------------------------------------------------------------------------
1 | __all__ = ()
2 |
--------------------------------------------------------------------------------
/pyodk/_utils/config.py:
--------------------------------------------------------------------------------
1 | import logging
2 | import os
3 | from dataclasses import dataclass, field
4 | from pathlib import Path
5 |
6 | import toml
7 |
8 | from pyodk.errors import PyODKError
9 |
10 | log = logging.getLogger(__name__)
11 |
12 | defaults = {
13 | "PYODK_CONFIG_FILE": Path.home() / ".pyodk_config.toml",
14 | "PYODK_CACHE_FILE": Path.home() / ".pyodk_cache.toml",
15 | }
16 |
17 |
18 | @dataclass
19 | class CentralConfig:
20 | base_url: str
21 | username: str
22 | password: str = field(repr=False)
23 | default_project_id: int | None = None
24 |
25 | def validate(self):
26 | for key in ["base_url", "username", "password"]: # Mandatory keys.
27 | if getattr(self, key) is None or getattr(self, key) == "":
28 | err = PyODKError(f"Config value '{key}' must not be empty.")
29 | log.error(err, exc_info=True)
30 | raise err
31 |
32 | def __post_init__(self):
33 | self.validate()
34 |
35 |
36 | @dataclass
37 | class Config:
38 | central: CentralConfig
39 |
40 |
41 | def objectify_config(config_data: dict) -> Config:
42 | """
43 | Convert a config dict into objects to validate the data.
44 | """
45 | central = CentralConfig(**config_data["central"])
46 | config = Config(central=central)
47 | return config
48 |
49 |
50 | def get_path(path: str, env_key: str) -> Path:
51 | """
52 | Get a path from the path argument, the environment key, or the default.
53 | """
54 | if path is not None:
55 | return Path(path)
56 | env_file_path = os.environ.get(env_key)
57 | if env_file_path is not None:
58 | return Path(env_file_path)
59 | return defaults[env_key]
60 |
61 |
62 | def get_config_path(config_path: str | None = None) -> Path:
63 | return get_path(path=config_path, env_key="PYODK_CONFIG_FILE")
64 |
65 |
66 | def get_cache_path(cache_path: str | None = None) -> Path:
67 | return get_path(path=cache_path, env_key="PYODK_CACHE_FILE")
68 |
69 |
70 | def read_toml(path: Path) -> dict:
71 | """
72 | Read a toml file.
73 | """
74 | try:
75 | with open(path) as f:
76 | return toml.load(f)
77 | except (FileNotFoundError, PermissionError) as err:
78 | pyodk_err = PyODKError(f"Could not read file at: {path}. {err!r}.")
79 | log.error(pyodk_err, exc_info=True)
80 | raise pyodk_err from err
81 |
82 |
83 | def read_config(config_path: str | None = None) -> Config:
84 | """
85 | Read the config file.
86 | """
87 | file_path = get_path(path=config_path, env_key="PYODK_CONFIG_FILE")
88 | file_data = read_toml(path=file_path)
89 | return objectify_config(config_data=file_data)
90 |
91 |
92 | def read_cache_token(cache_path: str | None = None) -> str:
93 | """
94 | Read the "token" key from the cache file.
95 | """
96 | file_path = get_cache_path(cache_path=cache_path)
97 | file_data = read_toml(path=file_path)
98 | if "token" not in file_data:
99 | err = PyODKError(f"Cached token not found in file: {file_path}")
100 | log.error(err, exc_info=True)
101 | raise err
102 | return file_data["token"]
103 |
104 |
105 | def write_cache(key: str, value: str, cache_path: str | None = None) -> None:
106 | """
107 | Append or overwrite the given key/value pair to the cache file.
108 | """
109 | file_path = get_cache_path(cache_path=cache_path)
110 | if file_path.exists() and file_path.is_file():
111 | file_data = read_toml(path=file_path)
112 | file_data[key] = value
113 | else:
114 | file_data = {key: value}
115 | with open(file_path, "w") as outfile:
116 | toml.dump(file_data, outfile)
117 |
118 |
119 | def delete_cache(cache_path: str | None = None) -> None:
120 | """
121 | Delete the cache file, if it exists.
122 | """
123 | file_path = get_cache_path(cache_path=cache_path)
124 | file_path.unlink(missing_ok=True)
125 |
--------------------------------------------------------------------------------
/pyodk/_utils/session.py:
--------------------------------------------------------------------------------
1 | from logging import Logger
2 | from string import Formatter
3 | from typing import Any
4 | from urllib.parse import quote, urljoin
5 | from uuid import uuid4
6 |
7 | from requests import PreparedRequest, Response
8 | from requests import Session as RequestsSession
9 | from requests.adapters import HTTPAdapter, Retry
10 | from requests.auth import AuthBase
11 | from requests.exceptions import HTTPError
12 |
13 | from pyodk.__version__ import __version__
14 | from pyodk._endpoints.auth import AuthService
15 | from pyodk.errors import PyODKError
16 |
17 |
18 | class URLFormatter(Formatter):
19 | """
20 | Makes a valid URL by sending each format input field through urllib.parse.quote.
21 |
22 | To parse/un-parse URLs, currently (v2023.5) Central uses JS default functions
23 | encodeURIComponent and decodeURIComponent, which comply with RFC2396. The more recent
24 | RFC3986 reserves hex characters 2A (asterisk), 27 (single quote), 28 (left
25 | parenthesis), and 29 (right parenthesis). Python 3.7+ urllib.parse complies with
26 | RFC3986 so in order for pyODK to behave as Central expects, these additional 4
27 | characters are specified as "safe" in `format_field()` to not percent-encode them.
28 |
29 | Currently (v2023.5) Central primarily supports the default submission instanceID
30 | format per the XForm spec, namely "uuid:" followed by the 36 character UUID string.
31 | In many endpoints, custom UUIDs (including non-ASCII/UTF-8 chars) will work, but in
32 | some places they won't. For example the Central page for viewing submission details
33 | fails on the Submissions OData call, because the OData function to filter by ID
34 | (`Submission('instanceId')`) only works for the default instanceID format.
35 | """
36 |
37 | def format_field(self, value: Any, format_spec: str) -> Any:
38 | return format(quote(str(value), safe="*'()"), format_spec)
39 |
40 |
41 | _URL_FORMATTER = URLFormatter()
42 |
43 |
44 | class Adapter(HTTPAdapter):
45 | def __init__(self, *args, **kwargs):
46 | if "timeout" in kwargs:
47 | self.timeout = kwargs["timeout"]
48 | del kwargs["timeout"]
49 | if "max_retries" not in kwargs:
50 | kwargs["max_retries"] = Retry(
51 | total=3,
52 | backoff_factor=2,
53 | status_forcelist=(429, 500, 502, 503, 504),
54 | allowed_methods=("GET", "PUT", "POST", "DELETE"),
55 | )
56 | if (blocksize := kwargs.get("blocksize")) is not None:
57 | self.blocksize = blocksize
58 | del kwargs["blocksize"]
59 | super().__init__(*args, **kwargs)
60 |
61 | def init_poolmanager(self, *args, **kwargs):
62 | if kwargs.get("blocksize") is None and hasattr(self, "blocksize"):
63 | kwargs["blocksize"] = self.blocksize
64 | super().init_poolmanager(*args, **kwargs)
65 |
66 | def send(self, request, **kwargs):
67 | timeout = kwargs.get("timeout")
68 | if timeout is None and hasattr(self, "timeout"):
69 | kwargs["timeout"] = self.timeout
70 | return super().send(request, **kwargs)
71 |
72 |
73 | class Auth(AuthBase):
74 | def __init__(self, session: "Session", username: str, password: str, cache_path: str):
75 | self.session: Session = session
76 | self.username: str = username
77 | self.password: str = password
78 | self.service: AuthService = AuthService(session=session, cache_path=cache_path)
79 | self._skip_auth_check: bool = False
80 |
81 | def login(self) -> str:
82 | """
83 | Log in to Central (create new session or verify existing).
84 |
85 | :return: Bearer
86 | """
87 | if "Authorization" not in self.session.headers:
88 | try:
89 | self._skip_auth_check = True # Avoid loop of death due to the below call.
90 | t = self.service.get_token(username=self.username, password=self.password)
91 | self.session.headers["Authorization"] = "Bearer " + t
92 | finally:
93 | self._skip_auth_check = False
94 | return self.session.headers["Authorization"]
95 |
96 | def __call__(self, r: PreparedRequest, *args, **kwargs):
97 | if "Authorization" not in r.headers and not self._skip_auth_check:
98 | r.headers["Authorization"] = self.login()
99 | return r
100 |
101 |
102 | class Session(RequestsSession):
103 | def __init__(
104 | self,
105 | base_url: str,
106 | api_version: str,
107 | username: str,
108 | password: str,
109 | cache_path: str,
110 | chunk_size: int = 16384,
111 | ) -> None:
112 | """
113 | :param base_url: Scheme/domain/port parts of the URL e.g. https://www.example.com
114 | :param api_version: The Central API version (first part of the URL path).
115 | :param username: The Central user name to log in with.
116 | :param password: The Central user's password to log in with.
117 | :param cache_path: Where to read/write pyodk_cache.toml.
118 | :param chunk_size: In bytes. For transferring large files (e.g. >1MB), it may be
119 | noticeably faster to use larger chunks than the default 16384 bytes (16KB).
120 | """
121 | super().__init__()
122 | self.base_url: str = self.base_url_validate(
123 | base_url=base_url, api_version=api_version
124 | )
125 | self.blocksize: int = chunk_size
126 | self.mount("https://", Adapter(timeout=30, blocksize=self.blocksize))
127 | self.headers.update({"User-Agent": f"pyodk v{__version__}"})
128 | self.auth: Auth = Auth(
129 | session=self, username=username, password=password, cache_path=cache_path
130 | )
131 |
132 | @staticmethod
133 | def base_url_validate(base_url: str, api_version: str):
134 | if not base_url.endswith(f"{api_version}/"):
135 | if base_url.endswith(api_version):
136 | base_url = base_url + "/"
137 | elif not base_url.endswith(api_version):
138 | base_url = base_url.rstrip("/") + f"/{api_version}/"
139 | return base_url
140 |
141 | def urljoin(self, url: str) -> str:
142 | return urljoin(self.base_url, url.lstrip("/"))
143 |
144 | @staticmethod
145 | def urlformat(url: str, *args, **kwargs) -> str:
146 | return _URL_FORMATTER.format(url, *args, **kwargs)
147 |
148 | @staticmethod
149 | def urlquote(url: str) -> str:
150 | return _URL_FORMATTER.format_field(url, format_spec="")
151 |
152 | def request(self, method, url, *args, **kwargs):
153 | return super().request(method, self.urljoin(url), *args, **kwargs)
154 |
155 | def prepare_request(self, request):
156 | request.url = self.urljoin(request.url)
157 | return super().prepare_request(request)
158 |
159 | def response_or_error(
160 | self, method: str, url: str, logger: Logger, *args, **kwargs
161 | ) -> Response:
162 | response = self.request(*args, method=method, url=url, **kwargs)
163 | try:
164 | response.raise_for_status()
165 | except HTTPError as e:
166 | msg = (
167 | f"The request to {self.urljoin(url)} failed."
168 | f" Status: {response.status_code}, content: {response.text}"
169 | )
170 | err = PyODKError(msg, response)
171 | logger.error(err, exc_info=True)
172 | raise err from e
173 | else:
174 | return response
175 |
176 | @staticmethod
177 | def get_xform_uuid() -> str:
178 | """
179 | Get XForm UUID, which is "uuid:" followed by a random uuid v4.
180 | """
181 | return f"uuid:{uuid4()}"
182 |
--------------------------------------------------------------------------------
/pyodk/_utils/utils.py:
--------------------------------------------------------------------------------
1 | def coalesce(*args):
2 | return next((a for a in args if a is not None), None)
3 |
--------------------------------------------------------------------------------
/pyodk/_utils/validators.py:
--------------------------------------------------------------------------------
1 | from collections.abc import Callable
2 | from os import PathLike
3 | from pathlib import Path
4 | from typing import Any
5 |
6 | from pydantic.v1 import validators as v
7 | from pydantic.v1.errors import PydanticTypeError, PydanticValueError
8 |
9 | from pyodk._utils.utils import coalesce
10 | from pyodk.errors import PyODKError
11 |
12 |
13 | def wrap_error(validator: Callable, key: str, value: Any) -> Any:
14 | """
15 | Wrap the error in a PyODKError, with a nicer message.
16 |
17 | :param validator: A pydantic validator function.
18 | :param key: The variable name to use in the error message.
19 | :param value: The variable value.
20 | :return:
21 | """
22 | try:
23 | return validator(value)
24 | except (PydanticTypeError, PydanticValueError) as err:
25 | msg = f"{key}: {err!s}"
26 | raise PyODKError(msg) from err
27 |
28 |
29 | def validate_project_id(*args: int) -> int:
30 | return wrap_error(
31 | validator=v.int_validator,
32 | key="project_id",
33 | value=coalesce(*args),
34 | )
35 |
36 |
37 | def validate_form_id(*args: str) -> str:
38 | return wrap_error(
39 | validator=v.str_validator,
40 | key="form_id",
41 | value=coalesce(*args),
42 | )
43 |
44 |
45 | def validate_table_name(*args: str) -> str:
46 | return wrap_error(
47 | validator=v.str_validator,
48 | key="table_name",
49 | value=coalesce(*args),
50 | )
51 |
52 |
53 | def validate_instance_id(*args: str) -> str:
54 | return wrap_error(
55 | validator=v.str_validator,
56 | key="instance_id",
57 | value=coalesce(*args),
58 | )
59 |
60 |
61 | def validate_entity_list_name(*args: str) -> str:
62 | return wrap_error(
63 | validator=v.str_validator,
64 | key="entity_list_name",
65 | value=coalesce(*args),
66 | )
67 |
68 |
69 | def validate_str(*args: str, key: str) -> str:
70 | return wrap_error(
71 | validator=v.str_validator,
72 | key=key,
73 | value=coalesce(*args),
74 | )
75 |
76 |
77 | def validate_bool(*args: bool, key: str) -> str:
78 | return wrap_error(
79 | validator=v.bool_validator,
80 | key=key,
81 | value=coalesce(*args),
82 | )
83 |
84 |
85 | def validate_int(*args: int, key: str) -> int:
86 | return wrap_error(
87 | validator=v.int_validator,
88 | key=key,
89 | value=coalesce(*args),
90 | )
91 |
92 |
93 | def validate_dict(*args: dict, key: str) -> int:
94 | return wrap_error(
95 | validator=v.dict_validator,
96 | key=key,
97 | value=coalesce(*args),
98 | )
99 |
100 |
101 | def validate_file_path(*args: PathLike | str, key: str = "file_path") -> Path:
102 | def validate_fp(f):
103 | p = v.path_validator(f)
104 | return v.path_exists_validator(p)
105 |
106 | return wrap_error(validator=validate_fp, key=key, value=coalesce(*args))
107 |
108 |
109 | def validate_is_instance(*args: Any, typ: Any, key: str):
110 | val = coalesce(*args)
111 | if not isinstance(val, typ):
112 | raise PyODKError(f"{key}: Unexpected type. Expected '{typ}'.")
113 | return val
114 |
--------------------------------------------------------------------------------
/pyodk/client.py:
--------------------------------------------------------------------------------
1 | from collections.abc import Callable
2 |
3 | from pyodk._endpoints.comments import CommentService
4 | from pyodk._endpoints.entities import EntityService
5 | from pyodk._endpoints.entity_lists import EntityListService
6 | from pyodk._endpoints.forms import FormService
7 | from pyodk._endpoints.projects import ProjectService
8 | from pyodk._endpoints.submissions import SubmissionService
9 | from pyodk._utils import config
10 | from pyodk._utils.session import Session
11 |
12 |
13 | class Client:
14 | """
15 | A connection to a specific ODK Central server. Manages authentication and provides
16 | access to Central functionality through methods organized by the Central resource
17 | they are most related to.
18 |
19 | :param config_path: Where to read the pyodk_config.toml. Defaults to the
20 | path in PYODK_CONFIG_FILE, then the user home directory.
21 | :param cache_path: Where to read/write pyodk_cache.toml. Defaults to the
22 | path in PYODK_CACHE_FILE, then the user home directory.
23 | :param project_id: The project ID to use for all client calls. Defaults to the
24 | "default_project_id" in pyodk_config.toml, or can be specified per call.
25 | :param session: A prepared pyodk.session.Session class instance, or an instance
26 | of a customised subclass.
27 | :param api_version: The ODK Central API version, which is used in the URL path
28 | e.g. 'v1' in 'https://www.example.com/v1/projects'.
29 | """
30 |
31 | def __init__(
32 | self,
33 | config_path: str | None = None,
34 | cache_path: str | None = None,
35 | project_id: int | None = None,
36 | session: Session | None = None,
37 | api_version: str | None = "v1",
38 | ) -> None:
39 | self.config: config.Config = config.read_config(config_path=config_path)
40 | self._project_id: int | None = project_id
41 | if session is None:
42 | session = Session(
43 | base_url=self.config.central.base_url,
44 | api_version=api_version,
45 | username=self.config.central.username,
46 | password=self.config.central.password,
47 | cache_path=cache_path,
48 | )
49 | self.session: Session = session
50 |
51 | # Delegate http verbs for ease of use.
52 | self.get: Callable = self.session.get
53 | self.post: Callable = self.session.post
54 | self.put: Callable = self.session.put
55 | self.patch: Callable = self.session.patch
56 | self.delete: Callable = self.session.delete
57 |
58 | # Endpoints
59 | self.projects: ProjectService = ProjectService(
60 | session=self.session,
61 | default_project_id=self.project_id,
62 | )
63 | self.forms: FormService = FormService(
64 | session=self.session, default_project_id=self.project_id
65 | )
66 | self.submissions: SubmissionService = SubmissionService(
67 | session=self.session, default_project_id=self.project_id
68 | )
69 | self._comments: CommentService = CommentService(
70 | session=self.session, default_project_id=self.project_id
71 | )
72 | self.entities: EntityService = EntityService(
73 | session=self.session, default_project_id=self.project_id
74 | )
75 | self.entity_lists: EntityListService = EntityListService(
76 | session=self.session, default_project_id=self.project_id
77 | )
78 |
79 | @property
80 | def project_id(self) -> int | None:
81 | if self._project_id is None:
82 | return self.config.central.default_project_id
83 | else:
84 | return self._project_id
85 |
86 | @project_id.setter
87 | def project_id(self, v: str):
88 | self._project_id = v
89 |
90 | def open(self) -> "Client":
91 | """Enter the session, and authenticate."""
92 | self.session.__enter__()
93 | self.session.auth.login()
94 | return self
95 |
96 | def close(self, *args):
97 | """Close the session."""
98 | self.session.__exit__(*args)
99 |
100 | def __enter__(self) -> "Client":
101 | return self.open()
102 |
103 | def __exit__(self, *args):
104 | self.close(*args)
105 |
--------------------------------------------------------------------------------
/pyodk/errors.py:
--------------------------------------------------------------------------------
1 | from requests import Response
2 |
3 |
4 | class PyODKError(Exception):
5 | """An error raised by pyodk."""
6 |
7 | def is_central_error(self, code: float | str) -> bool:
8 | """
9 | Does the PyODK error represent a Central error with the specified code?
10 |
11 | Per central-backend/lib/util/problem.js.
12 | """
13 | if len(self.args) >= 2 and isinstance(self.args[1], Response):
14 | err_detail = self.args[1].json()
15 | err_code = str(err_detail.get("code", ""))
16 | if err_code is not None and err_code == str(code):
17 | return True
18 | return False
19 |
--------------------------------------------------------------------------------
/pyproject.toml:
--------------------------------------------------------------------------------
1 | [project]
2 | name = "pyodk"
3 | version = "1.2.1"
4 | authors = [
5 | {name = "github.com/getodk", email = "support@getodk.org"},
6 | ]
7 | description = "The official Python library for ODK 🐍"
8 | readme = "README.md"
9 | requires-python = ">=3.10"
10 | dependencies = [
11 | "requests>=2.32.4,<2.33", # HTTP with Central.
12 | "toml==0.10.2", # Configuration files
13 | "pydantic>=2.6.4,<=2.11.7", # Data validation. Ensure actions verify.yml matches range.
14 | ]
15 |
16 | [project.optional-dependencies]
17 | # Install with `pip install pyodk[dev]`.
18 | dev = [
19 | "ruff==0.12.4", # Format and lint
20 | "openpyxl==3.1.5", # Create test XLSX files
21 | "xlwt==1.3.0", # Create test XLS files
22 | ]
23 | docs = [
24 | "mkdocs==1.6.1",
25 | "mkdocstrings==0.28.3",
26 | "mkdocstrings-python==1.16.3",
27 | "mkdocs-jupyter==0.25.1",
28 | ]
29 |
30 | [project.urls]
31 | Homepage = "https://pypi.python.org/pypi/pyodk/"
32 | Repository = "https://github.com/getodk/pyodk/"
33 |
34 | [build-system]
35 | requires = ["flit_core >=3.2,<4"]
36 | build-backend = "flit_core.buildapi"
37 |
38 | [tool.flit.module]
39 | name = "pyodk"
40 |
41 | [tool.flit.sdist]
42 | exclude = ["docs", "tests"]
43 |
44 | [tool.ruff]
45 | line-length = 90
46 | target-version = "py310"
47 | fix = true
48 | show-fixes = true
49 | output-format = "full"
50 | src = ["pyodk", "tests"]
51 |
52 | [tool.ruff.lint]
53 | # By default, ruff enables flake8's F rules, along with a subset of the E rules.
54 | select = [
55 | "B", # flake8-bugbear
56 | "C4", # flake8-comprehensions
57 | "E", # pycodestyle error
58 | # "ERA", # eradicate (commented out code)
59 | "F", # pyflakes
60 | "I", # isort
61 | "PERF", # perflint
62 | "PIE", # flake8-pie
63 | "PL", # pylint
64 | # "PTH", # flake8-use-pathlib
65 | "PYI", # flake8-pyi
66 | # "RET", # flake8-return
67 | "RUF", # ruff-specific rules
68 | "S", # flake8-bandit
69 | # "SIM", # flake8-simplify
70 | "TRY", # tryceratops
71 | "UP", # pyupgrade
72 | "W", # pycodestyle warning
73 | ]
74 | ignore = [
75 | "E501", # line-too-long (we have a lot of long strings)
76 | "F821", # undefined-name (doesn't work well with type hints, ruff 0.1.11).
77 | "PERF401", # manual-list-comprehension (false positives on selective transforms)
78 | "PERF402", # manual-list-copy (false positives on selective transforms)
79 | "PLR0911", # too-many-return-statements (complexity not useful to warn every time)
80 | "PLR0912", # too-many-branches (complexity not useful to warn every time)
81 | "PLR0913", # too-many-arguments (complexity not useful to warn every time)
82 | "PLR0915", # too-many-statements (complexity not useful to warn every time)
83 | "PLR2004", # magic-value-comparison (many tests expect certain numbers of things)
84 | "PLW2901", # redefined-loop-name (usually not a bug)
85 | "RUF001", # ambiguous-unicode-character-string (false positives on unicode tests)
86 | "S310", # suspicious-url-open-usage (prone to false positives, ruff 0.1.11)
87 | "S603", # subprocess-without-shell-equals-true (prone to false positives, ruff 0.1.11)
88 | "TRY003", # raise-vanilla-args (reasonable lint but would require large refactor)
89 | ]
90 | # per-file-ignores = {"tests/*" = ["E501"]}
91 |
--------------------------------------------------------------------------------
/runtime.txt:
--------------------------------------------------------------------------------
1 | python-3.12
2 |
--------------------------------------------------------------------------------
/tests/__init__.py:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/getodk/pyodk/9ba3b59770df034b01b5b0cd10db82ea4fe3b223/tests/__init__.py
--------------------------------------------------------------------------------
/tests/endpoints/__init__.py:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/getodk/pyodk/9ba3b59770df034b01b5b0cd10db82ea4fe3b223/tests/endpoints/__init__.py
--------------------------------------------------------------------------------
/tests/endpoints/test_auth.py:
--------------------------------------------------------------------------------
1 | from unittest import TestCase
2 | from unittest.mock import MagicMock, patch
3 |
4 | from pyodk._endpoints.auth import AuthService
5 | from pyodk._utils import config
6 | from pyodk.client import Client
7 | from pyodk.errors import PyODKError
8 | from requests import Session
9 |
10 | from tests.resources import CONFIG_DATA
11 | from tests.utils.utils import get_temp_dir
12 |
13 |
14 | @patch("pyodk._utils.config.read_config", MagicMock(return_value=CONFIG_DATA))
15 | class TestAuth(TestCase):
16 | """Test login."""
17 |
18 | def test_get_new_token__ok(self):
19 | """Should return the token from the response data."""
20 | with patch.object(Session, "post") as mock_session:
21 | mock_session.return_value.status_code = 200
22 | mock_session.return_value.json.return_value = {"token": "here"}
23 | conf = config.read_config().central
24 | client = Client()
25 | with client.session as s:
26 | token = s.auth.service.get_new_token(conf.username, conf.password)
27 | self.assertEqual("here", token)
28 |
29 | def test_get_new_token__error__response_status(self):
30 | """Should raise an error if login request is not OK (HTTP 200)."""
31 | with patch.object(Session, "post") as mock_session:
32 | mock_session.return_value.status_code = 404
33 | conf = config.read_config().central
34 | client = Client()
35 | with client.session as s, self.assertRaises(PyODKError) as err:
36 | s.auth.service.get_new_token(conf.username, conf.password)
37 | msg = "The login request failed. Status:"
38 | self.assertTrue(err.exception.args[0].startswith(msg))
39 |
40 | def test_get_new_token__error__response_data(self):
41 | """Should raise an error if login token not found in response data."""
42 | with patch.object(Session, "post") as mock_session:
43 | mock_session.return_value.status_code = 200
44 | mock_session.return_value.json.return_value = {"not": "here"}
45 | conf = config.read_config().central
46 | client = Client()
47 | with client.session as s, self.assertRaises(PyODKError) as err:
48 | s.auth.service.get_new_token(conf.username, conf.password)
49 | msg = "The login request was OK but there was no token in the response."
50 | self.assertTrue(err.exception.args[0].startswith(msg))
51 |
52 | def test_verify_token__ok(self):
53 | """Should return the token."""
54 | with patch.object(Session, "get") as mock_session:
55 | mock_session.return_value.status_code = 200
56 | client = Client()
57 | with client.session as s:
58 | token = s.auth.service.verify_token(token="123") # noqa: S106
59 | self.assertEqual("123", token)
60 |
61 | def test_verify_token__error__response_status(self):
62 | """Should raise an error if the request is not OK (HTTP 200)."""
63 | with patch.object(Session, "get") as mock_session:
64 | mock_session.return_value.status_code = 401
65 | client = Client()
66 | with client.session as s, self.assertRaises(PyODKError) as err:
67 | s.auth.service.verify_token(token="123") # noqa: S106
68 | msg = "The token verification request failed. Status:"
69 | self.assertTrue(err.exception.args[0].startswith(msg))
70 |
71 | def test_get_token__ok__new_cache(self):
72 | """Should return the token, and write it to the cache file."""
73 | with (
74 | patch.multiple(
75 | AuthService,
76 | get_new_token=MagicMock(return_value="123"),
77 | ),
78 | get_temp_dir() as tmp,
79 | ):
80 | cache_path = (tmp / "test_cache.toml").as_posix()
81 | client = Client(cache_path=cache_path)
82 | token = client.session.auth.service.get_token(
83 | username="user",
84 | password="pass", # noqa: S106
85 | )
86 | self.assertEqual("123", token)
87 | cache = config.read_cache_token(cache_path=cache_path)
88 | self.assertEqual("123", cache)
89 |
90 | def test_get_token__error__new_cache_bad_response(self):
91 | """Should raise an error, when no existing token and new token request fails."""
92 | verify_mock = MagicMock()
93 | verify_mock.side_effect = PyODKError("The token verification request failed.")
94 | get_new_mock = MagicMock()
95 | get_new_mock.side_effect = PyODKError("The login request failed.")
96 | with (
97 | patch.multiple(
98 | AuthService,
99 | verify_token=verify_mock,
100 | get_new_token=get_new_mock,
101 | ),
102 | get_temp_dir() as tmp,
103 | self.assertRaises(PyODKError) as err,
104 | ):
105 | cache_path = tmp / "test_cache.toml"
106 | client = Client(cache_path=cache_path.as_posix())
107 | client.session.auth.service.get_token(username="user", password="pass") # noqa: S106
108 | self.assertFalse(cache_path.exists())
109 | self.assertTrue(err.exception.args[0].startswith("The login request failed."))
110 |
111 | def test_get_token__ok__existing_cache(self):
112 | """Should return the token from the cache file."""
113 | with (
114 | patch.multiple(
115 | AuthService,
116 | verify_token=MagicMock(return_value="123"),
117 | ),
118 | get_temp_dir() as tmp,
119 | ):
120 | cache_path = (tmp / "test_cache.toml").as_posix()
121 | client = Client(cache_path=cache_path)
122 | config.write_cache("token", "123", cache_path=cache_path)
123 | token = client.session.auth.service.get_token(
124 | username="user",
125 | password="pass", # noqa: S106
126 | )
127 | self.assertEqual("123", token)
128 | cache = config.read_cache_token(cache_path=cache_path)
129 | self.assertEqual("123", cache)
130 |
131 | def test_get_token__error__existing_cache_bad_response(self):
132 | """Should get a new token, when verification of an existing token fails."""
133 | verify_mock = MagicMock()
134 | verify_mock.side_effect = PyODKError("The token verification request failed.")
135 | with (
136 | patch.multiple(
137 | AuthService,
138 | verify_token=verify_mock,
139 | get_new_token=MagicMock(return_value="123"),
140 | ),
141 | get_temp_dir() as tmp,
142 | ):
143 | cache_path = (tmp / "test_cache.toml").as_posix()
144 | client = Client(cache_path=cache_path)
145 | config.write_cache("token", "123", cache_path=cache_path)
146 | token = client.session.auth.service.get_token(
147 | username="user",
148 | password="pass", # noqa: S106
149 | )
150 | self.assertEqual("123", token)
151 | cache = config.read_cache_token(cache_path=cache_path)
152 | self.assertEqual("123", cache)
153 | self.assertEqual(1, verify_mock.call_count)
154 |
--------------------------------------------------------------------------------
/tests/endpoints/test_comments.py:
--------------------------------------------------------------------------------
1 | from unittest import TestCase
2 | from unittest.mock import MagicMock, patch
3 |
4 | from pyodk._endpoints.comments import Comment
5 | from pyodk._utils.session import Session
6 | from pyodk.client import Client
7 |
8 | from tests.resources import CONFIG_DATA, comments_data
9 |
10 |
11 | @patch("pyodk._utils.session.Auth.login", MagicMock())
12 | @patch("pyodk._utils.config.read_config", MagicMock(return_value=CONFIG_DATA))
13 | class TestComments(TestCase):
14 | def test_list__ok(self):
15 | """Should return a list of Comment objects."""
16 | fixture = comments_data.test_comments
17 | with patch.object(Session, "request") as mock_session:
18 | mock_session.return_value.status_code = 200
19 | mock_session.return_value.json.return_value = fixture["response_data"]
20 | with Client() as client:
21 | observed = client._comments.list(
22 | form_id=fixture["form_id"],
23 | instance_id=fixture["instance_id"],
24 | )
25 | self.assertEqual(4, len(observed))
26 | for i, o in enumerate(observed):
27 | with self.subTest(i):
28 | self.assertIsInstance(o, Comment)
29 |
30 | def test_post__ok(self):
31 | """Should return a Comment object."""
32 | fixture = comments_data.test_comments
33 | with patch.object(Session, "request") as mock_session:
34 | mock_session.return_value.status_code = 200
35 | mock_session.return_value.json.return_value = fixture["response_data"][0]
36 | with Client() as client:
37 | # Specify project
38 | observed = client._comments.post(
39 | project_id=fixture["project_id"],
40 | form_id=fixture["form_id"],
41 | instance_id=fixture["instance_id"],
42 | comment="Looks good",
43 | )
44 | self.assertIsInstance(observed, Comment)
45 | # Use default
46 | observed = client._comments.post(
47 | form_id=fixture["form_id"],
48 | instance_id=fixture["instance_id"],
49 | comment="Looks good",
50 | )
51 | self.assertIsInstance(observed, Comment)
52 |
--------------------------------------------------------------------------------
/tests/endpoints/test_entity_lists.py:
--------------------------------------------------------------------------------
1 | from unittest import TestCase
2 | from unittest.mock import MagicMock, patch
3 |
4 | from pyodk._endpoints.entity_lists import EntityList
5 | from pyodk._utils.session import Session
6 | from pyodk.client import Client
7 |
8 | from tests.resources import CONFIG_DATA, entity_lists_data
9 |
10 |
11 | @patch("pyodk._utils.session.Auth.login", MagicMock())
12 | @patch("pyodk._utils.config.read_config", MagicMock(return_value=CONFIG_DATA))
13 | class TestEntityLists(TestCase):
14 | def test_list__ok(self):
15 | """Should return a list of EntityList objects."""
16 | fixture = entity_lists_data.test_entity_lists
17 | with patch.object(Session, "request") as mock_session:
18 | mock_session.return_value.status_code = 200
19 | mock_session.return_value.json.return_value = fixture
20 | with Client() as client:
21 | observed = client.entity_lists.list()
22 | self.assertEqual(3, len(observed))
23 | for i, o in enumerate(observed):
24 | with self.subTest(i):
25 | self.assertIsInstance(o, EntityList)
26 |
27 | def test_get__ok(self):
28 | """Should return an EntityList object."""
29 | fixture = entity_lists_data.test_entity_lists[2]
30 | with patch.object(Session, "request") as mock_session:
31 | mock_session.return_value.status_code = 200
32 | mock_session.return_value.json.return_value = fixture
33 | with Client() as client:
34 | observed = client.entity_lists.get(entity_list_name="pyodk_test_eln")
35 | self.assertIsInstance(observed, EntityList)
36 |
37 | def test_create__ok(self):
38 | """Should return an EntityList object."""
39 | fixture = entity_lists_data.test_entity_lists
40 | with patch.object(Session, "request") as mock_session:
41 | mock_session.return_value.status_code = 200
42 | mock_session.return_value.json.return_value = fixture[0]
43 | with Client() as client:
44 | # Specify project
45 | observed = client.entity_lists.create(
46 | project_id=2,
47 | entity_list_name="test",
48 | approval_required=False,
49 | )
50 | self.assertIsInstance(observed, EntityList)
51 | # Use default
52 | client.entity_lists.default_entity_list_name = "test"
53 | client.entity_lists.default_project_id = 2
54 | observed = client.entity_lists.create()
55 | self.assertIsInstance(observed, EntityList)
56 |
--------------------------------------------------------------------------------
/tests/endpoints/test_projects.py:
--------------------------------------------------------------------------------
1 | from collections.abc import Callable
2 | from dataclasses import dataclass
3 | from functools import wraps
4 | from unittest import TestCase
5 | from unittest.mock import MagicMock, patch
6 |
7 | from pyodk._endpoints.form_assignments import FormAssignmentService
8 | from pyodk._endpoints.project_app_users import ProjectAppUser, ProjectAppUserService
9 | from pyodk._endpoints.projects import Project
10 | from pyodk.client import Client
11 | from requests import Session
12 |
13 | from tests.resources import CONFIG_DATA, projects_data
14 |
15 | PROJECT_APP_USERS = [
16 | ProjectAppUser(**d) for d in projects_data.project_app_users["response_data"]
17 | ]
18 |
19 |
20 | @dataclass
21 | class MockContext:
22 | fa_assign: MagicMock
23 | pau_list: MagicMock
24 | pau_create: MagicMock
25 |
26 |
27 | def get_mock_context(func) -> Callable:
28 | """
29 | Inject a context object with mocks for testing projects.
30 |
31 | To use, add a keyword argument "ctx" to the decorated function.
32 | """
33 |
34 | @wraps(func)
35 | def patched(*args, **kwargs):
36 | with (
37 | patch.object(FormAssignmentService, "assign", return_value=True) as fa_assign,
38 | patch.object(
39 | ProjectAppUserService, "list", return_value=PROJECT_APP_USERS
40 | ) as pau_list,
41 | patch.object(
42 | ProjectAppUserService, "create", return_value=True
43 | ) as pau_create,
44 | ):
45 | ctx = MockContext(
46 | fa_assign=fa_assign,
47 | pau_list=pau_list,
48 | pau_create=pau_create,
49 | )
50 | kwargs.update({"ctx": ctx})
51 | return func(*args, **kwargs)
52 |
53 | return patched
54 |
55 |
56 | @patch("pyodk._utils.session.Auth.login", MagicMock())
57 | @patch("pyodk._utils.config.read_config", MagicMock(return_value=CONFIG_DATA))
58 | class TestProjects(TestCase):
59 | """Tests for `client.project`."""
60 |
61 | def test_list__ok(self):
62 | """Should return a list of ProjectType objects."""
63 | fixture = projects_data.test_projects
64 | with patch.object(Session, "request") as mock_session:
65 | mock_session.return_value.status_code = 200
66 | mock_session.return_value.json.return_value = fixture["response_data"]
67 | client = Client()
68 | observed = client.projects.list()
69 | self.assertEqual(2, len(observed))
70 | for i, o in enumerate(observed):
71 | with self.subTest(i):
72 | self.assertIsInstance(o, Project)
73 |
74 | def test_get__ok(self):
75 | """Should return a ProjectType object."""
76 | fixture = projects_data.test_projects
77 | with patch.object(Session, "request") as mock_session:
78 | mock_session.return_value.status_code = 200
79 | mock_session.return_value.json.return_value = fixture["response_data"][0]
80 | client = Client()
81 | # Specify project
82 | observed = client.projects.get(project_id=fixture["response_data"][0]["id"])
83 | self.assertIsInstance(observed, Project)
84 | # Use default
85 | observed = client.projects.get()
86 | self.assertIsInstance(observed, Project)
87 |
88 |
89 | @patch("pyodk._utils.session.Auth.login", MagicMock())
90 | @patch("pyodk._utils.config.read_config", MagicMock(return_value=CONFIG_DATA))
91 | class TestProjectCreateAppUsers(TestCase):
92 | """Test for `client.project.create_app_users`."""
93 |
94 | @get_mock_context
95 | def test_names_only__list_create__no_existing_users(self, ctx: MockContext):
96 | """Should call pau.list, pau.create, not fa.assign (no forms specified)."""
97 | client = Client()
98 | unames = [u.displayName for u in PROJECT_APP_USERS]
99 | ctx.pau_list.return_value = []
100 | ctx.pau_create.return_value = PROJECT_APP_USERS[1]
101 | client.projects.create_app_users(display_names=unames)
102 | ctx.pau_list.assert_called_once_with(project_id=None)
103 | self.assertEqual(2, ctx.pau_create.call_count)
104 | ctx.pau_create.assert_any_call(display_name=unames[0], project_id=None)
105 | ctx.pau_create.assert_any_call(display_name=unames[1], project_id=None)
106 | ctx.fa_assign.assert_not_called()
107 |
108 | @get_mock_context
109 | def test_names_only__list_create__existing_user(self, ctx: MockContext):
110 | """Should call pau.create only for the user that doesn't exist."""
111 | client = Client()
112 | unames = [u.displayName for u in PROJECT_APP_USERS]
113 | client.projects.create_app_users(display_names=unames)
114 | ctx.pau_create.assert_called_once_with(display_name=unames[1], project_id=None)
115 |
116 | @get_mock_context
117 | def test_names_forms__list_create_assign(self, ctx: MockContext):
118 | """Should call pau.list, pau.create, fa.assign."""
119 | client = Client()
120 | unames = [u.displayName for u in PROJECT_APP_USERS]
121 | new_user = PROJECT_APP_USERS[1]
122 | forms = ["form1", "form2"]
123 | ctx.pau_create.return_value = new_user
124 | client.projects.create_app_users(display_names=unames, forms=forms)
125 | ctx.pau_list.assert_called_once_with(project_id=None)
126 | ctx.pau_create.assert_called_once_with(display_name=unames[1], project_id=None)
127 | self.assertEqual(2, ctx.fa_assign.call_count)
128 | ctx.fa_assign.assert_any_call(
129 | role_id=2,
130 | user_id=new_user.id,
131 | form_id=forms[0],
132 | project_id=None,
133 | )
134 | ctx.fa_assign.assert_any_call(
135 | role_id=2,
136 | user_id=new_user.id,
137 | form_id=forms[1],
138 | project_id=None,
139 | )
140 |
141 |
142 | @patch("pyodk._utils.session.Auth.login", MagicMock())
143 | @patch("pyodk._utils.config.read_config", MagicMock(return_value=CONFIG_DATA))
144 | class TestProjectAppUsers(TestCase):
145 | def test_list__ok(self):
146 | """Should return a list of ProjectAppUser objects."""
147 | fixture = projects_data.project_app_users
148 | with patch.object(Session, "request") as mock_session:
149 | mock_session.return_value.status_code = 200
150 | mock_session.return_value.json.return_value = fixture["response_data"]
151 | client = Client()
152 | observed = ProjectAppUserService(session=client.session).list(project_id=1)
153 | self.assertEqual(2, len(observed))
154 | for i, o in enumerate(observed):
155 | with self.subTest(i):
156 | self.assertIsInstance(o, ProjectAppUser)
157 |
158 | def test_create__ok(self):
159 | """Should return a ProjectAppUser object."""
160 | fixture = projects_data.project_app_users
161 | with patch.object(Session, "request") as mock_session:
162 | mock_session.return_value.status_code = 200
163 | mock_session.return_value.json.return_value = fixture["response_data"][0]
164 | client = Client()
165 | pau = ProjectAppUserService(session=client.session)
166 | observed = pau.create(
167 | display_name=fixture["response_data"][0]["displayName"],
168 | project_id=fixture["project_id"],
169 | )
170 | self.assertIsInstance(observed, ProjectAppUser)
171 |
--------------------------------------------------------------------------------
/tests/endpoints/test_submissions.py:
--------------------------------------------------------------------------------
1 | from collections.abc import Callable
2 | from dataclasses import dataclass
3 | from functools import wraps
4 | from unittest import TestCase
5 | from unittest.mock import MagicMock, patch
6 |
7 | from pyodk._endpoints.submission_attachments import SubmissionAttachmentService
8 | from pyodk._endpoints.submissions import Submission
9 | from pyodk._utils.session import Session
10 | from pyodk.client import Client
11 |
12 | from tests.resources import CONFIG_DATA, submissions_data
13 |
14 |
15 | @dataclass
16 | class MockContext:
17 | sa_list: MagicMock
18 | sa_upload: MagicMock
19 |
20 |
21 | def get_mock_context(func) -> Callable:
22 | """
23 | Inject a context object with mocks for testing related services.
24 |
25 | To use, add a keyword argument "ctx" to the decorated function.
26 | """
27 |
28 | @wraps(func)
29 | def patched(*args, **kwargs):
30 | with (
31 | patch.object(SubmissionAttachmentService, "list", return_value=[]) as sa_list,
32 | patch.object(
33 | SubmissionAttachmentService, "upload", return_value=True
34 | ) as sa_upload,
35 | ):
36 | ctx = MockContext(sa_list=sa_list, sa_upload=sa_upload)
37 | kwargs.update({"ctx": ctx})
38 | return func(*args, **kwargs)
39 |
40 | return patched
41 |
42 |
43 | @patch("pyodk._utils.session.Auth.login", MagicMock())
44 | @patch("pyodk._utils.config.read_config", MagicMock(return_value=CONFIG_DATA))
45 | class TestSubmissions(TestCase):
46 | def test_list__ok(self):
47 | """Should return a list of Submission objects."""
48 | fixture = submissions_data.test_submissions
49 | with patch.object(Session, "request") as mock_session:
50 | mock_session.return_value.status_code = 200
51 | mock_session.return_value.json.return_value = fixture["response_data"]
52 | with Client() as client:
53 | observed = client.submissions.list(form_id="range")
54 | self.assertEqual(4, len(observed))
55 | for i, o in enumerate(observed):
56 | with self.subTest(i):
57 | self.assertIsInstance(o, Submission)
58 |
59 | def test_get__ok(self):
60 | """Should return a Submission object."""
61 | fixture = submissions_data.test_submissions
62 | with patch.object(Session, "request") as mock_session:
63 | mock_session.return_value.status_code = 200
64 | mock_session.return_value.json.return_value = fixture["response_data"][0]
65 | with Client() as client:
66 | # Specify project
67 | observed = client.submissions.get(
68 | project_id=fixture["project_id"],
69 | form_id=fixture["form_id"],
70 | instance_id=fixture["response_data"][0]["instanceId"],
71 | )
72 | self.assertIsInstance(observed, Submission)
73 | # Use default
74 | observed = client.submissions.get(
75 | form_id=fixture["form_id"],
76 | instance_id=fixture["response_data"][0]["instanceId"],
77 | )
78 | self.assertIsInstance(observed, Submission)
79 |
80 | @get_mock_context
81 | def test_create__ok(self, ctx: MockContext):
82 | """Should return a Submission object."""
83 | fixture = submissions_data.test_submissions
84 | with patch.object(Session, "request") as mock_session:
85 | mock_session.return_value.status_code = 200
86 | mock_session.return_value.json.return_value = fixture["response_data"][0]
87 | with Client() as client:
88 | # Specify project
89 | observed = client.submissions.create(
90 | project_id=fixture["project_id"],
91 | form_id=fixture["form_id"],
92 | xml=submissions_data.test_xml,
93 | )
94 | self.assertIsInstance(observed, Submission)
95 | # Use default
96 | observed = client.submissions.create(
97 | form_id=fixture["form_id"],
98 | xml=submissions_data.test_xml,
99 | )
100 | self.assertIsInstance(observed, Submission)
101 |
102 | @get_mock_context
103 | def test_create__with_attachments__ok(self, ctx: MockContext):
104 | """Should return a Submission object, and call the attachments service."""
105 | fixture = submissions_data.test_submissions
106 | with patch.object(Session, "request") as mock_session:
107 | mock_session.return_value.status_code = 200
108 | mock_session.return_value.json.return_value = fixture["response_data"][1]
109 | with Client() as client:
110 | observed = client.submissions.create(
111 | project_id=fixture["project_id"],
112 | form_id=fixture["form_id"],
113 | xml=submissions_data.upload_file_xml.format(
114 | iid=fixture["response_data"][1]["instanceId"],
115 | file_name="a.jpg",
116 | ),
117 | attachments=["/some/path/a.jpg"],
118 | )
119 | self.assertIsInstance(observed, Submission)
120 | self.assertEqual(1, ctx.sa_upload.call_count)
121 | self.assertEqual(1, ctx.sa_list.call_count)
122 |
123 | def test__put__ok(self):
124 | """Should return a Submission object."""
125 | fixture = submissions_data.test_submissions
126 | with patch.object(Session, "request") as mock_session:
127 | mock_session.return_value.status_code = 200
128 | mock_session.return_value.json.return_value = fixture["response_data"][0]
129 | with Client() as client:
130 | # Specify project
131 | observed = client.submissions._put(
132 | project_id=fixture["project_id"],
133 | form_id=fixture["form_id"],
134 | instance_id=fixture["response_data"][0]["instanceId"],
135 | xml=submissions_data.test_xml,
136 | )
137 | self.assertIsInstance(observed, Submission)
138 | # Use default
139 | observed = client.submissions._put(
140 | form_id=fixture["form_id"],
141 | instance_id=fixture["response_data"][0]["instanceId"],
142 | xml=submissions_data.test_xml,
143 | )
144 | self.assertIsInstance(observed, Submission)
145 |
146 | def test_review__ok(self):
147 | """Should return a Submission object."""
148 | fixture = submissions_data.test_submissions
149 | with patch.object(Session, "request") as mock_session:
150 | mock_session.return_value.status_code = 200
151 | mock_session.return_value.json.return_value = fixture["response_data"][0]
152 | with Client() as client:
153 | # Specify project
154 | observed = client.submissions._patch(
155 | project_id=fixture["project_id"],
156 | form_id=fixture["form_id"],
157 | instance_id=fixture["response_data"][0]["instanceId"],
158 | review_state="edited",
159 | )
160 | self.assertIsInstance(observed, Submission)
161 | # Use default
162 | observed = client.submissions._patch(
163 | form_id=fixture["form_id"],
164 | instance_id=fixture["response_data"][0]["instanceId"],
165 | review_state="edited",
166 | )
167 | self.assertIsInstance(observed, Submission)
168 |
--------------------------------------------------------------------------------
/tests/resources/.pyodk_cache.toml:
--------------------------------------------------------------------------------
1 | token = "1234abcd"
2 |
--------------------------------------------------------------------------------
/tests/resources/.pyodk_config.toml:
--------------------------------------------------------------------------------
1 | [central]
2 | base_url = "https://example.com"
3 | username = "user@example.com"
4 | password = "SomePassword"
5 | default_project_id = 1
6 |
--------------------------------------------------------------------------------
/tests/resources/__init__.py:
--------------------------------------------------------------------------------
1 | from pathlib import Path
2 |
3 | from pyodk._utils import config
4 |
5 | from tests.resources import (
6 | forms_data, # noqa: F401
7 | projects_data, # noqa: F401
8 | )
9 |
10 | RESOURCES = Path.absolute(Path(__file__)).parent
11 |
12 | CONFIG_FILE = RESOURCES / ".pyodk_config.toml"
13 | CONFIG_DATA = config.read_config(config_path=CONFIG_FILE.as_posix())
14 |
15 | CACHE_FILE = RESOURCES / ".pyodk_cache.toml"
16 | CACHE_DATA = config.read_cache_token(cache_path=CACHE_FILE.as_posix())
17 |
--------------------------------------------------------------------------------
/tests/resources/comments_data.py:
--------------------------------------------------------------------------------
1 | test_comments = {
2 | "project_id": 51,
3 | "form_id": "range",
4 | "instance_id": "uuid:85cb9aff-005e-4edd-9739-dc9c1a829c47",
5 | "response_data": [
6 | {"body": "Test", "actorId": 650, "createdAt": "2022-10-06T09:08:07.722Z"},
7 | {"body": "Looks good", "actorId": 650, "createdAt": "2022-10-06T09:01:43.868Z"},
8 | {"body": "Looks good", "actorId": 650, "createdAt": "2022-10-06T09:00:41.433Z"},
9 | {"body": "Looks good", "actorId": 650, "createdAt": "2022-10-06T09:00:10.638Z"},
10 | ],
11 | }
12 |
--------------------------------------------------------------------------------
/tests/resources/entities_data.py:
--------------------------------------------------------------------------------
1 | test_entities = [
2 | {
3 | "uuid": "uuid:85cb9aff-005e-4edd-9739-dc9c1a829c44",
4 | "createdAt": "2018-01-19T23:58:03.395Z",
5 | "updatedAt": "2018-03-21T12:45:02.312Z",
6 | "deletedAt": "2018-03-21T12:45:02.312Z",
7 | "creatorId": 1,
8 | "currentVersion": {
9 | "label": "John (88)",
10 | "current": True,
11 | "createdAt": "2018-03-21T12:45:02.312Z",
12 | "creatorId": 1,
13 | "userAgent": "Enketo/3.0.4",
14 | "version": 1,
15 | "baseVersion": None,
16 | "conflictingProperties": None,
17 | "data": {"firstName": "John", "age": "88"},
18 | },
19 | },
20 | {
21 | "uuid": "uuid:85cb9aff-005e-4edd-9739-dc9c1a829c45",
22 | "createdAt": "2018-01-19T23:58:03.395Z",
23 | "updatedAt": "2018-03-21T12:45:02.312Z",
24 | "deletedAt": "2018-03-21T12:45:02.312Z",
25 | "creatorId": 1,
26 | "conflict": "soft",
27 | "currentVersion": {
28 | "label": "John (89)",
29 | "current": True,
30 | "createdAt": "2018-03-21T12:45:02.312Z",
31 | "creatorId": 1,
32 | "userAgent": "Enketo/3.0.4",
33 | "version": 2,
34 | "baseVersion": 1,
35 | "conflictingProperties": None,
36 | "data": {"firstName": "John", "age": "88"},
37 | },
38 | },
39 | ]
40 | test_entities_data = {
41 | "firstName": "John",
42 | "age": "88",
43 | }
44 |
--------------------------------------------------------------------------------
/tests/resources/entity_lists_data.py:
--------------------------------------------------------------------------------
1 | test_entity_lists = [
2 | {
3 | "name": "people",
4 | "createdAt": "2018-01-19T23:58:03.395Z",
5 | "projectId": 1,
6 | "approvalRequired": True,
7 | },
8 | {
9 | "name": "places",
10 | "createdAt": "2018-01-19T23:58:03.396Z",
11 | "projectId": 1,
12 | "approvalRequired": False,
13 | },
14 | {
15 | "name": "pyodk_test_eln",
16 | "createdAt": "2024-04-17T13:16:32.960Z",
17 | "projectId": 1,
18 | "approvalRequired": False,
19 | "entities": 5,
20 | "lastEntity": "2024-05-22T05:52:02.868Z",
21 | "conflicts": 0,
22 | "linkedForms": [],
23 | "sourceForms": [],
24 | "properties": [
25 | {
26 | "name": "test_label",
27 | "publishedAt": "2024-04-17T13:16:33.172Z",
28 | "odataName": "test_label",
29 | "forms": [],
30 | },
31 | {
32 | "name": "another_prop",
33 | "publishedAt": "2024-04-17T13:16:33.383Z",
34 | "odataName": "another_prop",
35 | "forms": [],
36 | },
37 | {
38 | "name": "third_property",
39 | "publishedAt": "2024-05-22T05:46:29.578Z",
40 | "odataName": "third_property",
41 | "forms": [],
42 | },
43 | ],
44 | },
45 | ]
46 |
--------------------------------------------------------------------------------
/tests/resources/forms/fruits.csv:
--------------------------------------------------------------------------------
1 | name_key,name
2 | mango,Mangoe
3 | orange,Orange
4 | banana,Banana
5 | papaya,Papaya
6 |
--------------------------------------------------------------------------------
/tests/resources/forms/range_draft.xml:
--------------------------------------------------------------------------------
1 |
2 |
7 |
8 | range_draft
9 |
10 |
11 |
12 |
13 |
14 |
15 |
16 |
17 |
18 |
19 |
20 |
22 |
24 |
26 |
28 |
29 |
30 |
31 |
33 |
34 |
35 |
36 |
--------------------------------------------------------------------------------
/tests/resources/forms_data.py:
--------------------------------------------------------------------------------
1 | from datetime import datetime, timezone
2 | from pathlib import Path
3 |
4 | test_forms = {
5 | "project_id": 8,
6 | "response_data": [
7 | {
8 | "projectId": 8,
9 | "xmlFormId": "dash-in-last-saved",
10 | "state": "open",
11 | "enketoId": "48Yd04BkdsshBUeux3m2y4Eea0KbYqB",
12 | "enketoOnceId": None,
13 | "createdAt": "2021-04-23T03:03:27.444Z",
14 | "updatedAt": None,
15 | "keyId": None,
16 | "version": "",
17 | "hash": "4401f8a5781f3c832b70023fb79442a1",
18 | "sha": "f116369d91b7f356e9d8b75dfd496ecaafa23c92",
19 | "sha256": "ae91ed9896bcc3a4ea13b52c9727d106634b6b17423b762d3e033f0989e7c1a3",
20 | "draftToken": "z2umc57Qpt0eZR8wngV9CjzhuEWtLvUttHng$qq6WZt0eptQqOKVMkYPPDStElzF",
21 | "publishedAt": None,
22 | "name": "dash-in-last-saved",
23 | },
24 | {
25 | "projectId": 8,
26 | "xmlFormId": "external_52k",
27 | "state": "open",
28 | "enketoId": None,
29 | "enketoOnceId": "0510ccec266c8e0c88e939c2597341e523535b0e18460fca7c8b4585826a157d",
30 | "createdAt": "2021-10-28T19:11:37.064Z",
31 | "updatedAt": "2021-10-28T19:11:59.047Z",
32 | "keyId": None,
33 | "version": "1",
34 | "hash": "31a52959d89621f995fd95b3822e54fd",
35 | "sha": "e8a128bbfaceb7265f12b903648f6f63700630aa",
36 | "sha256": "1c5ffdf837c153672fbd7858753c6fa41a8e5813423932e53162016139f11ca1",
37 | "draftToken": None,
38 | "publishedAt": "2021-10-28T19:11:57.082Z",
39 | "name": None,
40 | },
41 | {
42 | "projectId": 8,
43 | "xmlFormId": "Infos_registre_CTC_20210226",
44 | "state": "open",
45 | "enketoId": "fcH3ZtJxHEA5bHBRfp5eq3jBGjeEgbv",
46 | "enketoOnceId": "293dfdb6fcc501e0e323a8f5d27eb158f6f09b0885b32075a02d75bfdc8703e7",
47 | "createdAt": "2021-05-18T03:26:25.679Z",
48 | "updatedAt": "2021-05-18T03:29:23.487Z",
49 | "keyId": None,
50 | "version": "04052021",
51 | "hash": "ca51a72cc206f6557198ae92cb11f7a8",
52 | "sha": "b98783f9de7a20ff683842701aa897bbe108f933",
53 | "sha256": "eb8c7e52b4d685f0f8f1bb61fd5957f09ba0fdd26f6b87643cfcdd60389f842f",
54 | "draftToken": None,
55 | "publishedAt": "2021-05-18T03:29:23.487Z",
56 | "name": "Infos registre CTC/CTU",
57 | },
58 | {
59 | "projectId": 8,
60 | "xmlFormId": "range",
61 | "state": "open",
62 | "enketoId": "sRgCcrzoEYKc66geY9fC5vL28bmqFBJ",
63 | "enketoOnceId": "4e1f9feaa813149c67a8a4c709117f128e4d26153d9061fe70d9b1c5ca7215a6",
64 | "createdAt": "2021-04-20T21:11:50.794Z",
65 | "updatedAt": "2021-05-10T20:51:34.202Z",
66 | "keyId": None,
67 | "version": "2021042001",
68 | "hash": "50714e468ceb8a53e4294becc1bfc92a",
69 | "sha": "0c934f081e3236c2d2e21100ca05bb770885cdf3",
70 | "sha256": "cc1f15261da182655a77e2005798110ab95897f5d336ef77b60906320317bb30",
71 | "draftToken": None,
72 | "publishedAt": "2021-05-10T20:51:22.100Z",
73 | "name": "range",
74 | },
75 | ],
76 | }
77 | test_form_attachments = [
78 | {
79 | "name": "fruits.csv",
80 | "type": "file",
81 | "hash": "b61381f802d5ca6bc054e49e32471500",
82 | "exists": True,
83 | "blobExists": True,
84 | "datasetExists": False,
85 | "updatedAt": "2025-05-10T20:51:22.100Z",
86 | }
87 | ]
88 |
89 |
90 | def get_xml__range_draft(
91 | form_id: str | None = "range_draft", version: str | None = None
92 | ) -> str:
93 | if version is None:
94 | version = datetime.now(timezone.utc).isoformat()
95 | with open(Path(__file__).parent / "forms" / "range_draft.xml") as fd:
96 | return fd.read().format(form_id=form_id, version=version)
97 |
98 |
99 | def get_md__pull_data(version: str | None = None) -> str:
100 | if version is None:
101 | version = datetime.now(timezone.utc).isoformat()
102 | return f"""
103 | | settings |
104 | | | version |
105 | | | {version} |
106 | | survey | | | | |
107 | | | type | name | label | calculation |
108 | | | calculate | fruit | | pulldata('fruits', 'name', 'name_key', 'mango') |
109 | | | note | note_fruit | The fruit ${{fruit}} pulled from csv | |
110 | """
111 |
112 |
113 | md__symbols = """
114 | | settings |
115 | | | form_title | form_id | version |
116 | | | a non_ascii_form_id | ''=+/*-451%/% | 1 |
117 | | survey | | | | |
118 | | | type | name | label | calculation |
119 | | | calculate | fruit | | pulldata('fruits', 'name', 'name_key', 'mango') |
120 | | | note | note_fruit | The fruit ${fruit} pulled from csv | |
121 | """
122 | md__dingbat = """
123 | | settings |
124 | | | form_title | form_id | version |
125 | | | ✅ | ✅ | 1 |
126 | | survey | | | | |
127 | | | type | name | label | calculation |
128 | | | calculate | fruit | | pulldata('fruits', 'name', 'name_key', 'mango') |
129 | | | note | note_fruit | The fruit ${fruit} pulled from csv | |
130 | """
131 | md__upload_file = """
132 | | settings |
133 | | | form_title | form_id | version |
134 | | | upload_file | upload_file | 1 |
135 | | survey |
136 | | | type | name | label |
137 | | | text | name | Name |
138 | | | file | file | File |
139 | """
140 |
--------------------------------------------------------------------------------
/tests/resources/projects_data.py:
--------------------------------------------------------------------------------
1 | test_projects = {
2 | "response_data": [
3 | {
4 | "id": 8,
5 | "name": "Hélène",
6 | "description": "Some sample forms for me and others! Yay!",
7 | "archived": None,
8 | "keyId": None,
9 | "createdAt": "2021-02-15T01:19:41.923Z",
10 | "updatedAt": "2022-07-07T23:39:39.198Z",
11 | "deletedAt": None,
12 | "verbs": [
13 | "project.read",
14 | "form.list",
15 | "form.read",
16 | "submission.read",
17 | "submission.list",
18 | ],
19 | },
20 | {
21 | "id": 51,
22 | "name": "Lindsay",
23 | "description": None,
24 | "archived": None,
25 | "keyId": None,
26 | "createdAt": "2022-07-07T23:40:16.722Z",
27 | "updatedAt": None,
28 | "deletedAt": None,
29 | "verbs": [
30 | "project.read",
31 | "project.update",
32 | "project.delete",
33 | "form.create",
34 | "form.delete",
35 | "form.list",
36 | "form.read",
37 | "form.update",
38 | "submission.create",
39 | "submission.read",
40 | "submission.list",
41 | "submission.update",
42 | "field_key.create",
43 | "field_key.delete",
44 | "field_key.list",
45 | "assignment.list",
46 | "assignment.create",
47 | "assignment.delete",
48 | "public_link.create",
49 | "public_link.list",
50 | "public_link.read",
51 | "public_link.update",
52 | "public_link.delete",
53 | "session.end",
54 | "submission.update",
55 | "form.restore",
56 | ],
57 | },
58 | ]
59 | }
60 | project_app_users = {
61 | "project_id": 1,
62 | "response_data": [
63 | {
64 | "projectId": 1,
65 | "id": 115,
66 | "type": "field_key",
67 | "displayName": "test_user_1",
68 | "createdAt": "2018-04-18T23:19:14.802Z",
69 | "updatedAt": None,
70 | "deletedAt": None,
71 | "token": "d1!E2GVHgpr4h9bpxxtqUJ7EVJ1Q$Dusm2RBXg8XyVJMCBCbvyE8cGacxUx3bcUT",
72 | },
73 | {
74 | "projectId": 1,
75 | "id": 116,
76 | "type": "field_key",
77 | "displayName": "test_user_2",
78 | "createdAt": "2018-04-18T23:23:14.802Z",
79 | "updatedAt": None,
80 | "deletedAt": None,
81 | "token": None,
82 | },
83 | ],
84 | }
85 | roles = {
86 | "response_data": [
87 | {
88 | "id": 6,
89 | "name": "Project Viewer",
90 | "system": "viewer",
91 | "createdAt": None,
92 | "updatedAt": None,
93 | "verbs": [
94 | "project.read",
95 | "form.list",
96 | "form.read",
97 | "submission.read",
98 | "submission.list",
99 | "dataset.list",
100 | "entity.list",
101 | ],
102 | },
103 | {
104 | "id": 3,
105 | "name": "Password Reset Token",
106 | "system": "pwreset",
107 | "createdAt": "2022-10-06T16:39:20.979Z",
108 | "updatedAt": None,
109 | "verbs": ["user.password.reset"],
110 | },
111 | {
112 | "id": 2,
113 | "name": "App User",
114 | "system": "app-user",
115 | "createdAt": "2022-10-06T16:39:20.939Z",
116 | "updatedAt": None,
117 | "verbs": ["form.read", "submission.create"],
118 | },
119 | {
120 | "id": 7,
121 | "name": "Form Viewer (system internal)",
122 | "system": "formview",
123 | "createdAt": None,
124 | "updatedAt": None,
125 | "verbs": ["form.read"],
126 | },
127 | {
128 | "id": 8,
129 | "name": "Data Collector",
130 | "system": "formfill",
131 | "createdAt": None,
132 | "updatedAt": None,
133 | "verbs": ["project.read", "form.list", "form.read", "submission.create"],
134 | },
135 | {
136 | "id": 9,
137 | "name": "Public Link",
138 | "system": "pub-link",
139 | "createdAt": "2022-10-06T16:39:21.445Z",
140 | "updatedAt": None,
141 | "verbs": ["form.read", "submission.create"],
142 | },
143 | {
144 | "id": 1,
145 | "name": "Administrator",
146 | "system": "admin",
147 | "createdAt": "2022-10-06T16:39:20.939Z",
148 | "updatedAt": None,
149 | "verbs": [
150 | "config.read",
151 | "field_key.create",
152 | "field_key.delete",
153 | "field_key.list",
154 | "form.create",
155 | "form.delete",
156 | "form.list",
157 | "form.read",
158 | "form.update",
159 | "project.create",
160 | "project.delete",
161 | "project.read",
162 | "project.update",
163 | "session.end",
164 | "submission.create",
165 | "submission.read",
166 | "submission.list",
167 | "user.create",
168 | "user.list",
169 | "user.password.invalidate",
170 | "user.read",
171 | "user.update",
172 | "submission.update",
173 | "role.create",
174 | "role.update",
175 | "role.delete",
176 | "assignment.list",
177 | "assignment.create",
178 | "assignment.delete",
179 | "user.delete",
180 | "audit.read",
181 | "public_link.create",
182 | "public_link.list",
183 | "public_link.read",
184 | "public_link.update",
185 | "public_link.delete",
186 | "backup.run",
187 | "submission.update",
188 | "config.set",
189 | "analytics.read",
190 | "form.restore",
191 | "dataset.list",
192 | "entity.list",
193 | ],
194 | },
195 | {
196 | "id": 5,
197 | "name": "Project Manager",
198 | "system": "manager",
199 | "createdAt": None,
200 | "updatedAt": None,
201 | "verbs": [
202 | "project.read",
203 | "project.update",
204 | "project.delete",
205 | "form.create",
206 | "form.delete",
207 | "form.list",
208 | "form.read",
209 | "form.update",
210 | "submission.create",
211 | "submission.read",
212 | "submission.list",
213 | "submission.update",
214 | "field_key.create",
215 | "field_key.delete",
216 | "field_key.list",
217 | "assignment.list",
218 | "assignment.create",
219 | "assignment.delete",
220 | "public_link.create",
221 | "public_link.list",
222 | "public_link.read",
223 | "public_link.update",
224 | "public_link.delete",
225 | "session.end",
226 | "submission.update",
227 | "form.restore",
228 | "dataset.list",
229 | "entity.list",
230 | ],
231 | },
232 | ]
233 | }
234 |
--------------------------------------------------------------------------------
/tests/resources/submissions_data.py:
--------------------------------------------------------------------------------
1 | test_submissions = {
2 | "project_id": 8,
3 | "form_id": "range",
4 | "response_data": [
5 | {
6 | "instanceId": "uuid:96f2a014-eaa1-466a-abe2-3ccacc756d5a",
7 | "submitterId": 28,
8 | "deviceId": None,
9 | "createdAt": "2021-05-10T20:51:51.404Z",
10 | "updatedAt": None,
11 | "reviewState": None,
12 | },
13 | {
14 | "instanceId": "uuid:0ef76597-5a6d-4788-b924-d875f615025c",
15 | "submitterId": 28,
16 | "deviceId": None,
17 | "createdAt": "2021-05-10T20:51:48.198Z",
18 | "updatedAt": None,
19 | "reviewState": None,
20 | },
21 | {
22 | "instanceId": "uuid:2d434c15-096a-41aa-a9f5-714badf72d0d",
23 | "submitterId": 28,
24 | "deviceId": None,
25 | "createdAt": "2021-05-10T20:51:45.344Z",
26 | "updatedAt": None,
27 | "reviewState": None,
28 | },
29 | {
30 | "instanceId": "uuid:4ef8a694-2848-4c77-a0db-31a3b53c14cd",
31 | "submitterId": 28,
32 | "deviceId": None,
33 | "createdAt": "2021-05-10T20:51:40.330Z",
34 | "updatedAt": None,
35 | "reviewState": None,
36 | },
37 | ],
38 | }
39 | test_xml = """
40 |
41 |
42 | uuid:85cb9aff-005e-4edd-9739-dc9c1a829c44
43 |
44 | Alice
45 | 36
46 |
47 | """
48 | upload_file_xml = """
49 |
50 | {iid}
51 | file
52 | {file_name}
53 |
54 | """
55 | upload_file_submissions = {
56 | "project_id": 1,
57 | "form_id": "upload_file",
58 | "response_data": [
59 | {
60 | "instanceId": "uuid:85cb9aff-005e-4edd-9739-dc9c1a829c45",
61 | "submitterId": 10,
62 | "deviceId": None,
63 | "createdAt": "2025-03-14T06:21:29.581Z",
64 | "updatedAt": None,
65 | "reviewState": None,
66 | },
67 | ],
68 | }
69 |
70 |
71 | def get_xml__fruits(
72 | form_id: str,
73 | version: str,
74 | instance_id: str,
75 | deprecated_instance_id: str | None = None,
76 | selected_fruit: str = "Papaya",
77 | ) -> str:
78 | """
79 | Get Submission XML for the "fruits" form that uses an external data list.
80 |
81 | :param form_id: The xmlFormId of the Form being referenced.
82 | :param version: The version of the form that the submission is for.
83 | :param instance_id: The instanceId of the Submission being referenced.
84 | :param deprecated_instance_id: If the submission is an edit, then the instance_id of
85 | the submission being replaced must be provided.
86 | :param selected_fruit: Which delicious tropical fruit do you like?
87 | """
88 | iidd = ""
89 | if deprecated_instance_id is not None:
90 | iidd = f"{deprecated_instance_id}"
91 | return f"""
92 |
93 | {iidd}
94 | {instance_id}
95 |
96 | {selected_fruit}
97 |
98 |
99 | """
100 |
--------------------------------------------------------------------------------
/tests/test_config.py:
--------------------------------------------------------------------------------
1 | import os
2 | from unittest import TestCase
3 | from unittest.mock import patch
4 |
5 | from pyodk._utils import config
6 | from pyodk.errors import PyODKError
7 |
8 | from tests import resources
9 | from tests.utils.utils import get_temp_dir
10 |
11 |
12 | class TestConfig(TestCase):
13 | def setUp(self) -> None:
14 | self.section_data = {
15 | "base_url": "https://www.example.com",
16 | "username": "user",
17 | "password": "pass",
18 | }
19 |
20 | def test_read_config__ok(self):
21 | """Should return the configuration data when no path is specified."""
22 | cf = {"PYODK_CONFIG_FILE": resources.CONFIG_FILE.as_posix()}
23 | with patch.dict(os.environ, cf, clear=True):
24 | self.assertIsInstance(config.read_config(), config.Config)
25 |
26 | def test_read_config__ok__with_path(self):
27 | """Should return the configuration data when a path is specified."""
28 | self.assertIsInstance(
29 | config.read_config(config_path=resources.CONFIG_FILE), config.Config
30 | )
31 |
32 | def test_read_cache__ok(self):
33 | """Should return the cache data when no path is specified."""
34 | cf = {"PYODK_CACHE_FILE": resources.CACHE_FILE.as_posix()}
35 | with patch.dict(os.environ, cf, clear=True):
36 | self.assertIsInstance(config.read_cache_token(), str)
37 |
38 | def test_read_cache__ok__with_path(self):
39 | """Should return the cache data when a path is specified."""
40 | self.assertIsInstance(
41 | config.read_cache_token(cache_path=resources.CACHE_FILE), str
42 | )
43 |
44 | def test_read_toml__error__non_existent(self):
45 | """Should raise an error if the path is not valid."""
46 | bad_path = resources.RESOURCES / "nothing_here"
47 | with self.assertRaises(PyODKError) as err:
48 | config.read_toml(path=bad_path)
49 | self.assertTrue(
50 | str(err.exception.args[0]).startswith(f"Could not read file at: {bad_path}")
51 | )
52 |
53 | def test_write_cache__ok(self):
54 | """Should write the cache data when no path is specified."""
55 | with get_temp_dir() as tmp:
56 | path = tmp / "my_cache.toml"
57 | with patch.dict(os.environ, {"PYODK_CACHE_FILE": path.as_posix()}):
58 | self.assertFalse(path.exists())
59 | config.write_cache(key="token", value="1234abcd")
60 | self.assertTrue(path.exists())
61 |
62 | def test_write_cache__with_path(self):
63 | """Should write the cache data when a path is specified."""
64 | with get_temp_dir() as tmp:
65 | path = tmp / "my_cache.toml"
66 | self.assertFalse(path.exists())
67 | config.write_cache(key="token", value="1234abcd", cache_path=path.as_posix())
68 | self.assertTrue(path.exists())
69 |
70 | def test_objectify_config__error__missing_section(self):
71 | cfg = {"centrall": {}}
72 | with self.assertRaises(KeyError) as err:
73 | config.objectify_config(config_data=cfg)
74 | self.assertEqual("central", err.exception.args[0])
75 |
76 | def test_objectify_config__error__missing_key(self):
77 | del self.section_data["password"]
78 | cfg = {"central": self.section_data}
79 | with self.assertRaises(TypeError) as err:
80 | config.objectify_config(config_data=cfg)
81 | # Py3.8 doesn't prefix the class name to __init__(), but Py3.10 does.
82 | self.assertIn(
83 | "__init__() missing 1 required positional argument: 'password'",
84 | err.exception.args[0],
85 | )
86 |
87 | def test_objectify_config__error__empty_key(self):
88 | self.section_data["password"] = ""
89 | cfg = {"central": self.section_data}
90 | with self.assertRaises(PyODKError) as err:
91 | config.objectify_config(config_data=cfg)
92 | self.assertEqual(
93 | "Config value 'password' must not be empty.", err.exception.args[0]
94 | )
95 |
--------------------------------------------------------------------------------
/tests/test_session.py:
--------------------------------------------------------------------------------
1 | from pathlib import Path
2 | from unittest import TestCase
3 |
4 | from pyodk._utils.session import Session
5 |
6 |
7 | class TestSession(TestCase):
8 | def test_base_url_validate(self):
9 | """Should return base_url suffixed with '/{version}/', if not already added."""
10 | cases = (
11 | ("https://example.com", "https://example.com/v1/"),
12 | ("https://example.com/", "https://example.com/v1/"),
13 | ("https://example.com/v1", "https://example.com/v1/"),
14 | ("https://example.com/v1/", "https://example.com/v1/"),
15 | ("https://example.com/subpath/v1", "https://example.com/subpath/v1/"),
16 | ("https://example.com/subpath/v1/", "https://example.com/subpath/v1/"),
17 | )
18 | for base_url, expected in cases:
19 | with self.subTest(msg=f"{base_url}"):
20 | observed = Session.base_url_validate(base_url=base_url, api_version="v1")
21 | self.assertEqual(expected, observed)
22 |
23 | def test_urlformat(self):
24 | """Should replace input fields with url-encoded values."""
25 | url = "projects/{project_id}/forms/{form_id}"
26 | test_cases = (
27 | # Basic latin string
28 | ({"project_id": 1, "form_id": "a"}, "projects/1/forms/a"),
29 | # integer
30 | ({"project_id": 1, "form_id": 1}, "projects/1/forms/1"),
31 | # latin symbols
32 | ({"project_id": 1, "form_id": "+-_*%*"}, "projects/1/forms/%2B-_*%25*"),
33 | # lower case e, with combining acute accent (2 symbols)
34 | ({"project_id": 1, "form_id": "tést"}, "projects/1/forms/te%CC%81st"),
35 | # lower case e with acute (1 symbol)
36 | ({"project_id": 1, "form_id": "tést"}, "projects/1/forms/t%C3%A9st"),
37 | # white heavy check mark
38 | ({"project_id": 1, "form_id": "✅"}, "projects/1/forms/%E2%9C%85"),
39 | )
40 | for params, expected in test_cases:
41 | with self.subTest(msg=str(params)):
42 | self.assertEqual(expected, Session.urlformat(url, **params))
43 |
44 | def test_urlquote(self):
45 | """Should url-encode input values."""
46 | test_cases = (
47 | # Basic latin string
48 | ("test.xlsx", "test"),
49 | # integer
50 | ("1.xls", "1"),
51 | # latin symbols
52 | ("+-_*%*.xls", "%2B-_*%25*"),
53 | # spaces
54 | ("my form.xlsx", "my%20form"),
55 | # lower case e, with combining acute accent (2 symbols)
56 | ("tést.xlsx", "te%CC%81st"),
57 | # lower case e with acute (1 symbol)
58 | ("tést", "t%C3%A9st"),
59 | # white heavy check mark
60 | ("✅.xlsx", "%E2%9C%85"),
61 | )
62 | for params, expected in test_cases:
63 | with self.subTest(msg=str(params)):
64 | self.assertEqual(expected, Session.urlquote(Path(params).stem))
65 |
--------------------------------------------------------------------------------
/tests/test_validators.py:
--------------------------------------------------------------------------------
1 | from unittest import TestCase
2 |
3 | from pyodk._utils import validators as v
4 | from pyodk.errors import PyODKError
5 |
6 |
7 | class TestValidators(TestCase):
8 | def test_wrap_error__raises_pyodk_error(self):
9 | """Should raise a PyODK error (from Pydantic) if a validator check fails."""
10 |
11 | def a_func():
12 | pass
13 |
14 | cases = (
15 | (v.validate_project_id, False, (None, "a")),
16 | (v.validate_form_id, False, (None, a_func)),
17 | (v.validate_table_name, False, (None, a_func)),
18 | (v.validate_instance_id, False, (None, a_func)),
19 | (v.validate_entity_list_name, False, (None, a_func)),
20 | (v.validate_str, True, (None, a_func)),
21 | (v.validate_bool, True, (None, a_func)),
22 | (v.validate_int, True, (None, a_func)),
23 | (v.validate_dict, True, (None, ((("a",),),))),
24 | (
25 | v.validate_file_path,
26 | True,
27 | (
28 | None,
29 | "No such file",
30 | ),
31 | ),
32 | )
33 |
34 | for i, (func, has_key, values) in enumerate(cases):
35 | for j, value in enumerate(values):
36 | msg = f"Case {i}, Value {j}"
37 | with self.subTest(msg=msg), self.assertRaises(PyODKError):
38 | if has_key:
39 | func(value, key=msg)
40 | else:
41 | func(value)
42 |
--------------------------------------------------------------------------------
/tests/utils/__init__.py:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/getodk/pyodk/9ba3b59770df034b01b5b0cd10db82ea4fe3b223/tests/utils/__init__.py
--------------------------------------------------------------------------------
/tests/utils/entity_lists.py:
--------------------------------------------------------------------------------
1 | from pyodk._endpoints.entity_lists import EntityList
2 | from pyodk.client import Client
3 | from pyodk.errors import PyODKError
4 |
5 |
6 | def create_new_or_get_entity_list(
7 | client: Client, entity_list_name: str, entity_props: list[str]
8 | ) -> EntityList:
9 | """
10 | Create a new entity list, or get the entity list metadata.
11 |
12 | :param client: Client instance to use for API calls.
13 | :param entity_list_name: Name of the entity list.
14 | :param entity_props: Properties to add to the entity list.
15 | """
16 | try:
17 | entity_list = client.entity_lists.create(entity_list_name=entity_list_name)
18 | except PyODKError as err:
19 | if not err.is_central_error(code=409.3):
20 | raise
21 | entity_list = EntityList(
22 | **client.session.get(
23 | url=client.session.urlformat(
24 | "projects/{pid}/datasets/{eln}",
25 | pid=client.project_id,
26 | eln=entity_list_name,
27 | ),
28 | ).json()
29 | )
30 | try:
31 | for prop in entity_props:
32 | client.entity_lists.add_property(name=prop, entity_list_name=entity_list_name)
33 | except PyODKError as err:
34 | if not err.is_central_error(code=409.3):
35 | raise
36 | return entity_list
37 |
--------------------------------------------------------------------------------
/tests/utils/forms.py:
--------------------------------------------------------------------------------
1 | from os import PathLike
2 |
3 | from pyodk.client import Client
4 | from pyodk.errors import PyODKError
5 |
6 | from tests.utils import utils
7 | from tests.utils.md_table import md_table_to_temp_dir
8 |
9 |
10 | def create_ignore_duplicate_error(
11 | client: Client,
12 | definition: PathLike | str | bytes,
13 | form_id: str,
14 | ):
15 | """Create the form; ignore the error raised if it exists (409.3)."""
16 | try:
17 | client.forms.create(definition=definition, form_id=form_id)
18 | except PyODKError as err:
19 | if not err.is_central_error(code=409.3):
20 | raise
21 |
22 |
23 | def create_new_form__md(client: Client, form_id: str, form_def: str):
24 | """
25 | Create a new form from a MarkDown string.
26 |
27 | :param client: Client instance to use for API calls.
28 | :param form_id: The xmlFormId of the Form being referenced.
29 | :param form_def: The form definition MarkDown.
30 | """
31 | with (
32 | md_table_to_temp_dir(form_id=form_id, mdstr=form_def) as fp,
33 | ):
34 | create_ignore_duplicate_error(client=client, definition=fp, form_id=form_id)
35 |
36 |
37 | def create_new_form__xml(client: Client, form_id: str, form_def: str):
38 | """
39 | Create a new form from a XML string.
40 |
41 | :param client: Client instance to use for API calls.
42 | :param form_id: The xmlFormId of the Form being referenced.
43 | :param form_def: The form definition XML.
44 | """
45 | with utils.get_temp_file(suffix=".xml") as fp:
46 | fp.write_text(form_def)
47 | create_ignore_duplicate_error(client=client, definition=fp, form_id=form_id)
48 |
49 |
50 | def get_latest_form_version(client: Client, form_id: str) -> str:
51 | """
52 | Get the version name of the most recently published version of the form.
53 |
54 | :param client: Client instance to use for API calls.
55 | :param form_id: The xmlFormId of the Form being referenced.
56 | """
57 | versions = client.session.get(
58 | client.session.urlformat(
59 | "projects/{pid}/forms/{fid}/versions",
60 | pid=client.project_id,
61 | fid=form_id,
62 | )
63 | )
64 | return sorted(
65 | (s for s in versions.json()), key=lambda s: s["publishedAt"], reverse=True
66 | )[0]["version"]
67 |
--------------------------------------------------------------------------------
/tests/utils/md_table.py:
--------------------------------------------------------------------------------
1 | """
2 | Markdown table utility functions.
3 | """
4 |
5 | import re
6 | from contextlib import contextmanager
7 | from io import BytesIO
8 | from pathlib import Path
9 |
10 | from openpyxl import Workbook
11 | from xlwt import Workbook as XLSWorkbook
12 |
13 | from tests.utils.utils import get_temp_dir
14 |
15 |
16 | def _strp_cell(cell):
17 | val = cell.strip()
18 | if val == "":
19 | return None
20 | val = val.replace(r"\|", "|")
21 | return val
22 |
23 |
24 | def _extract_array(mdtablerow):
25 | match = re.match(r"\s*\|(.*)\|\s*", mdtablerow)
26 | if match:
27 | mtchstr = match.groups()[0]
28 | if re.match(r"^[\|-]+$", mtchstr):
29 | return False
30 | else:
31 | return [_strp_cell(c) for c in re.split(r"(? list[tuple[str, list[list[str]]]]:
45 | ss_arr = []
46 | for item in mdstr.split("\n"):
47 | arr = _extract_array(item)
48 | if arr:
49 | ss_arr.append(arr)
50 | sheet_name = False
51 | sheet_arr = False
52 | sheets = []
53 | for row in ss_arr:
54 | if row[0] is not None:
55 | if sheet_arr:
56 | sheets.append((sheet_name, sheet_arr))
57 | sheet_arr = []
58 | sheet_name = row[0]
59 | excluding_first_col = row[1:]
60 | if sheet_name and not _is_null_row(excluding_first_col):
61 | sheet_arr.append(excluding_first_col)
62 | sheets.append((sheet_name, sheet_arr))
63 |
64 | return sheets
65 |
66 |
67 | def md_table_to_workbook(mdstr: str) -> Workbook:
68 | """
69 | Convert Markdown table string to an openpyxl.Workbook. Call wb.save() to persist.
70 | """
71 | md_data = md_table_to_ss_structure(mdstr=mdstr)
72 | wb = Workbook(write_only=True)
73 | for key, rows in md_data:
74 | sheet = wb.create_sheet(title=key)
75 | for r in rows:
76 | sheet.append(r)
77 | return wb
78 |
79 |
80 | @contextmanager
81 | def md_table_to_temp_dir(form_id: str, mdstr: str) -> Path:
82 | """
83 | Convert MarkDown table string to a XLSX file saved in a temp directory.
84 |
85 | :param form_id: The xmlFormId of the Form being referenced.
86 | :param mdstr: The MarkDown table string.
87 | :return: The path of the XLSX file.
88 | """
89 | with get_temp_dir() as td:
90 | fp = Path(td) / f"{form_id}.xlsx"
91 | md_table_to_workbook(mdstr).save(fp.as_posix())
92 | yield fp
93 |
94 |
95 | def md_table_to_bytes(mdstr: str) -> bytes:
96 | """
97 | Convert MarkDown table string to XLSX Workbook bytes.
98 |
99 | :param mdstr: The MarkDown table string.
100 | """
101 | wb = md_table_to_workbook(mdstr=mdstr)
102 | fd = BytesIO()
103 | wb.save(fd)
104 | fd.seek(0)
105 | return fd.getvalue()
106 |
107 |
108 | def md_table_to_bytes_xls(mdstr: str) -> bytes:
109 | """
110 | Convert MarkDown table string to XLS Workbook bytes.
111 |
112 | :param mdstr: The MarkDown table string.
113 | """
114 | md_data = md_table_to_ss_structure(mdstr=mdstr)
115 | wb = XLSWorkbook()
116 | for key, rows in md_data:
117 | sheet = wb.add_sheet(sheetname=key)
118 | for ir, row in enumerate(rows):
119 | for ic, cell in enumerate(row):
120 | sheet.write(ir, ic, cell)
121 | fd = BytesIO()
122 | wb.save(fd)
123 | fd.seek(0)
124 | return fd.getvalue()
125 |
--------------------------------------------------------------------------------
/tests/utils/submissions.py:
--------------------------------------------------------------------------------
1 | from uuid import uuid4
2 |
3 | from pyodk.client import Client
4 | from pyodk.errors import PyODKError
5 |
6 | from tests.resources import submissions_data
7 | from tests.utils.forms import get_latest_form_version
8 |
9 |
10 | def create_new_or_get_last_submission(
11 | client: Client, form_id: str, instance_id: str
12 | ) -> str:
13 | """
14 | Create a new submission, or get the most recent version, and return it's instance_id.
15 |
16 | :param client: Client instance to use for API calls.
17 | :param form_id: The xmlFormId of the Form being referenced.
18 | :param instance_id: The instanceId of the Submission being referenced.
19 | :return: The created instance_id or the instance_id of the most recent version.
20 | """
21 | try:
22 | old_iid = client.submissions.create(
23 | xml=submissions_data.get_xml__fruits(
24 | form_id=form_id,
25 | version=get_latest_form_version(client=client, form_id=form_id),
26 | instance_id=instance_id,
27 | ),
28 | form_id=form_id,
29 | ).instanceId
30 | except PyODKError as err:
31 | if not err.is_central_error(code=409.3):
32 | raise
33 | subvs = client.session.get(
34 | client.session.urlformat(
35 | "projects/{pid}/forms/{fid}/submissions/{iid}/versions",
36 | pid=client.project_id,
37 | fid=form_id,
38 | iid=instance_id,
39 | ),
40 | )
41 | old_iid = sorted(
42 | (s for s in subvs.json()), key=lambda s: s["createdAt"], reverse=True
43 | )[0]["instanceId"]
44 | return old_iid
45 |
46 |
47 | def create_or_update_submission_with_comment(
48 | client: Client, form_id: str, instance_id: str
49 | ):
50 | """
51 | Create and/or update a submission, adding a comment with the edit.
52 |
53 | :param client: Client instance to use for API calls.
54 | :param form_id: The xmlFormId of the Form being referenced.
55 | :param instance_id: The instanceId of the Submission being referenced.
56 | """
57 | pd_iid = create_new_or_get_last_submission(
58 | client=client,
59 | form_id=form_id,
60 | instance_id=instance_id,
61 | )
62 | client.submissions.edit(
63 | xml=submissions_data.get_xml__fruits(
64 | form_id=form_id,
65 | version=get_latest_form_version(client=client, form_id=form_id),
66 | instance_id=uuid4().hex,
67 | deprecated_instance_id=pd_iid,
68 | ),
69 | form_id=form_id,
70 | instance_id=instance_id,
71 | comment="pyODK edit",
72 | )
73 |
--------------------------------------------------------------------------------
/tests/utils/utils.py:
--------------------------------------------------------------------------------
1 | import shutil
2 | import tempfile
3 | from contextlib import contextmanager
4 | from pathlib import Path
5 |
6 |
7 | @contextmanager
8 | def get_temp_file(**kwargs) -> Path:
9 | """
10 | Create a temporary file.
11 |
12 | :param kwargs: File handling options passed through to NamedTemporaryFile.
13 | :return: The path of the temporary file.
14 | """
15 | temp_file = tempfile.NamedTemporaryFile(delete=False, **kwargs)
16 | temp_file.close()
17 | temp_path = Path(temp_file.name)
18 | try:
19 | yield temp_path
20 | finally:
21 | temp_file.close()
22 | temp_path.unlink(missing_ok=True)
23 |
24 |
25 | @contextmanager
26 | def get_temp_dir() -> Path:
27 | temp_dir = tempfile.mkdtemp(prefix="pyodk_tmp_")
28 | temp_path = Path(temp_dir)
29 | try:
30 | yield temp_path
31 | finally:
32 | if temp_path.exists():
33 | shutil.rmtree(temp_dir)
34 |
--------------------------------------------------------------------------------