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