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,166 @@
1
+ # SPDX-License-Identifier: AGPL-3.0-or-later
2
+ # Copyright (C) 2026 MessageFoundry Organization and contributors
3
+ """Cross-field HL7 consistency checks for Routers/Handlers (WP-7b; ASVS 2.2.3/2.1.2/2.2.1).
4
+
5
+ Strict validation ([validate.py](validate.py), opt-in `validation.strict`) checks message
6
+ *structure* — segment cardinality, datatypes, table values, lengths — against the official HL7
7
+ schema. It does **not** check *business coherence across fields*: that a required identifier is
8
+ present, that a value is echoed consistently across segments, or that admit ≤ discharge. ASVS 2.2.3 /
9
+ 2.1.2 place that "combined-item" consistency on the application — here, the Router/Handler.
10
+
11
+ This module is a small, **pure** (side-effect-free) toolkit of reusable checks a Handler composes.
12
+ Each primitive takes a parsed :class:`~messagefoundry.parsing.message.Message` plus field *paths*
13
+ (``"PID-3"``, ``"PV1-44.1"``) and returns a list of :class:`Violation`\\s — it **detects, it does not
14
+ decide**. The Handler chooses what to do with a non-empty result: ``return None`` to drop the message
15
+ (logged ``FILTERED``) or ``raise ConsistencyError(...)`` to send it to the error/dead-letter path
16
+ (logged ``ERROR``). Keeping the library pure preserves the at-least-once *re-run* invariant (a Handler
17
+ must be a pure function of the message — see CLAUDE.md §2).
18
+
19
+ **PHI-safety:** a :class:`Violation` records the **rule and the field path(s)**, never the field
20
+ *value*. So a Handler can log/aggregate violations, or let them ride the ``safe_exc()`` chokepoint
21
+ (WP-6c) into a stored disposition, without leaking PHI (a date violation reads "PID-7 is not a valid
22
+ HL7 date/time", never the offending value).
23
+
24
+ The primitives are **generic** (no HL7 version or message-type assumptions); compose them into
25
+ message-type-specific checks in your config (see ``samples/consistency`` for a worked ADT example).
26
+ """
27
+
28
+ from __future__ import annotations
29
+
30
+ import re
31
+ from collections.abc import Iterable, Sequence
32
+ from dataclasses import dataclass
33
+ from datetime import datetime
34
+
35
+ from messagefoundry.parsing.message import Message
36
+
37
+ __all__ = [
38
+ "Violation",
39
+ "ConsistencyError",
40
+ "required",
41
+ "same_across",
42
+ "valid_date",
43
+ "dates_in_order",
44
+ "matches",
45
+ "check",
46
+ ]
47
+
48
+
49
+ @dataclass(frozen=True)
50
+ class Violation:
51
+ """A single failed consistency check. ``rule`` names the primitive; ``paths`` are the field
52
+ path(s) involved. PHI-safe by construction — it never carries a field *value*."""
53
+
54
+ rule: str
55
+ paths: tuple[str, ...]
56
+
57
+ @property
58
+ def message(self) -> str:
59
+ """A PHI-safe, human-readable description (rule + paths only, never values)."""
60
+ where = ", ".join(self.paths)
61
+ return {
62
+ "required": f"{where} is required but missing/empty",
63
+ "same_across": f"fields disagree (must match): {where}",
64
+ "valid_date": f"{where} is not a valid HL7 date/time",
65
+ "dates_in_order": f"dates out of order (must be non-decreasing): {where}",
66
+ "matches": f"{where} does not match the required format",
67
+ }.get(self.rule, f"{self.rule}: {where}")
68
+
69
+ def __str__(self) -> str:
70
+ return self.message
71
+
72
+
73
+ class ConsistencyError(Exception):
74
+ """Raise from a Handler to route an inconsistent message to the error/dead-letter path. Its
75
+ string form is PHI-safe (built from each :attr:`Violation.message`), so it is safe to log."""
76
+
77
+ def __init__(self, violations: Iterable[Violation]) -> None:
78
+ self.violations: list[Violation] = list(violations)
79
+ super().__init__(
80
+ "; ".join(v.message for v in self.violations) or "consistency check failed"
81
+ )
82
+
83
+
84
+ def required(msg: Message, *paths: str) -> list[Violation]:
85
+ """A :class:`Violation` for each path that is absent or empty (``msg.field(path) is None``)."""
86
+ return [Violation("required", (p,)) for p in paths if msg.field(p) is None]
87
+
88
+
89
+ def same_across(msg: Message, *paths: str) -> list[Violation]:
90
+ """One :class:`Violation` if the values at ``paths`` are not all identical — covering both
91
+ differing values *and* present-vs-absent (``None`` is treated as a distinct value). Two or more
92
+ paths required; all-absent is considered consistent (use :func:`required` to assert presence)."""
93
+ if len(paths) < 2:
94
+ return []
95
+ values = {msg.field(p) for p in paths}
96
+ return [Violation("same_across", tuple(paths))] if len(values) > 1 else []
97
+
98
+
99
+ def valid_date(msg: Message, path: str) -> list[Violation]:
100
+ """A :class:`Violation` if the value at ``path`` is present but not a valid HL7 date/time. An
101
+ **absent** value is not flagged (assert presence with :func:`required` if it's mandatory)."""
102
+ value = msg.field(path)
103
+ if value is None:
104
+ return []
105
+ return [] if _parse_hl7_dt(value) is not None else [Violation("valid_date", (path,))]
106
+
107
+
108
+ def dates_in_order(msg: Message, earlier: str, later: str) -> list[Violation]:
109
+ """A :class:`Violation` if both dates are present and valid but ``earlier`` > ``later`` (e.g.
110
+ admit after discharge). Skips the check when either is absent or unparseable — those are the
111
+ concern of :func:`required` / :func:`valid_date`, kept separate so each violation is precise."""
112
+ a, b = msg.field(earlier), msg.field(later)
113
+ if a is None or b is None:
114
+ return []
115
+ da, db = _parse_hl7_dt(a), _parse_hl7_dt(b)
116
+ if da is None or db is None:
117
+ return []
118
+ return [Violation("dates_in_order", (earlier, later))] if da > db else []
119
+
120
+
121
+ def matches(msg: Message, path: str, pattern: str) -> list[Violation]:
122
+ """A :class:`Violation` if the value at ``path`` is present but does not **fully** match
123
+ ``pattern`` (an anchored :func:`re.fullmatch`). An absent value is not flagged."""
124
+ value = msg.field(path)
125
+ if value is None:
126
+ return []
127
+ return [] if re.fullmatch(pattern, value) is not None else [Violation("matches", (path,))]
128
+
129
+
130
+ def check(*groups: Sequence[Violation]) -> list[Violation]:
131
+ """Flatten the results of several checks into one list, for a single ``if violations:`` decision::
132
+
133
+ violations = check(
134
+ required(msg, "PID-3", "PID-5", "MSH-10"),
135
+ dates_in_order(msg, "PV1-44", "PV1-45"),
136
+ )
137
+ """
138
+ return [v for group in groups for v in group]
139
+
140
+
141
+ # HL7 TS/DTM is ``YYYY[MM[DD[HH[MM[SS[.S+]]]]]][+/-ZZZZ]`` — all digits, optional fractional second
142
+ # and timezone offset. We accept the common precisions and require a real calendar date; missing
143
+ # low-order parts default to their minimum so partial timestamps still compare for ordering.
144
+ _HL7_DT = re.compile(r"(?P<digits>\d{4,14})(?:\.\d+)?(?:[+-]\d{2,4})?$")
145
+
146
+
147
+ def _parse_hl7_dt(value: str) -> datetime | None:
148
+ """Parse an HL7 date/time to a comparable ``datetime``; ``None`` if malformed or not a real date.
149
+ Pure and timezone-naive: the optional ``+/-ZZZZ`` offset is ignored for ordering (a feed mixing
150
+ offsets is rare; document it if it matters)."""
151
+ m = _HL7_DT.fullmatch(value.strip())
152
+ if m is None:
153
+ return None
154
+ digits = m.group("digits")
155
+ if len(digits) % 2 != 0: # valid precisions are 4/6/8/10/12/14 — odd lengths are malformed
156
+ return None
157
+ try:
158
+ year = int(digits[0:4])
159
+ month = int(digits[4:6]) if len(digits) >= 6 else 1
160
+ day = int(digits[6:8]) if len(digits) >= 8 else 1
161
+ hour = int(digits[8:10]) if len(digits) >= 10 else 0
162
+ minute = int(digits[10:12]) if len(digits) >= 12 else 0
163
+ second = int(digits[12:14]) if len(digits) >= 14 else 0
164
+ return datetime(year, month, day, hour, minute, second) # noqa: DTZ001 (naive by design)
165
+ except ValueError:
166
+ return None
@@ -0,0 +1,228 @@
1
+ # SPDX-License-Identifier: AGPL-3.0-or-later
2
+ # Copyright (C) 2026 MessageFoundry Organization and contributors
3
+ """Group-scoped structural editing on top of the flat :class:`~messagefoundry.parsing.message.Message`.
4
+
5
+ Real HL7 feeds carry **repeating order/observation groups**: an ORU is a header (MSH/PID/…) followed
6
+ by one or more ``OBR`` runs, each owning the ``OBX`` (and ``NTE``…) segments that belong to *that*
7
+ order. The flat Message API addresses segments by id and occurrence (``OBX`` occurrence 2), but it
8
+ can't say "the OBX segments belonging to the **2nd** OBR" or "rebuild **this** order's body" — the
9
+ operations Corepoint expresses as per-OrderGroup ``ItemNew``/``ItemClear``/per-OBR rebuilds.
10
+
11
+ A :class:`SegmentGroup` is a **view** over a contiguous run of segments in the parent message: it
12
+ begins at a **boundary** segment (default ``OBR``; pass ``ORC`` etc.) and runs up to — but not
13
+ including — the next boundary (or end of message). Segments before the first boundary are the
14
+ message *header* and belong to no group. The view is computed from the current segment order at the
15
+ moment :meth:`Message.groups` is called; a mutating call recomputes the live span first, so the
16
+ position never goes stale even after an earlier group was edited.
17
+
18
+ Every mutation routes through the existing Message primitives (``add_segment(index=)``,
19
+ delete-by-position) and re-encodes — never raw string slicing — and reads the message's own
20
+ separators (MSH-1/MSH-2), never hardcoded ``|^~\\&``. The module is **pure** (no I/O, no engine
21
+ state), so the console may import it for client-side rendering just like the rest of ``parsing/``.
22
+ """
23
+
24
+ from __future__ import annotations
25
+
26
+ from typing import TYPE_CHECKING
27
+
28
+ if TYPE_CHECKING: # avoid a runtime import cycle — SegmentGroup only *refers back* to Message
29
+ from messagefoundry.parsing.message import Message
30
+
31
+ #: The default boundary segment. ORU-style result feeds open each order with an ``OBR``; an
32
+ #: order-control feed may instead group on ``ORC`` — hence the caller-overridable boundary.
33
+ DEFAULT_BOUNDARY = "OBR"
34
+
35
+
36
+ class SegmentGroup:
37
+ """A live view over one order/observation group — the boundary segment plus the segments that
38
+ follow it up to the next boundary.
39
+
40
+ Do not construct directly; obtain instances from :meth:`Message.groups`. A group is addressed by
41
+ its **ordinal** — the Nth boundary segment — and its row span is re-derived on every call, so a
42
+ *non-deleting* edit to a sibling group (append/clear that only shifts rows) never leaves this
43
+ view pointing at the wrong span.
44
+
45
+ **Ordinals re-index after a whole-group delete.** Because a group is positional (the Nth
46
+ boundary), deleting the group at ordinal *k* makes the former ordinal *k+1* become *k*; a view
47
+ held on the old top ordinal then raises :class:`LookupError` (its boundary no longer exists). A
48
+ :class:`SegmentGroup` is therefore a positional cursor, not a stable handle to a specific order:
49
+ after any structural :meth:`delete`, discard your views and re-fetch :meth:`Message.groups`.
50
+
51
+ Mutations operate on the parent :class:`~messagefoundry.parsing.message.Message` in place; call
52
+ :meth:`Message.encode` on the parent afterwards to serialize.
53
+ """
54
+
55
+ def __init__(self, message: Message, boundary: str, ordinal: int) -> None:
56
+ """``ordinal`` is the 1-based index of this group's boundary *among boundary segments* (the
57
+ Nth ``OBR``). Storing the ordinal rather than a raw row index is what lets the group survive
58
+ edits to earlier/later groups: the live span is re-derived from it on demand."""
59
+ self._m = message
60
+ self._boundary = boundary
61
+ self._ordinal = ordinal
62
+
63
+ # --- span resolution -----------------------------------------------------
64
+
65
+ def _span(self) -> tuple[int, int]:
66
+ """Current ``(start, end)`` row indices of this group, end-exclusive, into the parent's
67
+ segment list — re-derived every call so it reflects any intervening edits.
68
+
69
+ ``start`` is the boundary segment's row; ``end`` is the next boundary's row (or the segment
70
+ count). Raises :class:`LookupError` if the group no longer exists (e.g. it was deleted),
71
+ which is the honest signal that a stale view is being used."""
72
+ ids = self._m.segments()
73
+ seen = 0
74
+ start = -1
75
+ for i, seg_id in enumerate(ids):
76
+ if seg_id == self._boundary:
77
+ seen += 1
78
+ if seen == self._ordinal:
79
+ start = i
80
+ break
81
+ if start == -1:
82
+ raise LookupError(
83
+ f"group {self._ordinal} of boundary {self._boundary!r} no longer exists"
84
+ )
85
+ end = len(ids)
86
+ for j in range(start + 1, len(ids)):
87
+ if ids[j] == self._boundary:
88
+ end = j
89
+ break
90
+ return start, end
91
+
92
+ # --- read ----------------------------------------------------------------
93
+
94
+ @property
95
+ def boundary(self) -> str:
96
+ """The boundary segment id that opens this group (e.g. ``OBR``)."""
97
+ return self._boundary
98
+
99
+ @property
100
+ def ordinal(self) -> int:
101
+ """This group's 1-based position among the boundary segments (the Nth ``OBR``)."""
102
+ return self._ordinal
103
+
104
+ def segment_ids(self) -> list[str]:
105
+ """The ids of every segment in the group, boundary first — e.g. ``["OBR", "OBX", "OBX"]``.
106
+
107
+ Mirrors :meth:`Message.segments` but scoped to this group, so a caller can see a single
108
+ order's shape without scanning the whole message."""
109
+ start, end = self._span()
110
+ return self._m.segments()[start:end]
111
+
112
+ def __len__(self) -> int:
113
+ """Number of segments in the group (boundary included)."""
114
+ start, end = self._span()
115
+ return end - start
116
+
117
+ def count(self, segment_id: str) -> int:
118
+ """How many segments of ``segment_id`` are in this group — e.g. the OBX count for *this*
119
+ order, which the flat :meth:`Message.count_segments` (whole-message) can't give."""
120
+ return sum(1 for sid in self.segment_ids() if sid == segment_id)
121
+
122
+ def field(self, path: str, *, occurrence: int = 1, repetition: int | None = None) -> str | None:
123
+ """Read a field within this group, with ``occurrence`` scoped to the group.
124
+
125
+ ``path``'s segment must be a segment *of this group* (the boundary or one of its members);
126
+ ``occurrence`` (1-based) selects the Nth such segment **within the group**, so
127
+ ``group.field("OBX-5", occurrence=2)`` reads the 2nd OBX of *this* order, not the message.
128
+ Delegates to :meth:`Message.field` translated to the message-wide occurrence, so all the
129
+ separator/escaping/repetition behavior is identical. Returns None if absent."""
130
+ if occurrence < 1:
131
+ raise ValueError("occurrence is 1-based (>= 1)")
132
+ seg = self._segment_of_path(path)
133
+ global_occurrence = self._to_global_occurrence(seg, occurrence)
134
+ if global_occurrence is None:
135
+ return None
136
+ return self._m.field(path, occurrence=global_occurrence, repetition=repetition)
137
+
138
+ # --- mutate --------------------------------------------------------------
139
+
140
+ def append_segment(self, line: str) -> None:
141
+ """Add a raw segment ``line`` (``"OBX|3|NM|K^Potassium||4.1"``) at the **end of this group**.
142
+
143
+ Inserts just before the next group's boundary (or at end of message for the last group) so
144
+ the new segment belongs to *this* order rather than drifting into the following one — the
145
+ group-scoped analogue of :meth:`Message.add_segment`. Delegates to ``Message.add_segment`` so
146
+ the line is split on the message's own separators and re-parses into real components."""
147
+ _start, end = self._span()
148
+ # Message.add_segment uses 1-based positions where index 1 is *after* MSH (i.e. row index 1
149
+ # == position 1). The end row index is end-exclusive, so it is exactly the position at which
150
+ # an inserted segment lands as the group's new last member.
151
+ self._m.add_segment(line, index=end)
152
+
153
+ def clear(self) -> int:
154
+ """Remove the group's **non-boundary** segments (keep the boundary), returning the count
155
+ removed — Corepoint ``ItemClear``: empty an order's observations but keep the order header.
156
+
157
+ Deletes back-to-front by position so indices stay valid; uses the parent's positional delete
158
+ primitive, never string slicing."""
159
+ start, end = self._span()
160
+ # Everything after the boundary row, removed back-to-front so earlier positions don't shift.
161
+ removed = 0
162
+ for row in range(end - 1, start, -1):
163
+ self._m._delete_segment_at(row)
164
+ removed += 1
165
+ return removed
166
+
167
+ def delete(self) -> int:
168
+ """Remove the **entire** group, boundary included, returning the count removed — drop a whole
169
+ order. After this the view is stale; calling another method raises :class:`LookupError`."""
170
+ start, end = self._span()
171
+ removed = 0
172
+ for row in range(end - 1, start - 1, -1):
173
+ self._m._delete_segment_at(row)
174
+ removed += 1
175
+ return removed
176
+
177
+ def rebuild(self, lines: list[str]) -> None:
178
+ """Replace the group's **body** (non-boundary segments) with ``lines``, in order — the
179
+ per-OBR "rebuild this order's segments" operation as one atomic call.
180
+
181
+ The boundary segment is preserved; every other segment is cleared, then each raw line is
182
+ appended within the group. Equivalent to :meth:`clear` followed by an
183
+ :meth:`append_segment` per line, but expressed as one intent so a reviewer sees a rebuild,
184
+ not a clear-that-happens-to-be-followed-by-adds. Each line goes through
185
+ :meth:`Message.add_segment` (own-separator split, re-parse), never string slicing."""
186
+ self.clear()
187
+ for line in lines:
188
+ self.append_segment(line)
189
+
190
+ # --- internals -----------------------------------------------------------
191
+
192
+ def _segment_of_path(self, path: str) -> str:
193
+ """The segment id of an HL7 field ``path`` (e.g. ``"OBX-5"`` -> ``"OBX"``), validated."""
194
+ from messagefoundry.parsing.peek import parse_path
195
+
196
+ seg, _fld, _comp, _sub = parse_path(path)
197
+ return seg
198
+
199
+ def _to_global_occurrence(self, segment_id: str, group_occurrence: int) -> int | None:
200
+ """Translate a 1-based occurrence *within this group* into the message-wide occurrence the
201
+ flat :meth:`Message.field` expects, or None if the group has fewer than ``group_occurrence``
202
+ such segments.
203
+
204
+ Counts how many ``segment_id`` segments precede this group's span, then adds the in-group
205
+ offset — so a group-scoped read maps cleanly onto the existing flat primitive instead of
206
+ duplicating field-extraction logic."""
207
+ start, end = self._span()
208
+ ids = self._m.segments()
209
+ before = sum(1 for sid in ids[:start] if sid == segment_id)
210
+ in_group = sum(1 for sid in ids[start:end] if sid == segment_id)
211
+ if group_occurrence > in_group:
212
+ return None
213
+ return before + group_occurrence
214
+
215
+
216
+ def groups_of(message: Message, boundary: str = DEFAULT_BOUNDARY) -> list[SegmentGroup]:
217
+ """Every :class:`SegmentGroup` in ``message`` for ``boundary``, in document order.
218
+
219
+ Backs :meth:`Message.groups`. One group per boundary segment; the header (segments before the
220
+ first boundary) is intentionally **not** a group. Returns ``[]`` when no boundary segment is
221
+ present. Kept as a free function (not a Message method body) so the grouping logic lives beside
222
+ the view it produces; Message just forwards to it."""
223
+ if boundary == "MSH":
224
+ # MSH is the singleton header; treating it as a boundary would make the whole message one
225
+ # "group" and contradicts the header-belongs-to-no-group rule.
226
+ raise ValueError("MSH cannot be a group boundary")
227
+ count = message.count_segments(boundary)
228
+ return [SegmentGroup(message, boundary, ordinal) for ordinal in range(1, count + 1)]