oehrpy 0.1.0__py3-none-any.whl → 0.2.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.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.3
2
2
  Name: oehrpy
3
- Version: 0.1.0
3
+ Version: 0.2.0
4
4
  Summary: A Python SDK for openEHR with type-safe Reference Model classes, template builders, and EHRBase client
5
5
  Project-URL: Homepage, https://github.com/platzhersh/oehrpy
6
6
  Project-URL: Documentation, https://github.com/platzhersh/oehrpy#readme
@@ -1,10 +1,10 @@
1
1
  openehr_sdk/__init__.py,sha256=Rzs3NFgVa-P2ljZu2-OXcgkKBrhE_mAwWf9HnnVwrB8,1295
2
2
  openehr_sdk/aql/__init__.py,sha256=auR0SKnnnAhzRzq2nU0YZSzUPKmAzYoXXdWs9Kxzoq8,958
3
- openehr_sdk/aql/builder.py,sha256=APYpX-MhgKllz8USb1XLnW8zI4BN3ZTe5Wxv1DjIex4,17053
4
- openehr_sdk/client/__init__.py,sha256=swqiwHCbwyIvq_newgkUEfGq6FvHV_CFfU_fYw2rw08,626
5
- openehr_sdk/client/ehrbase.py,sha256=kTY6j0cTDWfJxinWizODzhZ2xO9NcAGjkyFgaYArvtQ,20740
3
+ openehr_sdk/aql/builder.py,sha256=XptfUJVuMbUAa-LY-XFWxWDXFzBbMSHZStNloz2PAGo,17053
4
+ openehr_sdk/client/__init__.py,sha256=GTa8fnE-6tLCYGCthxF_PAVVoJ_ESVkPfVWFdlemAlE,822
5
+ openehr_sdk/client/ehrbase.py,sha256=ZVR7sySYfIc6aB5WZnnx3-TDnAjCT7vPFJZr61ziJww,28480
6
6
  openehr_sdk/rm/__init__.py,sha256=yS5JAFaUuoo3D-k_Q9V78j2cBQKTW4EMK9LYEl34ZAc,457
7
- openehr_sdk/rm/rm_types.py,sha256=QQtWk5QZD-Xh8VAPScPQYnXu-zoQar94ZFv2JPSzQUc,50511
7
+ openehr_sdk/rm/rm_types.py,sha256=7DLcfubGplYPIhF6byBsrnEO0ayOw_bBeJbzrDqbZ-Q,50518
8
8
  openehr_sdk/serialization/__init__.py,sha256=aucmds3NXlq1nQrjQNZnYR6PzqT63lAcCLh3og2AtM8,748
9
9
  openehr_sdk/serialization/canonical.py,sha256=hQMLaKWne9Ep_mv0VrT7kJa0y89C37YEB5owRpACRag,6325
10
10
  openehr_sdk/serialization/flat.py,sha256=w8hh6jGGsVHzvnjm1RlMoAhAxuhLumfuV_rrC5sH60A,13122
@@ -12,7 +12,7 @@ openehr_sdk/templates/__init__.py,sha256=_9Fwua5Mw_mM5n9CaKqXql12GSQyVvqdc_-J-jx
12
12
  openehr_sdk/templates/builder_generator.py,sha256=oTYcsvmwGasUfGU3zmW9O79eCFyyprO4hkGJ5SvMB5I,13679
13
13
  openehr_sdk/templates/builders.py,sha256=CJuOrobrCNWBbEyHbVJTzGqxApZ7CAyZrhUmY9ctvWg,13665
14
14
  openehr_sdk/templates/opt_parser.py,sha256=WzOFYUHgAYTn7hk8LWghqP2gjiG9t3LWlYpQ3-999pg,12423
