oehrpy 0.1.0__py3-none-any.whl

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,37 @@
1
+ """
2
+ Serialization utilities for openEHR Reference Model objects.
3
+
4
+ This module provides functions for serializing and deserializing
5
+ openEHR RM objects to/from various formats:
6
+
7
+ - Canonical JSON: Standard openEHR JSON with _type discriminator
8
+ - FLAT format: Simplified format used by EHRBase
9
+ """
10
+
11
+ from .canonical import (
12
+ from_canonical,
13
+ get_type_registry,
14
+ register_type,
15
+ to_canonical,
16
+ )
17
+ from .flat import (
18
+ FlatBuilder,
19
+ FlatContext,
20
+ FlatPath,
21
+ flatten_dict,
22
+ unflatten_dict,
23
+ )
24
+
25
+ __all__ = [
26
+ # Canonical JSON
27
+ "from_canonical",
28
+ "to_canonical",
29
+ "register_type",
30
+ "get_type_registry",
31
+ # FLAT format
32
+ "FlatBuilder",
33
+ "FlatContext",
34
+ "FlatPath",
35
+ "flatten_dict",
36
+ "unflatten_dict",
37
+ ]
@@ -0,0 +1,203 @@
1
+ """
2
+ Canonical JSON serialization for openEHR Reference Model objects.
3
+
4
+ The canonical JSON format is the standard openEHR JSON serialization format,
5
+ which includes a `_type` field for polymorphic type identification.
6
+
7
+ Example:
8
+ >>> from openehr_sdk.rm import DV_QUANTITY
9
+ >>> from openehr_sdk.serialization import to_canonical, from_canonical
10
+ >>>
11
+ >>> # Create a DV_QUANTITY
12
+ >>> bp = DV_QUANTITY(magnitude=120.0, units="mm[Hg]", property=...)
13
+ >>>
14
+ >>> # Serialize to canonical JSON
15
+ >>> json_data = to_canonical(bp)
16
+ >>> # {
17
+ >>> # "_type": "DV_QUANTITY",
18
+ >>> # "magnitude": 120.0,
19
+ >>> # "units": "mm[Hg]",
20
+ >>> # ...
21
+ >>> # }
22
+ >>>
23
+ >>> # Deserialize back (with type detection)
24
+ >>> restored = from_canonical(json_data)
25
+ >>> assert isinstance(restored, DV_QUANTITY)
26
+ """
27
+
28
+ from __future__ import annotations
29
+
30
+ from typing import Any, TypeVar
31
+
32
+ from pydantic import BaseModel
33
+
34
+ # Type registry: maps _type string to class
35
+ _TYPE_REGISTRY: dict[str, type[BaseModel]] = {}
36
+
37
+ T = TypeVar("T", bound=BaseModel)
38
+
39
+
40
+ def register_type(cls: type[T]) -> type[T]:
41
+ """Register a type in the canonical JSON type registry.
42
+
43
+ Args:
44
+ cls: The Pydantic model class to register.
45
+
46
+ Returns:
47
+ The same class (allows use as decorator).
48
+ """
49
+ type_name = getattr(cls, "_type_", cls.__name__)
50
+ _TYPE_REGISTRY[type_name] = cls
51
+ return cls
52
+
53
+
54
+ def get_type_registry() -> dict[str, type[BaseModel]]:
55
+ """Get a copy of the type registry."""
56
+ return _TYPE_REGISTRY.copy()
57
+
58
+
59
+ def _build_registry() -> None:
60
+ """Build the type registry from all RM classes."""
61
+ if _TYPE_REGISTRY:
62
+ return # Already built
63
+
64
+ # Import all RM types and register them
65
+ from openehr_sdk import rm
66
+
67
+ for name in dir(rm):
68
+ cls = getattr(rm, name)
69
+ if isinstance(cls, type) and issubclass(cls, BaseModel) and cls is not BaseModel:
70
+ type_name = getattr(cls, "_type_", name)
71
+ _TYPE_REGISTRY[type_name] = cls
72
+
73
+
74
+ def to_canonical(
75
+ obj: BaseModel,
76
+ *,
77
+ exclude_none: bool = True,
78
+ by_alias: bool = False,
79
+ ) -> dict[str, Any]:
80
+ """Serialize a Pydantic model to canonical JSON format.
81
+
82
+ The canonical format includes a `_type` field at the root level and
83
+ recursively in any nested objects that have a _type_ class attribute.
84
+
85
+ Args:
86
+ obj: The Pydantic model to serialize.
87
+ exclude_none: Whether to exclude None values from output.
88
+ by_alias: Whether to use field aliases in output.
89
+
90
+ Returns:
91
+ A dictionary suitable for JSON serialization.
92
+ """
93
+ data = obj.model_dump(exclude_none=exclude_none, by_alias=by_alias)
94
+
95
+ # Add _type field
96
+ type_name = getattr(obj.__class__, "_type_", obj.__class__.__name__)
97
+ result = {"_type": type_name}
98
+ result.update(data)
99
+
100
+ # Recursively add _type to nested objects
101
+ _add_types_recursive(result, obj, exclude_none)
102
+
103
+ return result
104
+
105
+
106
+ def _add_types_recursive(
107
+ data: dict[str, Any],
108
+ obj: BaseModel,
109
+ exclude_none: bool,
110
+ ) -> None:
111
+ """Recursively add _type fields to nested objects."""
112
+ for key, value in list(data.items()):
113
+ if key == "_type":
114
+ continue
115
+
116
+ # Get the corresponding attribute from the object
117
+ attr = getattr(obj, key, None)
118
+
119
+ if attr is None:
120
+ continue
121
+
122
+ if isinstance(attr, BaseModel):
123
+ # Nested Pydantic model
124
+ type_name = getattr(attr.__class__, "_type_", attr.__class__.__name__)
125
+ nested_data = {"_type": type_name}
126
+ if isinstance(value, dict):
127
+ nested_data.update(value)
128
+ data[key] = nested_data
129
+ _add_types_recursive(nested_data, attr, exclude_none)
130
+ elif isinstance(attr, list):
131
+ # List of objects
132
+ for i, item in enumerate(attr):
133
+ if isinstance(item, BaseModel) and i < len(value):
134
+ type_name = getattr(item.__class__, "_type_", item.__class__.__name__)
135
+ if isinstance(value[i], dict):
136
+ nested_data = {"_type": type_name}
137
+ nested_data.update(value[i])
138
+ value[i] = nested_data
139
+ _add_types_recursive(nested_data, item, exclude_none)
140
+
141
+
142
+ def from_canonical(
143
+ data: dict[str, Any],
144
+ *,
145
+ expected_type: type[T] | None = None,
146
+ ) -> T | BaseModel:
147
+ """Deserialize canonical JSON to a Pydantic model.
148
+
149
+ Uses the `_type` field to determine the correct class for polymorphic
150
+ deserialization.
151
+
152
+ Args:
153
+ data: The canonical JSON data.
154
+ expected_type: Optional expected type. If provided, validates that
155
+ the deserialized object is of this type.
156
+
157
+ Returns:
158
+ The deserialized Pydantic model.
159
+
160
+ Raises:
161
+ ValueError: If the _type is not recognized or doesn't match expected_type.
162
+ """
163
+ _build_registry()
164
+
165
+ # Get the type from the data
166
+ type_name = data.get("_type")
167
+ if not type_name:
168
+ if expected_type:
169
+ return expected_type.model_validate(data)
170
+ raise ValueError("Missing _type field in canonical JSON data")
171
+
172
+ # Look up the class
173
+ cls = _TYPE_REGISTRY.get(type_name)
174
+ if not cls:
175
+ raise ValueError(f"Unknown type: {type_name}")
176
+
177
+ # Validate expected_type if provided
178
+ if expected_type and not issubclass(cls, expected_type):
179
+ raise ValueError(f"Type mismatch: expected {expected_type.__name__}, got {type_name}")
180
+
181
+ # Remove _type field from data before validation
182
+ clean_data = {k: v for k, v in data.items() if k != "_type"}
183
+
184
+ # Recursively process nested objects
185
+ _process_nested_types(clean_data)
186
+
187
+ return cls.model_validate(clean_data)
188
+
189
+
190
+ def _process_nested_types(data: dict[str, Any]) -> None:
191
+ """Recursively remove _type fields from nested data."""
192
+ for _key, value in data.items():
193
+ if isinstance(value, dict):
194
+ # Remove _type and recurse
195
+ if "_type" in value:
196
+ value.pop("_type")
197
+ _process_nested_types(value)
198
+ elif isinstance(value, list):
199
+ for item in value:
200
+ if isinstance(item, dict):
201
+ if "_type" in item:
202
+ item.pop("_type")
203
+ _process_nested_types(item)
@@ -0,0 +1,372 @@
1
+ """
2
+ FLAT format serialization for EHRBase.
3
+
4
+ The FLAT format is a simplified key-value representation used by EHRBase
5
+ for composition submission and retrieval. It flattens the hierarchical
6
+ openEHR composition structure into dot-separated paths.
7
+
8
+ Example FLAT format:
9
+ {
10
+ "ctx/language": "en",
11
+ "ctx/territory": "US",
12
+ "vital_signs/blood_pressure:0/any_event:0/systolic|magnitude": 120,
13
+ "vital_signs/blood_pressure:0/any_event:0/systolic|unit": "mm[Hg]",
14
+ ...
15
+ }
16
+
17
+ Note: Full FLAT format support requires template knowledge (OPT files).
18
+ This module provides utilities for working with FLAT format data.
19
+ """
20
+
21
+ from __future__ import annotations
22
+
23
+ import re
24
+ from dataclasses import dataclass, field
25
+ from typing import Any
26
+
27
+
28
+ @dataclass
29
+ class FlatPath:
30
+ """Represents a parsed FLAT format path."""
31
+
32
+ segments: list[str] = field(default_factory=list)
33
+ index: int | None = None
34
+ attribute: str | None = None
35
+
36
+ @classmethod
37
+ def parse(cls, path: str) -> FlatPath:
38
+ """Parse a FLAT format path string.
39
+
40
+ Examples:
41
+ - "ctx/language" -> FlatPath(["ctx", "language"])
42
+ - "vital_signs/bp:0/systolic|magnitude" ->
43
+ FlatPath(["vital_signs", "bp", "systolic"], 0, "magnitude")
44
+ """
45
+ result = cls()
46
+
47
+ # Split by attribute separator first
48
+ if "|" in path:
49
+ path_part, result.attribute = path.rsplit("|", 1)
50
+ else:
51
+ path_part = path
52
+
53
+ # Split by path separator
54
+ parts = path_part.split("/")
55
+
56
+ for part in parts:
57
+ # Check for index notation (e.g., "bp:0")
58
+ match = re.match(r"^(.+):(\d+)$", part)
59
+ if match:
60
+ result.segments.append(match.group(1))
61
+ result.index = int(match.group(2))
62
+ else:
63
+ result.segments.append(part)
64
+
65
+ return result
66
+
67
+ def __str__(self) -> str:
68
+ """Convert back to FLAT path string."""
69
+ path = "/".join(self.segments)
70
+ if self.index is not None:
71
+ # Add index to the last segment
72
+ parts = path.rsplit("/", 1)
73
+ if len(parts) == 2:
74
+ path = f"{parts[0]}/{parts[1]}:{self.index}"
75
+ else:
76
+ path = f"{parts[0]}:{self.index}"
77
+ if self.attribute:
78
+ path = f"{path}|{self.attribute}"
79
+ return path
80
+
81
+
82
+ @dataclass
83
+ class FlatContext:
84
+ """Context fields for FLAT format compositions."""
85
+
86
+ language: str = "en"
87
+ territory: str = "US"
88
+ composer_name: str | None = None
89
+ composer_id: str | None = None
90
+ id_scheme: str | None = None
91
+ id_namespace: str | None = None
92
+ health_care_facility_name: str | None = None
93
+ health_care_facility_id: str | None = None
94
+ time: str | None = None
95
+ end_time: str | None = None
96
+ history_origin: str | None = None
97
+ participation_name: str | None = None
98
+ participation_function: str | None = None
99
+ participation_mode: str | None = None
100
+ participation_id: str | None = None
101
+
102
+ def to_flat(self, prefix: str = "ctx") -> dict[str, Any]:
103
+ """Convert context to FLAT format.
104
+
105
+ Args:
106
+ prefix: Path prefix to use. Use "ctx" for legacy format,
107
+ or composition ID (e.g., "vital_signs_observations") for EHRBase 2.26.0+.
108
+ """
109
+ result: dict[str, Any] = {
110
+ f"{prefix}/language|terminology": "ISO_639-1",
111
+ f"{prefix}/language|code": self.language,
112
+ f"{prefix}/territory|terminology": "ISO_3166-1",
113
+ f"{prefix}/territory|code": self.territory,
114
+ }
115
+
116
+ if self.composer_name:
117
+ result[f"{prefix}/composer|name"] = self.composer_name
118
+ if self.composer_id:
119
+ result[f"{prefix}/composer|id"] = self.composer_id
120
+ if self.id_scheme:
121
+ result[f"{prefix}/id_scheme"] = self.id_scheme
122
+ if self.id_namespace:
123
+ result[f"{prefix}/id_namespace"] = self.id_namespace
124
+ if self.health_care_facility_name:
125
+ result[f"{prefix}/health_care_facility|name"] = self.health_care_facility_name
126
+ if self.health_care_facility_id:
127
+ result[f"{prefix}/health_care_facility|id"] = self.health_care_facility_id
128
+ if self.time:
129
+ result[f"{prefix}/time"] = self.time
130
+ if self.end_time:
131
+ result[f"{prefix}/end_time"] = self.end_time
132
+ if self.history_origin:
133
+ result[f"{prefix}/history_origin"] = self.history_origin
134
+ if self.participation_name:
135
+ result[f"{prefix}/participation_name"] = self.participation_name
136
+ if self.participation_function:
137
+ result[f"{prefix}/participation_function"] = self.participation_function
138
+ if self.participation_mode:
139
+ result[f"{prefix}/participation_mode"] = self.participation_mode
140
+ if self.participation_id:
141
+ result[f"{prefix}/participation_id"] = self.participation_id
142
+
143
+ return result
144
+
145
+ @classmethod
146
+ def from_flat(cls, data: dict[str, Any]) -> FlatContext:
147
+ """Create context from FLAT format data."""
148
+ return cls(
149
+ language=data.get("ctx/language", "en"),
150
+ territory=data.get("ctx/territory", "US"),
151
+ composer_name=data.get("ctx/composer_name"),
152
+ composer_id=data.get("ctx/composer_id"),
153
+ id_scheme=data.get("ctx/id_scheme"),
154
+ id_namespace=data.get("ctx/id_namespace"),
155
+ health_care_facility_name=data.get("ctx/health_care_facility|name"),
156
+ health_care_facility_id=data.get("ctx/health_care_facility|id"),
157
+ time=data.get("ctx/time"),
158
+ end_time=data.get("ctx/end_time"),
159
+ history_origin=data.get("ctx/history_origin"),
160
+ participation_name=data.get("ctx/participation_name"),
161
+ participation_function=data.get("ctx/participation_function"),
162
+ participation_mode=data.get("ctx/participation_mode"),
163
+ participation_id=data.get("ctx/participation_id"),
164
+ )
165
+
166
+
167
+ def flatten_dict(data: dict[str, Any], prefix: str = "") -> dict[str, Any]:
168
+ """Flatten a nested dictionary to FLAT format paths.
169
+
170
+ Args:
171
+ data: Nested dictionary to flatten.
172
+ prefix: Prefix for all keys.
173
+
174
+ Returns:
175
+ Flattened dictionary with path keys.
176
+ """
177
+ result: dict[str, Any] = {}
178
+
179
+ for key, value in data.items():
180
+ new_key = f"{prefix}/{key}" if prefix else key
181
+
182
+ if isinstance(value, dict):
183
+ # Recursively flatten nested dicts
184
+ result.update(flatten_dict(value, new_key))
185
+ elif isinstance(value, list):
186
+ # Handle lists with index notation
187
+ for i, item in enumerate(value):
188
+ indexed_key = f"{new_key}:{i}"
189
+ if isinstance(item, dict):
190
+ result.update(flatten_dict(item, indexed_key))
191
+ else:
192
+ result[indexed_key] = item
193
+ else:
194
+ result[new_key] = value
195
+
196
+ return result
197
+
198
+
199
+ def unflatten_dict(data: dict[str, Any]) -> dict[str, Any]:
200
+ """Unflatten FLAT format paths to nested dictionary.
201
+
202
+ Args:
203
+ data: Flattened dictionary with path keys.
204
+
205
+ Returns:
206
+ Nested dictionary.
207
+ """
208
+ result: dict[str, Any] = {}
209
+
210
+ for path, value in data.items():
211
+ parts = path.replace("|", "/").split("/")
212
+ current = result
213
+
214
+ for _i, part in enumerate(parts[:-1]):
215
+ # Check for index notation
216
+ match = re.match(r"^(.+):(\d+)$", part)
217
+ if match:
218
+ name, idx = match.group(1), int(match.group(2))
219
+ if name not in current:
220
+ current[name] = []
221
+ while len(current[name]) <= idx:
222
+ current[name].append({})
223
+ current = current[name][idx]
224
+ else:
225
+ if part not in current:
226
+ current[part] = {}
227
+ current = current[part]
228
+
229
+ # Set the final value
230
+ final_key = parts[-1]
231
+ match = re.match(r"^(.+):(\d+)$", final_key)
232
+ if match:
233
+ name, idx = match.group(1), int(match.group(2))
234
+ if name not in current:
235
+ current[name] = []
236
+ while len(current[name]) <= idx:
237
+ current[name].append(None)
238
+ current[name][idx] = value
239
+ else:
240
+ current[final_key] = value
241
+
242
+ return result
243
+
244
+
245
+ class FlatBuilder:
246
+ """Builder for creating FLAT format compositions.
247
+
248
+ This provides a fluent API for constructing FLAT format data
249
+ without needing to know the exact path structure.
250
+
251
+ Example:
252
+ >>> builder = FlatBuilder()
253
+ >>> builder.context(language="en", territory="US", composer_name="Dr. Smith")
254
+ >>> builder.set("vital_signs/blood_pressure:0/any_event:0/systolic|magnitude", 120)
255
+ >>> builder.set("vital_signs/blood_pressure:0/any_event:0/systolic|unit", "mm[Hg]")
256
+ >>> flat_data = builder.build()
257
+ """
258
+
259
+ def __init__(self, composition_prefix: str | None = None) -> None:
260
+ """Initialize the builder.
261
+
262
+ Args:
263
+ composition_prefix: Composition ID prefix for EHRBase 2.26.0+ format.
264
+ If None, uses legacy "ctx" format.
265
+ """
266
+ self._data: dict[str, Any] = {}
267
+ self._context = FlatContext()
268
+ self._composition_prefix = composition_prefix
269
+
270
+ def context(
271
+ self,
272
+ language: str = "en",
273
+ territory: str = "US",
274
+ composer_name: str | None = None,
275
+ **kwargs: Any,
276
+ ) -> FlatBuilder:
277
+ """Set context fields."""
278
+ self._context.language = language
279
+ self._context.territory = territory
280
+ if composer_name:
281
+ self._context.composer_name = composer_name
282
+ for key, value in kwargs.items():
283
+ if hasattr(self._context, key):
284
+ setattr(self._context, key, value)
285
+ return self
286
+
287
+ def set(self, path: str, value: Any) -> FlatBuilder:
288
+ """Set a value at the given FLAT path."""
289
+ self._data[path] = value
290
+ return self
291
+
292
+ def set_quantity(
293
+ self,
294
+ path: str,
295
+ magnitude: float,
296
+ unit: str,
297
+ precision: int | None = None,
298
+ ) -> FlatBuilder:
299
+ """Set a DV_QUANTITY at the given path."""
300
+ self._data[f"{path}|magnitude"] = magnitude
301
+ self._data[f"{path}|unit"] = unit
302
+ if precision is not None:
303
+ self._data[f"{path}|precision"] = precision
304
+ return self
305
+
306
+ def set_coded_text(
307
+ self,
308
+ path: str,
309
+ value: str,
310
+ code: str,
311
+ terminology: str = "local",
312
+ ) -> FlatBuilder:
313
+ """Set a DV_CODED_TEXT at the given path."""
314
+ self._data[f"{path}|value"] = value
315
+ self._data[f"{path}|code"] = code
316
+ self._data[f"{path}|terminology"] = terminology
317
+ return self
318
+
319
+ def set_proportion(self, path: str, numerator: float, denominator: float) -> FlatBuilder:
320
+ """Set a DV_PROPORTION at the given path."""
321
+ self._data[f"{path}|numerator"] = numerator
322
+ self._data[f"{path}|denominator"] = denominator
323
+ return self
324
+
325
+ def set_text(self, path: str, value: str) -> FlatBuilder:
326
+ """Set a DV_TEXT at the given path."""
327
+ self._data[path] = value
328
+ return self
329
+
330
+ def set_datetime(self, path: str, value: str) -> FlatBuilder:
331
+ """Set a DV_DATE_TIME at the given path."""
332
+ self._data[path] = value
333
+ return self
334
+
335
+ def build(self) -> dict[str, Any]:
336
+ """Build the final FLAT format dictionary."""
337
+ prefix = self._composition_prefix if self._composition_prefix else "ctx"
338
+ result = self._context.to_flat(prefix=prefix)
339
+
340
+ # Add category and context fields for EHRBase 2.26.0+ format
341
+ if self._composition_prefix:
342
+ from datetime import datetime, timezone
343
+
344
+ # Add category
345
+ result[f"{self._composition_prefix}/category|terminology"] = "openehr"
346
+ result[f"{self._composition_prefix}/category|code"] = "433"
347
+ result[f"{self._composition_prefix}/category|value"] = "event"
348
+
349
+ # Add context/start_time if not already set
350
+ context_time_key = f"{self._composition_prefix}/context/start_time"
351
+ if context_time_key not in result and context_time_key not in self._data:
352
+ result[context_time_key] = datetime.now(timezone.utc).isoformat()
353
+
354
+ # Add context/setting if not already set
355
+ context_setting_code = f"{self._composition_prefix}/context/setting|code"
356
+ if context_setting_code not in result and context_setting_code not in self._data:
357
+ result[f"{self._composition_prefix}/context/setting|terminology"] = "openehr"
358
+ result[context_setting_code] = "238"
359
+ result[f"{self._composition_prefix}/context/setting|value"] = "other care"
360
+
361
+ result.update(self._data)
362
+ return result
363
+
364
+
365
+ # Re-export for convenience
366
+ __all__ = [
367
+ "FlatPath",
368
+ "FlatContext",
369
+ "FlatBuilder",
370
+ "flatten_dict",
371
+ "unflatten_dict",
372
+ ]
@@ -0,0 +1,40 @@
1
+ """
2
+ Template builders and OPT parsing for openEHR.
3
+
4
+ This module provides:
5
+ - OPT (Operational Template) XML parser
6
+ - Template-specific composition builders
7
+ - Pre-built builders for common templates
8
+ - OPT-to-Builder code generator
9
+ """
10
+
11
+ from .builder_generator import (
12
+ BuilderGenerator,
13
+ generate_builder_from_opt,
14
+ )
15
+ from .builders import (
16
+ TemplateBuilder,
17
+ VitalSignsBuilder,
18
+ )
19
+ from .opt_parser import (
20
+ ArchetypeNode,
21
+ ConstraintDefinition,
22
+ OPTParser,
23
+ TemplateDefinition,
24
+ parse_opt,
25
+ )
26
+
27
+ __all__ = [
28
+ # OPT Parser
29
+ "OPTParser",
30
+ "TemplateDefinition",
31
+ "ArchetypeNode",
32
+ "ConstraintDefinition",
33
+ "parse_opt",
34
+ # Builder Generator
35
+ "BuilderGenerator",
36
+ "generate_builder_from_opt",
37
+ # Builders
38
+ "TemplateBuilder",
39
+ "VitalSignsBuilder",
40
+ ]