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.
Files changed (142) hide show
  1. messagefoundry/__init__.py +108 -0
  2. messagefoundry/__main__.py +1155 -0
  3. messagefoundry/api/__init__.py +27 -0
  4. messagefoundry/api/app.py +1581 -0
  5. messagefoundry/api/approvals.py +184 -0
  6. messagefoundry/api/auth_models.py +211 -0
  7. messagefoundry/api/auth_routes.py +655 -0
  8. messagefoundry/api/field_authz.py +96 -0
  9. messagefoundry/api/models.py +374 -0
  10. messagefoundry/api/security.py +247 -0
  11. messagefoundry/api/tls.py +47 -0
  12. messagefoundry/auth/__init__.py +39 -0
  13. messagefoundry/auth/data/common_passwords.NOTICE +13 -0
  14. messagefoundry/auth/data/common_passwords.txt +10000 -0
  15. messagefoundry/auth/identity.py +71 -0
  16. messagefoundry/auth/ldap.py +264 -0
  17. messagefoundry/auth/notifications.py +68 -0
  18. messagefoundry/auth/passwords.py +53 -0
  19. messagefoundry/auth/permissions.py +120 -0
  20. messagefoundry/auth/policy.py +153 -0
  21. messagefoundry/auth/ratelimit.py +55 -0
  22. messagefoundry/auth/service.py +1323 -0
  23. messagefoundry/auth/tokens.py +26 -0
  24. messagefoundry/auth/totp.py +174 -0
  25. messagefoundry/checks.py +174 -0
  26. messagefoundry/config/__init__.py +30 -0
  27. messagefoundry/config/active_environment.py +80 -0
  28. messagefoundry/config/ai_policy.py +140 -0
  29. messagefoundry/config/code_sets.py +260 -0
  30. messagefoundry/config/connections_edit.py +200 -0
  31. messagefoundry/config/connections_file.py +287 -0
  32. messagefoundry/config/db_lookup.py +117 -0
  33. messagefoundry/config/environments.py +116 -0
  34. messagefoundry/config/ingest_time.py +83 -0
  35. messagefoundry/config/models.py +240 -0
  36. messagefoundry/config/reference.py +158 -0
  37. messagefoundry/config/response.py +83 -0
  38. messagefoundry/config/run_context.py +153 -0
  39. messagefoundry/config/settings.py +1311 -0
  40. messagefoundry/config/state.py +99 -0
  41. messagefoundry/config/tls_policy.py +110 -0
  42. messagefoundry/config/wiring.py +1918 -0
  43. messagefoundry/console/__init__.py +20 -0
  44. messagefoundry/console/__main__.py +274 -0
  45. messagefoundry/console/_async.py +107 -0
  46. messagefoundry/console/change_password.py +111 -0
  47. messagefoundry/console/client.py +552 -0
  48. messagefoundry/console/connections.py +324 -0
  49. messagefoundry/console/login.py +107 -0
  50. messagefoundry/console/mfa.py +205 -0
  51. messagefoundry/console/reauth.py +94 -0
  52. messagefoundry/console/search.py +57 -0
  53. messagefoundry/console/service_control.py +137 -0
  54. messagefoundry/console/sessions.py +122 -0
  55. messagefoundry/console/shell.py +410 -0
  56. messagefoundry/console/status.py +377 -0
  57. messagefoundry/console/users_page.py +282 -0
  58. messagefoundry/console/widgets.py +553 -0
  59. messagefoundry/generators/README.md +27 -0
  60. messagefoundry/generators/__init__.py +15 -0
  61. messagefoundry/generators/_core.py +589 -0
  62. messagefoundry/generators/_hl7data.py +428 -0
  63. messagefoundry/generators/adt.py +286 -0
  64. messagefoundry/generators/all_types.py +24 -0
  65. messagefoundry/generators/bar.py +28 -0
  66. messagefoundry/generators/dft.py +20 -0
  67. messagefoundry/generators/mdm.py +39 -0
  68. messagefoundry/generators/mfn.py +46 -0
  69. messagefoundry/generators/oml.py +32 -0
  70. messagefoundry/generators/orl.py +30 -0
  71. messagefoundry/generators/orm.py +23 -0
  72. messagefoundry/generators/oru.py +21 -0
  73. messagefoundry/generators/ras.py +20 -0
  74. messagefoundry/generators/rde.py +54 -0
  75. messagefoundry/generators/siu.py +64 -0
  76. messagefoundry/generators/vxu.py +20 -0
  77. messagefoundry/hl7schema.py +75 -0
  78. messagefoundry/last_resort.py +55 -0
  79. messagefoundry/logging_setup.py +332 -0
  80. messagefoundry/parsing/__init__.py +64 -0
  81. messagefoundry/parsing/consistency.py +166 -0
  82. messagefoundry/parsing/groups.py +228 -0
  83. messagefoundry/parsing/message.py +453 -0
  84. messagefoundry/parsing/peek.py +237 -0
  85. messagefoundry/parsing/split.py +120 -0
  86. messagefoundry/parsing/summary.py +46 -0
  87. messagefoundry/parsing/tree.py +128 -0
  88. messagefoundry/parsing/validate.py +95 -0
  89. messagefoundry/parsing/x12/__init__.py +46 -0
  90. messagefoundry/parsing/x12/delimiters.py +140 -0
  91. messagefoundry/parsing/x12/errors.py +30 -0
  92. messagefoundry/parsing/x12/interchange.py +232 -0
  93. messagefoundry/parsing/x12/message.py +200 -0
  94. messagefoundry/parsing/x12/peek.py +207 -0
  95. messagefoundry/pipeline/__init__.py +21 -0
  96. messagefoundry/pipeline/alert_sinks.py +486 -0
  97. messagefoundry/pipeline/alerts.py +100 -0
  98. messagefoundry/pipeline/cert_expiry.py +219 -0
  99. messagefoundry/pipeline/cluster.py +955 -0
  100. messagefoundry/pipeline/cluster_sqlserver.py +444 -0
  101. messagefoundry/pipeline/config_convergence.py +137 -0
  102. messagefoundry/pipeline/dryrun.py +450 -0
  103. messagefoundry/pipeline/engine.py +756 -0
  104. messagefoundry/pipeline/leader_tasks.py +158 -0
  105. messagefoundry/pipeline/reference_sync.py +369 -0
  106. messagefoundry/pipeline/retention.py +289 -0
  107. messagefoundry/pipeline/security_notify.py +168 -0
  108. messagefoundry/pipeline/state_convergence.py +143 -0
  109. messagefoundry/pipeline/wiring_runner.py +1722 -0
  110. messagefoundry/py.typed +0 -0
  111. messagefoundry/redaction.py +71 -0
  112. messagefoundry/scaffold.py +321 -0
  113. messagefoundry/secrets_dpapi.py +129 -0
  114. messagefoundry/store/__init__.py +46 -0
  115. messagefoundry/store/audit_tee.py +67 -0
  116. messagefoundry/store/base.py +758 -0
  117. messagefoundry/store/crypto.py +166 -0
  118. messagefoundry/store/keyprovider.py +192 -0
  119. messagefoundry/store/postgres.py +3447 -0
  120. messagefoundry/store/sqlserver.py +3014 -0
  121. messagefoundry/store/store.py +3790 -0
  122. messagefoundry/timezone.py +207 -0
  123. messagefoundry/transports/__init__.py +50 -0
  124. messagefoundry/transports/base.py +269 -0
  125. messagefoundry/transports/database.py +693 -0
  126. messagefoundry/transports/file.py +551 -0
  127. messagefoundry/transports/framing.py +164 -0
  128. messagefoundry/transports/loopback.py +53 -0
  129. messagefoundry/transports/mllp.py +644 -0
  130. messagefoundry/transports/remotefile.py +664 -0
  131. messagefoundry/transports/rest.py +281 -0
  132. messagefoundry/transports/signing.py +321 -0
  133. messagefoundry/transports/soap.py +507 -0
  134. messagefoundry/transports/tcp.py +307 -0
  135. messagefoundry/transports/timer.py +146 -0
  136. messagefoundry/transports/x12.py +323 -0
  137. messagefoundry-0.1.0.dist-info/METADATA +212 -0
  138. messagefoundry-0.1.0.dist-info/RECORD +142 -0
  139. messagefoundry-0.1.0.dist-info/WHEEL +4 -0
  140. messagefoundry-0.1.0.dist-info/entry_points.txt +2 -0
  141. messagefoundry-0.1.0.dist-info/licenses/LICENSE +662 -0
  142. 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