15
- oehrpy-0.1.0.dist-info/METADATA,sha256=Tf-pVSj8lJ4wPt7rFBMnAkTjRgcnznH8p2ZC5WYm91E,11972
16
- oehrpy-0.1.0.dist-info/WHEEL,sha256=zEMcRr9Kr03x1ozGwg5v9NQBKn3kndp6LSoSlVg-jhU,87
17
- oehrpy-0.1.0.dist-info/licenses/LICENSE,sha256=2RQ4UN7dGDVJh_e9NTzuaUzdMWMDsy1jgLPxKxJahus,1073
18
- oehrpy-0.1.0.dist-info/RECORD,,
15
+ oehrpy-0.2.0.dist-info/METADATA,sha256=l4yTTO-SCYrJRjifCxqQ4wJUEN2i5gt8-jVuC-wCqKQ,11972
16
+ oehrpy-0.2.0.dist-info/WHEEL,sha256=zEMcRr9Kr03x1ozGwg5v9NQBKn3kndp6LSoSlVg-jhU,87
17
+ oehrpy-0.2.0.dist-info/licenses/LICENSE,sha256=2RQ4UN7dGDVJh_e9NTzuaUzdMWMDsy1jgLPxKxJahus,1073
18
+ oehrpy-0.2.0.dist-info/RECORD,,
@@ -66,7 +66,7 @@ class FromClause:
66
66
 
67
67
  # EHR clause
68
68
  if self.ehr_id_param:
69
- parts.append(f"EHR {self.ehr_alias}[ehr_id/value=:{self.ehr_id_param}]")
69
+ parts.append(f"EHR {self.ehr_alias}[ehr_id/value=${self.ehr_id_param}]")
70
70
  else:
71
71
  parts.append(f"EHR {self.ehr_alias}")
72
72
 
@@ -178,7 +178,7 @@ class AQLBuilder:
178
178
  ... .select("c/context/start_time/value", alias="time")
179
179
  ... .from_ehr("e")
180
180
  ... .contains_composition("c", "IDCR - Vital Signs Encounter.v1")
181
- ... .where("e/ehr_id/value = :ehr_id")
181
+ ... .where("e/ehr_id/value = $ehr_id")
182
182
  ... .order_by("c/context/start_time/value", descending=True)
183
183
  ... .limit(10)
184
184
  ... .build()
@@ -269,7 +269,7 @@ class AQLBuilder:
269
269
 
270
270
  if archetype_id:
271
271
  param_name = f"{alias}_archetype_id"
272
- containment = f"{rm_type} {alias}[archetype_id/value=:{param_name}]"
272
+ containment = f"{rm_type} {alias}[archetype_id/value=${param_name}]"
273
273
  self._parameters[param_name] = archetype_id
274
274
  else:
275
275
  containment = f"{rm_type} {alias}"
@@ -299,7 +299,7 @@ class AQLBuilder:
299
299
  self._from_clause = FromClause()
300
300
 
301
301
  if archetype_id:
302
- containment = f"COMPOSITION {alias}[archetype_id/value=:{alias}_archetype_id]"
302
+ containment = f"COMPOSITION {alias}[archetype_id/value=${alias}_archetype_id]"
303
303
  self._parameters[f"{alias}_archetype_id"] = archetype_id
304
304
  else:
305
305
  containment = f"COMPOSITION {alias}"
@@ -309,7 +309,7 @@ class AQLBuilder:
309
309
  # Add template_id filter as parameterized WHERE clause
310
310
  if template_id:
311
311
  self._where_clause.add(
312
- f"{alias}/archetype_details/template_id/value = :{template_id_param}"
312
+ f"{alias}/archetype_details/template_id/value = ${template_id_param}"
313
313
  )
314
314
  self._parameters[template_id_param] = template_id
315
315
 
@@ -373,7 +373,7 @@ class AQLBuilder:
373
373
  Returns:
374
374
  Self for method chaining.
375
375
  """
376
- return self.where(f"{ehr_alias}/ehr_id/value = :{param_name}")
376
+ return self.where(f"{ehr_alias}/ehr_id/value = ${param_name}")
377
377
 
378
378
  def where_template(
379
379
  self,
@@ -391,7 +391,7 @@ class AQLBuilder:
391
391
  Returns:
392
392
  Self for method chaining.
393
393
  """
394
- self.where(f"{composition_alias}/archetype_details/template_id/value = :{param_name}")
394
+ self.where(f"{composition_alias}/archetype_details/template_id/value = ${param_name}")
395
395
  if template_id:
396
396
  self._parameters[param_name] = template_id
397
397
  return self
