messagefoundry 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.
- messagefoundry/__init__.py +108 -0
- messagefoundry/__main__.py +1155 -0
- messagefoundry/api/__init__.py +27 -0
- messagefoundry/api/app.py +1581 -0
- messagefoundry/api/approvals.py +184 -0
- messagefoundry/api/auth_models.py +211 -0
- messagefoundry/api/auth_routes.py +655 -0
- messagefoundry/api/field_authz.py +96 -0
- messagefoundry/api/models.py +374 -0
- messagefoundry/api/security.py +247 -0
- messagefoundry/api/tls.py +47 -0
- messagefoundry/auth/__init__.py +39 -0
- messagefoundry/auth/data/common_passwords.NOTICE +13 -0
- messagefoundry/auth/data/common_passwords.txt +10000 -0
- messagefoundry/auth/identity.py +71 -0
- messagefoundry/auth/ldap.py +264 -0
- messagefoundry/auth/notifications.py +68 -0
- messagefoundry/auth/passwords.py +53 -0
- messagefoundry/auth/permissions.py +120 -0
- messagefoundry/auth/policy.py +153 -0
- messagefoundry/auth/ratelimit.py +55 -0
- messagefoundry/auth/service.py +1323 -0
- messagefoundry/auth/tokens.py +26 -0
- messagefoundry/auth/totp.py +174 -0
- messagefoundry/checks.py +174 -0
- messagefoundry/config/__init__.py +30 -0
- messagefoundry/config/active_environment.py +80 -0
- messagefoundry/config/ai_policy.py +140 -0
- messagefoundry/config/code_sets.py +260 -0
- messagefoundry/config/connections_edit.py +200 -0
- messagefoundry/config/connections_file.py +287 -0
- messagefoundry/config/db_lookup.py +117 -0
- messagefoundry/config/environments.py +116 -0
- messagefoundry/config/ingest_time.py +83 -0
- messagefoundry/config/models.py +240 -0
- messagefoundry/config/reference.py +158 -0
- messagefoundry/config/response.py +83 -0
- messagefoundry/config/run_context.py +153 -0
- messagefoundry/config/settings.py +1311 -0
- messagefoundry/config/state.py +99 -0
- messagefoundry/config/tls_policy.py +110 -0
- messagefoundry/config/wiring.py +1918 -0
- messagefoundry/console/__init__.py +20 -0
- messagefoundry/console/__main__.py +274 -0
- messagefoundry/console/_async.py +107 -0
- messagefoundry/console/change_password.py +111 -0
- messagefoundry/console/client.py +552 -0
- messagefoundry/console/connections.py +324 -0
- messagefoundry/console/login.py +107 -0
- messagefoundry/console/mfa.py +205 -0
- messagefoundry/console/reauth.py +94 -0
- messagefoundry/console/search.py +57 -0
- messagefoundry/console/service_control.py +137 -0
- messagefoundry/console/sessions.py +122 -0
- messagefoundry/console/shell.py +410 -0
- messagefoundry/console/status.py +377 -0
- messagefoundry/console/users_page.py +282 -0
- messagefoundry/console/widgets.py +553 -0
- messagefoundry/generators/README.md +27 -0
- messagefoundry/generators/__init__.py +15 -0
- messagefoundry/generators/_core.py +589 -0
- messagefoundry/generators/_hl7data.py +428 -0
- messagefoundry/generators/adt.py +286 -0
- messagefoundry/generators/all_types.py +24 -0
- messagefoundry/generators/bar.py +28 -0
- messagefoundry/generators/dft.py +20 -0
- messagefoundry/generators/mdm.py +39 -0
- messagefoundry/generators/mfn.py +46 -0
- messagefoundry/generators/oml.py +32 -0
- messagefoundry/generators/orl.py +30 -0
- messagefoundry/generators/orm.py +23 -0
- messagefoundry/generators/oru.py +21 -0
- messagefoundry/generators/ras.py +20 -0
- messagefoundry/generators/rde.py +54 -0
- messagefoundry/generators/siu.py +64 -0
- messagefoundry/generators/vxu.py +20 -0
- messagefoundry/hl7schema.py +75 -0
- messagefoundry/last_resort.py +55 -0
- messagefoundry/logging_setup.py +332 -0
- messagefoundry/parsing/__init__.py +64 -0
- messagefoundry/parsing/consistency.py +166 -0
- messagefoundry/parsing/groups.py +228 -0
- messagefoundry/parsing/message.py +453 -0
- messagefoundry/parsing/peek.py +237 -0
- messagefoundry/parsing/split.py +120 -0
- messagefoundry/parsing/summary.py +46 -0
- messagefoundry/parsing/tree.py +128 -0
- messagefoundry/parsing/validate.py +95 -0
- messagefoundry/parsing/x12/__init__.py +46 -0
- messagefoundry/parsing/x12/delimiters.py +140 -0
- messagefoundry/parsing/x12/errors.py +30 -0
- messagefoundry/parsing/x12/interchange.py +232 -0
- messagefoundry/parsing/x12/message.py +200 -0
- messagefoundry/parsing/x12/peek.py +207 -0
- messagefoundry/pipeline/__init__.py +21 -0
- messagefoundry/pipeline/alert_sinks.py +486 -0
- messagefoundry/pipeline/alerts.py +100 -0
- messagefoundry/pipeline/cert_expiry.py +219 -0
- messagefoundry/pipeline/cluster.py +955 -0
- messagefoundry/pipeline/cluster_sqlserver.py +444 -0
- messagefoundry/pipeline/config_convergence.py +137 -0
- messagefoundry/pipeline/dryrun.py +450 -0
- messagefoundry/pipeline/engine.py +756 -0
- messagefoundry/pipeline/leader_tasks.py +158 -0
- messagefoundry/pipeline/reference_sync.py +369 -0
- messagefoundry/pipeline/retention.py +289 -0
- messagefoundry/pipeline/security_notify.py +168 -0
- messagefoundry/pipeline/state_convergence.py +143 -0
- messagefoundry/pipeline/wiring_runner.py +1722 -0
- messagefoundry/py.typed +0 -0
- messagefoundry/redaction.py +71 -0
- messagefoundry/scaffold.py +321 -0
- messagefoundry/secrets_dpapi.py +129 -0
- messagefoundry/store/__init__.py +46 -0
- messagefoundry/store/audit_tee.py +67 -0
- messagefoundry/store/base.py +758 -0
- messagefoundry/store/crypto.py +166 -0
- messagefoundry/store/keyprovider.py +192 -0
- messagefoundry/store/postgres.py +3447 -0
- messagefoundry/store/sqlserver.py +3014 -0
- messagefoundry/store/store.py +3790 -0
- messagefoundry/timezone.py +207 -0
- messagefoundry/transports/__init__.py +50 -0
- messagefoundry/transports/base.py +269 -0
- messagefoundry/transports/database.py +693 -0
- messagefoundry/transports/file.py +551 -0
- messagefoundry/transports/framing.py +164 -0
- messagefoundry/transports/loopback.py +53 -0
- messagefoundry/transports/mllp.py +644 -0
- messagefoundry/transports/remotefile.py +664 -0
- messagefoundry/transports/rest.py +281 -0
- messagefoundry/transports/signing.py +321 -0
- messagefoundry/transports/soap.py +507 -0
- messagefoundry/transports/tcp.py +307 -0
- messagefoundry/transports/timer.py +146 -0
- messagefoundry/transports/x12.py +323 -0
- messagefoundry-0.1.0.dist-info/METADATA +212 -0
- messagefoundry-0.1.0.dist-info/RECORD +142 -0
- messagefoundry-0.1.0.dist-info/WHEEL +4 -0
- messagefoundry-0.1.0.dist-info/entry_points.txt +2 -0
- messagefoundry-0.1.0.dist-info/licenses/LICENSE +662 -0
- messagefoundry-0.1.0.dist-info/licenses/NOTICE +27 -0
|
@@ -0,0 +1,453 @@
|
|
|
1
|
+
# SPDX-License-Identifier: AGPL-3.0-or-later
|
|
2
|
+
# Copyright (C) 2026 MessageFoundry Organization and contributors
|
|
3
|
+
"""A mutable HL7 v2 message — read and set fields by path, then re-encode.
|
|
4
|
+
|
|
5
|
+
Wraps a ``python-hl7`` parse. Field paths use the same ``SEG-F[.C[.S]]`` syntax as
|
|
6
|
+
:class:`~messagefoundry.parsing.peek.Peek` and the declarative transforms. Components and
|
|
7
|
+
subcomponents are rebuilt at the *string* level (split on the message's own separators, modify,
|
|
8
|
+
re-join, assign the whole field) — which avoids a python-hl7 quirk where assigning to a component
|
|
9
|
+
of a not-yet-componentized field raises.
|
|
10
|
+
|
|
11
|
+
By default a read/write addresses the **first** segment of an id and (for a component) the **first**
|
|
12
|
+
repetition of a field — the common case. Real-world feeds also need to **iterate field repetitions** (PID-3 identifier lists, repeating OBX/IN1) and
|
|
13
|
+
to **address, add, and remove whole segments** (e.g. rebuilding a repeating ODS/OBX block). Those are
|
|
14
|
+
the ``occurrence=``/``repetition=`` keywords on :meth:`field`/:meth:`set`, plus :meth:`repetitions`,
|
|
15
|
+
:meth:`add_repetition`, :meth:`count_segments`, :meth:`add_segment`, and :meth:`delete_segments`.
|
|
16
|
+
Every one of them reads the message's own separators (MSH-1/MSH-2), never hardcoded defaults.
|
|
17
|
+
|
|
18
|
+
This is the read/mutate primitive that code-first **Routers** and **Handlers** work against (and
|
|
19
|
+
that the declarative transforms now reuse). Never string-slice raw HL7 — go through here and
|
|
20
|
+
re-encode.
|
|
21
|
+
"""
|
|
22
|
+
|
|
23
|
+
from __future__ import annotations
|
|
24
|
+
|
|
25
|
+
import json
|
|
26
|
+
import re
|
|
27
|
+
from typing import TYPE_CHECKING, Any
|
|
28
|
+
|
|
29
|
+
import hl7
|
|
30
|
+
|
|
31
|
+
from messagefoundry.parsing.peek import normalize, parse_path
|
|
32
|
+
|
|
33
|
+
if TYPE_CHECKING: # the SegmentGroup view imports Message back; keep the cycle out of runtime
|
|
34
|
+
from messagefoundry.parsing.groups import SegmentGroup
|
|
35
|
+
|
|
36
|
+
# A segment id is exactly three chars: an upper-case letter then two alphanumerics (HL7 §2.5),
|
|
37
|
+
# e.g. ``MSH``/``PID``/``ZAL``. Used to validate :meth:`Message.add_segment` input.
|
|
38
|
+
_SEG_ID_RE = re.compile(r"^[A-Z][A-Z0-9]{2}$")
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
class Message:
|
|
42
|
+
"""A parsed HL7 message you can read (``msg["MSH-9.2"]``), mutate (``msg["MSH-3"] = …``),
|
|
43
|
+
and re-encode (``msg.encode()``)."""
|
|
44
|
+
|
|
45
|
+
#: Symmetry with :class:`RawMessage` — a Router/Handler can branch on ``msg.content_type``.
|
|
46
|
+
content_type = "hl7v2"
|
|
47
|
+
|
|
48
|
+
def __init__(self, message: hl7.Message) -> None:
|
|
49
|
+
self._m = message
|
|
50
|
+
|
|
51
|
+
@classmethod
|
|
52
|
+
def parse(cls, raw: str | bytes) -> Message:
|
|
53
|
+
"""Parse ``raw`` (line endings normalized to ``\\r``) into a mutable message."""
|
|
54
|
+
return cls(hl7.parse(normalize(raw)))
|
|
55
|
+
|
|
56
|
+
# --- read ----------------------------------------------------------------
|
|
57
|
+
|
|
58
|
+
def field(self, path: str, *, occurrence: int = 1, repetition: int | None = None) -> str | None:
|
|
59
|
+
"""Value at ``path`` (``"MSH-9"``, ``"MSH-9.1"``, ``"PID-3.1.1"``), or None if absent/empty.
|
|
60
|
+
|
|
61
|
+
A whole-field read returns the raw (structural) text, **including** any repetitions. A
|
|
62
|
+
component/subcomponent read is taken from the **first repetition** (matching
|
|
63
|
+
:class:`~messagefoundry.parsing.peek.Peek`) and is **unescaped** so HL7 escape sequences
|
|
64
|
+
(e.g. ``\\S\\``) round-trip back to their literal value (the inverse of :meth:`set`'s
|
|
65
|
+
escaping).
|
|
66
|
+
|
|
67
|
+
``occurrence`` (1-based) selects which segment of that id to read — ``occurrence=2`` reads
|
|
68
|
+
the **second** ``OBX``, etc. ``repetition`` (1-based) scopes the read to one field
|
|
69
|
+
repetition: when given, a whole-field read returns just that repetition's text and a
|
|
70
|
+
component read takes that repetition's component (default ``None`` keeps the behavior above —
|
|
71
|
+
all reps for a whole field, first rep for a component). Returns None if the occurrence or
|
|
72
|
+
repetition is absent."""
|
|
73
|
+
if occurrence < 1:
|
|
74
|
+
raise ValueError("occurrence is 1-based (>= 1)")
|
|
75
|
+
if repetition is not None and repetition < 1:
|
|
76
|
+
raise ValueError("repetition is 1-based (>= 1)")
|
|
77
|
+
seg, fld, comp, sub = parse_path(path)
|
|
78
|
+
text = self._raw_field(seg, fld, occurrence)
|
|
79
|
+
_field_sep, comp_sep, rep_sep, _esc, sub_sep = self._encoding_chars()
|
|
80
|
+
if comp is None:
|
|
81
|
+
if repetition is None:
|
|
82
|
+
return text or None # whole field: every repetition (review H-9)
|
|
83
|
+
reps = text.split(rep_sep)
|
|
84
|
+
return (reps[repetition - 1] or None) if repetition <= len(reps) else None
|
|
85
|
+
# Component/subcomponent: one repetition (the first unless asked otherwise), so a read of a
|
|
86
|
+
# repeating field (e.g. PID-3 "111^^^A~222^^^B") returns a single rep's component, not
|
|
87
|
+
# cross-repetition text (review H-9).
|
|
88
|
+
reps = text.split(rep_sep)
|
|
89
|
+
rep_index = 1 if repetition is None else repetition
|
|
90
|
+
if rep_index > len(reps):
|
|
91
|
+
return None
|
|
92
|
+
return self._extract(reps[rep_index - 1], comp, sub, comp_sep, sub_sep)
|
|
93
|
+
|
|
94
|
+
def __getitem__(self, path: str) -> str | None:
|
|
95
|
+
return self.field(path)
|
|
96
|
+
|
|
97
|
+
def repetitions(self, path: str, *, occurrence: int = 1) -> list[str | None]:
|
|
98
|
+
"""Every repetition of the field at ``path``, in order (``[]`` if the field is absent/empty).
|
|
99
|
+
|
|
100
|
+
For a whole-field path each element is that repetition's full text; for a component/
|
|
101
|
+
subcomponent path each element is that part **within** each repetition (unescaped), with
|
|
102
|
+
None where a repetition lacks it. This is the iterate-the-``~``-list primitive — e.g.
|
|
103
|
+
``msg.repetitions("PID-3.1")`` → every identifier's first component. ``occurrence`` selects
|
|
104
|
+
the segment as in :meth:`field`."""
|
|
105
|
+
if occurrence < 1:
|
|
106
|
+
raise ValueError("occurrence is 1-based (>= 1)")
|
|
107
|
+
seg, fld, comp, sub = parse_path(path)
|
|
108
|
+
text = self._raw_field(seg, fld, occurrence)
|
|
109
|
+
if not text:
|
|
110
|
+
return []
|
|
111
|
+
_field_sep, comp_sep, rep_sep, _esc, sub_sep = self._encoding_chars()
|
|
112
|
+
if comp is None:
|
|
113
|
+
return [rep or None for rep in text.split(rep_sep)]
|
|
114
|
+
return [self._extract(rep, comp, sub, comp_sep, sub_sep) for rep in text.split(rep_sep)]
|
|
115
|
+
|
|
116
|
+
def count_segments(self, segment_id: str) -> int:
|
|
117
|
+
"""How many segments of ``segment_id`` the message has (0 if none)."""
|
|
118
|
+
return sum(1 for seg in self._m if str(seg[0]) == segment_id)
|
|
119
|
+
|
|
120
|
+
@property
|
|
121
|
+
def message_code(self) -> str | None:
|
|
122
|
+
return self.field("MSH-9.1")
|
|
123
|
+
|
|
124
|
+
@property
|
|
125
|
+
def trigger_event(self) -> str | None:
|
|
126
|
+
return self.field("MSH-9.2")
|
|
127
|
+
|
|
128
|
+
@property
|
|
129
|
+
def message_type(self) -> str | None:
|
|
130
|
+
return self.field("MSH-9")
|
|
131
|
+
|
|
132
|
+
@property
|
|
133
|
+
def control_id(self) -> str | None:
|
|
134
|
+
return self.field("MSH-10")
|
|
135
|
+
|
|
136
|
+
def segments(self) -> list[str]:
|
|
137
|
+
"""Ordered segment ids, e.g. ``["MSH", "EVN", "PID"]``."""
|
|
138
|
+
return [str(seg[0]) for seg in self._m]
|
|
139
|
+
|
|
140
|
+
# --- mutate --------------------------------------------------------------
|
|
141
|
+
|
|
142
|
+
def set(
|
|
143
|
+
self, path: str, value: str, *, occurrence: int = 1, repetition: int | None = None
|
|
144
|
+
) -> None:
|
|
145
|
+
"""Write ``value`` at ``path``, extending the field/components as needed.
|
|
146
|
+
|
|
147
|
+
``value`` may never contain a **segment separator** (CR/LF) — that would inject a new segment
|
|
148
|
+
downstream — and a **whole-field** write may not contain the **field separator** (it would
|
|
149
|
+
split into extra fields); both raise ``ValueError`` (XFORM-1, review M-12). A component/
|
|
150
|
+
subcomponent write **escapes** the value's structural delimiters (``| ^ ~ & \\``) so they are
|
|
151
|
+
carried as data, not new structure (e.g. ``PID-5.1 = "O^Brien"`` stays one component), while
|
|
152
|
+
non-delimiter characters — incl. CJK/accented names — pass through intact (review M-13).
|
|
153
|
+
|
|
154
|
+
``occurrence`` (1-based) selects which segment of that id to write — ``occurrence=2`` edits
|
|
155
|
+
the **second** ``OBX``. ``repetition`` (1-based) scopes the write to one field repetition: a
|
|
156
|
+
component write edits that repetition (padding earlier reps if needed) and preserves the
|
|
157
|
+
others (default — and ``repetition=1`` — edit the first, preserve the rest; review H-9); a
|
|
158
|
+
whole-field write with ``repetition`` replaces just that repetition's text (and the value may
|
|
159
|
+
then not contain the repetition separator). Without ``repetition``, a whole-field write
|
|
160
|
+
assigns the caller's text verbatim (their structure, repetitions and all). Raises
|
|
161
|
+
``KeyError`` if the target segment (occurrence) isn't present."""
|
|
162
|
+
if "\r" in value or "\n" in value:
|
|
163
|
+
raise ValueError("HL7 field value may not contain a segment separator (CR/LF)")
|
|
164
|
+
if occurrence < 1:
|
|
165
|
+
raise ValueError("occurrence is 1-based (>= 1)")
|
|
166
|
+
if repetition is not None and repetition < 1:
|
|
167
|
+
raise ValueError("repetition is 1-based (>= 1)")
|
|
168
|
+
seg, fld, comp, sub = parse_path(path)
|
|
169
|
+
if self._segment_obj(seg, occurrence) is None:
|
|
170
|
+
where = f"{seg!r}" + (f" occurrence {occurrence}" if occurrence > 1 else "")
|
|
171
|
+
raise KeyError(f"cannot set absent segment {where}")
|
|
172
|
+
|
|
173
|
+
field_sep, comp_sep, rep_sep, esc, sub_sep = self._encoding_chars()
|
|
174
|
+
|
|
175
|
+
if comp is None:
|
|
176
|
+
# Whole-field write assigns the caller's structure verbatim — but the field separator
|
|
177
|
+
# would split it into extra fields downstream, so reject it (review M-12). Components and
|
|
178
|
+
# repetitions are the caller's intended structure and remain allowed.
|
|
179
|
+
if field_sep in value:
|
|
180
|
+
raise ValueError(
|
|
181
|
+
f"HL7 field value may not contain the field separator {field_sep!r}; "
|
|
182
|
+
"write structured values via a component/subcomponent path"
|
|
183
|
+
)
|
|
184
|
+
if repetition is None:
|
|
185
|
+
self._assign_field(seg, fld, occurrence, value)
|
|
186
|
+
return
|
|
187
|
+
# Repetition-scoped whole-field write: replace just that rep, keep the others. The value
|
|
188
|
+
# targets one repetition, so it may not itself carry the repetition separator.
|
|
189
|
+
if rep_sep in value:
|
|
190
|
+
raise ValueError(
|
|
191
|
+
f"a repetition-scoped value may not contain the repetition separator {rep_sep!r}"
|
|
192
|
+
)
|
|
193
|
+
current = self._raw_field(seg, fld, occurrence)
|
|
194
|
+
reps = current.split(rep_sep) if current else [""]
|
|
195
|
+
while len(reps) < repetition:
|
|
196
|
+
reps.append("")
|
|
197
|
+
reps[repetition - 1] = value
|
|
198
|
+
self._assign_field(seg, fld, occurrence, rep_sep.join(reps))
|
|
199
|
+
return
|
|
200
|
+
|
|
201
|
+
escaped = self._escape_leaf(value, field_sep, comp_sep, rep_sep, esc, sub_sep)
|
|
202
|
+
current = self._raw_field(seg, fld, occurrence)
|
|
203
|
+
# Edit one repetition (the first unless asked otherwise), matching the read side; preserve
|
|
204
|
+
# any further reps (H-9).
|
|
205
|
+
rep_index = 1 if repetition is None else repetition
|
|
206
|
+
reps = current.split(rep_sep) if current else [""]
|
|
207
|
+
while len(reps) < rep_index:
|
|
208
|
+
reps.append("")
|
|
209
|
+
comps = reps[rep_index - 1].split(comp_sep) if reps[rep_index - 1] else []
|
|
210
|
+
while len(comps) < comp:
|
|
211
|
+
comps.append("")
|
|
212
|
+
if sub is None:
|
|
213
|
+
comps[comp - 1] = escaped
|
|
214
|
+
else:
|
|
215
|
+
subs = comps[comp - 1].split(sub_sep) if comps[comp - 1] else []
|
|
216
|
+
while len(subs) < sub:
|
|
217
|
+
subs.append("")
|
|
218
|
+
subs[sub - 1] = escaped
|
|
219
|
+
comps[comp - 1] = sub_sep.join(subs)
|
|
220
|
+
reps[rep_index - 1] = comp_sep.join(comps)
|
|
221
|
+
self._assign_field(seg, fld, occurrence, rep_sep.join(reps))
|
|
222
|
+
|
|
223
|
+
def __setitem__(self, path: str, value: str) -> None:
|
|
224
|
+
self.set(path, value)
|
|
225
|
+
|
|
226
|
+
def add_repetition(self, path: str, value: str, *, occurrence: int = 1) -> None:
|
|
227
|
+
"""Append a new ``~`` repetition carrying ``value`` to the field at ``path``.
|
|
228
|
+
|
|
229
|
+
``path`` must be a **whole-field** path (no component) — a repetition is a field-level unit;
|
|
230
|
+
``value`` is the new repetition's structure (components with ``^`` are kept as the caller's
|
|
231
|
+
intent), so it may not contain the field or repetition separator or a CR/LF. If the field is
|
|
232
|
+
currently empty the value becomes its first repetition. ``occurrence`` selects the segment.
|
|
233
|
+
Raises ``KeyError`` if the segment (occurrence) is absent, ``ValueError`` on a component path
|
|
234
|
+
or an illegal separator in ``value``."""
|
|
235
|
+
if "\r" in value or "\n" in value:
|
|
236
|
+
raise ValueError("HL7 field value may not contain a segment separator (CR/LF)")
|
|
237
|
+
if occurrence < 1:
|
|
238
|
+
raise ValueError("occurrence is 1-based (>= 1)")
|
|
239
|
+
seg, fld, comp, _sub = parse_path(path)
|
|
240
|
+
if comp is not None:
|
|
241
|
+
raise ValueError("add_repetition takes a whole-field path (no component)")
|
|
242
|
+
if self._segment_obj(seg, occurrence) is None:
|
|
243
|
+
where = f"{seg!r}" + (f" occurrence {occurrence}" if occurrence > 1 else "")
|
|
244
|
+
raise KeyError(f"cannot add a repetition to absent segment {where}")
|
|
245
|
+
field_sep, _comp_sep, rep_sep, _esc, _sub_sep = self._encoding_chars()
|
|
246
|
+
if field_sep in value:
|
|
247
|
+
raise ValueError(
|
|
248
|
+
f"a repetition value may not contain the field separator {field_sep!r}"
|
|
249
|
+
)
|
|
250
|
+
if rep_sep in value:
|
|
251
|
+
raise ValueError(
|
|
252
|
+
f"a repetition value may not contain the repetition separator {rep_sep!r}; "
|
|
253
|
+
"call add_repetition once per repetition"
|
|
254
|
+
)
|
|
255
|
+
current = self._raw_field(seg, fld, occurrence)
|
|
256
|
+
self._assign_field(seg, fld, occurrence, f"{current}{rep_sep}{value}" if current else value)
|
|
257
|
+
|
|
258
|
+
def add_segment(self, line: str, *, index: int | None = None) -> None:
|
|
259
|
+
"""Add a whole segment from a raw ``line`` like ``"ODS|R|^ODS123|GEN^Regular^Diet"``.
|
|
260
|
+
|
|
261
|
+
The line is split on the message's **own** field separator and grafted in so it re-encodes
|
|
262
|
+
byte-for-byte and re-parses into real components. It must be a **single** segment (no CR/LF)
|
|
263
|
+
beginning with a 3-char segment id. By default the segment is appended at the end; pass
|
|
264
|
+
``index`` (1-based position among segments, ``1`` = just after MSH) to insert it earlier.
|
|
265
|
+
Adding an ``MSH`` is refused (there is exactly one). Raises ``ValueError`` on a malformed
|
|
266
|
+
line or out-of-range ``index``."""
|
|
267
|
+
if "\r" in line or "\n" in line:
|
|
268
|
+
raise ValueError(
|
|
269
|
+
"add_segment takes one segment line (no CR/LF); call it once per segment"
|
|
270
|
+
)
|
|
271
|
+
field_sep, *_ = self._encoding_chars()
|
|
272
|
+
tokens = line.split(field_sep)
|
|
273
|
+
segment_id = tokens[0]
|
|
274
|
+
if not _SEG_ID_RE.match(segment_id):
|
|
275
|
+
raise ValueError(f"segment must begin with a 3-char segment id, got {segment_id!r}")
|
|
276
|
+
if segment_id == "MSH":
|
|
277
|
+
raise ValueError("refusing to add a second MSH segment")
|
|
278
|
+
new_segment = self._m.create_segment([self._m.create_field([tok]) for tok in tokens])
|
|
279
|
+
if index is None:
|
|
280
|
+
self._m.append(new_segment)
|
|
281
|
+
return
|
|
282
|
+
if index < 1 or index > len(self._m):
|
|
283
|
+
raise ValueError(
|
|
284
|
+
f"index {index} out of range (1..{len(self._m)}); index 1 is after MSH"
|
|
285
|
+
)
|
|
286
|
+
self._m.insert(index, new_segment)
|
|
287
|
+
|
|
288
|
+
def delete_segments(self, segment_id: str) -> int:
|
|
289
|
+
"""Remove every segment with ``segment_id`` and return how many were removed.
|
|
290
|
+
|
|
291
|
+
Deleting ``MSH`` is refused — the message must keep its header. Common for clearing a
|
|
292
|
+
repeating block (e.g. ``delete_segments("ODS")``) before rebuilding it with
|
|
293
|
+
:meth:`add_segment`."""
|
|
294
|
+
if segment_id == "MSH":
|
|
295
|
+
raise ValueError("refusing to delete the MSH segment")
|
|
296
|
+
removed = 0
|
|
297
|
+
for i in range(len(self._m) - 1, -1, -1): # back-to-front keeps indices valid
|
|
298
|
+
if str(self._m[i][0]) == segment_id:
|
|
299
|
+
del self._m[i]
|
|
300
|
+
removed += 1
|
|
301
|
+
return removed
|
|
302
|
+
|
|
303
|
+
def _delete_segment_at(self, position: int) -> None:
|
|
304
|
+
"""Remove the segment at 1-based ``position`` among segments (``1`` = just after MSH,
|
|
305
|
+
matching :meth:`add_segment`'s ``index``).
|
|
306
|
+
|
|
307
|
+
This is the positional delete that group-scoped edits use: a group addresses its members by
|
|
308
|
+
position, not by id (an ``OBX`` may belong to any order), so id-based
|
|
309
|
+
:meth:`delete_segments` can't target one order's segments. Deleting MSH (position 0) is
|
|
310
|
+
refused so the header always survives. Raises ``ValueError`` on an out-of-range position."""
|
|
311
|
+
if position < 1 or position >= len(self._m):
|
|
312
|
+
raise ValueError(
|
|
313
|
+
f"position {position} out of range (1..{len(self._m) - 1}); position 1 is after MSH"
|
|
314
|
+
)
|
|
315
|
+
del self._m[position]
|
|
316
|
+
|
|
317
|
+
# --- group-scoped structural view ----------------------------------------
|
|
318
|
+
|
|
319
|
+
def groups(self, boundary: str = "OBR") -> list[SegmentGroup]:
|
|
320
|
+
"""The message's order/observation groups for ``boundary`` (default ``OBR``), in order.
|
|
321
|
+
|
|
322
|
+
A group is a contiguous run starting at a boundary segment and ending just before the next
|
|
323
|
+
boundary (or end of message); segments before the first boundary are the header and form no
|
|
324
|
+
group. Use it to scope structural edits to **one** order — per-OBR rebuilds, per-group
|
|
325
|
+
OBX prune/renumber — which the flat (by-id, by-occurrence) API can't address. Returns ``[]``
|
|
326
|
+
if the message has no boundary segment. See :class:`SegmentGroup`."""
|
|
327
|
+
from messagefoundry.parsing.groups import groups_of
|
|
328
|
+
|
|
329
|
+
return groups_of(self, boundary)
|
|
330
|
+
|
|
331
|
+
# --- encode --------------------------------------------------------------
|
|
332
|
+
|
|
333
|
+
def encode(self) -> str:
|
|
334
|
+
"""Serialize back to a ``\\r``-delimited HL7 string."""
|
|
335
|
+
return str(self._m)
|
|
336
|
+
|
|
337
|
+
def __str__(self) -> str:
|
|
338
|
+
return self.encode()
|
|
339
|
+
|
|
340
|
+
# --- internals -----------------------------------------------------------
|
|
341
|
+
|
|
342
|
+
def _raw_field(self, seg: str, fld: int, occurrence: int = 1) -> str:
|
|
343
|
+
"""Raw field text (``""`` if the segment/field/occurrence is absent) — for component
|
|
344
|
+
rebuilds. ``occurrence`` (1-based) picks which segment of that id."""
|
|
345
|
+
segment = self._segment_obj(seg, occurrence)
|
|
346
|
+
if segment is None:
|
|
347
|
+
return ""
|
|
348
|
+
try:
|
|
349
|
+
return str(segment[fld])
|
|
350
|
+
except (KeyError, IndexError):
|
|
351
|
+
return ""
|
|
352
|
+
|
|
353
|
+
def _segment_obj(self, segment_id: str, occurrence: int = 1) -> Any:
|
|
354
|
+
"""The ``occurrence``-th (1-based) segment object with ``segment_id``, or None if absent."""
|
|
355
|
+
seen = 0
|
|
356
|
+
for segment in self._m:
|
|
357
|
+
if str(segment[0]) == segment_id:
|
|
358
|
+
seen += 1
|
|
359
|
+
if seen == occurrence:
|
|
360
|
+
return segment
|
|
361
|
+
return None
|
|
362
|
+
|
|
363
|
+
def _assign_field(self, seg: str, fld: int, occurrence: int, raw_value: str) -> None:
|
|
364
|
+
"""Write the whole raw field text at ``seg``/``fld`` for the given segment ``occurrence``.
|
|
365
|
+
|
|
366
|
+
Occurrence 1 uses python-hl7's accessor, which auto-extends the field list; a later
|
|
367
|
+
occurrence is written on its segment object directly, padding empty fields up to ``fld``
|
|
368
|
+
first (a bare index assignment past the end raises). The string content (components,
|
|
369
|
+
repetitions) round-trips verbatim and re-parses into structure either way."""
|
|
370
|
+
if occurrence == 1:
|
|
371
|
+
self._m[f"{seg}.F{fld}"] = raw_value
|
|
372
|
+
return
|
|
373
|
+
segment = self._segment_obj(seg, occurrence)
|
|
374
|
+
if segment is None: # pragma: no cover - callers check first
|
|
375
|
+
raise KeyError(f"cannot set absent segment {seg!r} occurrence {occurrence}")
|
|
376
|
+
while len(segment) <= fld:
|
|
377
|
+
segment.append(self._m.create_field([""]))
|
|
378
|
+
segment[fld] = self._m.create_field([raw_value])
|
|
379
|
+
|
|
380
|
+
def _extract(
|
|
381
|
+
self, rep_text: str, comp: int, sub: int | None, comp_sep: str, sub_sep: str
|
|
382
|
+
) -> str | None:
|
|
383
|
+
"""The component/subcomponent value within a single repetition's text, unescaped, or None
|
|
384
|
+
if that part is absent."""
|
|
385
|
+
comps = rep_text.split(comp_sep)
|
|
386
|
+
if comp > len(comps):
|
|
387
|
+
return None
|
|
388
|
+
value = comps[comp - 1]
|
|
389
|
+
if sub is None:
|
|
390
|
+
return self._m.unescape(value) or None
|
|
391
|
+
subs = value.split(sub_sep)
|
|
392
|
+
return (self._m.unescape(subs[sub - 1]) or None) if sub <= len(subs) else None
|
|
393
|
+
|
|
394
|
+
def _encoding_chars(self) -> tuple[str, str, str, str, str]:
|
|
395
|
+
"""The message's ``(field, component, repetition, escape, subcomponent)`` delimiters, read
|
|
396
|
+
from its **own** MSH-1 (field separator) + MSH-2 (the other four).
|
|
397
|
+
|
|
398
|
+
Derived from the actual encoding characters, not hardcoded defaults — so a custom-delimiter
|
|
399
|
+
message isn't split on the wrong characters. Raises ``ValueError`` if they can't be determined
|
|
400
|
+
rather than guess (XFORM-2/3). ``self._m.unescape`` uses the same MSH-2-derived characters, so
|
|
401
|
+
write-escaping and read-unescaping stay consistent."""
|
|
402
|
+
field_sep = self._raw_field("MSH", 1) # MSH-1 is the field separator itself
|
|
403
|
+
enc = self._raw_field("MSH", 2) # e.g. "^~\&": component, repetition, escape, subcomponent
|
|
404
|
+
if len(field_sep) != 1 or len(enc) < 4:
|
|
405
|
+
raise ValueError(f"cannot determine HL7 separators: MSH-1={field_sep!r}, MSH-2={enc!r}")
|
|
406
|
+
return field_sep, enc[0], enc[1], enc[2], enc[3]
|
|
407
|
+
|
|
408
|
+
@staticmethod
|
|
409
|
+
def _escape_leaf(
|
|
410
|
+
value: str, field_sep: str, comp_sep: str, rep_sep: str, esc: str, sub_sep: str
|
|
411
|
+
) -> str:
|
|
412
|
+
"""Escape ONLY the structural delimiters (and the escape char) so a leaf value carries them
|
|
413
|
+
as data, not new structure. Every other character — including code points above U+00FF
|
|
414
|
+
(CJK/Cyrillic/Greek names) — passes through untouched and round-trips via ``unescape``;
|
|
415
|
+
python-hl7's own ``escape()`` instead hex-encodes those as byte pairs that ``unescape()``
|
|
416
|
+
then mis-decodes, silently corrupting them (review M-13)."""
|
|
417
|
+
out = value.replace(esc, f"{esc}E{esc}") # the escape char first, so we don't double-escape
|
|
418
|
+
out = out.replace(field_sep, f"{esc}F{esc}")
|
|
419
|
+
out = out.replace(comp_sep, f"{esc}S{esc}")
|
|
420
|
+
out = out.replace(rep_sep, f"{esc}R{esc}")
|
|
421
|
+
out = out.replace(sub_sep, f"{esc}T{esc}")
|
|
422
|
+
return out
|
|
423
|
+
|
|
424
|
+
|
|
425
|
+
class RawMessage:
|
|
426
|
+
"""A **non-HL7** inbound payload (ADR 0004) — what a code-first Router/Handler receives when the
|
|
427
|
+
inbound connection's ``content_type`` is not ``hl7v2`` (a database row, a JSON/SOAP body, …).
|
|
428
|
+
|
|
429
|
+
The body is committed and routed **verbatim** (no HL7 parse). Read it via :attr:`raw` / :attr:`text`,
|
|
430
|
+
branch on :attr:`content_type`, or :meth:`json` it; a Handler then builds an output **string** and
|
|
431
|
+
returns ``Send(to, that_string)`` (the built destinations accept a ``str`` payload). It deliberately
|
|
432
|
+
has **no** HL7 ``msg["MSH-9.1"]`` API — that surface is HL7-only."""
|
|
433
|
+
|
|
434
|
+
def __init__(self, raw: str, content_type: str) -> None:
|
|
435
|
+
self.raw = raw
|
|
436
|
+
self.content_type = content_type
|
|
437
|
+
|
|
438
|
+
@property
|
|
439
|
+
def text(self) -> str:
|
|
440
|
+
"""The body as text (alias for :attr:`raw`)."""
|
|
441
|
+
return self.raw
|
|
442
|
+
|
|
443
|
+
def json(self) -> Any:
|
|
444
|
+
"""Parse the body as JSON. Raises ``json.JSONDecodeError`` on malformed input — a Handler can
|
|
445
|
+
return ``None`` (FILTERED) or let it raise (ERROR / dead-letter)."""
|
|
446
|
+
return json.loads(self.raw)
|
|
447
|
+
|
|
448
|
+
def encode(self) -> str:
|
|
449
|
+
"""The body verbatim — symmetry with :meth:`Message.encode` so a pass-through ``Send`` works."""
|
|
450
|
+
return self.raw
|
|
451
|
+
|
|
452
|
+
def __str__(self) -> str:
|
|
453
|
+
return self.raw
|