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,164 @@
|
|
|
1
|
+
# SPDX-License-Identifier: AGPL-3.0-or-later
|
|
2
|
+
# Copyright (C) 2026 MessageFoundry Organization and contributors
|
|
3
|
+
"""Configurable delimiter framing — the shared codec under MLLP and raw-TCP transports.
|
|
4
|
+
|
|
5
|
+
A *frame codec* wraps each message in a small envelope of fixed bytes::
|
|
6
|
+
|
|
7
|
+
<start> payload-bytes <end>[<trailer>]
|
|
8
|
+
|
|
9
|
+
MLLP is one preset of this (``start=0x0B``, ``end=0x1C``, ``trailer=0x0D`` — VT/FS+CR);
|
|
10
|
+
raw X12-over-TCP feeds use others (STX/ETX, ``0x02``/``0x03``, no trailer). The single most
|
|
11
|
+
common place toy engines break is framing: forgetting a trailer, treating the start/end bytes
|
|
12
|
+
as message content, or assuming one message per TCP read. A real peer may split a message
|
|
13
|
+
across reads or pack several into one. :class:`FrameDecoder` is a stateful, byte-accurate
|
|
14
|
+
reassembler that handles both, for any configured delimiters.
|
|
15
|
+
|
|
16
|
+
Length-prefix framing (a leading byte count instead of an end delimiter) is **out of scope**
|
|
17
|
+
here and is a documented follow-up — this codec is delimiter-framed only.
|
|
18
|
+
"""
|
|
19
|
+
|
|
20
|
+
from __future__ import annotations
|
|
21
|
+
|
|
22
|
+
from dataclasses import dataclass
|
|
23
|
+
from typing import Iterator
|
|
24
|
+
|
|
25
|
+
__all__ = [
|
|
26
|
+
"FrameError",
|
|
27
|
+
"FrameCodec",
|
|
28
|
+
"FrameDecoder",
|
|
29
|
+
"MLLP_CODEC",
|
|
30
|
+
"STX_ETX_CODEC",
|
|
31
|
+
"PRESETS",
|
|
32
|
+
"codec_for",
|
|
33
|
+
]
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
class FrameError(ValueError):
|
|
37
|
+
"""Raised when a frame exceeds its configured byte cap before the end delimiter.
|
|
38
|
+
|
|
39
|
+
Signals the caller to drop the connection rather than buffer an unbounded frame.
|
|
40
|
+
"""
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
@dataclass(frozen=True)
|
|
44
|
+
class FrameCodec:
|
|
45
|
+
"""A delimiter framing scheme: a ``start`` byte, an ``end`` byte, and an optional ``trailer``.
|
|
46
|
+
|
|
47
|
+
All three are byte values in ``0..255``. ``frame()`` wraps a payload; :meth:`decoder` builds a
|
|
48
|
+
stateful streaming :class:`FrameDecoder`. The ``trailer`` (e.g. MLLP's CR) is **emitted** when
|
|
49
|
+
framing but **not required** when decoding — a tolerant receiver treats any inter-frame bytes,
|
|
50
|
+
including a stray trailer after the end delimiter, as noise to discard.
|
|
51
|
+
"""
|
|
52
|
+
|
|
53
|
+
start: int
|
|
54
|
+
end: int
|
|
55
|
+
trailer: int | None = None
|
|
56
|
+
|
|
57
|
+
def __post_init__(self) -> None:
|
|
58
|
+
for name, value in (("start", self.start), ("end", self.end), ("trailer", self.trailer)):
|
|
59
|
+
if value is None:
|
|
60
|
+
continue
|
|
61
|
+
if not isinstance(value, int) or isinstance(value, bool) or not 0 <= value <= 255:
|
|
62
|
+
raise ValueError(f"frame {name} must be a byte value in 0..255, got {value!r}")
|
|
63
|
+
if self.start == self.end:
|
|
64
|
+
raise ValueError("frame start and end delimiters must differ")
|
|
65
|
+
|
|
66
|
+
def frame(self, payload: str | bytes, encoding: str = "utf-8") -> bytes:
|
|
67
|
+
"""Wrap a message: ``start payload end [trailer]``."""
|
|
68
|
+
body = payload.encode(encoding) if isinstance(payload, str) else bytes(payload)
|
|
69
|
+
tail = [self.end] if self.trailer is None else [self.end, self.trailer]
|
|
70
|
+
return bytes([self.start]) + body + bytes(tail)
|
|
71
|
+
|
|
72
|
+
def decoder(self, max_frame_bytes: int | None = None) -> FrameDecoder:
|
|
73
|
+
"""A fresh stateful reassembler for this scheme."""
|
|
74
|
+
return FrameDecoder(self, max_frame_bytes=max_frame_bytes)
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
class FrameDecoder:
|
|
78
|
+
"""Stateful frame reassembler for a :class:`FrameCodec`.
|
|
79
|
+
|
|
80
|
+
Feed it whatever bytes arrive; it yields complete message payloads (delimiters stripped) as they
|
|
81
|
+
complete. Bytes outside a frame — a stray trailer after the end delimiter, keep-alives, or junk
|
|
82
|
+
before the next start byte — are discarded, matching tolerant real-world receivers.
|
|
83
|
+
"""
|
|
84
|
+
|
|
85
|
+
#: Exception type raised on an over-cap frame. A subclass (e.g. MLLP's historical
|
|
86
|
+
#: ``MLLPFrameError``) can override this so callers' existing ``except`` clauses keep matching.
|
|
87
|
+
error_class: type[FrameError] = FrameError
|
|
88
|
+
|
|
89
|
+
def __init__(self, codec: FrameCodec, max_frame_bytes: int | None = None) -> None:
|
|
90
|
+
self._codec = codec
|
|
91
|
+
self._buf = bytearray()
|
|
92
|
+
self._in_block = False
|
|
93
|
+
self.max_frame_bytes = max_frame_bytes
|
|
94
|
+
|
|
95
|
+
def feed(self, data: bytes) -> Iterator[bytes]:
|
|
96
|
+
start, end = self._codec.start, self._codec.end
|
|
97
|
+
for byte in data:
|
|
98
|
+
if not self._in_block:
|
|
99
|
+
if byte == start:
|
|
100
|
+
self._in_block = True
|
|
101
|
+
self._buf.clear()
|
|
102
|
+
# else: discard inter-frame noise (trailer after end, keep-alives, etc.)
|
|
103
|
+
continue
|
|
104
|
+
if byte == end:
|
|
105
|
+
# End of block. Any trailer that follows is left to be discarded as inter-frame
|
|
106
|
+
# noise, so a missing/extra trailer is tolerated.
|
|
107
|
+
self._in_block = False
|
|
108
|
+
yield bytes(self._buf)
|
|
109
|
+
self._buf.clear()
|
|
110
|
+
else:
|
|
111
|
+
if self.max_frame_bytes is not None and len(self._buf) >= self.max_frame_bytes:
|
|
112
|
+
# Oversized open frame: a peer that never sends the end delimiter would grow the
|
|
113
|
+
# buffer without bound. Reset state and signal the caller to drop the connection.
|
|
114
|
+
self._buf.clear()
|
|
115
|
+
self._in_block = False
|
|
116
|
+
raise self.error_class(
|
|
117
|
+
f"frame exceeded {self.max_frame_bytes} bytes before the end delimiter"
|
|
118
|
+
)
|
|
119
|
+
self._buf.append(byte)
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
#: MLLP preset: VT start, FS end, CR trailer (``0x0B``/``0x1C``/``0x0D``).
|
|
123
|
+
MLLP_CODEC = FrameCodec(start=0x0B, end=0x1C, trailer=0x0D)
|
|
124
|
+
#: STX/ETX preset: ``0x02``/``0x03``, no trailer — the most common raw-X12-over-TCP framing.
|
|
125
|
+
STX_ETX_CODEC = FrameCodec(start=0x02, end=0x03, trailer=None)
|
|
126
|
+
|
|
127
|
+
#: Named framing presets selectable by string (e.g. ``Tcp(framing="stx_etx")``). ``vt_fs`` aliases
|
|
128
|
+
#: ``mllp`` (they are the same bytes), since some estates name the same scheme either way.
|
|
129
|
+
PRESETS: dict[str, FrameCodec] = {
|
|
130
|
+
"mllp": MLLP_CODEC,
|
|
131
|
+
"vt_fs": MLLP_CODEC,
|
|
132
|
+
"stx_etx": STX_ETX_CODEC,
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
def codec_for(
|
|
137
|
+
framing: str | None,
|
|
138
|
+
*,
|
|
139
|
+
start: int | None = None,
|
|
140
|
+
end: int | None = None,
|
|
141
|
+
trailer: int | None = None,
|
|
142
|
+
) -> FrameCodec:
|
|
143
|
+
"""Resolve a :class:`FrameCodec` from a config surface (a preset name OR explicit byte ints).
|
|
144
|
+
|
|
145
|
+
Either pass ``framing`` (a key in :data:`PRESETS`) **or** explicit ``start``/``end`` (with an
|
|
146
|
+
optional ``trailer``) — not both. A bad preset name or out-of-range/contradictory bytes raise
|
|
147
|
+
``ValueError`` (surfaced loud at connector construction, not deep in a read loop)."""
|
|
148
|
+
explicit = start is not None or end is not None or trailer is not None
|
|
149
|
+
if framing is not None:
|
|
150
|
+
if explicit:
|
|
151
|
+
raise ValueError(
|
|
152
|
+
"specify either a framing preset OR explicit start/end/trailer bytes, not both"
|
|
153
|
+
)
|
|
154
|
+
try:
|
|
155
|
+
return PRESETS[framing.lower()]
|
|
156
|
+
except KeyError:
|
|
157
|
+
raise ValueError(
|
|
158
|
+
f"unknown framing preset {framing!r}; expected one of {', '.join(sorted(PRESETS))}"
|
|
159
|
+
) from None
|
|
160
|
+
if start is None or end is None:
|
|
161
|
+
raise ValueError(
|
|
162
|
+
"framing requires a preset name or both start and end delimiter bytes (start/end)"
|
|
163
|
+
)
|
|
164
|
+
return FrameCodec(start=start, end=end, trailer=trailer)
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
# SPDX-License-Identifier: AGPL-3.0-or-later
|
|
2
|
+
# Copyright (C) 2026 MessageFoundry Organization and contributors
|
|
3
|
+
"""Loopback source — a deliberately inert inbound (ADR 0013 Increment 2).
|
|
4
|
+
|
|
5
|
+
A loopback inbound has **no listening or polling source**: messages arrive *exclusively* via the
|
|
6
|
+
engine-internal ``ingress_handoff`` (a captured reply re-ingressed as a new inbound message). This
|
|
7
|
+
connector exists only to satisfy the source-registry contract so the runner can build a loopback
|
|
8
|
+
inbound like any other (and so its router/transform/**response** workers spawn). Its ``start`` records
|
|
9
|
+
the handler and returns; its run loop never fires; routing a re-ingressed message through the
|
|
10
|
+
source/listener seam would be the bare-``enqueue_ingress`` double-injection trap (ADR 0013), so this
|
|
11
|
+
connector guards against that by never invoking the handler at all.
|
|
12
|
+
|
|
13
|
+
Unlike :class:`~messagefoundry.transports.timer.TimerSource`, a loopback reads **no** shared external
|
|
14
|
+
resource, so ``polls_shared_resource = False`` — there is nothing to leader-gate at the source. The
|
|
15
|
+
re-ingress work is leader-gated at the *worker* (the per-lane claim owner), consistent with every other
|
|
16
|
+
stage.
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
from __future__ import annotations
|
|
20
|
+
|
|
21
|
+
import logging
|
|
22
|
+
from typing import Callable
|
|
23
|
+
|
|
24
|
+
from messagefoundry.config.models import ConnectorType, Source
|
|
25
|
+
from messagefoundry.transports.base import InboundHandler, SourceConnector, register_source
|
|
26
|
+
|
|
27
|
+
logger = logging.getLogger(__name__)
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
class LoopbackSource(SourceConnector):
|
|
31
|
+
"""An inert inbound: no socket, no poll. Re-ingress delivers via ``ingress_handoff``, never here."""
|
|
32
|
+
|
|
33
|
+
# Reads no shared external resource → nothing to leader-gate at the source (re-ingress is gated at
|
|
34
|
+
# the worker's per-lane claim owner instead — see the re-ingress worker).
|
|
35
|
+
polls_shared_resource = False
|
|
36
|
+
|
|
37
|
+
def __init__(self, config: Source) -> None:
|
|
38
|
+
# No settings to read — a loopback carries only its router/content_type on the inbound itself.
|
|
39
|
+
self._config = config
|
|
40
|
+
self._handler: InboundHandler | None = None
|
|
41
|
+
|
|
42
|
+
async def start(
|
|
43
|
+
self, handler: InboundHandler, *, leader_gate: Callable[[], bool] | None = None
|
|
44
|
+
) -> None:
|
|
45
|
+
"""Record the handler and return live. The handler is intentionally **never** invoked — a
|
|
46
|
+
re-ingressed message reaches the pipeline via the internal ``ingress_handoff``, not this seam."""
|
|
47
|
+
self._handler = handler
|
|
48
|
+
|
|
49
|
+
async def stop(self) -> None:
|
|
50
|
+
return None
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
register_source(ConnectorType.LOOPBACK, LoopbackSource)
|