@@ -417,10 +417,10 @@ class AQLBuilder:
417
417
  Self for method chaining.
418
418
  """
419
419
  if start:
420
- self.where(f"{path} >= :{start_param}")
420
+ self.where(f"{path} >= ${start_param}")
421
421
  self._parameters[start_param] = start
422
422
  if end:
423
- self.where(f"{path} <= :{end_param}")
423
+ self.where(f"{path} <= ${end_param}")
424
424
  self._parameters[end_param] = end
425
425
  return self
426
426
 
@@ -9,13 +9,16 @@ from .ehrbase import (
9
9
  AuthenticationError,
10
10
  CompositionFormat,
11
11
  CompositionResponse,
12
+ CompositionVersionResponse,
12
13
  EHRBaseClient,
13
14
  EHRBaseConfig,
14
15
  EHRBaseError,
15
16
  EHRResponse,
16
17
  NotFoundError,
18
+ PreconditionFailedError,
17
19
  QueryResponse,
18
20
  ValidationError,
21
+ VersionedCompositionResponse,
19
22
  )
20
23
 
21
24
  __all__ = [
@@ -24,9 +27,12 @@ __all__ = [
24
27
  "EHRResponse",
25
28
  "CompositionResponse",
26
29
  "CompositionFormat",
30
+ "CompositionVersionResponse",
27
31
  "QueryResponse",
32
+ "VersionedCompositionResponse",
28
33
  "EHRBaseError",
29
34
  "AuthenticationError",
30
35
  "NotFoundError",
36
+ "PreconditionFailedError",
31
37
  "ValidationError",
32
38
  ]
@@ -72,6 +72,12 @@ class ValidationError(EHRBaseError):
72
72
  pass
73
73
 
74
74
 
75
+ class PreconditionFailedError(EHRBaseError):
76
+ """Version conflict — the If-Match header did not match (HTTP 412)."""
77
+
78
+ pass
79
+
80
+
75
81
  # Response dataclasses
76
82
 
77
83
 
@@ -110,13 +116,25 @@ class CompositionResponse:
110
116
  @classmethod
111
117
  def from_response(cls, data: dict[str, Any], ehr_id: str | None = None) -> CompositionResponse:
112
118
  """Create from API response."""
119
+ # Try canonical format first (uid is a dict with "value" key)
113
120
  uid_data = data.get("uid")
114
121
  uid = uid_data.get("value", "") if isinstance(uid_data, dict) else uid_data or ""
122
+
123
+ template_id = data.get("archetype_details", {}).get("template_id", {}).get("value")
124
+ archetype_id = data.get("archetype_details", {}).get("archetype_id", {}).get("value")
125
+
126
+ # For FLAT format responses, extract uid from */_uid key
127
+ if not uid:
128
+ for key, value in data.items():
129
+ if key.endswith("/_uid") and isinstance(value, str):
130
+ uid = value
131
+ break
132
+
115
133
  return cls(
116
134
  uid=uid,
117
135
  ehr_id=ehr_id,
118
- template_id=data.get("archetype_details", {}).get("template_id", {}).get("value"),
119
- archetype_id=data.get("archetype_details", {}).get("archetype_id", {}).get("value"),
136
+ template_id=template_id,
137
+ archetype_id=archetype_id,
120
138
  composition=data,
121
139
  )
122
140
 
@@ -166,6 +184,58 @@ class TemplateResponse:
166
184
  )
167
185
 
168
186
 
187
+ @dataclass
188
+ class VersionedCompositionResponse:
189
+ """Response from versioned composition metadata endpoint."""
190
+
191
+ uid: str
192
+ owner_id: str | None = None
193
+ time_created: str | None = None
194
+
195
+ @classmethod
196
+ def from_response(cls, data: dict[str, Any]) -> VersionedCompositionResponse:
197
+ """Create from API response."""
198
+ uid_data = data.get("uid", {})
199
+ uid = uid_data.get("value", "") if isinstance(uid_data, dict) else uid_data or ""
200
+ owner_data = data.get("owner_id", {})
201
+ owner_id = owner_data.get("value", "") if isinstance(owner_data, dict) else owner_data
202
+ time_created_data = data.get("time_created", {})
203
+ time_created = (
204
+ time_created_data.get("value", "")
205
+ if isinstance(time_created_data, dict)
206
+ else time_created_data
207
+ )
208
+ return cls(uid=uid, owner_id=owner_id, time_created=time_created)
209
+
210
+
211
+ @dataclass
212
+ class CompositionVersionResponse:
213
+ """Response from a specific composition version endpoint."""
214
+
215
+ version_uid: str
216
+ preceding_version_uid: str | None = None
217
+ lifecycle_state: str | None = None
218
+ commit_audit: dict[str, Any] | None = None
219
+ data: dict[str, Any] | None = None
220
+
221
+ @classmethod
222
+ def from_response(cls, data: dict[str, Any]) -> CompositionVersionResponse:
223
+ """Create from API response."""
224
+ uid_data = data.get("uid", {})
225
+ version_uid = uid_data.get("value", "") if isinstance(uid_data, dict) else uid_data or ""
226
+ preceding = data.get("preceding_version_uid", {})
227
+ preceding_uid = preceding.get("value", "") if isinstance(preceding, dict) else preceding
228
+ lifecycle = data.get("lifecycle_state", {})
229
+ lifecycle_state = lifecycle.get("value", "") if isinstance(lifecycle, dict) else lifecycle
230
+ return cls(
231
+ version_uid=version_uid,
232
+ preceding_version_uid=preceding_uid or None,
233
+ lifecycle_state=lifecycle_state or None,
234
+ commit_audit=data.get("commit_audit"),
235
+ data=data.get("data"),
236
+ )
237
+
238
+
169
239
  @dataclass
170
240
  class EHRBaseConfig:
171
241
  """Configuration for EHRBase client."""
@@ -268,6 +338,11 @@ class EHRBaseClient:
268
338
  "Resource not found",
269
339
  status_code=response.status_code,
270
340
  )
341
+ if response.status_code == 412:
342
+ raise PreconditionFailedError(
343
+ "Version conflict: the preceding version UID does not match the latest version",
344
+ status_code=response.status_code,
345
+ )
271
346
  if response.status_code == 400 or response.status_code == 422:
272
347
  try:
273
348
  error_data = response.json()
@@ -318,24 +393,37 @@ class EHRBaseClient:
318
393
  Returns:
319
394
  EHRResponse with the created EHR details.
320
395
  """
