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,421 @@
1
+ """
2
+ Generate template-specific builder classes from OPT files.
3
+
4
+ This module generates type-safe Python builder classes from parsed OPT
5
+ (Operational Template) files, eliminating the need for manual FLAT path construction.
6
+
7
+ Example:
8
+ >>> from openehr_sdk.templates.opt_parser import parse_opt
9
+ >>> from openehr_sdk.templates.builder_generator import BuilderGenerator
10
+ >>>
11
+ >>> template = parse_opt("vital_signs.opt")
12
+ >>> generator = BuilderGenerator()
13
+ >>> code = generator.generate(template)
14
+ >>> print(code)
15
+ """
16
+
17
+ from __future__ import annotations
18
+
19
+ import re
20
+ from dataclasses import dataclass
21
+ from pathlib import Path
22
+
23
+ from .opt_parser import ArchetypeNode, TemplateDefinition
24
+
25
+
26
+ @dataclass
27
+ class ObservationMetadata:
28
+ """Metadata extracted from an OBSERVATION archetype."""
29
+
30
+ archetype_id: str
31
+ node_id: str
32
+ name: str
33
+ short_name: str # For method/variable names (e.g., "blood_pressure")
34
+ flat_path: str # Base FLAT path (e.g., "vital_signs/blood_pressure")
35
+ elements: list[ElementMetadata]
36
+
37
+
38
+ @dataclass
39
+ class ElementMetadata:
40
+ """Metadata for an ELEMENT within an observation."""
41
+
42
+ name: str
43
+ node_id: str
44
+ rm_type: str = "DV_QUANTITY" # Default to quantity
45
+ unit: str | None = None
46
+
47
+
48
+ class BuilderGenerator:
49
+ """Generate builder classes from OPT templates."""
50
+
51
+ def __init__(self) -> None:
52
+ """Initialize the builder generator."""
53
+ self._observations: list[ObservationMetadata] = []
54
+ self._template: TemplateDefinition | None = None
55
+
56
+ def generate(self, template: TemplateDefinition, class_name: str | None = None) -> str:
57
+ """Generate a complete builder class from a template.
58
+
59
+ Args:
60
+ template: Parsed template definition.
61
+ class_name: Optional custom class name (defaults to derived name).
62
+
63
+ Returns:
64
+ Python source code for the builder class.
65
+ """
66
+ # Store template for use in path generation
67
+ self._template = template
68
+
69
+ if class_name is None:
70
+ class_name = self._derive_class_name(template.template_id)
71
+
72
+ # Extract observations from template
73
+ self._observations = self._extract_observations(template)
74
+
75
+ # Generate code sections
76
+ imports = self._generate_imports()
77
+ class_def = self._generate_class_definition(template, class_name)
78
+ methods = self._generate_methods()
79
+
80
+ return f"""{imports}
81
+
82
+ {class_def}
83
+ {methods}"""
84
+
85
+ def generate_to_file(
86
+ self, template: TemplateDefinition, output_path: Path | str, class_name: str | None = None
87
+ ) -> None:
88
+ """Generate builder class and write to file.
89
+
90
+ Args:
91
+ template: Parsed template definition.
92
+ output_path: Path to write the generated Python file.
93
+ class_name: Optional custom class name.
94
+ """
95
+ code = self.generate(template, class_name)
96
+ output_path = Path(output_path)
97
+ output_path.parent.mkdir(parents=True, exist_ok=True)
98
+ output_path.write_text(code)
99
+
100
+ def _derive_class_name(self, template_id: str) -> str:
101
+ """Derive a Python class name from template ID.
102
+
103
+ Examples:
104
+ "IDCR - Vital Signs Encounter.v1" -> "VitalSignsEncounterBuilder"
105
+ "Problem List.v1" -> "ProblemListBuilder"
106
+ """
107
+ # Remove version suffix
108
+ name = re.sub(r"\.v\d+$", "", template_id)
109
+ # Remove common prefixes
110
+ name = re.sub(r"^(IDCR|openEHR)\s*-\s*", "", name, flags=re.IGNORECASE)
111
+ # Convert to PascalCase
112
+ words = re.findall(r"[A-Za-z0-9]+", name)
113
+ pascal = "".join(word.capitalize() for word in words)
114
+ return f"{pascal}Builder"
115
+
116
+ def _derive_short_name(self, text: str) -> str:
117
+ """Derive a Python identifier from text.
118
+
119
+ Examples:
120
+ "Blood Pressure" -> "blood_pressure"
121
+ "Pulse/Heart Beat" -> "pulse_heart_beat"
122
+ """
123
+ # Replace non-alphanumeric with spaces
124
+ text = re.sub(r"[^A-Za-z0-9]+", " ", text)
125
+ # Convert to snake_case
126
+ words = text.lower().split()
127
+ return "_".join(words)
128
+
129
+ def _extract_observations(self, template: TemplateDefinition) -> list[ObservationMetadata]:
130
+ """Extract all OBSERVATION archetypes from the template."""
131
+ observations = []
132
+
133
+ for node in template.list_observations():
134
+ obs = self._extract_observation_metadata(node)
135
+ if obs:
136
+ observations.append(obs)
137
+
138
+ return observations
139
+
140
+ def _extract_observation_metadata(self, node: ArchetypeNode) -> ObservationMetadata | None:
141
+ """Extract metadata from an OBSERVATION node."""
142
+ if not node.archetype_id:
143
+ return None
144
+
145
+ # Derive names from archetype ID
146
+ # e.g., "openEHR-EHR-OBSERVATION.blood_pressure.v1" -> "blood_pressure"
147
+ archetype_parts = node.archetype_id.split(".")
148
+ if len(archetype_parts) >= 2:
149
+ short_name = archetype_parts[-2] # e.g., "blood_pressure"
150
+ else:
151
+ short_name = self._derive_short_name(node.name)
152
+
153
+ # Build FLAT path (simplified - assumes composition root)
154
+ flat_path = self._build_flat_path(node, short_name)
155
+
156
+ # Extract elements (data points like systolic, diastolic, rate, etc.)
157
+ elements = self._extract_elements(node)
158
+
159
+ return ObservationMetadata(
160
+ archetype_id=node.archetype_id,
161
+ node_id=node.node_id,
162
+ name=node.name,
163
+ short_name=short_name,
164
+ flat_path=flat_path,
165
+ elements=elements,
166
+ )
167
+
168
+ def _build_flat_path(self, node: ArchetypeNode, short_name: str) -> str:
169
+ """Build the FLAT format path prefix for an observation.
170
+
171
+ Derives the composition name from the template to build correct paths.
172
+ """
173
+ # Derive composition name from template
174
+ composition_name = self._derive_composition_name()
175
+
176
+ # Standard pattern: "composition_name/observation_name"
177
+ return f"{composition_name}/{short_name}"
178
+
179
+ def _derive_composition_name(self) -> str:
180
+ """Derive the composition name from the template.
181
+
182
+ Uses the template concept or template_id to create a snake_case name.
183
+ """
184
+ if not self._template:
185
+ return "composition"
186
+
187
+ # Use concept if available, otherwise template_id
188
+ name = self._template.concept or self._template.template_id
189
+
190
+ # Remove common prefixes and suffixes
191
+ name = re.sub(r"\s+(composition|encounter|template)$", "", name, flags=re.IGNORECASE)
192
+ name = re.sub(r"^(IDCR|openEHR)\s*-\s*", "", name, flags=re.IGNORECASE)
193
+
194
+ # Convert to snake_case
195
+ return self._derive_short_name(name)
196
+
197
+ def _extract_elements(self, obs_node: ArchetypeNode) -> list[ElementMetadata]:
198
+ """Extract ELEMENT nodes from an observation.
199
+
200
+ This traverses the OBSERVATION -> HISTORY -> EVENT -> ITEM_TREE -> ELEMENT path.
201
+ """
202
+ elements = []
203
+
204
+ def traverse(node: ArchetypeNode, depth: int = 0) -> None:
205
+ """Recursively find ELEMENT nodes."""
206
+ if node.rm_type == "ELEMENT" and node.name:
207
+ # Found a data element
208
+ element = ElementMetadata(
209
+ name=node.name,
210
+ node_id=node.node_id,
211
+ rm_type="DV_QUANTITY", # Default assumption
212
+ )
213
+ elements.append(element)
214
+
215
+ # Traverse children
216
+ for child in node.children:
217
+ traverse(child, depth + 1)
218
+
219
+ traverse(obs_node)
220
+ return elements
221
+
222
+ def _generate_imports(self) -> str:
223
+ """Generate import statements."""
224
+ return '''"""
225
+ Generated template builder from OPT file.
226
+
227
+ This file was auto-generated. Do not edit manually.
228
+ """
229
+
230
+ from __future__ import annotations
231
+
232
+ from datetime import datetime, timezone
233
+ from typing import Any
234
+
235
+ from ..serialization.flat import FlatBuilder
236
+ from .builders import TemplateBuilder'''
237
+
238
+ def _generate_class_definition(self, template: TemplateDefinition, class_name: str) -> str:
239
+ """Generate the class definition and __init__ method."""
240
+ doc = template.description or "Template builder"
241
+ template_id = template.template_id
242
+
243
+ return f'''
244
+
245
+ class {class_name}(TemplateBuilder):
246
+ """Builder for {template.concept}.
247
+
248
+ {doc}
249
+
250
+ Template ID: {template_id}
251
+ """
252
+
253
+ template_id = "{template_id}"'''
254
+
255
+ def _generate_methods(self) -> str:
256
+ """Generate add_* methods for each observation."""
257
+ methods = []
258
+
259
+ for obs in self._observations:
260
+ method = self._generate_observation_method(obs)
261
+ methods.append(method)
262
+
263
+ return "\n\n".join(methods)
264
+
265
+ def _generate_observation_method(self, obs: ObservationMetadata) -> str:
266
+ """Generate an add_* method for a specific observation type."""
267
+ method_name = f"add_{obs.short_name}"
268
+
269
+ # Generate parameters from elements
270
+ params = self._generate_method_params(obs.elements)
271
+ param_docs = self._generate_param_docs(obs.elements)
272
+
273
+ # Generate method body
274
+ body = self._generate_method_body(obs)
275
+
276
+ return f'''
277
+ def {method_name}(
278
+ self,
279
+ {params}
280
+ time: datetime | str | None = None,
281
+ event_index: int | None = None,
282
+ ) -> TemplateBuilder:
283
+ """Add a {obs.name} observation.
284
+
285
+ Args:
286
+ {param_docs}
287
+ time: Measurement time (defaults to now).
288
+ event_index: Optional specific event index.
289
+
290
+ Returns:
291
+ Self for method chaining.
292
+ """
293
+ {body}
294
+ return self'''
295
+
296
+ def _generate_method_params(self, elements: list[ElementMetadata]) -> str:
297
+ """Generate method parameters from elements."""
298
+ if not elements:
299
+ return ""
300
+
301
+ params = []
302
+ for elem in elements:
303
+ param_name = self._derive_short_name(elem.name)
304
+ params.append(f" {param_name}: float,")
305
+
306
+ return "\n".join(params)
307
+
308
+ def _generate_param_docs(self, elements: list[ElementMetadata]) -> str:
309
+ """Generate parameter documentation."""
310
+ if not elements:
311
+ return ""
312
+
313
+ docs = []
314
+ for elem in elements:
315
+ param_name = self._derive_short_name(elem.name)
316
+ docs.append(f" {param_name}: {elem.name} value.")
317
+
318
+ return "\n".join(docs)
319
+
320
+ def _generate_method_body(self, obs: ObservationMetadata) -> str:
321
+ """Generate the method body for adding an observation."""
322
+ lines = []
323
+
324
+ # Get event index
325
+ lines.append(" if event_index is None:")
326
+ lines.append(f' event_index = self._next_event_index("{obs.short_name}")')
327
+ lines.append("")
328
+
329
+ # Build path prefix
330
+ path_template = f"{obs.flat_path}:{{event_index}}/any_event:{{event_index}}"
331
+ lines.append(f' prefix = f"{path_template}"')
332
+ lines.append("")
333
+
334
+ # Set time
335
+ lines.append(" # Set time")
336
+ lines.append(" time_str = self._format_time(time)")
337
+ lines.append(' self._flat.set(f"{prefix}/time", time_str)')
338
+ lines.append("")
339
+
340
+ # Set elements
341
+ if obs.elements:
342
+ lines.append(" # Set measurements")
343
+ for elem in obs.elements:
344
+ elem_name = self._derive_short_name(elem.name)
345
+ elem_path = self._derive_short_name(elem.name)
346
+ # Default unit based on common patterns
347
+ unit = self._guess_unit(elem.name)
348
+ quantity_call = (
349
+ f" self._flat.set_quantity("
350
+ f'f"{{{{prefix}}}}/{elem_path}", {elem_name}, "{unit}")'
351
+ )
352
+ lines.append(quantity_call)
353
+
354
+ return "\n".join(lines)
355
+
356
+ def _guess_unit(self, element_name: str) -> str:
357
+ """Guess the unit for a data element based on its name."""
358
+ name_lower = element_name.lower()
359
+
360
+ # Blood pressure
361
+ if "systolic" in name_lower or "diastolic" in name_lower or "pressure" in name_lower:
362
+ return "mm[Hg]"
363
+
364
+ # Heart rate / pulse
365
+ if "rate" in name_lower or "pulse" in name_lower or "heart" in name_lower:
366
+ return "/min"
367
+
368
+ # Temperature
369
+ if "temperature" in name_lower or "temp" in name_lower:
370
+ return "Cel"
371
+
372
+ # Respiration
373
+ if "respiration" in name_lower or "breathing" in name_lower:
374
+ return "/min"
375
+
376
+ # Oxygen saturation
377
+ if "spo2" in name_lower or "saturation" in name_lower or "oxygen" in name_lower:
378
+ return "%"
379
+
380
+ # Weight
381
+ if "weight" in name_lower:
382
+ return "kg"
383
+
384
+ # Height
385
+ if "height" in name_lower:
386
+ return "cm"
387
+
388
+ # Default
389
+ return "1"
390
+
391
+
392
+ def generate_builder_from_opt(
393
+ opt_path: Path | str,
394
+ output_path: Path | str | None = None,
395
+ class_name: str | None = None,
396
+ ) -> str:
397
+ """Generate a builder class from an OPT file.
398
+
399
+ Args:
400
+ opt_path: Path to the OPT XML file.
401
+ output_path: Optional path to write the generated code.
402
+ class_name: Optional custom class name.
403
+
404
+ Returns:
405
+ Generated Python source code.
406
+ """
407
+ from .opt_parser import parse_opt
408
+
409
+ template = parse_opt(opt_path)
410
+ generator = BuilderGenerator()
411
+
412
+ # Generate once
413
+ code = generator.generate(template, class_name)
414
+
415
+ # Write to file if requested
416
+ if output_path:
417
+ output_path = Path(output_path)
418
+ output_path.parent.mkdir(parents=True, exist_ok=True)
419
+ output_path.write_text(code)
420
+
421
+ return code