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,140 @@
|
|
|
1
|
+
# SPDX-License-Identifier: AGPL-3.0-or-later
|
|
2
|
+
# Copyright (C) 2026 MessageFoundry Organization and contributors
|
|
3
|
+
"""Fixed-offset X12 ISA delimiter discovery — the X12 analog of reading the HL7 MSH separators.
|
|
4
|
+
|
|
5
|
+
An ASC X12 interchange opens with a **fixed-length 106-character ISA segment** whose element layout is
|
|
6
|
+
rigid, so the four delimiters are read by *absolute offset* rather than guessed:
|
|
7
|
+
|
|
8
|
+
* **element separator** — the character at offset ``+3`` (immediately after the literal ``ISA``);
|
|
9
|
+
* **component separator** — ISA16, at offset ``+104``;
|
|
10
|
+
* **segment terminator** — offset ``+105`` (or the two-character ``CR``+``LF`` at ``+105..+106`` when a
|
|
11
|
+
partner terminates segments that way);
|
|
12
|
+
* **repetition separator** — ISA11, at offset ``+82`` — **but only from version 00501**; in 00401 and
|
|
13
|
+
earlier ISA11 carries the literal ``U`` (Interchange Control Standards Identifier) and there is no
|
|
14
|
+
repetition separator.
|
|
15
|
+
|
|
16
|
+
Trading partners legitimately vary all four delimiters, so hardcoding ``*~:^`` is wrong. We discover
|
|
17
|
+
them and **fail loud** (:class:`~messagefoundry.parsing.x12.errors.X12PeekError`) on a malformed /
|
|
18
|
+
non-X12 header rather than guess — inbound EDI is untrusted, so bad input is routed to the
|
|
19
|
+
error/dead-letter path. This module is **pure** (no I/O, no engine imports): it works on ``str`` only.
|
|
20
|
+
"""
|
|
21
|
+
|
|
22
|
+
from __future__ import annotations
|
|
23
|
+
|
|
24
|
+
from dataclasses import dataclass
|
|
25
|
+
|
|
26
|
+
from messagefoundry.parsing.x12.errors import X12PeekError
|
|
27
|
+
|
|
28
|
+
__all__ = [
|
|
29
|
+
"Delimiters",
|
|
30
|
+
"discover_delimiters",
|
|
31
|
+
"find_isa_start",
|
|
32
|
+
"ISA_SEGMENT_LEN",
|
|
33
|
+
"ELEMENT_SEP_OFFSETS",
|
|
34
|
+
"DEFAULT_MAX_INTERCHANGE_BYTES",
|
|
35
|
+
]
|
|
36
|
+
|
|
37
|
+
#: An ISA segment is ``ISA`` + 16 fixed-width elements + the segment terminator at offset 105.
|
|
38
|
+
ISA_SEGMENT_LEN = 106
|
|
39
|
+
|
|
40
|
+
#: A pathological interchange would otherwise be buffered/parsed whole; cap it (``None`` disables).
|
|
41
|
+
#: Matches the HL7/MLLP/file ingress caps (16 MiB).
|
|
42
|
+
DEFAULT_MAX_INTERCHANGE_BYTES = 16 * 1024 * 1024
|
|
43
|
+
|
|
44
|
+
# ISA01..ISA16 fixed element widths (the X12 standard), used to *derive* the element-separator offsets
|
|
45
|
+
# below instead of hand-typing them — so the rigid layout is documented once, in code.
|
|
46
|
+
_ISA_ELEMENT_WIDTHS = (2, 10, 2, 10, 2, 15, 2, 15, 6, 4, 1, 5, 9, 1, 1, 1)
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def _element_separator_offsets() -> tuple[int, ...]:
|
|
50
|
+
"""The absolute offsets that must all hold the element separator in a well-formed ISA, derived
|
|
51
|
+
from the fixed element widths: ``(3, 6, 17, 20, 31, 34, 50, 53, 69, 76, 81, 83, 89, 99, 101,
|
|
52
|
+
103)``. Offset 3 (the *defining* separator) is first; the rest are the sanity-gate positions."""
|
|
53
|
+
offsets: list[int] = []
|
|
54
|
+
pos = 3 # the first element separator sits immediately after the literal "ISA"
|
|
55
|
+
for width in _ISA_ELEMENT_WIDTHS:
|
|
56
|
+
offsets.append(pos)
|
|
57
|
+
pos += 1 + width
|
|
58
|
+
return tuple(offsets)
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
ELEMENT_SEP_OFFSETS = _element_separator_offsets()
|
|
62
|
+
_COMPONENT_SEP_OFFSET = 104 # ISA16
|
|
63
|
+
_SEGMENT_TERM_OFFSET = 105
|
|
64
|
+
_REPETITION_SEP_OFFSET = 82 # ISA11
|
|
65
|
+
_VERSION_START, _VERSION_END = 84, 89 # ISA12 (5 chars), e.g. "00501"
|
|
66
|
+
_REPETITION_MIN_VERSION = "00501" # ISA11 is a repetition separator from 005010 onward
|
|
67
|
+
|
|
68
|
+
# Leading characters tolerated before the ISA: whitespace + the UTF-8 BOM (U+FEFF) — pretty-printed or
|
|
69
|
+
# BOM-prefixed feeds.
|
|
70
|
+
_LEADING_NOISE = " \t\r\n\x0b\x0c\ufeff"
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
@dataclass(frozen=True)
|
|
74
|
+
class Delimiters:
|
|
75
|
+
r"""The four X12 delimiters discovered from an ISA header.
|
|
76
|
+
|
|
77
|
+
``segment`` is the segment terminator — normally one character, but ``"\r\n"`` when a partner
|
|
78
|
+
terminates segments with ``CR``+``LF``. ``repetition`` is ``None`` for ISA12 < 00501 (ISA11 is the
|
|
79
|
+
literal ``"U"``, not a delimiter).
|
|
80
|
+
"""
|
|
81
|
+
|
|
82
|
+
element: str
|
|
83
|
+
component: str
|
|
84
|
+
segment: str
|
|
85
|
+
repetition: str | None
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def find_isa_start(raw: str, start: int = 0) -> int:
|
|
89
|
+
"""Index of the ``ISA`` that opens the next interchange at/after ``start`` (skipping leading
|
|
90
|
+
whitespace/BOM). Raises :class:`X12PeekError` if no ``ISA`` is found there."""
|
|
91
|
+
i = start
|
|
92
|
+
n = len(raw)
|
|
93
|
+
while i < n and raw[i] in _LEADING_NOISE:
|
|
94
|
+
i += 1
|
|
95
|
+
if raw[i : i + 3] != "ISA":
|
|
96
|
+
raise X12PeekError("X12 interchange does not begin with an ISA segment")
|
|
97
|
+
return i
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def discover_delimiters(raw: str, isa_start: int | None = None) -> Delimiters:
|
|
101
|
+
"""Read the four delimiters by absolute offset from the ISA at ``isa_start`` (auto-located when
|
|
102
|
+
``None``). Raises :class:`X12PeekError` on a truncated/malformed ISA or non-distinct delimiters."""
|
|
103
|
+
start = find_isa_start(raw) if isa_start is None else isa_start
|
|
104
|
+
if len(raw) < start + ISA_SEGMENT_LEN:
|
|
105
|
+
raise X12PeekError(
|
|
106
|
+
f"X12 ISA header truncated: need {ISA_SEGMENT_LEN} characters, got {len(raw) - start}"
|
|
107
|
+
)
|
|
108
|
+
|
|
109
|
+
element = raw[start + 3]
|
|
110
|
+
component = raw[start + _COMPONENT_SEP_OFFSET]
|
|
111
|
+
terminator = raw[start + _SEGMENT_TERM_OFFSET]
|
|
112
|
+
# The terminator is one character, or CR+LF when raw[+105]=CR is immediately followed by LF.
|
|
113
|
+
if terminator == "\r" and raw[start + 106 : start + 107] == "\n":
|
|
114
|
+
segment = "\r\n"
|
|
115
|
+
else:
|
|
116
|
+
segment = terminator
|
|
117
|
+
|
|
118
|
+
# ISA11 is a repetition separator only from version 00501; earlier it is the literal "U".
|
|
119
|
+
version = raw[start + _VERSION_START : start + _VERSION_END]
|
|
120
|
+
repetition: str | None = (
|
|
121
|
+
raw[start + _REPETITION_SEP_OFFSET] if version >= _REPETITION_MIN_VERSION else None
|
|
122
|
+
)
|
|
123
|
+
|
|
124
|
+
# Sanity gate: every fixed element-separator position (except the defining one at +3) must hold the
|
|
125
|
+
# element separator — catches a mis-sized / non-X12 header before we trust the offsets.
|
|
126
|
+
for offset in ELEMENT_SEP_OFFSETS[1:]:
|
|
127
|
+
if raw[start + offset] != element:
|
|
128
|
+
raise X12PeekError(
|
|
129
|
+
f"X12 ISA header malformed: expected element separator {element!r} at offset {offset}"
|
|
130
|
+
)
|
|
131
|
+
|
|
132
|
+
# The delimiters must be mutually distinct or tokenizing is ambiguous. Compare the single-character
|
|
133
|
+
# terminator (raw[+105]) so a CR+LF terminator is checked by its CR.
|
|
134
|
+
distinct = [element, component, terminator]
|
|
135
|
+
if repetition is not None:
|
|
136
|
+
distinct.append(repetition)
|
|
137
|
+
if len(set(distinct)) != len(distinct):
|
|
138
|
+
raise X12PeekError(f"X12 delimiters are not mutually distinct: {distinct!r}")
|
|
139
|
+
|
|
140
|
+
return Delimiters(element=element, component=component, segment=segment, repetition=repetition)
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
# SPDX-License-Identifier: AGPL-3.0-or-later
|
|
2
|
+
# Copyright (C) 2026 MessageFoundry Organization and contributors
|
|
3
|
+
"""Exceptions for the X12 codec.
|
|
4
|
+
|
|
5
|
+
Kept in their own module (mirroring how :mod:`messagefoundry.parsing.peek` owns ``HL7PeekError``) so
|
|
6
|
+
the delimiter / peek / interchange / message modules can raise them without importing each other. All
|
|
7
|
+
derive from :class:`ValueError`, so a Router/Handler that already routes ``ValueError`` to the
|
|
8
|
+
error/dead-letter path catches them without special-casing X12.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
__all__ = ["X12Error", "X12PeekError", "X12FrameError"]
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class X12Error(ValueError):
|
|
17
|
+
"""Base class for every X12 codec error."""
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class X12PeekError(X12Error):
|
|
21
|
+
"""The bytes are not a parseable X12 interchange (no ISA, a truncated/malformed ISA header,
|
|
22
|
+
non-mutually-distinct delimiters) or an X12 field path is malformed. The X12 analog of
|
|
23
|
+
:class:`~messagefoundry.parsing.peek.HL7PeekError` — a Router routes the message to the
|
|
24
|
+
error/dead-letter path rather than guessing."""
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class X12FrameError(X12Error):
|
|
28
|
+
"""A streaming interchange exceeded its byte cap before the closing ``IEA`` segment — signals the
|
|
29
|
+
transport to drop the connection rather than buffer without bound (the X12 analog of
|
|
30
|
+
:class:`~messagefoundry.transports.framing.FrameError`)."""
|
|
@@ -0,0 +1,232 @@
|
|
|
1
|
+
# SPDX-License-Identifier: AGPL-3.0-or-later
|
|
2
|
+
# Copyright (C) 2026 MessageFoundry Organization and contributors
|
|
3
|
+
"""Interchange framing for X12 — splitting one stream into complete ``ISA…IEA`` interchanges.
|
|
4
|
+
|
|
5
|
+
X12-over-TCP has **no transport sentinel**: the *frame is the interchange*, bounded by the opening
|
|
6
|
+
``ISA`` and the closing ``IEA`` segment, and the segment terminator is **discovered from each ISA
|
|
7
|
+
header** (it may even be a two-byte ``CR``+``LF``). The shared single-byte
|
|
8
|
+
:class:`~messagefoundry.transports.framing.FrameDecoder` cannot express any of that, so X12 needs its
|
|
9
|
+
own assembler. It lives **here**, in the pure parsing layer, so it is unit-testable without a socket
|
|
10
|
+
and reusable by File/console paths; :mod:`messagefoundry.transports.x12` is a thin socket wrapper.
|
|
11
|
+
|
|
12
|
+
Two surfaces:
|
|
13
|
+
|
|
14
|
+
* :func:`split` — split a complete ``str`` buffer into interchange substrings (for a Router/console
|
|
15
|
+
handed a ``RawMessage`` that carries more than one interchange).
|
|
16
|
+
* :class:`X12FrameReader` — a stateful **byte** reassembler (feed it socket chunks, it yields complete
|
|
17
|
+
interchange bytes) for the transport.
|
|
18
|
+
|
|
19
|
+
The ``IEA`` trailer is recognised only at a **segment boundary** (the three bytes immediately after a
|
|
20
|
+
segment terminator, tolerating cosmetic whitespace), so ``IEA`` appearing inside element data never
|
|
21
|
+
truncates an interchange early. Both surfaces preserve the interchange **verbatim** (cosmetic newlines
|
|
22
|
+
included) — re-encoding/normalisation is :class:`~messagefoundry.parsing.x12.message.X12Message`'s job.
|
|
23
|
+
"""
|
|
24
|
+
|
|
25
|
+
from __future__ import annotations
|
|
26
|
+
|
|
27
|
+
from collections.abc import Iterator
|
|
28
|
+
|
|
29
|
+
from messagefoundry.parsing.x12.delimiters import (
|
|
30
|
+
ISA_SEGMENT_LEN,
|
|
31
|
+
discover_delimiters,
|
|
32
|
+
find_isa_start,
|
|
33
|
+
)
|
|
34
|
+
from messagefoundry.parsing.x12.errors import X12FrameError, X12PeekError
|
|
35
|
+
|
|
36
|
+
__all__ = ["split", "X12FrameReader", "check_integrity"]
|
|
37
|
+
|
|
38
|
+
# Offset of the segment terminator within the ISA (mirrors delimiters._SEGMENT_TERM_OFFSET, but kept
|
|
39
|
+
# local so the byte reader needs only this module + the public ISA length).
|
|
40
|
+
_SEGMENT_TERM_OFFSET = 105
|
|
41
|
+
_WHITESPACE_BYTES = b" \t\r\n\x0b\x0c"
|
|
42
|
+
_WHITESPACE_STR = " \t\r\n\x0b\x0c"
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def split(raw: str) -> list[str]:
|
|
46
|
+
"""Split a complete buffer into its ``ISA…IEA`` interchange substrings (verbatim).
|
|
47
|
+
|
|
48
|
+
Inter-interchange whitespace/BOM is skipped. A trailing fragment that opens with ``ISA`` but never
|
|
49
|
+
closes with ``IEA`` is returned as the final (malformed) element so nothing is silently dropped —
|
|
50
|
+
the caller can dead-letter it. Raises :class:`X12PeekError` if a fragment's ISA header is itself
|
|
51
|
+
unparseable (truncated/malformed)."""
|
|
52
|
+
out: list[str] = []
|
|
53
|
+
pos = 0
|
|
54
|
+
n = len(raw)
|
|
55
|
+
while pos < n:
|
|
56
|
+
try:
|
|
57
|
+
isa = find_isa_start(raw, pos)
|
|
58
|
+
except X12PeekError:
|
|
59
|
+
break # no further interchange — only trailing noise remains
|
|
60
|
+
terminator = discover_delimiters(raw, isa).segment
|
|
61
|
+
end = _find_iea_end_str(raw, isa, terminator)
|
|
62
|
+
if end is None:
|
|
63
|
+
out.append(raw[isa:]) # unterminated final interchange — surface it, don't lose it
|
|
64
|
+
break
|
|
65
|
+
out.append(raw[isa:end])
|
|
66
|
+
pos = end
|
|
67
|
+
return out
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def _find_iea_end_str(raw: str, isa: int, terminator: str) -> int | None:
|
|
71
|
+
"""Index just past the terminator that closes the ``IEA`` segment of the interchange at ``isa``,
|
|
72
|
+
or None if it is not complete. ``IEA`` is matched only at a segment boundary."""
|
|
73
|
+
seg_start = isa
|
|
74
|
+
while True:
|
|
75
|
+
idx = raw.find(terminator, seg_start)
|
|
76
|
+
if idx == -1:
|
|
77
|
+
return None
|
|
78
|
+
if raw[seg_start:idx].lstrip(_WHITESPACE_STR)[:3] == "IEA":
|
|
79
|
+
return idx + len(terminator)
|
|
80
|
+
seg_start = idx + len(terminator)
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
class X12FrameReader:
|
|
84
|
+
"""Stateful byte reassembler that yields complete X12 interchanges from a raw TCP stream.
|
|
85
|
+
|
|
86
|
+
Feed it whatever bytes arrive; it yields each complete ``ISA…IEA<terminator>`` interchange (bytes,
|
|
87
|
+
verbatim) as it completes. Inter-interchange noise is discarded. ``max_interchange_bytes`` caps a
|
|
88
|
+
single open interchange (``None`` disables) — exceeding it raises :class:`X12FrameError` so the
|
|
89
|
+
transport drops the connection rather than buffer without bound.
|
|
90
|
+
"""
|
|
91
|
+
|
|
92
|
+
def __init__(self, max_interchange_bytes: int | None = None) -> None:
|
|
93
|
+
self._buf = bytearray()
|
|
94
|
+
self.max_interchange_bytes = max_interchange_bytes
|
|
95
|
+
|
|
96
|
+
def feed(self, data: bytes) -> Iterator[bytes]:
|
|
97
|
+
self._buf.extend(data)
|
|
98
|
+
while True:
|
|
99
|
+
frame = self._take_one()
|
|
100
|
+
if frame is None:
|
|
101
|
+
return
|
|
102
|
+
yield frame
|
|
103
|
+
|
|
104
|
+
def _take_one(self) -> bytes | None:
|
|
105
|
+
buf = self._buf
|
|
106
|
+
isa = buf.find(b"ISA")
|
|
107
|
+
if isa == -1:
|
|
108
|
+
# No ISA yet: discard noise but keep a 2-byte tail in case "IS" straddles two reads.
|
|
109
|
+
if len(buf) > 2:
|
|
110
|
+
del buf[: len(buf) - 2]
|
|
111
|
+
self._check_cap()
|
|
112
|
+
return None
|
|
113
|
+
if isa > 0:
|
|
114
|
+
del buf[:isa] # drop inter-interchange noise before the ISA
|
|
115
|
+
if len(buf) < ISA_SEGMENT_LEN:
|
|
116
|
+
self._check_cap()
|
|
117
|
+
return None # need the full ISA to discover the terminator
|
|
118
|
+
if (
|
|
119
|
+
buf[_SEGMENT_TERM_OFFSET : _SEGMENT_TERM_OFFSET + 1] == b"\r"
|
|
120
|
+
and len(buf) < ISA_SEGMENT_LEN + 1
|
|
121
|
+
):
|
|
122
|
+
# +105 is CR, so the terminator may be a 2-byte CR+LF; wait for +106 before reading it so a
|
|
123
|
+
# terminator split exactly across two socket reads is still read whole (not a bare CR).
|
|
124
|
+
self._check_cap()
|
|
125
|
+
return None
|
|
126
|
+
terminator = self._terminator(buf)
|
|
127
|
+
# Cheap guard: don't walk segments until an IEA could plausibly be present.
|
|
128
|
+
if buf.find(b"IEA", 3) == -1:
|
|
129
|
+
self._check_cap()
|
|
130
|
+
return None
|
|
131
|
+
seg_start = 0
|
|
132
|
+
while True:
|
|
133
|
+
idx = buf.find(terminator, seg_start)
|
|
134
|
+
if idx == -1:
|
|
135
|
+
self._check_cap()
|
|
136
|
+
return None # IEA not complete yet
|
|
137
|
+
if bytes(buf[seg_start:idx]).lstrip(_WHITESPACE_BYTES)[:3] == b"IEA":
|
|
138
|
+
end = idx + len(terminator)
|
|
139
|
+
if self.max_interchange_bytes is not None and end > self.max_interchange_bytes:
|
|
140
|
+
# A complete interchange that still exceeds the cap is rejected (a too-large
|
|
141
|
+
# message), not relayed — mirrors the eager cap on the shared FrameDecoder.
|
|
142
|
+
self._buf.clear()
|
|
143
|
+
raise X12FrameError(
|
|
144
|
+
f"X12 interchange ({end} bytes) exceeds the "
|
|
145
|
+
f"{self.max_interchange_bytes}-byte cap"
|
|
146
|
+
)
|
|
147
|
+
frame = bytes(buf[:end])
|
|
148
|
+
del buf[:end]
|
|
149
|
+
return frame
|
|
150
|
+
seg_start = idx + len(terminator)
|
|
151
|
+
|
|
152
|
+
@staticmethod
|
|
153
|
+
def _terminator(buf: bytearray) -> bytes:
|
|
154
|
+
"""The segment terminator from the ISA at the front of ``buf`` — one byte, or CR+LF."""
|
|
155
|
+
term = bytes(buf[_SEGMENT_TERM_OFFSET : _SEGMENT_TERM_OFFSET + 1])
|
|
156
|
+
if term == b"\r" and buf[106:107] == b"\n":
|
|
157
|
+
return b"\r\n"
|
|
158
|
+
return term
|
|
159
|
+
|
|
160
|
+
def _check_cap(self) -> None:
|
|
161
|
+
if self.max_interchange_bytes is not None and len(self._buf) > self.max_interchange_bytes:
|
|
162
|
+
self._buf.clear()
|
|
163
|
+
raise X12FrameError(
|
|
164
|
+
f"X12 interchange exceeded {self.max_interchange_bytes} bytes before the IEA segment"
|
|
165
|
+
)
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
def check_integrity(raw: str) -> list[str]:
|
|
169
|
+
"""Structural integrity tie-out for one interchange (a pure helper, opt-in).
|
|
170
|
+
|
|
171
|
+
Returns a list of human-readable problems (empty when the interchange ties out): the control-number
|
|
172
|
+
pairs ISA13==IEA02, GS06==GE02, ST02==SE02, and the counts GE01==#ST, IEA01==#GS. This is *not*
|
|
173
|
+
implementation-guide validation (deferred) — just the envelope self-consistency a receiver can cheaply
|
|
174
|
+
confirm. Raises :class:`X12PeekError` only if the interchange header itself is unparseable."""
|
|
175
|
+
isa = find_isa_start(raw)
|
|
176
|
+
delims = discover_delimiters(raw, isa)
|
|
177
|
+
element, terminator = delims.element, delims.segment
|
|
178
|
+
segments: list[list[str]] = []
|
|
179
|
+
for chunk in raw[isa:].split(terminator):
|
|
180
|
+
stripped = chunk.lstrip(_WHITESPACE_STR)
|
|
181
|
+
if not stripped:
|
|
182
|
+
continue
|
|
183
|
+
segments.append(stripped.split(element))
|
|
184
|
+
if segments[-1][0] == "IEA":
|
|
185
|
+
break
|
|
186
|
+
|
|
187
|
+
problems: list[str] = []
|
|
188
|
+
|
|
189
|
+
def get(fields: list[str], i: int) -> str:
|
|
190
|
+
return fields[i].strip() if i < len(fields) else ""
|
|
191
|
+
|
|
192
|
+
isa_seg = segments[0] if segments and segments[0][0] == "ISA" else None
|
|
193
|
+
iea_seg = next((s for s in segments if s[0] == "IEA"), None)
|
|
194
|
+
if isa_seg is not None and iea_seg is not None:
|
|
195
|
+
# ISA13 is read by offset (fixed-width); IEA02 from the tokenized IEA segment.
|
|
196
|
+
isa13 = raw[isa + 90 : isa + 99].strip()
|
|
197
|
+
if isa13 != get(iea_seg, 2):
|
|
198
|
+
problems.append(f"ISA13 {isa13!r} != IEA02 {get(iea_seg, 2)!r}")
|
|
199
|
+
gs_count = sum(1 for s in segments if s[0] == "GS")
|
|
200
|
+
if get(iea_seg, 1) != str(gs_count):
|
|
201
|
+
problems.append(f"IEA01 {get(iea_seg, 1)!r} != actual GS count {gs_count}")
|
|
202
|
+
|
|
203
|
+
# Per functional group: GS06==GE02 and GE01==#ST in the group.
|
|
204
|
+
current_gs: list[str] | None = None
|
|
205
|
+
st_in_group = 0
|
|
206
|
+
# Per transaction set: ST02==SE02 and SE01==#segments in the set (inclusive of ST and SE).
|
|
207
|
+
current_st: list[str] | None = None
|
|
208
|
+
seg_in_set = 0
|
|
209
|
+
for fields in segments:
|
|
210
|
+
tag = fields[0]
|
|
211
|
+
if current_st is not None:
|
|
212
|
+
seg_in_set += 1
|
|
213
|
+
if tag == "GS":
|
|
214
|
+
current_gs, st_in_group = fields, 0
|
|
215
|
+
elif tag == "ST":
|
|
216
|
+
current_st, seg_in_set = fields, 1 # the ST itself is segment 1 of the set
|
|
217
|
+
st_in_group += 1
|
|
218
|
+
elif tag == "SE":
|
|
219
|
+
if current_st is not None:
|
|
220
|
+
if get(fields, 2) != get(current_st, 2):
|
|
221
|
+
problems.append(f"SE02 {get(fields, 2)!r} != ST02 {get(current_st, 2)!r}")
|
|
222
|
+
if get(fields, 1) != str(seg_in_set):
|
|
223
|
+
problems.append(f"SE01 {get(fields, 1)!r} != actual segment count {seg_in_set}")
|
|
224
|
+
current_st = None
|
|
225
|
+
elif tag == "GE":
|
|
226
|
+
if current_gs is not None:
|
|
227
|
+
if get(fields, 2) != get(current_gs, 6):
|
|
228
|
+
problems.append(f"GE02 {get(fields, 2)!r} != GS06 {get(current_gs, 6)!r}")
|
|
229
|
+
if get(fields, 1) != str(st_in_group):
|
|
230
|
+
problems.append(f"GE01 {get(fields, 1)!r} != actual ST count {st_in_group}")
|
|
231
|
+
current_gs = None
|
|
232
|
+
return problems
|
|
@@ -0,0 +1,200 @@
|
|
|
1
|
+
# SPDX-License-Identifier: AGPL-3.0-or-later
|
|
2
|
+
# Copyright (C) 2026 MessageFoundry Organization and contributors
|
|
3
|
+
"""A mutable X12 message — read and set elements by path, then re-encode (the HL7
|
|
4
|
+
:class:`~messagefoundry.parsing.message.Message` analog for X12 EDI).
|
|
5
|
+
|
|
6
|
+
Field paths use ``SEG-EE[.CC]`` syntax: a 2–3 char segment id, the 1-based element index (element 0
|
|
7
|
+
is the segment tag, so ``NM1-03`` is the 3rd element of an ``NM1``; ``ISA-06`` the interchange sender
|
|
8
|
+
id), and an optional 1-based component index. Reads/writes address the **first** occurrence of a
|
|
9
|
+
segment unless ``occurrence=`` is given. All structure is rebuilt with the interchange's **own**
|
|
10
|
+
discovered delimiters (never hardcoded), and a write rejects values carrying a delimiter so it can't
|
|
11
|
+
inject new structure. Re-encoding uses the discovered delimiters only, so it is **not** guaranteed
|
|
12
|
+
byte-identical when the source carried cosmetic whitespace between segments (the store keeps the raw
|
|
13
|
+
verbatim — this model is for transforms). Pure: no I/O, no engine imports.
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
from __future__ import annotations
|
|
17
|
+
|
|
18
|
+
import re
|
|
19
|
+
|
|
20
|
+
from messagefoundry.parsing.x12.delimiters import (
|
|
21
|
+
Delimiters,
|
|
22
|
+
discover_delimiters,
|
|
23
|
+
find_isa_start,
|
|
24
|
+
)
|
|
25
|
+
from messagefoundry.parsing.x12.errors import X12PeekError
|
|
26
|
+
|
|
27
|
+
__all__ = ["X12Message"]
|
|
28
|
+
|
|
29
|
+
# SEG-EE[.CC] — segment id (2–3 chars, alpha then alphanumerics), 1-based element, optional component.
|
|
30
|
+
_X12_PATH = re.compile(r"^(?P<seg>[A-Z][A-Z0-9]{1,2})-(?P<elem>\d+)(?:\.(?P<comp>\d+))?$")
|
|
31
|
+
_WHITESPACE = " \t\r\n\x0b\x0c"
|
|
32
|
+
# Segment ids whose addition/removal would corrupt the interchange envelope.
|
|
33
|
+
_ENVELOPE_SEGMENTS = {"ISA", "IEA"}
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def _parse_path(path: str) -> tuple[str, int, int | None]:
|
|
37
|
+
m = _X12_PATH.match(path)
|
|
38
|
+
if not m:
|
|
39
|
+
raise X12PeekError(f"invalid X12 field path: {path!r}")
|
|
40
|
+
elem = int(m["elem"])
|
|
41
|
+
if elem < 1:
|
|
42
|
+
raise X12PeekError(f"X12 element index is 1-based (>= 1): {path!r}")
|
|
43
|
+
comp = int(m["comp"]) if m["comp"] else None
|
|
44
|
+
if comp is not None and comp < 1:
|
|
45
|
+
raise X12PeekError(f"X12 component index is 1-based (>= 1): {path!r}")
|
|
46
|
+
return m["seg"], elem, comp
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
class X12Message:
|
|
50
|
+
"""A parsed X12 interchange you can read (``msg["NM1-03"]``), mutate (``msg["BHT-06"] = "RP"``),
|
|
51
|
+
and re-encode (``msg.encode()``)."""
|
|
52
|
+
|
|
53
|
+
def __init__(self, segments: list[list[str]], delimiters: Delimiters) -> None:
|
|
54
|
+
self._segments = segments
|
|
55
|
+
self._delims = delimiters
|
|
56
|
+
|
|
57
|
+
@classmethod
|
|
58
|
+
def parse(cls, raw: str | bytes) -> X12Message:
|
|
59
|
+
"""Parse the interchange starting ``raw`` (after leading whitespace/BOM) into a mutable model.
|
|
60
|
+
|
|
61
|
+
Raises :class:`X12PeekError` if the ISA header is not parseable."""
|
|
62
|
+
if isinstance(raw, (bytes, bytearray)):
|
|
63
|
+
raw = bytes(raw).decode("utf-8", "replace")
|
|
64
|
+
isa = find_isa_start(raw)
|
|
65
|
+
delims = discover_delimiters(raw, isa)
|
|
66
|
+
segments: list[list[str]] = []
|
|
67
|
+
for chunk in raw[isa:].split(delims.segment):
|
|
68
|
+
stripped = chunk.lstrip(_WHITESPACE)
|
|
69
|
+
if not stripped:
|
|
70
|
+
continue
|
|
71
|
+
segments.append(stripped.split(delims.element))
|
|
72
|
+
if segments[-1][0] == "IEA":
|
|
73
|
+
break
|
|
74
|
+
return cls(segments, delims)
|
|
75
|
+
|
|
76
|
+
@property
|
|
77
|
+
def delimiters(self) -> Delimiters:
|
|
78
|
+
return self._delims
|
|
79
|
+
|
|
80
|
+
# --- read ----------------------------------------------------------------
|
|
81
|
+
|
|
82
|
+
def get(self, path: str, *, occurrence: int = 1) -> str | None:
|
|
83
|
+
"""Value at ``path`` (``"ISA-06"``, ``"NM1-03"``, ``"NM1-03.1"``), or None if absent/empty.
|
|
84
|
+
``occurrence`` (1-based) selects which segment of that id to read."""
|
|
85
|
+
seg_id, elem, comp = _parse_path(path)
|
|
86
|
+
seg = self._nth_segment(seg_id, occurrence)
|
|
87
|
+
if seg is None or elem >= len(seg):
|
|
88
|
+
return None
|
|
89
|
+
value = seg[elem]
|
|
90
|
+
if comp is None:
|
|
91
|
+
return value or None
|
|
92
|
+
comps = value.split(self._delims.component)
|
|
93
|
+
return (comps[comp - 1] or None) if comp <= len(comps) else None
|
|
94
|
+
|
|
95
|
+
def __getitem__(self, path: str) -> str | None:
|
|
96
|
+
return self.get(path)
|
|
97
|
+
|
|
98
|
+
def segment_ids(self) -> list[str]:
|
|
99
|
+
"""Ordered segment ids, e.g. ``["ISA", "GS", "ST", "BHT", …, "IEA"]``."""
|
|
100
|
+
return [seg[0] for seg in self._segments]
|
|
101
|
+
|
|
102
|
+
def count_segments(self, segment_id: str) -> int:
|
|
103
|
+
"""How many segments of ``segment_id`` the interchange has (0 if none)."""
|
|
104
|
+
return sum(1 for seg in self._segments if seg[0] == segment_id)
|
|
105
|
+
|
|
106
|
+
# --- mutate --------------------------------------------------------------
|
|
107
|
+
|
|
108
|
+
def set(self, path: str, value: str, *, occurrence: int = 1) -> None:
|
|
109
|
+
"""Write ``value`` at ``path``, extending the element/components as needed.
|
|
110
|
+
|
|
111
|
+
``value`` may not contain a delimiter (element/component/repetition separator or a segment
|
|
112
|
+
terminator character, incl. CR/LF) — that would inject new structure — and raises ``ValueError``
|
|
113
|
+
if it does. Raises ``KeyError`` if the target segment occurrence is absent."""
|
|
114
|
+
seg_id, elem, comp = _parse_path(path)
|
|
115
|
+
self._reject_delimiters(value, whole_element=comp is None)
|
|
116
|
+
seg = self._nth_segment(seg_id, occurrence)
|
|
117
|
+
if seg is None:
|
|
118
|
+
where = f"{seg_id!r}" + (f" occurrence {occurrence}" if occurrence > 1 else "")
|
|
119
|
+
raise KeyError(f"cannot set absent segment {where}")
|
|
120
|
+
while len(seg) <= elem:
|
|
121
|
+
seg.append("")
|
|
122
|
+
if comp is None:
|
|
123
|
+
seg[elem] = value
|
|
124
|
+
return
|
|
125
|
+
comps = seg[elem].split(self._delims.component) if seg[elem] else []
|
|
126
|
+
while len(comps) < comp:
|
|
127
|
+
comps.append("")
|
|
128
|
+
comps[comp - 1] = value
|
|
129
|
+
seg[elem] = self._delims.component.join(comps)
|
|
130
|
+
|
|
131
|
+
def __setitem__(self, path: str, value: str) -> None:
|
|
132
|
+
self.set(path, value)
|
|
133
|
+
|
|
134
|
+
def add_segment(self, line: str, *, index: int | None = None) -> None:
|
|
135
|
+
"""Add a whole segment from a raw ``line`` like ``"REF*EI*123456789"`` (split on the
|
|
136
|
+
interchange's own element separator). It must be a single segment (no terminator/CR/LF) with a
|
|
137
|
+
valid 2–3 char id, and may not be an envelope segment (``ISA``/``IEA``). Appended by default;
|
|
138
|
+
pass a 1-based ``index`` to insert earlier. Raises ``ValueError`` on a malformed line/index."""
|
|
139
|
+
for ch in self._delims.segment + "\r\n":
|
|
140
|
+
if ch in line:
|
|
141
|
+
raise ValueError("add_segment takes one segment line (no terminator/CR/LF)")
|
|
142
|
+
fields = line.split(self._delims.element)
|
|
143
|
+
seg_id = fields[0]
|
|
144
|
+
if not re.fullmatch(r"[A-Z][A-Z0-9]{1,2}", seg_id):
|
|
145
|
+
raise ValueError(f"segment must begin with a 2-3 char id, got {seg_id!r}")
|
|
146
|
+
if seg_id in _ENVELOPE_SEGMENTS:
|
|
147
|
+
raise ValueError(f"refusing to add an envelope segment {seg_id!r}")
|
|
148
|
+
if index is None:
|
|
149
|
+
self._segments.append(fields)
|
|
150
|
+
return
|
|
151
|
+
if index < 1 or index > len(self._segments):
|
|
152
|
+
raise ValueError(f"index {index} out of range (1..{len(self._segments)})")
|
|
153
|
+
self._segments.insert(index, fields)
|
|
154
|
+
|
|
155
|
+
def delete_segments(self, segment_id: str) -> int:
|
|
156
|
+
"""Remove every segment with ``segment_id`` and return how many were removed. Refuses to delete
|
|
157
|
+
an envelope segment (``ISA``/``IEA``)."""
|
|
158
|
+
if segment_id in _ENVELOPE_SEGMENTS:
|
|
159
|
+
raise ValueError(f"refusing to delete envelope segment {segment_id!r}")
|
|
160
|
+
before = len(self._segments)
|
|
161
|
+
self._segments = [seg for seg in self._segments if seg[0] != segment_id]
|
|
162
|
+
return before - len(self._segments)
|
|
163
|
+
|
|
164
|
+
# --- encode --------------------------------------------------------------
|
|
165
|
+
|
|
166
|
+
def encode(self) -> str:
|
|
167
|
+
"""Serialize back to a delimited X12 string (each segment closed by the terminator)."""
|
|
168
|
+
element, terminator = self._delims.element, self._delims.segment
|
|
169
|
+
body = terminator.join(element.join(seg) for seg in self._segments)
|
|
170
|
+
return body + terminator if body else body
|
|
171
|
+
|
|
172
|
+
def __str__(self) -> str:
|
|
173
|
+
return self.encode()
|
|
174
|
+
|
|
175
|
+
# --- internals -----------------------------------------------------------
|
|
176
|
+
|
|
177
|
+
def _nth_segment(self, segment_id: str, occurrence: int) -> list[str] | None:
|
|
178
|
+
if occurrence < 1:
|
|
179
|
+
raise ValueError("occurrence is 1-based (>= 1)")
|
|
180
|
+
seen = 0
|
|
181
|
+
for seg in self._segments:
|
|
182
|
+
if seg[0] == segment_id:
|
|
183
|
+
seen += 1
|
|
184
|
+
if seen == occurrence:
|
|
185
|
+
return seg
|
|
186
|
+
return None
|
|
187
|
+
|
|
188
|
+
def _reject_delimiters(self, value: str, *, whole_element: bool) -> None:
|
|
189
|
+
forbidden = {self._delims.element, "\r", "\n"}
|
|
190
|
+
forbidden.update(self._delims.segment)
|
|
191
|
+
if self._delims.repetition:
|
|
192
|
+
forbidden.add(self._delims.repetition)
|
|
193
|
+
if not whole_element:
|
|
194
|
+
forbidden.add(self._delims.component)
|
|
195
|
+
present = sorted(ch for ch in forbidden if ch in value)
|
|
196
|
+
if present:
|
|
197
|
+
raise ValueError(
|
|
198
|
+
f"X12 value may not contain delimiter character(s) {present!r}; "
|
|
199
|
+
"it would inject new structure"
|
|
200
|
+
)
|