321
- headers = {}
396
+ headers: dict[str, str] = {"Prefer": "return=representation"}
322
397
  if ehr_id:
323
- headers["Prefer"] = "return=representation"
324
398
  response = await self.client.put(
325
399
  f"/rest/openehr/v1/ehr/{ehr_id}",
326
400
  headers=headers,
327
401
  )
328
402
  else:
329
- headers["Prefer"] = "return=representation"
330
- params = {}
331
- if subject_id:
332
- params["subject_id"] = subject_id
333
- if subject_namespace:
334
- params["subject_namespace"] = subject_namespace
403
+ body = None
404
+ if subject_id and subject_namespace:
405
+ body = {
406
+ "_type": "EHR_STATUS",
407
+ "archetype_node_id": "openEHR-EHR-EHR_STATUS.generic.v1",
408
+ "name": {"value": "EHR Status"},
409
+ "subject": {
410
+ "external_ref": {
411
+ "id": {
412
+ "_type": "GENERIC_ID",
413
+ "value": subject_id,
414
+ "scheme": "id_scheme",
415
+ },
416
+ "namespace": subject_namespace,
417
+ "type": "PERSON",
418
+ }
419
+ },
420
+ "is_modifiable": True,
421
+ "is_queryable": True,
422
+ }
335
423
  response = await self.client.post(
336
424
  "/rest/openehr/v1/ehr",
337
425
  headers=headers,
338
- params=params if params else None,
426
+ json=body,
339
427
  )
340
428
 
341
429
  data = self._handle_response(response)
