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