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,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)]
|