fhir-sdc-testkit 3.0.0a1__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,5 @@
1
+ dist/
2
+ .venv/
3
+ .mypy_cache/
4
+ .ruff_cache/
5
+ fhir_sdc_testkit/_version.py
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 beda.software
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,141 @@
1
+ Metadata-Version: 2.5
2
+ Name: fhir-sdc-testkit
3
+ Version: 3.0.0a1
4
+ Summary: Drive an SDC Questionnaire through $populate, answers and $extract in tests
5
+ Project-URL: Homepage, https://github.com/beda-software/fhir-sdc
6
+ Project-URL: Source, https://github.com/beda-software/fhir-sdc/tree/master/fhir-sdc-testkit
7
+ Author-email: "beda.software" <hi@beda.software>
8
+ License-Expression: MIT
9
+ License-File: LICENSE
10
+ Keywords: fhir,pytest,questionnaire,sdc,testing
11
+ Classifier: Development Status :: 3 - Alpha
12
+ Classifier: Framework :: Pytest
13
+ Classifier: Intended Audience :: Developers
14
+ Classifier: Programming Language :: Python :: 3
15
+ Classifier: Topic :: Software Development :: Testing
16
+ Requires-Python: >=3.11
17
+ Requires-Dist: fhirpy-types-r4b>=0.1.1
18
+ Requires-Dist: fhirpy>=2.2.0
19
+ Description-Content-Type: text/markdown
20
+
21
+ # fhir-sdc-testkit
22
+
23
+ Test an SDC Questionnaire and its extraction the way a form is really filled: `$populate` runs, answers are placed at
24
+ each `linkId`'s nesting, then `QuestionnaireResponse/$extract` runs. With `submit=True` the QuestionnaireResponse is
25
+ saved before extraction and the bundle is submitted after it. Only what a user types (or the frontend calculates) is
26
+ faked; everything else runs on
27
+ a real server implementing the [SDC](https://build.fhir.org/ig/HL7/sdc/) operations, such as
28
+ [fhir-sdc](../README.md).
29
+
30
+ ```sh
31
+ pip install fhir-sdc-testkit
32
+ ```
33
+
34
+ It is released from the fhir-sdc repository, with the same version as the fhir-sdc release it is tested against.
35
+
36
+ ## The client
37
+
38
+ Everything takes an `AsyncFHIRClient` rooted at the server's `/fhir` base, built with `dump_resource` — fhirpy cannot
39
+ serialize pydantic resources without it:
40
+
41
+ ```python
42
+ fhir_client = AsyncFHIRClient(
43
+ f"{base_url}/fhir",
44
+ authorization=authorization,
45
+ dump_resource=lambda resource: resource.model_dump(),
46
+ )
47
+ ```
48
+
49
+ ## Usage
50
+
51
+ ```python
52
+ import fhirpy_types_r4b as r4b
53
+ from fhir_sdc_testkit import answer, launch_context, populate_and_extract
54
+
55
+
56
+ async def test_intake_records_the_address(fhir_client):
57
+ patient = await fhir_client.create(r4b.Patient(name=[r4b.HumanName(family="Test")]))
58
+
59
+ await populate_and_extract(
60
+ fhir_client,
61
+ "patient-intake",
62
+ parameters=launch_context(
63
+ {"Patient": patient}, subject=r4b.Reference(reference=f"Patient/{patient.id}")
64
+ ),
65
+ answers={"postal-code": [answer(valueString="75001")]},
66
+ submit=True,
67
+ )
68
+
69
+ updated = await fhir_client.get(r4b.Patient, patient.id)
70
+ assert updated.address[0].postalCode == "75001"
71
+ ```
72
+
73
+ `populate_and_extract` returns the QuestionnaireResponse and the bundle `$extract` returned (`None` when there is
74
+ nothing to extract). Without `submit` neither is saved, so the bundle can be asserted on directly. With it, the
75
+ response is the saved one — saved first, since a Mapping may reference it by id — and the bundle has been submitted.
76
+
77
+ `populate` and `extract` run the two operations on their own and save nothing.
78
+
79
+ ### Answers
80
+
81
+ Keys are `linkId`s, addressed flatly — groups in between are created for you:
82
+
83
+ ```python
84
+ answers={
85
+ "country": [answer(valueCoding=r4b.Coding(code="FR"))],
86
+ "postal-code": [answer(valueString="75001")],
87
+ }
88
+ ```
89
+
90
+ A repeating group takes one answer set per occurrence, filled positionally over the occurrences `$populate` produced,
91
+ appending any that are missing and leaving extra populated ones alone:
92
+
93
+ ```python
94
+ answers={
95
+ "contacts": [
96
+ {"phone": [answer(valueString="+1 555 0100")], "label": [answer(valueString="home")]},
97
+ {"phone": [answer(valueString="+1 555 0111")], "label": [answer(valueString="work")]},
98
+ ],
99
+ }
100
+ ```
101
+
102
+ Inside a set, `linkId`s are flat again. A `linkId` that does not belong to the group raises, naming it.
103
+
104
+ ### Parameters
105
+
106
+ `parameters` is required: it is passed to `$populate` and merged into the `$extract` call, so anything the server
107
+ accepts can go in it. `launch_context()` builds the SDC `$populate` input — a resource or a `Reference` under each
108
+ context name, and the optional `subject`. Pass at least one of them: a `$populate` reply that is not `Parameters`
109
+ raises `ValueError`.
110
+
111
+ ```python
112
+ parameters=launch_context({"Patient": patient, "Author": r4b.Reference(reference="Practitioner/1")})
113
+ ```
114
+
115
+ A list under a name builds one `context` with a `content` part per value, as SDC defines it. fhir-sdc uses only the
116
+ first `content` for now.
117
+
118
+ ## Failures
119
+
120
+ An error response from the server raises fhirpy's exception for its status — `OperationOutcome` for most 4xx and 5xx
121
+ responses. A violated `itemConstraint` the server checks during `$extract` and a rejected transaction both arrive this
122
+ way; they differ in `issue[].code`. fhir-sdc reports a constraint's `key`, and a server's own validation code, such as
123
+ `invalid`, for a transaction. An `error` or `fatal` issue in the `$extract` result and, with `submit`, a batch entry
124
+ the server rejects raise `ExtractionError`.
125
+
126
+ ```python
127
+ with pytest.raises(OperationOutcome) as refused:
128
+ await populate_and_extract(fhir_client, "patient-intake", parameters=parameters, answers=answers)
129
+ assert refused.value.resource["issue"][0]["code"] == "postal-code-required"
130
+ ```
131
+
132
+ ## Development
133
+
134
+ ```sh
135
+ uv sync
136
+ uv run pytest
137
+ uv run ruff format . && uv run ruff check . && uv run mypy
138
+ ```
139
+
140
+ That suite runs without a server. `$populate`, constraints and extraction are covered against a running Aidbox and
141
+ fhir-sdc by `tests/testkit/` in the repository root — `./run_test.sh tests/testkit`.
@@ -0,0 +1,121 @@
1
+ # fhir-sdc-testkit
2
+
3
+ Test an SDC Questionnaire and its extraction the way a form is really filled: `$populate` runs, answers are placed at
4
+ each `linkId`'s nesting, then `QuestionnaireResponse/$extract` runs. With `submit=True` the QuestionnaireResponse is
5
+ saved before extraction and the bundle is submitted after it. Only what a user types (or the frontend calculates) is
6
+ faked; everything else runs on
7
+ a real server implementing the [SDC](https://build.fhir.org/ig/HL7/sdc/) operations, such as
8
+ [fhir-sdc](../README.md).
9
+
10
+ ```sh
11
+ pip install fhir-sdc-testkit
12
+ ```
13
+
14
+ It is released from the fhir-sdc repository, with the same version as the fhir-sdc release it is tested against.
15
+
16
+ ## The client
17
+
18
+ Everything takes an `AsyncFHIRClient` rooted at the server's `/fhir` base, built with `dump_resource` — fhirpy cannot
19
+ serialize pydantic resources without it:
20
+
21
+ ```python
22
+ fhir_client = AsyncFHIRClient(
23
+ f"{base_url}/fhir",
24
+ authorization=authorization,
25
+ dump_resource=lambda resource: resource.model_dump(),
26
+ )
27
+ ```
28
+
29
+ ## Usage
30
+
31
+ ```python
32
+ import fhirpy_types_r4b as r4b
33
+ from fhir_sdc_testkit import answer, launch_context, populate_and_extract
34
+
35
+
36
+ async def test_intake_records_the_address(fhir_client):
37
+ patient = await fhir_client.create(r4b.Patient(name=[r4b.HumanName(family="Test")]))
38
+
39
+ await populate_and_extract(
40
+ fhir_client,
41
+ "patient-intake",
42
+ parameters=launch_context(
43
+ {"Patient": patient}, subject=r4b.Reference(reference=f"Patient/{patient.id}")
44
+ ),
45
+ answers={"postal-code": [answer(valueString="75001")]},
46
+ submit=True,
47
+ )
48
+
49
+ updated = await fhir_client.get(r4b.Patient, patient.id)
50
+ assert updated.address[0].postalCode == "75001"
51
+ ```
52
+
53
+ `populate_and_extract` returns the QuestionnaireResponse and the bundle `$extract` returned (`None` when there is
54
+ nothing to extract). Without `submit` neither is saved, so the bundle can be asserted on directly. With it, the
55
+ response is the saved one — saved first, since a Mapping may reference it by id — and the bundle has been submitted.
56
+
57
+ `populate` and `extract` run the two operations on their own and save nothing.
58
+
59
+ ### Answers
60
+
61
+ Keys are `linkId`s, addressed flatly — groups in between are created for you:
62
+
63
+ ```python
64
+ answers={
65
+ "country": [answer(valueCoding=r4b.Coding(code="FR"))],
66
+ "postal-code": [answer(valueString="75001")],
67
+ }
68
+ ```
69
+
70
+ A repeating group takes one answer set per occurrence, filled positionally over the occurrences `$populate` produced,
71
+ appending any that are missing and leaving extra populated ones alone:
72
+
73
+ ```python
74
+ answers={
75
+ "contacts": [
76
+ {"phone": [answer(valueString="+1 555 0100")], "label": [answer(valueString="home")]},
77
+ {"phone": [answer(valueString="+1 555 0111")], "label": [answer(valueString="work")]},
78
+ ],
79
+ }
80
+ ```
81
+
82
+ Inside a set, `linkId`s are flat again. A `linkId` that does not belong to the group raises, naming it.
83
+
84
+ ### Parameters
85
+
86
+ `parameters` is required: it is passed to `$populate` and merged into the `$extract` call, so anything the server
87
+ accepts can go in it. `launch_context()` builds the SDC `$populate` input — a resource or a `Reference` under each
88
+ context name, and the optional `subject`. Pass at least one of them: a `$populate` reply that is not `Parameters`
89
+ raises `ValueError`.
90
+
91
+ ```python
92
+ parameters=launch_context({"Patient": patient, "Author": r4b.Reference(reference="Practitioner/1")})
93
+ ```
94
+
95
+ A list under a name builds one `context` with a `content` part per value, as SDC defines it. fhir-sdc uses only the
96
+ first `content` for now.
97
+
98
+ ## Failures
99
+
100
+ An error response from the server raises fhirpy's exception for its status — `OperationOutcome` for most 4xx and 5xx
101
+ responses. A violated `itemConstraint` the server checks during `$extract` and a rejected transaction both arrive this
102
+ way; they differ in `issue[].code`. fhir-sdc reports a constraint's `key`, and a server's own validation code, such as
103
+ `invalid`, for a transaction. An `error` or `fatal` issue in the `$extract` result and, with `submit`, a batch entry
104
+ the server rejects raise `ExtractionError`.
105
+
106
+ ```python
107
+ with pytest.raises(OperationOutcome) as refused:
108
+ await populate_and_extract(fhir_client, "patient-intake", parameters=parameters, answers=answers)
109
+ assert refused.value.resource["issue"][0]["code"] == "postal-code-required"
110
+ ```
111
+
112
+ ## Development
113
+
114
+ ```sh
115
+ uv sync
116
+ uv run pytest
117
+ uv run ruff format . && uv run ruff check . && uv run mypy
118
+ ```
119
+
120
+ That suite runs without a server. `$populate`, constraints and extraction are covered against a running Aidbox and
121
+ fhir-sdc by `tests/testkit/` in the repository root — `./run_test.sh tests/testkit`.
@@ -0,0 +1,14 @@
1
+ from fhir_sdc_testkit.extraction import ExtractionError
2
+ from fhir_sdc_testkit.forms import extract, populate, populate_and_extract
3
+ from fhir_sdc_testkit.sugar import Answers, LaunchContext, answer, launch_context
4
+
5
+ __all__ = [
6
+ "Answers",
7
+ "ExtractionError",
8
+ "LaunchContext",
9
+ "answer",
10
+ "extract",
11
+ "launch_context",
12
+ "populate",
13
+ "populate_and_extract",
14
+ ]
@@ -0,0 +1,24 @@
1
+ # file generated by vcs-versioning
2
+ # don't change, don't track in version control
3
+ from __future__ import annotations
4
+
5
+ __all__ = [
6
+ "__version__",
7
+ "__version_tuple__",
8
+ "version",
9
+ "version_tuple",
10
+ "__commit_id__",
11
+ "commit_id",
12
+ ]
13
+
14
+ version: str
15
+ __version__: str
16
+ __version_tuple__: tuple[int | str, ...]
17
+ version_tuple: tuple[int | str, ...]
18
+ commit_id: str | None
19
+ __commit_id__: str | None
20
+
21
+ __version__ = version = '3.0.0a1'
22
+ __version_tuple__ = version_tuple = (3, 0, 0, 'a1')
23
+
24
+ __commit_id__ = commit_id = None
@@ -0,0 +1,48 @@
1
+ """Raising for a failed SDC `QuestionnaireResponse/$extract` and for a rejected submission."""
2
+
3
+ from collections.abc import Mapping
4
+ from typing import Any
5
+
6
+ import fhirpy_types_r4b as r4b
7
+
8
+ # $extract returns what the mappers created, which may be resources R4B does not model.
9
+ Json = Mapping[str, Any]
10
+ FAILED_SEVERITIES = ("error", "fatal")
11
+ SUCCESSFUL_CODES = range(200, 300)
12
+
13
+
14
+ class ExtractionError(Exception):
15
+ """A failure the server reported in a successful response."""
16
+
17
+
18
+ def raise_for_failed_issues(extracted: Json) -> None:
19
+ """SDC puts hints and warnings in `issues` on success, so only error and fatal are failures."""
20
+ for parameter in extracted.get("parameter") or []:
21
+ if parameter["name"] == "issues":
22
+ issues = r4b.OperationOutcome.model_validate(parameter["resource"]).issue
23
+ failures = [issue for issue in issues if issue.severity in FAILED_SEVERITIES]
24
+ if failures:
25
+ raise ExtractionError(str(failures))
26
+
27
+
28
+ def find_bundle_to_submit(extracted: Json) -> dict[str, Any] | None:
29
+ return next(
30
+ (
31
+ parameter["resource"]
32
+ for parameter in extracted.get("parameter") or []
33
+ if parameter["name"] == "return"
34
+ ),
35
+ None,
36
+ )
37
+
38
+
39
+ def raise_for_rejected_entries(bundle: Json) -> None:
40
+ """A batch reports a rejected entry in the entry's response, under HTTP 200 for the bundle."""
41
+ for entry in bundle.get("entry") or []:
42
+ if not is_successful_status(entry["response"]["status"]):
43
+ raise ExtractionError(f"{entry.get('request', {})} -> {entry['response']}")
44
+
45
+
46
+ def is_successful_status(status: str) -> bool:
47
+ """An entry status leads with the HTTP code, as in `201 Created`."""
48
+ return int(status.split(maxsplit=1)[0]) in SUCCESSFUL_CODES
@@ -0,0 +1,202 @@
1
+ """Only user-entered answers are faked; $populate, constraints and the Mapping run for real."""
2
+
3
+ from collections.abc import Mapping, Sequence
4
+ from typing import Any, NamedTuple, cast
5
+
6
+ import fhirpy_types_r4b as r4b
7
+ from fhirpy import AsyncFHIRClient
8
+
9
+ from fhir_sdc_testkit.extraction import (
10
+ find_bundle_to_submit,
11
+ raise_for_failed_issues,
12
+ raise_for_rejected_entries,
13
+ )
14
+ from fhir_sdc_testkit.sugar import Answers
15
+
16
+
17
+ class ItemPath(NamedTuple):
18
+ path: tuple[str, ...]
19
+ item: r4b.QuestionnaireItem
20
+
21
+
22
+ async def populate_and_extract(
23
+ fhir_client: AsyncFHIRClient,
24
+ questionnaire_id: str,
25
+ *,
26
+ parameters: r4b.Parameters,
27
+ answers: Answers,
28
+ submit: bool = False,
29
+ ) -> tuple[r4b.QuestionnaireResponse, dict[str, Any] | None]:
30
+ """The filled QuestionnaireResponse and its extracted bundle; `submit` saves both, as a form does."""
31
+ questionnaire = await fhir_client.get(r4b.Questionnaire, questionnaire_id)
32
+ populated = await populate(fhir_client, questionnaire_id, parameters=parameters)
33
+ response = fill_questionnaire_response(questionnaire, populated, answers)
34
+ if submit:
35
+ # A Mapping may reference the response by id, so it is saved before extraction.
36
+ response = await fhir_client.create(response)
37
+ bundle = await extract(fhir_client, questionnaire, response, parameters=parameters)
38
+ if submit and bundle is not None:
39
+ raise_for_rejected_entries(await fhir_client.execute("/", data=bundle))
40
+ return response, bundle
41
+
42
+
43
+ async def populate(
44
+ fhir_client: AsyncFHIRClient, questionnaire_id: str, *, parameters: r4b.Parameters
45
+ ) -> r4b.QuestionnaireResponse:
46
+ """SDC `Questionnaire/{id}/$populate`: the populated response, not saved."""
47
+ populated = await fhir_client.execute(
48
+ f"Questionnaire/{questionnaire_id}/$populate", data=parameters.model_dump()
49
+ )
50
+ return get_populated_response(populated)
51
+
52
+
53
+ def get_populated_response(populated: Mapping[str, Any]) -> r4b.QuestionnaireResponse:
54
+ if populated.get("resourceType") != "Parameters":
55
+ raise ValueError(
56
+ f"$populate returned {populated.get('resourceType')}, not SDC Parameters: "
57
+ "pass a subject or a launch context"
58
+ )
59
+
60
+ response = next(p for p in populated["parameter"] if p["name"] == "response")
61
+ return r4b.QuestionnaireResponse.model_validate(response["resource"])
62
+
63
+
64
+ def fill_questionnaire_response(
65
+ questionnaire: r4b.Questionnaire, populated: r4b.QuestionnaireResponse, answers: Answers
66
+ ) -> r4b.QuestionnaireResponse:
67
+ """Set each answer at its linkId's nesting in the Questionnaire, replacing a populated one."""
68
+ response = populated.model_copy(deep=True, update={"status": "completed"})
69
+ children = get_or_create_children(response)
70
+ fill_items(
71
+ questionnaire.item or [], children, answers, scope=f"Questionnaire/{questionnaire.id}"
72
+ )
73
+ sort_items(questionnaire.item or [], children)
74
+ return response
75
+
76
+
77
+ def fill_items(
78
+ items: list[r4b.QuestionnaireItem],
79
+ children: list[r4b.QuestionnaireResponseItem],
80
+ answers: Answers,
81
+ *,
82
+ scope: str,
83
+ ) -> None:
84
+ paths = build_link_id_paths(items)
85
+ for link_id, value in answers.items():
86
+ if link_id not in paths:
87
+ raise KeyError(f"{link_id!r} is not an item of {scope} outside a repeating group")
88
+
89
+ path, item = paths[link_id]
90
+ siblings = find_or_create_siblings(children, path)
91
+ if is_repeating_group(item):
92
+ fill_occurrences(item, siblings, cast(Sequence[Answers], value))
93
+ else:
94
+ find_or_append_item(siblings, link_id).answer = list(
95
+ cast(Sequence[r4b.QuestionnaireResponseItemAnswer], value)
96
+ )
97
+
98
+
99
+ def build_link_id_paths(
100
+ items: list[r4b.QuestionnaireItem], ancestors: tuple[str, ...] = ()
101
+ ) -> dict[str, ItemPath]:
102
+ """linkId → the linkIds from the top-level item down to it. Repeating items are not descended."""
103
+ paths: dict[str, ItemPath] = {}
104
+ for item in items:
105
+ path = (*ancestors, item.linkId)
106
+ paths[item.linkId] = ItemPath(path, item)
107
+ if not item.repeats:
108
+ paths.update(build_link_id_paths(item.item or [], path))
109
+ return paths
110
+
111
+
112
+ def find_or_create_siblings(
113
+ children: list[r4b.QuestionnaireResponseItem], path: tuple[str, ...]
114
+ ) -> list[r4b.QuestionnaireResponseItem]:
115
+ """The item list holding the path's last linkId, creating the groups above it."""
116
+ for link_id in path[:-1]:
117
+ children = get_or_create_children(find_or_append_item(children, link_id))
118
+ return children
119
+
120
+
121
+ def is_repeating_group(item: r4b.QuestionnaireItem) -> bool:
122
+ return item.type == "group" and bool(item.repeats)
123
+
124
+
125
+ def fill_occurrences(
126
+ group: r4b.QuestionnaireItem,
127
+ siblings: list[r4b.QuestionnaireResponseItem],
128
+ answer_sets: Sequence[Answers],
129
+ ) -> None:
130
+ """One answer set per occurrence, positionally over the populated ones. Extra ones are kept."""
131
+ occurrences = find_or_append_occurrences(siblings, group.linkId, len(answer_sets))
132
+ for occurrence, answers in zip(occurrences, answer_sets, strict=True):
133
+ fill_items(
134
+ group.item or [],
135
+ get_or_create_children(occurrence),
136
+ answers,
137
+ scope=f"repeating group {group.linkId!r}",
138
+ )
139
+
140
+
141
+ def find_or_append_occurrences(
142
+ children: list[r4b.QuestionnaireResponseItem], link_id: str, count: int
143
+ ) -> list[r4b.QuestionnaireResponseItem]:
144
+ occurrences = [child for child in children if child.linkId == link_id]
145
+ while len(occurrences) < count:
146
+ item = r4b.QuestionnaireResponseItem(linkId=link_id)
147
+ children.append(item)
148
+ occurrences.append(item)
149
+ return occurrences[:count]
150
+
151
+
152
+ def find_or_append_item(
153
+ children: list[r4b.QuestionnaireResponseItem], link_id: str
154
+ ) -> r4b.QuestionnaireResponseItem:
155
+ for child in children:
156
+ if child.linkId == link_id:
157
+ return child
158
+
159
+ item = r4b.QuestionnaireResponseItem(linkId=link_id)
160
+ children.append(item)
161
+ return item
162
+
163
+
164
+ def get_or_create_children(
165
+ parent: r4b.QuestionnaireResponse | r4b.QuestionnaireResponseItem,
166
+ ) -> list[r4b.QuestionnaireResponseItem]:
167
+ if parent.item is None:
168
+ parent.item = []
169
+ return parent.item
170
+
171
+
172
+ def sort_items(
173
+ items: list[r4b.QuestionnaireItem], children: list[r4b.QuestionnaireResponseItem]
174
+ ) -> None:
175
+ """Answered items are appended as they come; a response follows the Questionnaire's order."""
176
+ order = {item.linkId: index for index, item in enumerate(items)}
177
+ children.sort(key=lambda child: order[child.linkId])
178
+ definitions = {item.linkId: item for item in items}
179
+ for child in children:
180
+ if child.item:
181
+ sort_items(definitions[child.linkId].item or [], child.item)
182
+
183
+
184
+ async def extract(
185
+ fhir_client: AsyncFHIRClient,
186
+ questionnaire: r4b.Questionnaire,
187
+ questionnaire_response: r4b.QuestionnaireResponse,
188
+ *,
189
+ parameters: r4b.Parameters,
190
+ ) -> dict[str, Any] | None:
191
+ """SDC `QuestionnaireResponse/$extract`: the bundle to submit, not submitted."""
192
+ extract_parameters = [
193
+ r4b.ParametersParameter(name="questionnaire", resource=questionnaire),
194
+ r4b.ParametersParameter(name="questionnaire-response", resource=questionnaire_response),
195
+ *(parameters.parameter or []),
196
+ ]
197
+ extracted = await fhir_client.execute(
198
+ "QuestionnaireResponse/$extract",
199
+ data=r4b.Parameters(parameter=extract_parameters).model_dump(),
200
+ )
201
+ raise_for_failed_issues(extracted)
202
+ return find_bundle_to_submit(extracted)
@@ -0,0 +1,43 @@
1
+ """What a test writes: an answer, a set of answers, and the launch context to populate with."""
2
+
3
+ from collections.abc import Mapping, Sequence
4
+
5
+ import fhirpy_types_r4b as r4b
6
+
7
+ answer = r4b.QuestionnaireResponseItemAnswer
8
+ Answers = Mapping[str, "Sequence[r4b.QuestionnaireResponseItemAnswer] | Sequence[Answers]"]
9
+ LaunchContextValue = r4b.AnyResource | r4b.Reference
10
+ LaunchContext = dict[str, "LaunchContextValue | list[LaunchContextValue]"]
11
+
12
+
13
+ def launch_context(
14
+ context: LaunchContext, *, subject: r4b.Reference | None = None
15
+ ) -> r4b.Parameters:
16
+ """SDC `$populate` input: one `context` parameter per name, with a `content` part per value."""
17
+ parameters = [
18
+ build_context_parameter(name, values if isinstance(values, list) else [values])
19
+ for name, values in context.items()
20
+ ]
21
+ if subject is not None:
22
+ parameters.append(r4b.ParametersParameter(name="subject", valueReference=subject))
23
+ return r4b.Parameters(parameter=parameters)
24
+
25
+
26
+ def build_context_parameter(name: str, values: list[LaunchContextValue]) -> r4b.ParametersParameter:
27
+ if not values:
28
+ raise ValueError(f"Launch context {name!r} needs at least one value")
29
+
30
+ return r4b.ParametersParameter(
31
+ name="context",
32
+ part=[
33
+ r4b.ParametersParameter(name="name", valueString=name),
34
+ *(build_content_part(value) for value in values),
35
+ ],
36
+ )
37
+
38
+
39
+ def build_content_part(value: LaunchContextValue) -> r4b.ParametersParameter:
40
+ if isinstance(value, r4b.Reference):
41
+ return r4b.ParametersParameter(name="content", valueReference=value)
42
+
43
+ return r4b.ParametersParameter(name="content", resource=value)