@@ -439,9 +527,17 @@ class EHRBaseClient:
439
527
  """
440
528
  format_str = format.value if isinstance(format, CompositionFormat) else format
441
529
 
530
+ # Extract versioned object UID (uuid::system::version -> uuid::system)
531
+ uid_parts = composition_uid.split("::")
532
+ versioned_object_uid = "::".join(uid_parts[:2]) if len(uid_parts) >= 2 else composition_uid
533
+
534
+ params: dict[str, str] = {}
535
+ if format_str:
536
+ params["format"] = format_str
537
+
442
538
  response = await self.client.get(
443
- f"/rest/openehr/v1/ehr/{ehr_id}/composition/{composition_uid}",
444
- params={"format": format_str} if format_str else None,
539
+ f"/rest/openehr/v1/ehr/{ehr_id}/composition/{versioned_object_uid}",
540
+ params=params if params else None,
445
541
  )
446
542
 
447
543
  data = self._handle_response(response)
@@ -450,7 +546,8 @@ class EHRBaseClient:
450
546
  async def update_composition(
451
547
  self,
452
548
  ehr_id: str,
453
- composition_uid: str,
549
+ versioned_object_uid: str,
550
+ preceding_version_uid: str,
454
551
  composition: dict[str, Any],
455
552
  template_id: str | None = None,
456
553
  format: str | CompositionFormat = CompositionFormat.FLAT,
@@ -459,23 +556,27 @@ class EHRBaseClient:
459
556
 
460
557
  Args:
461
558
  ehr_id: The EHR ID.
462
- composition_uid: The composition UID (versioned).
559
+ versioned_object_uid: The composition's UUID (used in the request path).
560
+ preceding_version_uid: Full version string (e.g. ``uuid::domain::1``),
561
+ sent as the ``If-Match`` header for optimistic concurrency.
463
562
  composition: The updated composition data.
464
563
  template_id: Template ID.
465
564
  format: Composition format.
466
565
 
467
566
  Returns:
468
567
  CompositionResponse with updated composition.
568
+
569
+ Raises:
570
+ PreconditionFailedError: If the preceding version UID does not match
571
+ the latest version (HTTP 412).
572
+ NotFoundError: If the composition has been deleted (HTTP 404).
469
573
  """
470
574
  format_str = format.value if isinstance(format, CompositionFormat) else format
471
575
 
472
- # Extract base UID without version
473
- base_uid = composition_uid.split("::")[0] if "::" in composition_uid else composition_uid
474
-
475
576
  headers = {
476
577
  "Prefer": "return=representation",
477
578
  "Content-Type": "application/json",
478
- "If-Match": composition_uid,
579
+ "If-Match": preceding_version_uid,
479
580
  }
480
581
 
481
582
  params = {}
@@ -485,7 +586,7 @@ class EHRBaseClient:
485
586
  params["format"] = format_str
486
587
 
487
588
  response = await self.client.put(
488
- f"/rest/openehr/v1/ehr/{ehr_id}/composition/{base_uid}",
589
+ f"/rest/openehr/v1/ehr/{ehr_id}/composition/{versioned_object_uid}",
489
590
  json=composition,
490
591
  headers=headers,
491
592
  params=params if params else None,
@@ -505,13 +606,120 @@ class EHRBaseClient:
505
606
  ehr_id: The EHR ID.
506
607
  composition_uid: The composition UID.
507
608
  """
508
- base_uid = composition_uid.split("::")[0] if "::" in composition_uid else composition_uid
609
+ # Extract versioned object UID (uuid::system::version -> uuid::system)
610
+ uid_parts = composition_uid.split("::")
611
+ versioned_object_uid = "::".join(uid_parts[:2]) if len(uid_parts) >= 2 else composition_uid
509
612
 
510
613
  response = await self.client.delete(
511
- f"/rest/openehr/v1/ehr/{ehr_id}/composition/{base_uid}",
614
+ f"/rest/openehr/v1/ehr/{ehr_id}/composition/{versioned_object_uid}",
512
615
  )
513
616
  self._handle_response(response)
514
617
 
618
+ # Composition Versioning Operations
619
+
620
+ async def get_composition_at_time(
621
+ self,
622
+ ehr_id: str,
623
+ versioned_object_uid: str,
624
+ version_at_time: str,
625
+ format: str | CompositionFormat = CompositionFormat.CANONICAL,
626
+ ) -> CompositionResponse:
627
+ """Get a composition as it existed at a specific point in time.
628
+
629
+ Args:
630
+ ehr_id: The EHR ID.
631
+ versioned_object_uid: The composition's UUID.
632
+ version_at_time: ISO 8601 timestamp.
633
+ format: Desired response format.
634
+
635
+ Returns:
636
+ CompositionResponse with the composition at that time.
637
+ """
638
+ format_str = format.value if isinstance(format, CompositionFormat) else format
639
+
640
+ params: dict[str, str] = {"version_at_time": version_at_time}
641
+ if format_str:
642
+ params["format"] = format_str
643
+
644
+ response = await self.client.get(
645
+ f"/rest/openehr/v1/ehr/{ehr_id}/composition/{versioned_object_uid}",
646
+ params=params,
647
+ )
648
+
649
+ data = self._handle_response(response)
650
+ return CompositionResponse.from_response(data, ehr_id)
651
+
652
+ async def get_versioned_composition(
653
+ self,
654
+ ehr_id: str,
655
+ versioned_object_uid: str,
656
+ ) -> VersionedCompositionResponse:
657
+ """Get versioned composition metadata.
658
+
659
+ Args:
660
+ ehr_id: The EHR ID.
661
+ versioned_object_uid: The composition's UUID.
662
+
663
+ Returns:
664
+ VersionedCompositionResponse with metadata (UID, owner ID, time created).
665
+ """
666
+ response = await self.client.get(
667
+ f"/rest/openehr/v1/ehr/{ehr_id}/versioned_composition/{versioned_object_uid}",
668
+ )
669
+
670
+ data = self._handle_response(response)
671
+ return VersionedCompositionResponse.from_response(data)
672
+
673
+ async def get_composition_version(
674
+ self,
675
+ ehr_id: str,
676
+ versioned_object_uid: str,
677
+ version_uid: str,
678
+ ) -> CompositionVersionResponse:
679
+ """Get a specific version of a composition.
680
+
681
+ Args:
682
+ ehr_id: The EHR ID.
683
+ versioned_object_uid: The composition's UUID.
684
+ version_uid: The specific version UID (e.g. ``uuid::domain::1``).
685
+
686
+ Returns:
687
+ CompositionVersionResponse with full version and audit metadata.
688
+ """
689
+ response = await self.client.get(
690
+ f"/rest/openehr/v1/ehr/{ehr_id}/versioned_composition/{versioned_object_uid}/version/{version_uid}",
691
+ )
692
+
693
+ data = self._handle_response(response)
694
+ return CompositionVersionResponse.from_response(data)
695
+
696
+ async def list_composition_versions(
697
+ self,
698
+ ehr_id: str,
699
+ versioned_object_uid: str,
700
+ ) -> list[CompositionVersionResponse]:
701
+ """List all versions of a composition.
702
+
703
+ Args:
704
+ ehr_id: The EHR ID.
705
+ versioned_object_uid: The composition's UUID.
706
+
707
+ Returns:
708
+ List of CompositionVersionResponse with version descriptors.
709
+ """
710
+ response = await self.client.get(
711
+ f"/rest/openehr/v1/ehr/{ehr_id}/versioned_composition/{versioned_object_uid}/version",
712
+ )
713
+
714
+ data = self._handle_response(response)
715
+ if isinstance(data, list):
716
+ return [CompositionVersionResponse.from_response(v) for v in data]
717
+ # Some servers return the list under a key
718
+ versions = data.get("versions", data.get("items", []))
719
+ if isinstance(versions, list):
720
+ return [CompositionVersionResponse.from_response(v) for v in versions]
721
+ return []
722
+
515
723
  # Query Operations
516
724
 
517
725
  async def query(
@@ -1030,7 +1030,7 @@ class HISTORY(BaseModel):
1030
1030
  archetype_details: ARCHETYPED | None = None
1031
1031
  feeder_audit: FEEDER_AUDIT | None = None
1032
1032
  links: list[LINK] | None = None
1033
- origin: DV_DATE_TIME | None
1033
+ origin: DV_DATE_TIME | None = None
1034
1034
  period: DV_DURATION | None = None
1035
1035
  duration: DV_DURATION | None = None
1036
1036
  summary: Any | None = None
File without changes