From 6214c8c32badb90d9a748ca88a098d044bda9dc5 Mon Sep 17 00:00:00 2001 From: lindsay stevens Date: Wed, 10 Apr 2024 20:49:31 +1000 Subject: [PATCH] add: document and mostly automate e2e tests, fix percent-encoding - updated readme with section on testing and setting up for E2E - automated create/update of forms and submissions for E2E tests - copied over md_table functionality from pyxform to allow specifying test forms as markdown instead of accumulating XLSX files - added openpyxl as a dev dependency to support conversion of md_table XLSForm data to XLSX format - updated test_client.py / TestUsage.test_direct to use the new `$select` parameter, also showing how to select sub-properties - fixed percent-encoding in `session.py` to match Central behaviour, see new docstring comments for details, update tests accordingly. --- README.md | 12 + pyodk/_endpoints/form_drafts.py | 33 ++- pyodk/_utils/session.py | 20 +- pyproject.toml | 1 + tests/endpoints/test_auth.py | 10 +- tests/resources/forms/non_ascii_form_id.xlsx | Bin 6666 -> 0 bytes tests/resources/forms/pull_data.xlsx | Bin 8176 -> 0 bytes tests/resources/forms/range_draft.xml | 2 +- "tests/resources/forms/\342\234\205.xlsx" | Bin 6577 -> 0 bytes tests/resources/forms_data.py | 44 ++++ tests/resources/submissions_data.py | 31 +++ tests/test_client.py | 219 +++++++++++++------ tests/test_config.py | 7 +- tests/test_session.py | 6 +- tests/utils/__init__.py | 0 tests/utils/forms.py | 87 ++++++++ tests/utils/md_table.py | 90 ++++++++ tests/utils/submissions.py | 70 ++++++ tests/{ => utils}/utils.py | 10 +- 19 files changed, 552 insertions(+), 90 deletions(-) delete mode 100644 tests/resources/forms/non_ascii_form_id.xlsx delete mode 100644 tests/resources/forms/pull_data.xlsx delete mode 100644 "tests/resources/forms/\342\234\205.xlsx" create mode 100644 tests/utils/__init__.py create mode 100644 tests/utils/forms.py create mode 100644 tests/utils/md_table.py create mode 100644 tests/utils/submissions.py rename tests/{ => utils}/utils.py (65%) diff --git a/README.md b/README.md index a2907e2..8745dbc 100644 --- a/README.md +++ b/README.md @@ -164,6 +164,18 @@ You can run tests with: python -m unittest ``` +### Testing + +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. + +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. + +1. Create a test project in Central. +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. +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". +4. When the tests in `test_client.py` are run, the test setup method should automatically create a few forms and submissions for testing with. At a minimum these allow the tests to pass, but can also be used to interactively test or debug. + + ## Release 1. Run all linting and tests. diff --git a/pyodk/_endpoints/form_drafts.py b/pyodk/_endpoints/form_drafts.py index 7bf3981..918d9f6 100644 --- a/pyodk/_endpoints/form_drafts.py +++ b/pyodk/_endpoints/form_drafts.py @@ -34,20 +34,21 @@ def __init__( self.default_project_id: int | None = default_project_id self.default_form_id: str | None = default_form_id - def create( + def _prep_form_post( self, - file_path: str | None = None, + file_path: Path | str | None = None, ignore_warnings: bool | None = True, form_id: str | None = None, project_id: int | None = None, - ) -> bool: + ) -> (str, str, dict, dict): """ - Create a Form Draft. + Prepare / validate input arguments for POSTing a new form definition or version. :param file_path: The path to the file to upload. :param form_id: The xmlFormId of the Form being referenced. :param project_id: The id of the project this form belongs to. :param ignore_warnings: If True, create the form if there are XLSForm warnings. + :return: project_id, form_id, headers, params """ try: pid = pv.validate_project_id(project_id, self.default_project_id) @@ -81,6 +82,30 @@ def create( log.error(err, exc_info=True) raise + return pid, fid, headers, params + + def create( + self, + file_path: Path | str | None = None, + ignore_warnings: bool | None = True, + form_id: str | None = None, + project_id: int | None = None, + ) -> bool: + """ + Create a Form Draft. + + :param file_path: The path to the file to upload. + :param form_id: The xmlFormId of the Form being referenced. + :param project_id: The id of the project this form belongs to. + :param ignore_warnings: If True, create the form if there are XLSForm warnings. + """ + pid, fid, headers, params = self._prep_form_post( + file_path=file_path, + ignore_warnings=ignore_warnings, + form_id=form_id, + project_id=project_id, + ) + with open(file_path, "rb") if file_path is not None else nullcontext() as fd: response = self.session.response_or_error( method="POST", diff --git a/pyodk/_utils/session.py b/pyodk/_utils/session.py index fc8597e..3aacb2b 100644 --- a/pyodk/_utils/session.py +++ b/pyodk/_utils/session.py @@ -1,7 +1,7 @@ from logging import Logger from string import Formatter from typing import Any -from urllib.parse import quote_plus, urljoin +from urllib.parse import quote, urljoin from requests import PreparedRequest, Response from requests import Session as RequestsSession @@ -16,11 +16,25 @@ class URLFormatter(Formatter): """ - Makes a valid URL by sending each format input field through urllib.parse.quote_plus. + Makes a valid URL by sending each format input field through urllib.parse.quote. + + To parse/un-parse URLs, currently (v2023.5) Central uses JS default functions + encodeURIComponent and decodeURIComponent, which comply with RFC2396. The more recent + RFC3986 reserves hex characters 2A (asterisk), 27 (single quote), 28 (left + parenthesis), and 29 (right parenthesis). Python 3.7+ urllib.parse complies with + RFC3986 so in order for pyODK to behave as Central expects, these additional 4 + characters are specified as "safe" in `format_field()` to not percent-encode them. + + Currently (v2023.5) Central primarily supports the default submission instanceID + format per the XForm spec, namely "uuid:" followed by the 36 character UUID string. + In many endpoints, custom UUIDs (including non-ASCII/UTF-8 chars) will work, but in + some places they won't. For example the Central page for viewing submission details + fails on the Submissions OData call, because the OData function to filter by ID + (`Submission('instanceId')`) only works for the default instanceID format. """ def format_field(self, value: Any, format_spec: str) -> Any: - return format(quote_plus(str(value)), format_spec) + return format(quote(str(value), safe="*'()"), format_spec) _URL_FORMATTER = URLFormatter() diff --git a/pyproject.toml b/pyproject.toml index 63dc5cb..80622e7 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -17,6 +17,7 @@ dependencies = [ # Install with `pip install pyodk[dev]`. dev = [ "ruff==0.3.4", # Format and lint + "openpyxl==3.1.2" # Create test XLSX files ] docs = [ "mkdocs==1.5.3", diff --git a/tests/endpoints/test_auth.py b/tests/endpoints/test_auth.py index 5e8c8dd..b87b695 100644 --- a/tests/endpoints/test_auth.py +++ b/tests/endpoints/test_auth.py @@ -7,8 +7,8 @@ from pyodk.errors import PyODKError from requests import Session -from tests import utils from tests.resources import CONFIG_DATA +from tests.utils.utils import get_temp_dir @patch("pyodk._utils.config.read_config", MagicMock(return_value=CONFIG_DATA)) @@ -75,7 +75,7 @@ def test_get_token__ok__new_cache(self): AuthService, get_new_token=MagicMock(return_value="123"), ), - utils.get_temp_dir() as tmp, + get_temp_dir() as tmp, ): cache_path = (tmp / "test_cache.toml").as_posix() client = Client(cache_path=cache_path) @@ -99,7 +99,7 @@ def test_get_token__error__new_cache_bad_response(self): verify_token=verify_mock, get_new_token=get_new_mock, ), - utils.get_temp_dir() as tmp, + get_temp_dir() as tmp, self.assertRaises(PyODKError) as err, ): cache_path = tmp / "test_cache.toml" @@ -115,7 +115,7 @@ def test_get_token__ok__existing_cache(self): AuthService, verify_token=MagicMock(return_value="123"), ), - utils.get_temp_dir() as tmp, + get_temp_dir() as tmp, ): cache_path = (tmp / "test_cache.toml").as_posix() client = Client(cache_path=cache_path) @@ -138,7 +138,7 @@ def test_get_token__error__existing_cache_bad_response(self): verify_token=verify_mock, get_new_token=MagicMock(return_value="123"), ), - utils.get_temp_dir() as tmp, + get_temp_dir() as tmp, ): cache_path = (tmp / "test_cache.toml").as_posix() client = Client(cache_path=cache_path) diff --git a/tests/resources/forms/non_ascii_form_id.xlsx b/tests/resources/forms/non_ascii_form_id.xlsx deleted file mode 100644 index fb488cb1799db44f2c970aad49a73e86e463acd4..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 6666 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 a@Lvc-MF9hO*Z{y?(jg%TNUOATe@E}V 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 - + diff --git "a/tests/resources/forms/\342\234\205.xlsx" "b/tests/resources/forms/\342\234\205.xlsx" deleted file mode 100644 index ce3274c5edc0bcd8897bd73d7c632cf8f6335597..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 6577 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 diff --git a/tests/resources/forms_data.py b/tests/resources/forms_data.py index 5817b93..b997641 100644 --- a/tests/resources/forms_data.py +++ b/tests/resources/forms_data.py @@ -1,3 +1,6 @@ +from datetime import datetime +from pathlib import Path + test_forms = { "project_id": 8, "response_data": [ @@ -71,3 +74,44 @@ }, ], } + + +def get_xml__range_draft(version: str | None = None) -> str: + if version is None: + version = datetime.now().isoformat() + with open(Path(__file__).parent / "forms" / "range_draft.xml") as fd: + return fd.read().format(version=version) + + +def get_md__pull_data(version: str | None = None) -> str: + if version is None: + version = datetime.now().isoformat() + return f""" + | settings | + | | version | + | | {version} | + | survey | | | | | + | | type | name | label | calculation | + | | calculate | fruit | | pulldata('fruits', 'name', 'name_key', 'mango') | + | | note | note_fruit | The fruit ${{fruit}} pulled from csv | | + """ + + +md__symbols = """ +| settings | +| | form_title | form_id | version | +| | a non_ascii_form_id | ''=+/*-451%/% | 1 | +| survey | | | | | +| | type | name | label | calculation | +| | calculate | fruit | | pulldata('fruits', 'name', 'name_key', 'mango') | +| | note | note_fruit | The fruit ${{fruit}} pulled from csv | | +""" +md__dingbat = """ +| settings | +| | form_title | form_id | version | +| | ✅ | ✅ | 1 | +| survey | | | | | +| | type | name | label | calculation | +| | calculate | fruit | | pulldata('fruits', 'name', 'name_key', 'mango') | +| | note | note_fruit | The fruit ${{fruit}} pulled from csv | | +""" diff --git a/tests/resources/submissions_data.py b/tests/resources/submissions_data.py index e377c68..7561f38 100644 --- a/tests/resources/submissions_data.py +++ b/tests/resources/submissions_data.py @@ -45,3 +45,34 @@ 36 """ + + +def get_xml__fruits( + form_id: str, + version: str, + instance_id: str, + deprecated_instance_id: str | None = None, + selected_fruit: str = "Papaya", +) -> str: + """ + Get Submission XML for the "fruits" form that uses an external data list. + + :param form_id: The xmlFormId of the Form being referenced. + :param version: The version of the form that the submission is for. + :param instance_id: The instanceId of the Submission being referenced. + :param deprecated_instance_id: If the submission is an edit, then the instance_id of + the submission being replaced must be provided. + :param selected_fruit: Which delicious tropical fruit do you like? + """ + iidd = "" + if deprecated_instance_id is not None: + iidd = f"{deprecated_instance_id}" + return f""" + + {iidd} + {instance_id} + + {selected_fruit} + + + """ diff --git a/tests/test_client.py b/tests/test_client.py index 1ad39b2..777195d 100644 --- a/tests/test_client.py +++ b/tests/test_client.py @@ -3,27 +3,100 @@ from pyodk.client import Client -from tests.resources import RESOURCES +from tests.resources import RESOURCES, forms_data, submissions_data +from tests.utils import utils +from tests.utils.forms import ( + create_new_form__md, + create_new_form__xml, + get_latest_form_version, +) +from tests.utils.md_table import md_table_to_temp_dir +from tests.utils.submissions import ( + create_new_or_get_last_submission, + create_or_update_submission_with_comment, +) + + +def create_test_forms(client: Client | None = None) -> Client: + """ + Create test forms if they don't already exist. + + :param client: Client instance to use for API calls. + :return: The original client instance, or a new one if none was provided. + """ + if client is None: + client = Client() + create_new_form__xml( + client=client, + form_id="range_draft", + form_def=forms_data.get_xml__range_draft(), + ) + create_new_form__md( + client=client, + form_id="pull_data", + form_def=forms_data.get_md__pull_data(), + ) + create_new_form__md( + client=client, + form_id="non_ascii_form_id", + form_def=forms_data.md__symbols, + ) + create_new_form__md( + client=client, + form_id="✅", + form_def=forms_data.md__dingbat, + ) + return client + + +def create_test_submissions(client: Client | None = None) -> Client: + """ + Create test submissions, if they don't already exist. + + :param client: Client instance to use for API calls. + :return: The original client instance, or a new one if none was provided. + """ + if client is None: + client = Client() + create_or_update_submission_with_comment( + client=client, + form_id="pull_data", + instance_id="uuid:07ee9b2f-2271-474c-b9f3-c92ffba80c79", + ) + create_or_update_submission_with_comment( + client=client, + form_id="pull_data", + instance_id="uuid:4e2d1f60-aa3a-4065-bb97-af69b0cc8187", + ) + return client @skip class TestUsage(TestCase): """Tests for experimenting with usage scenarios / general debugging / integration.""" + client: Client | None = None + + @classmethod + def setUpClass(cls): + cls.client = Client() + create_test_forms(client=cls.client) + create_test_submissions(client=cls.client) + def test_direct(self): - client = Client() - projects = client.projects.list() - forms = client.forms.list() - submissions = client.submissions.list(form_id=forms[3].xmlFormId) - form_data = client.submissions.get_table(form_id=forms[3].xmlFormId) - form_data_params = client.submissions.get_table( - form_id="range", + projects = self.client.projects.list() + forms = self.client.forms.list() + submissions = self.client.submissions.list(form_id="pull_data") + form_data = self.client.submissions.get_table(form_id="pull_data") + form_data_params = self.client.submissions.get_table( + form_id="pull_data", table_name="Submissions", count=True, + select="__id,meta/instanceID,__system/formVersion,fruit", ) - comments = client.submissions.list_comments( - form_id="range", - instance_id="uuid:2c296eae-2708-4a89-bfe7-0f2d440b7fe8", + comments = self.client.submissions.list_comments( + form_id="pull_data", + instance_id=next(s.instanceId for s in submissions), ) print([projects, forms, submissions, form_data, form_data_params, comments]) @@ -36,104 +109,110 @@ def test_direct_context(self): # Below tests assume project has forms by these names already published. def test_form_update__new_definition(self): """Should create a new version with the new definition.""" - with Client() as client: - client.forms.update( + with utils.get_temp_file(suffix=".xml") as fp: + fp.write_text(forms_data.get_xml__range_draft()) + self.client.forms.update( form_id="range_draft", - definition=(RESOURCES / "forms" / "range_draft.xml").as_posix(), + definition=fp.as_posix(), ) def test_form_update__new_definition_and_attachments(self): """Should create a new version with new definition and attachment.""" - with Client() as client: - client.forms.update( + # To test the API without a version_updater, a timestamped version is created. + with md_table_to_temp_dir( + form_id="pull_data", mdstr=forms_data.get_md__pull_data() + ) as fp: + self.client.forms.update( form_id="pull_data", - definition=(RESOURCES / "forms" / "pull_data.xlsx").as_posix(), + definition=fp.as_posix(), attachments=[(RESOURCES / "forms" / "fruits.csv").as_posix()], ) def test_form_update__new_definition_and_attachments__non_ascii_dingbat(self): """Should create a new version with new definition and attachment.""" - with Client() as client: - client.forms.update( + with md_table_to_temp_dir( + form_id="✅", mdstr=forms_data.get_md__pull_data() + ) as fp: + self.client.forms.update( form_id="✅", - definition=(RESOURCES / "forms" / "✅.xlsx").as_posix(), + definition=fp.as_posix(), attachments=[(RESOURCES / "forms" / "fruits.csv").as_posix()], ) - form = client.forms.get("✅") + form = self.client.forms.get("✅") self.assertEqual(form.xmlFormId, "✅") def test_form_update__with_version_updater__non_ascii_specials(self): """Should create a new version with new definition and attachment.""" - with Client() as client: - client.forms.update( - form_id="'=+/*-451%/%", - attachments=[], - version_updater=lambda v: datetime.now().isoformat(), - ) + self.client.forms.update( + form_id="'=+/*-451%/%", + attachments=[], + version_updater=lambda v: datetime.now().isoformat(), + ) def test_form_update__attachments(self): """Should create a new version with new attachment.""" - with Client() as client: - client.forms.update( - form_id="pull_data", - attachments=[(RESOURCES / "forms" / "fruits.csv").as_posix()], - ) + self.client.forms.update( + form_id="pull_data", + attachments=[(RESOURCES / "forms" / "fruits.csv").as_posix()], + ) def test_form_update__attachments__with_version_updater(self): """Should create a new version with new attachment and updated version.""" - with Client() as client: - client.forms.update( - form_id="pull_data", - attachments=[(RESOURCES / "forms" / "fruits.csv").as_posix()], - version_updater=lambda v: v + "_1", - ) + self.client.forms.update( + form_id="pull_data", + attachments=[(RESOURCES / "forms" / "fruits.csv").as_posix()], + version_updater=lambda v: v + "_1", + ) def test_project_create_app_users__names_only(self): """Should create project app users.""" - client = Client() - client.projects.create_app_users(display_names=["test_role3", "test_user3"]) + self.client.projects.create_app_users(display_names=["test_role3", "test_user3"]) def test_project_create_app_users__names_and_forms(self): """Should create project app users, and assign forms to them.""" - client = Client() - client.projects.create_app_users( + self.client.projects.create_app_users( display_names=["test_assign3", "test_assign_23"], - forms=["range", "pull_data"], + forms=["range_draft", "pull_data"], ) def test_submission_create__non_ascii(self): """Should create an instance of the form, encoded to utf-8.""" - client = Client() - xml = """ - - - ~!@#$%^&*()_+=-✅✅ - - Banana - - - """ - client.submissions.create(xml=xml, form_id="'=+/*-451%/%") - submission = client.submissions.get( - form_id="'=+/*-451%/%", instance_id="~!@#$%^&*()_+=-✅✅" + form_id = "'=+/*-451%/%" + iid = f"""scna+~!@#$%^&*()_+=-✅✅+{datetime.now().isoformat()}""" + + self.client.submissions.create( + xml=submissions_data.get_xml__fruits( + form_id=form_id, + version=get_latest_form_version(client=self.client, form_id=form_id), + instance_id=iid, + ), + form_id=form_id, ) - self.assertEqual("~!@#$%^&*()_+=-✅✅", submission.instanceId) + submission = self.client.submissions.get(form_id=form_id, instance_id=iid) + self.assertEqual(iid, submission.instanceId) def test_submission_edit__non_ascii(self): """Should edit an existing instance of the form, encoded to utf-8.""" - client = Client() # The "instance_id" remains the id of the first submission, not the # instanceID/deprecatedID used in the XML. - xml = """ - - - ~!@#$%^&*()_+=-✘✘ - ~!@#$%^&*()_+=-✘✘✘ - - Papaya - - - """ - client.submissions.edit( - xml=xml, form_id="'=+/*-451%/%", instance_id="~!@#$%^&*()_+=-✅✅" + form_id = "'=+/*-451%/%" + iid = """sena_~~!@#$%^&*()_+=-✅✅""" + + # So we have a submission to edit, create one or find the most recent prior edit. + old_iid = create_new_or_get_last_submission( + client=self.client, + form_id=form_id, + instance_id=iid, + ) + now = datetime.now().isoformat() + self.client.submissions.edit( + xml=submissions_data.get_xml__fruits( + form_id=form_id, + version=get_latest_form_version(client=self.client, form_id=form_id), + instance_id=iid + now, + deprecated_instance_id=old_iid, + ), + form_id=form_id, + instance_id=iid, + comment=f"pyODK edit {now}", ) diff --git a/tests/test_config.py b/tests/test_config.py index 5e4d31f..9b4ba53 100644 --- a/tests/test_config.py +++ b/tests/test_config.py @@ -5,7 +5,8 @@ from pyodk._utils import config from pyodk.errors import PyODKError -from tests import resources, utils +from tests import resources +from tests.utils.utils import get_temp_dir class TestConfig(TestCase): @@ -51,7 +52,7 @@ def test_read_toml__error__non_existent(self): def test_write_cache__ok(self): """Should write the cache data when no path is specified.""" - with utils.get_temp_dir() as tmp: + with get_temp_dir() as tmp: path = tmp / "my_cache.toml" with patch.dict(os.environ, {"PYODK_CACHE_FILE": path.as_posix()}): self.assertFalse(path.exists()) @@ -60,7 +61,7 @@ def test_write_cache__ok(self): def test_write_cache__with_path(self): """Should write the cache data when a path is specified.""" - with utils.get_temp_dir() as tmp: + with get_temp_dir() as tmp: path = tmp / "my_cache.toml" self.assertFalse(path.exists()) config.write_cache(key="token", value="1234abcd", cache_path=path.as_posix()) diff --git a/tests/test_session.py b/tests/test_session.py index bafe41f..321e07f 100644 --- a/tests/test_session.py +++ b/tests/test_session.py @@ -29,7 +29,7 @@ def test_urlformat(self): # integer ({"project_id": 1, "form_id": 1}, "projects/1/forms/1"), # latin symbols - ({"project_id": 1, "form_id": "+-_*%*"}, "projects/1/forms/%2B-_%2A%25%2A"), + ({"project_id": 1, "form_id": "+-_*%*"}, "projects/1/forms/%2B-_*%25*"), # lower case e, with combining acute accent (2 symbols) ({"project_id": 1, "form_id": "tést"}, "projects/1/forms/te%CC%81st"), # lower case e with acute (1 symbol) @@ -49,7 +49,9 @@ def test_urlquote(self): # integer ("1.xls", "1"), # latin symbols - ("+-_*%*.xls", "%2B-_%2A%25%2A"), + ("+-_*%*.xls", "%2B-_*%25*"), + # spaces + ("my form.xlsx", "my%20form"), # lower case e, with combining acute accent (2 symbols) ("tést.xlsx", "te%CC%81st"), # lower case e with acute (1 symbol) diff --git a/tests/utils/__init__.py b/tests/utils/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/utils/forms.py b/tests/utils/forms.py new file mode 100644 index 0000000..3bdb6e2 --- /dev/null +++ b/tests/utils/forms.py @@ -0,0 +1,87 @@ +from pathlib import Path +from typing import IO + +from pyodk._endpoints.form_drafts import FormDraftService +from pyodk.client import Client +from pyodk.errors import PyODKError + +from tests.utils import utils +from tests.utils.md_table import md_table_to_temp_dir + + +def create_new_form(client: Client, file_path: Path | str, form_id: str, form_data: IO): + """ + Create a new form. Ignores any pyODK errors. + + :param client: Client instance to use for API calls. + :param file_path: Path of the form definition. + :param form_id: The xmlFormId of the Form being referenced. + :param form_data: The form file descriptor which can be read() from. + :return: + """ + try: + fd = FormDraftService( + session=client.session, default_project_id=client.project_id + ) + pid, fid, headers, params = fd._prep_form_post( + file_path=file_path, form_id=form_id + ) + params["publish"] = True + client.post( + url=client.session.urlformat("projects/{pid}/forms", pid=client.project_id), + headers=headers, + params=params, + data=form_data, + ) + except PyODKError: + pass + + +def create_new_form__md(client: Client, form_id: str, form_def: str): + """ + Create a new form from a MarkDown string. + + :param client: Client instance to use for API calls. + :param form_id: The xmlFormId of the Form being referenced. + :param form_def: The form definition MarkDown. + """ + with ( + md_table_to_temp_dir(form_id=form_id, mdstr=form_def) as fp, + open(fp, "rb") as form_data, + ): + create_new_form(client=client, file_path=fp, form_id=form_id, form_data=form_data) + + +def create_new_form__xml(client: Client, form_id: str, form_def: str): + """ + Create a new form from a XML string. + + :param client: Client instance to use for API calls. + :param form_id: The xmlFormId of the Form being referenced. + :param form_def: The form definition XML. + """ + with utils.get_temp_file(suffix=".xml") as fp: + fp.write_text(form_def) + with open(fp, "rb") as form_data: + create_new_form( + client=client, file_path=fp, form_id=form_id, form_data=form_data + ) + + +def get_latest_form_version(client: Client, form_id: str) -> str: + """ + Get the version name of the most recently published version of the form. + + :param client: Client instance to use for API calls. + :param form_id: The xmlFormId of the Form being referenced. + """ + versions = client.session.get( + client.session.urlformat( + "projects/{pid}/forms/{fid}/versions", + pid=client.project_id, + fid=form_id, + ) + ) + return sorted( + (s for s in versions.json()), key=lambda s: s["publishedAt"], reverse=True + )[0]["version"] diff --git a/tests/utils/md_table.py b/tests/utils/md_table.py new file mode 100644 index 0000000..17a8467 --- /dev/null +++ b/tests/utils/md_table.py @@ -0,0 +1,90 @@ +""" +Markdown table utility functions. +""" + +import re +from contextlib import contextmanager +from pathlib import Path + +from openpyxl import Workbook + +from tests.utils.utils import get_temp_dir + + +def _strp_cell(cell): + val = cell.strip() + if val == "": + return None + val = val.replace(r"\|", "|") + return val + + +def _extract_array(mdtablerow): + match = re.match(r"\s*\|(.*)\|\s*", mdtablerow) + if match: + mtchstr = match.groups()[0] + if re.match(r"^[\|-]+$", mtchstr): + return False + else: + return [_strp_cell(c) for c in re.split(r"(? list[tuple[str, list[list[str]]]]: + ss_arr = [] + for item in mdstr.split("\n"): + arr = _extract_array(item) + if arr: + ss_arr.append(arr) + sheet_name = False + sheet_arr = False + sheets = [] + for row in ss_arr: + if row[0] is not None: + if sheet_arr: + sheets.append((sheet_name, sheet_arr)) + sheet_arr = [] + sheet_name = row[0] + excluding_first_col = row[1:] + if sheet_name and not _is_null_row(excluding_first_col): + sheet_arr.append(excluding_first_col) + sheets.append((sheet_name, sheet_arr)) + + return sheets + + +def md_table_to_workbook(mdstr: str) -> Workbook: + """ + Convert Markdown table string to an openpyxl.Workbook. Call wb.save() to persist. + """ + md_data = md_table_to_ss_structure(mdstr=mdstr) + wb = Workbook(write_only=True) + for key, rows in md_data: + sheet = wb.create_sheet(title=key) + for r in rows: + sheet.append(r) + return wb + + +@contextmanager +def md_table_to_temp_dir(form_id: str, mdstr: str) -> Path: + """ + Convert MarkDown table string to a XLSX file saved in a temp directory. + + :param form_id: The xmlFormId of the Form being referenced. + :param mdstr: The MarkDown table string. + :return: The path of the XLSX file. + """ + with get_temp_dir() as td: + fp = Path(td) / f"{form_id}.xlsx" + md_table_to_workbook(mdstr).save(fp.as_posix()) + yield fp diff --git a/tests/utils/submissions.py b/tests/utils/submissions.py new file mode 100644 index 0000000..371505f --- /dev/null +++ b/tests/utils/submissions.py @@ -0,0 +1,70 @@ +from uuid import uuid4 + +from pyodk.client import Client +from pyodk.errors import PyODKError + +from tests.resources import submissions_data +from tests.utils.forms import get_latest_form_version + + +def create_new_or_get_last_submission( + client: Client, form_id: str, instance_id: str +) -> str: + """ + Create a new submission, or get the most recent version, and return it's instance_id. + + :param client: Client instance to use for API calls. + :param form_id: The xmlFormId of the Form being referenced. + :param instance_id: The instanceId of the Submission being referenced. + :return: The created instance_id or the instance_id of the most recent version. + """ + try: + old_iid = client.submissions.create( + xml=submissions_data.get_xml__fruits( + form_id=form_id, + version=get_latest_form_version(client=client, form_id=form_id), + instance_id=instance_id, + ), + form_id=form_id, + ).instanceId + except PyODKError: + subvs = client.session.get( + client.session.urlformat( + "projects/{pid}/forms/{fid}/submissions/{iid}/versions", + pid=client.project_id, + fid=form_id, + iid=instance_id, + ), + ) + old_iid = sorted( + (s for s in subvs.json()), key=lambda s: s["createdAt"], reverse=True + )[0]["instanceId"] + return old_iid + + +def create_or_update_submission_with_comment( + client: Client, form_id: str, instance_id: str +): + """ + Create and/or update a submission, adding a comment with the edit. + + :param client: Client instance to use for API calls. + :param form_id: The xmlFormId of the Form being referenced. + :param instance_id: The instanceId of the Submission being referenced. + """ + pd_iid = create_new_or_get_last_submission( + client=client, + form_id=form_id, + instance_id=instance_id, + ) + client.submissions.edit( + xml=submissions_data.get_xml__fruits( + form_id=form_id, + version=get_latest_form_version(client=client, form_id=form_id), + instance_id=uuid4().hex, + deprecated_instance_id=pd_iid, + ), + form_id=form_id, + instance_id=instance_id, + comment="pyODK edit", + ) diff --git a/tests/utils.py b/tests/utils/utils.py similarity index 65% rename from tests/utils.py rename to tests/utils/utils.py index 82ca65c..aee6891 100644 --- a/tests/utils.py +++ b/tests/utils/utils.py @@ -5,8 +5,14 @@ @contextmanager -def get_temp_file() -> Path: - temp_file = tempfile.NamedTemporaryFile(delete=False) +def get_temp_file(**kwargs) -> Path: + """ + Create a temporary file. + + :param kwargs: File handling options passed through to NamedTemporaryFile. + :return: The path of the temporary file. + """ + temp_file = tempfile.NamedTemporaryFile(delete=False, **kwargs) temp_file.close() temp_path = Path(temp_file.name) try: