fpml 0.0.1__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.
fpml-0.0.1/PKG-INFO ADDED
@@ -0,0 +1,89 @@
1
+ Metadata-Version: 2.3
2
+ Name: fpml
3
+ Version: 0.0.1
4
+ Summary: The FHIRPath mapping language is a data DSL designed to convert data from QuestionnaireResponse (and not only) to any FHIR Resource.
5
+ License: MIT
6
+ Keywords: fhir,fhirpath
7
+ Author: Beda Software
8
+ Author-email: ilya@beda.software
9
+ Maintainer: Vadim Laletin
10
+ Maintainer-email: vadim@beda.software
11
+ Requires-Python: >=3.9
12
+ Classifier: Development Status :: 5 - Production/Stable
13
+ Classifier: Environment :: Web Environment
14
+ Classifier: Intended Audience :: Developers
15
+ Classifier: License :: OSI Approved :: MIT License
16
+ Classifier: Operating System :: OS Independent
17
+ Classifier: Programming Language :: Python
18
+ Classifier: Programming Language :: Python :: 3
19
+ Classifier: Programming Language :: Python :: 3.9
20
+ Classifier: Programming Language :: Python :: 3.10
21
+ Classifier: Programming Language :: Python :: 3.11
22
+ Classifier: Programming Language :: Python :: 3.12
23
+ Classifier: Programming Language :: Python :: 3.13
24
+ Classifier: Topic :: Software Development :: Libraries :: Python Modules
25
+ Requires-Dist: fhirpathpy (>=1.2.1,<2.0.0)
26
+ Project-URL: Bug Tracker, https://github.com/beda-software/FHIRPathMappingLanguage/issues
27
+ Project-URL: Documentation, https://github.com/beda-software/FHIRPathMappingLanguage/tree/main/python/README.md
28
+ Project-URL: Homepage, https://github.com/beda-software/FHIRPathMappingLanguage/tree/main/python
29
+ Project-URL: Repository, https://github.com/beda-software/FHIRPathMappingLanguage/tree/main/python
30
+ Description-Content-Type: text/markdown
31
+
32
+ # FHIRPathMappingLanguage - fpml python package
33
+
34
+ ## Installation
35
+
36
+ ```bash
37
+ pip install fpml
38
+ ```
39
+
40
+ ## Usage
41
+
42
+ ```python
43
+ from fpml import resolve_template
44
+
45
+
46
+ resource = {
47
+ "resourceType": "QuestionnaireResponse",
48
+ "status": "completed",
49
+ "item": [
50
+ {
51
+ "linkId": "name",
52
+ "answer": [
53
+ {
54
+ "valueString": "Name"
55
+ }
56
+ ]
57
+ }
58
+ ]
59
+ }
60
+
61
+ template = {
62
+ "resourceType": "Patient",
63
+ "name": "{{ item.where(linkId='name').answer.valueString }}"
64
+ }
65
+
66
+ context = {}
67
+
68
+ result = resolve_template(resource, template, context)
69
+
70
+ print(result)
71
+ # {'resourceType': 'Patient', 'name': 'Name'}
72
+ ```
73
+
74
+
75
+ ## Development
76
+
77
+ In `./python` directory:
78
+
79
+ Run in the shell
80
+ ```
81
+ autohooks activate
82
+ ```
83
+
84
+ And edit `../.git/hooks/pre-commit` by replacing the first line with
85
+ ```
86
+ #!/usr/bin/env -S poetry --project=./python run python
87
+ ```
88
+
89
+
fpml-0.0.1/README.md ADDED
@@ -0,0 +1,57 @@
1
+ # FHIRPathMappingLanguage - fpml python package
2
+
3
+ ## Installation
4
+
5
+ ```bash
6
+ pip install fpml
7
+ ```
8
+
9
+ ## Usage
10
+
11
+ ```python
12
+ from fpml import resolve_template
13
+
14
+
15
+ resource = {
16
+ "resourceType": "QuestionnaireResponse",
17
+ "status": "completed",
18
+ "item": [
19
+ {
20
+ "linkId": "name",
21
+ "answer": [
22
+ {
23
+ "valueString": "Name"
24
+ }
25
+ ]
26
+ }
27
+ ]
28
+ }
29
+
30
+ template = {
31
+ "resourceType": "Patient",
32
+ "name": "{{ item.where(linkId='name').answer.valueString }}"
33
+ }
34
+
35
+ context = {}
36
+
37
+ result = resolve_template(resource, template, context)
38
+
39
+ print(result)
40
+ # {'resourceType': 'Patient', 'name': 'Name'}
41
+ ```
42
+
43
+
44
+ ## Development
45
+
46
+ In `./python` directory:
47
+
48
+ Run in the shell
49
+ ```
50
+ autohooks activate
51
+ ```
52
+
53
+ And edit `../.git/hooks/pre-commit` by replacing the first line with
54
+ ```
55
+ #!/usr/bin/env -S poetry --project=./python run python
56
+ ```
57
+
@@ -0,0 +1,12 @@
1
+ import importlib.metadata
2
+
3
+ from .core.exceptions import FPMLValidationError
4
+ from .core.extract import resolve_template
5
+
6
+ __title__ = "fpml"
7
+ __version__ = importlib.metadata.version("fpml")
8
+ __author__ = "beda.software"
9
+ __license__ = "MIT"
10
+ __copyright__ = "Copyright 2025 beda.software"
11
+
12
+ __all__ = ["FPMLValidationError", "resolve_template"]
File without changes
@@ -0,0 +1,5 @@
1
+ # special root node key for the simplification
2
+ root_node_key = "__rootNode__"
3
+
4
+ # undefined is a special object used to remove keys from the object (similar to JS)
5
+ undefined = object()
@@ -0,0 +1,11 @@
1
+ from .constants import root_node_key
2
+ from .types import Path
3
+
4
+
5
+ class FPMLValidationError(Exception):
6
+ def __init__(self, message: str, path: Path) -> None:
7
+ path_str = ".".join(str(x) for x in path if x != root_node_key)
8
+ super().__init__(f"{message}. Path '{path_str}'")
9
+
10
+ self.error_message = message
11
+ self.error_path = path_str
@@ -0,0 +1,354 @@
1
+ import re
2
+ from typing import Any, Optional, cast
3
+
4
+ from fhirpathpy import evaluate # type: ignore
5
+
6
+ from .constants import root_node_key, undefined
7
+ from .exceptions import FPMLValidationError
8
+ from .types import (
9
+ Context,
10
+ DictNode,
11
+ FPOptions,
12
+ Matcher,
13
+ MatcherResult,
14
+ Node,
15
+ Path,
16
+ Resource,
17
+ StrNode,
18
+ Transformer,
19
+ )
20
+ from .utils import flatten, omit_key
21
+
22
+
23
+ def resolve_template(
24
+ resource: Resource,
25
+ template: Any,
26
+ context: Optional[Context] = None,
27
+ fp_options: Optional[FPOptions] = None,
28
+ strict: bool = False,
29
+ ) -> Any:
30
+ assert strict is False, "strict is not supported yet"
31
+
32
+ return resolve_template_recur(
33
+ [],
34
+ resource,
35
+ template,
36
+ context or {},
37
+ fp_options,
38
+ )
39
+
40
+
41
+ def resolve_template_recur(
42
+ start_path: Path,
43
+ resource: Resource,
44
+ template: Any,
45
+ context: Context,
46
+ fp_options: Optional[FPOptions] = None,
47
+ ) -> Any:
48
+ return iterate_node(
49
+ start_path,
50
+ {root_node_key: template},
51
+ context or {},
52
+ lambda path, node, context: process_node(path, resource, node, context, fp_options),
53
+ ).get(root_node_key, None)
54
+
55
+
56
+ def process_node(
57
+ path: Path,
58
+ resource: Resource,
59
+ node: Node,
60
+ context: Context,
61
+ fp_options: Optional[FPOptions],
62
+ ) -> tuple[Node, Context]:
63
+ if isinstance(node, dict):
64
+ new_node, new_context = process_assign_block(path, resource, node, context, fp_options)
65
+
66
+ matchers: list[Matcher] = [
67
+ process_context_block,
68
+ process_merge_block,
69
+ process_for_block,
70
+ process_if_block,
71
+ ]
72
+
73
+ for matcher in matchers:
74
+ result = matcher(path, resource, new_node, new_context, fp_options)
75
+ if result:
76
+ return result["node"], new_context
77
+
78
+ return new_node, new_context
79
+
80
+ if isinstance(node, str):
81
+ return process_template_string(path, resource, node, context, fp_options), context
82
+
83
+ return node, context
84
+
85
+
86
+ def iterate_node(start_path: Path, node: Node, context: Context, transform: Transformer) -> Node:
87
+ if isinstance(node, list):
88
+ # Arrays are flattened and null/undefined values are removed here
89
+ return flatten(
90
+ [
91
+ value
92
+ for value in [
93
+ iterate_node(
94
+ [*start_path, index],
95
+ *transform([*start_path, index], value, context),
96
+ transform,
97
+ )
98
+ for index, value in enumerate(node)
99
+ ]
100
+ if value is not None and value is not undefined
101
+ ]
102
+ )
103
+ if isinstance(node, dict):
104
+ # undefined values are removed from dicts, but nulls are preserved
105
+ return {
106
+ key: value
107
+ for key, value in {
108
+ key: iterate_node(
109
+ [*start_path, key],
110
+ *transform([*start_path, key], value, context),
111
+ transform,
112
+ )
113
+ for key, value in node.items()
114
+ }.items()
115
+ if value is not undefined
116
+ }
117
+
118
+ return transform(start_path, node, context)[0]
119
+
120
+
121
+ def process_template_string(
122
+ path: Path,
123
+ resource: Resource,
124
+ node: StrNode,
125
+ context: Context,
126
+ fp_options: Optional[FPOptions],
127
+ ) -> Any:
128
+ template_regexp = re.compile(r"{{\+?\s*([\s\S]+?)\s*\+?}}")
129
+ result = node
130
+
131
+ for match in template_regexp.finditer(node):
132
+ expr = match.group(1)
133
+ try:
134
+ replacement = evaluate_expression(path, resource, expr, context, fp_options)[0]
135
+ except IndexError:
136
+ return None if match.group(0).startswith("{{+") else undefined
137
+ if match.group(0) == node:
138
+ return replacement
139
+ result = result.replace(match.group(0), str(replacement))
140
+
141
+ return result
142
+
143
+
144
+ def process_context_block(
145
+ path: Path,
146
+ resource: Resource,
147
+ node: DictNode,
148
+ context: Context,
149
+ fp_options: Optional[FPOptions],
150
+ ) -> Optional[MatcherResult]:
151
+ keys = list(node.keys())
152
+ context_regexp = re.compile(r"{{\s*(.+?)\s*}}")
153
+ context_key = next((k for k in keys if context_regexp.match(k)), None)
154
+
155
+ if context_key:
156
+ matches = context_regexp.match(context_key)
157
+ expr = matches.group(1) if matches else ""
158
+
159
+ if len(keys) > 1:
160
+ raise FPMLValidationError("Context block must be presented as single key", path)
161
+
162
+ answers = evaluate_expression(path, resource, expr, context, fp_options)
163
+ return {
164
+ "node": [
165
+ resolve_template_recur(path, answer, node[context_key], context, fp_options)
166
+ for answer in answers
167
+ ]
168
+ }
169
+
170
+ return None
171
+
172
+
173
+ def process_for_block(
174
+ path: Path,
175
+ resource: Resource,
176
+ node: DictNode,
177
+ context: Context,
178
+ fp_options: Optional[FPOptions],
179
+ ) -> Optional[MatcherResult]:
180
+ keys = list(node.keys())
181
+
182
+ for_regexp = re.compile(r"{%\s*for\s+(?:(\w+?)\s*,\s*)?(\w+?)\s+in\s+(.+?)\s*%}")
183
+ for_key = next((k for k in keys if for_regexp.match(k)), None)
184
+
185
+ if for_key:
186
+ matches = for_regexp.match(for_key)
187
+ if not matches:
188
+ return None
189
+
190
+ has_index_key = len(matches.groups()) == 3 # noqa: PLR2004
191
+ index_key = cast(str, matches.group(1)) if has_index_key else None
192
+ item_key = cast(str, matches.group(2) if has_index_key else matches.group(1))
193
+ expr = matches.group(3) if has_index_key else matches.group(2)
194
+
195
+ if len(keys) > 1:
196
+ raise FPMLValidationError("For block must be presented as single key", path)
197
+
198
+ answers = evaluate_expression(path, resource, expr, context, fp_options)
199
+
200
+ return {
201
+ "node": [
202
+ resolve_template_recur(
203
+ path,
204
+ resource,
205
+ node[for_key],
206
+ {
207
+ **context,
208
+ item_key: answer,
209
+ **({index_key: index} if index_key else {}),
210
+ },
211
+ fp_options,
212
+ )
213
+ for index, answer in enumerate(answers)
214
+ ]
215
+ }
216
+
217
+ return None
218
+
219
+
220
+ def process_if_block(
221
+ path: Path,
222
+ resource: Resource,
223
+ node: dict[str, Any],
224
+ context: Context,
225
+ fp_options: Optional[FPOptions],
226
+ ) -> Optional[MatcherResult]:
227
+ keys = list(node.keys())
228
+
229
+ if_regexp = re.compile(r"{%\s*if\s+(.+?)\s*%}")
230
+ else_regexp = re.compile(r"{%\s*else\s*%}")
231
+
232
+ if_keys = [k for k in keys if if_regexp.match(k)]
233
+ if len(if_keys) > 1:
234
+ raise FPMLValidationError("If block must be presented once", path)
235
+ if_key = if_keys[0] if if_keys else None
236
+
237
+ else_keys = [k for k in keys if else_regexp.match(k)]
238
+ if len(else_keys) > 1:
239
+ raise FPMLValidationError("Else block must be presented once", path)
240
+ else_key = else_keys[0] if else_keys else None
241
+
242
+ if else_key and not if_key:
243
+ raise FPMLValidationError(
244
+ "Else block must be presented only when if block is presented", path
245
+ )
246
+
247
+ if not if_key:
248
+ return None
249
+
250
+ matches = if_regexp.match(if_key)
251
+ expr = matches.group(1) if matches else ""
252
+
253
+ answer = evaluate_expression(path, resource, f"iif({expr}, true, false)", context, fp_options)[
254
+ 0
255
+ ]
256
+
257
+ new_node = (
258
+ resolve_template_recur(path, resource, node[if_key], context, fp_options)
259
+ if answer
260
+ else (
261
+ resolve_template_recur(path, resource, node[else_key], context, fp_options)
262
+ if else_key
263
+ else undefined
264
+ )
265
+ )
266
+
267
+ is_merge_behavior = len(keys) != (2 if else_key else 1)
268
+ if is_merge_behavior:
269
+ if not isinstance(new_node, dict) and new_node is not None and new_node is not undefined:
270
+ raise FPMLValidationError(
271
+ "If/else block must return object for implicit merge into existing node",
272
+ path,
273
+ )
274
+
275
+ return {
276
+ "node": {
277
+ **omit_key(omit_key(node, if_key), else_key),
278
+ **(new_node if isinstance(new_node, dict) else {}),
279
+ }
280
+ }
281
+
282
+ return {"node": new_node}
283
+
284
+
285
+ def process_merge_block(
286
+ path: Path,
287
+ resource: Resource,
288
+ node: DictNode,
289
+ context: Context,
290
+ fp_options: Optional[FPOptions],
291
+ ) -> Optional[MatcherResult]:
292
+ merge_key = next((k for k in node if re.match(r"{%\s*merge\s*%}", k)), None)
293
+ if merge_key:
294
+ merged_node = omit_key(node, merge_key)
295
+ values = node[merge_key] if isinstance(node[merge_key], list) else [node[merge_key]]
296
+ for value in values:
297
+ result = resolve_template_recur(path, resource, value, context, fp_options)
298
+ if not isinstance(result, dict) and result is not None and result is not undefined:
299
+ raise FPMLValidationError("Merge block must contain object", path)
300
+ if result is not undefined and result is not None:
301
+ merged_node.update(result)
302
+ return {"node": merged_node}
303
+ return None
304
+
305
+
306
+ def process_assign_block(
307
+ path: Path,
308
+ resource: Resource,
309
+ node: DictNode,
310
+ context: Context,
311
+ fp_options: Optional[FPOptions],
312
+ ) -> tuple[DictNode, Context]:
313
+ extended_context = context.copy()
314
+ assign_key = next((k for k in node if re.match(r"{%\s*assign\s*%}", k)), None)
315
+ if assign_key:
316
+ if isinstance(node[assign_key], list):
317
+ for obj in node[assign_key]:
318
+ if len(obj) != 1:
319
+ raise FPMLValidationError(
320
+ "Assign block must accept only one key per object", path
321
+ )
322
+ extended_context.update(
323
+ resolve_template_recur(path, resource, obj, extended_context, fp_options)
324
+ )
325
+ elif isinstance(node[assign_key], dict) and len(node[assign_key]) == 1:
326
+ extended_context.update(
327
+ resolve_template_recur(
328
+ path,
329
+ resource,
330
+ node[assign_key],
331
+ extended_context,
332
+ fp_options,
333
+ )
334
+ )
335
+ else:
336
+ raise FPMLValidationError("Assign block must accept array or object", path)
337
+ return omit_key(node, assign_key), extended_context
338
+ return node, context
339
+
340
+
341
+ def evaluate_expression(
342
+ path: Path,
343
+ resource: Resource,
344
+ expression: str,
345
+ context: Context,
346
+ fp_options: Optional[FPOptions] = None,
347
+ ) -> list[Any]:
348
+ fp_options_with_default = cast(FPOptions, fp_options or {})
349
+ model = fp_options_with_default.get("model")
350
+
351
+ try:
352
+ return evaluate(resource, expression, context, model)
353
+ except Exception as exc:
354
+ raise FPMLValidationError(f"Cannot evaluate '{expression}': {exc}", path) from exc
@@ -0,0 +1,39 @@
1
+ # noqa: A005
2
+ from typing import Any, Callable, Optional, TypedDict, Union
3
+
4
+ Resource = dict[str, Any]
5
+ Node = Any
6
+ DictNode = dict[str, Any]
7
+ StrNode = str
8
+ Context = dict[str, Any]
9
+
10
+ Path = list[Union[str, int]]
11
+
12
+
13
+ class Model(TypedDict):
14
+ choiceTypePaths: dict[str, list[str]]
15
+ pathsDefinedElsewhere: dict[str, str]
16
+ type2Parent: dict[str, str]
17
+ path2Type: dict[str, str]
18
+
19
+
20
+ class FPOptions(TypedDict):
21
+ model: Optional[Model]
22
+
23
+
24
+ class MatcherResult(TypedDict):
25
+ node: Optional[Node]
26
+
27
+
28
+ Matcher = Callable[
29
+ [
30
+ Path,
31
+ Resource,
32
+ DictNode,
33
+ Context,
34
+ Optional[FPOptions],
35
+ ],
36
+ Optional[MatcherResult],
37
+ ]
38
+
39
+ Transformer = Callable[[Path, Node, Context], tuple[Node, Context]]
@@ -0,0 +1,17 @@
1
+ from typing import Any, Optional
2
+
3
+
4
+ def flatten(lst: list):
5
+ result = []
6
+ for item in lst:
7
+ if isinstance(item, list):
8
+ result.extend(flatten(item))
9
+ else:
10
+ result.append(item)
11
+ return result
12
+
13
+
14
+ def omit_key(obj: dict[str, Any], key: Optional[str]) -> dict[str, Any]:
15
+ if key is None:
16
+ return obj
17
+ return {k: v for k, v in obj.items() if k != key}
@@ -0,0 +1,79 @@
1
+ [project]
2
+ name = "fpml"
3
+ description = "The FHIRPath mapping language is a data DSL designed to convert data from QuestionnaireResponse (and not only) to any FHIR Resource."
4
+ authors = [{ name = "Beda Software", email = "ilya@beda.software" }]
5
+ maintainers = [
6
+ { name = "Vadim Laletin", email = "vadim@beda.software" },
7
+ { name = "Ilya Beda", email = "ilya@beda.software" },
8
+ ]
9
+ license = { text = "MIT" }
10
+ requires-python = ">=3.9"
11
+ keywords = ["fhir", "fhirpath"]
12
+ dynamic = ["version", "classifiers"]
13
+ dependencies = ["fhirpathpy (>=1.2.1,<2.0.0)"]
14
+
15
+ [project.urls]
16
+ homepage = "https://github.com/beda-software/FHIRPathMappingLanguage/tree/main/python"
17
+ repository = "https://github.com/beda-software/FHIRPathMappingLanguage/tree/main/python"
18
+ documentation = "https://github.com/beda-software/FHIRPathMappingLanguage/tree/main/python/README.md"
19
+ "Bug Tracker" = "https://github.com/beda-software/FHIRPathMappingLanguage/issues"
20
+
21
+ [tool.poetry]
22
+ version = "0.0.1"
23
+ packages = [{ include = "fpml" }]
24
+ classifiers = [
25
+ "Development Status :: 5 - Production/Stable",
26
+ "Environment :: Web Environment",
27
+ "Intended Audience :: Developers",
28
+ "Operating System :: OS Independent",
29
+ "Programming Language :: Python",
30
+ "Programming Language :: Python :: 3",
31
+ "Programming Language :: Python :: 3.9",
32
+ "Programming Language :: Python :: 3.10",
33
+ "Programming Language :: Python :: 3.11",
34
+ "Programming Language :: Python :: 3.12",
35
+ "Programming Language :: Python :: 3.13",
36
+ "Topic :: Software Development :: Libraries :: Python Modules",
37
+ ]
38
+ readme = ["README.md"]
39
+
40
+ [tool.poetry.dependencies]
41
+ python = ">=3.9,<4.0"
42
+
43
+ [tool.poetry.group.dev.dependencies]
44
+ ruff = "^0.9.7"
45
+ mypy = "^1.15.0"
46
+ autohooks-plugin-mypy = "^23.10.0"
47
+ autohooks-plugin-ruff = "^25.2.0"
48
+ autohooks = "^25.2.0"
49
+
50
+ [tool.poetry.group.test.dependencies]
51
+ pytest = "^8.3.4"
52
+ pytest-cov = "^6.0.0"
53
+ pyyaml = "^6.0.2"
54
+ types-PyYAML = "^6.0.12"
55
+
56
+ [build-system]
57
+ requires = ["poetry-core>=2.0.0,<3.0.0"]
58
+ build-backend = "poetry.core.masonry.api"
59
+
60
+ [tool.ruff]
61
+ target-version = "py39"
62
+ line-length = 100
63
+
64
+ [tool.ruff.lint]
65
+ select = ["I", "E", "F", "N", "B", "C4", "PT", "UP", "I001", "A", "RET", "TID251", "RUF", "SIM", "PYI", "T20", "PIE", "G", "ISC", "PL"]
66
+
67
+ [tool.mypy]
68
+ files = ["fpml", "tests"]
69
+ ignore_missing_imports = true
70
+ check_untyped_defs = true
71
+
72
+ [tool.autohooks]
73
+ mode = "poetry"
74
+ pre-commit = ["autohooks.plugins.mypy", "autohooks.plugins.ruff.format", "autohooks.plugins.ruff.check"]
75
+
76
+ [tool.pytest.ini_options]
77
+ addopts = "-ra --color=yes --cov fpml --cov-report html --doctest-modules"
78
+ log_cli = true
79
+ log_cli_level = "WARNING"