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,450 @@
1
+ # SPDX-License-Identifier: AGPL-3.0-or-later
2
+ # Copyright (C) 2026 MessageFoundry Organization and contributors
3
+ """Dry-run a wiring Registry against messages — pure routing/handling, no I/O.
4
+
5
+ Runs a message through an inbound connection's Router and Handler(s) exactly as the engine would,
6
+ but with **no store, connectors, network, or ACK** — capturing the routing decision, the disposition
7
+ (RECEIVED/UNROUTED/FILTERED/ERROR), and the payload each Handler *would* send. This powers the IDE
8
+ Test Bench and the ``dryrun`` CLI. The routing core (:func:`route_message`) is shared with the live
9
+ engine (:class:`~messagefoundry.pipeline.wiring_runner.RegistryRunner`) so both route identically.
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ import logging
15
+ import time
16
+ from collections.abc import Collection, Mapping
17
+ from dataclasses import dataclass, field
18
+ from pathlib import Path
19
+ from typing import Any
20
+
21
+ from messagefoundry.config.code_sets import CodeSetError, load_code_set
22
+ from messagefoundry.config.models import ContentType
23
+ from messagefoundry.config.run_context import RunContext, run_contexts
24
+ from messagefoundry.config.wiring import (
25
+ HandlerFn,
26
+ InboundConnection,
27
+ Registry,
28
+ Send,
29
+ SetState,
30
+ StateValue,
31
+ )
32
+ from messagefoundry.parsing import (
33
+ HL7PeekError,
34
+ Message,
35
+ Peek,
36
+ RawMessage,
37
+ normalize,
38
+ split_batch,
39
+ summarize,
40
+ validate,
41
+ )
42
+ from messagefoundry.store import MessageStatus
43
+
44
+ __all__ = [
45
+ "DeliveryPreview",
46
+ "StateOpPreview",
47
+ "RouteOutcome",
48
+ "DryRunResult",
49
+ "route_message",
50
+ "route_only",
51
+ "transform_one",
52
+ "disposition_for",
53
+ "dry_run",
54
+ "select_inbound",
55
+ "read_messages",
56
+ "read_message_sets",
57
+ "split_messages",
58
+ ]
59
+
60
+ log = logging.getLogger(__name__)
61
+
62
+
63
+ def _handler_names(result: list[str] | str | None) -> list[str]:
64
+ if result is None:
65
+ return []
66
+ return [result] if isinstance(result, str) else list(result)
67
+
68
+
69
+ def _partition(
70
+ result: Send | SetState | list[Send | SetState] | None,
71
+ ) -> tuple[list[Send], list[SetState]]:
72
+ """Split a Handler's return into its (deliveries, state writes) — ADR 0005.
73
+
74
+ A Handler may now return :class:`Send`\\ s and/or :class:`SetState`\\ s (a single value, a mixed
75
+ list, or ``None``). ``Send``-only returns yield ``([...], [])`` — backward compatible."""
76
+ if result is None:
77
+ return [], []
78
+ items = result if isinstance(result, list) else [result]
79
+ sends = [it for it in items if isinstance(it, Send)]
80
+ state_ops = [it for it in items if isinstance(it, SetState)]
81
+ return sends, state_ops
82
+
83
+
84
+ def _payload(raw: str | bytes, content_type: str) -> Message | RawMessage:
85
+ """The object a Router/Handler receives (ADR 0004): a mutable HL7 :class:`Message` for ``hl7v2``,
86
+ or a verbatim :class:`RawMessage` (``.raw``/``.text``/``.json()``) for any other ``content_type``."""
87
+ if content_type == ContentType.HL7V2.value:
88
+ return Message.parse(raw)
89
+ return RawMessage(raw if isinstance(raw, str) else raw.decode("utf-8"), content_type)
90
+
91
+
92
+ @dataclass(frozen=True)
93
+ class DeliveryPreview:
94
+ """What a Handler would deliver to an outbound connection (no send happens)."""
95
+
96
+ to: str
97
+ payload: str
98
+
99
+
100
+ @dataclass(frozen=True)
101
+ class StateOpPreview:
102
+ """A state write a Handler would declare (ADR 0005) — captured for the dry-run, applied nowhere.
103
+
104
+ ``value`` is the would-be-stored value; it may carry PHI (e.g. an MRN→anon mapping), so the CLI
105
+ gates it behind ``--show-phi`` exactly like a delivery payload."""
106
+
107
+ namespace: str
108
+ key: str
109
+ value: Any
110
+
111
+
112
+ @dataclass(frozen=True)
113
+ class RouteOutcome:
114
+ """The result of running a Router + its Handlers (without validation/disposition)."""
115
+
116
+ handlers: list[str] # handler names the Router selected ([] = routed nowhere)
117
+ deliveries: list[DeliveryPreview]
118
+ state_ops: list[StateOpPreview] = field(default_factory=list) # declared writes (ADR 0005)
119
+
120
+ @property
121
+ def routed(self) -> bool:
122
+ return bool(self.handlers)
123
+
124
+
125
+ def route_only(registry: Registry, ic: InboundConnection, raw: str | bytes) -> list[str]:
126
+ """Run ``ic``'s Router and return the handler name(s) it selected (``[]`` = routed nowhere).
127
+
128
+ The **router half** of the split routing core (ADR 0001 Step B): it decides *which* handlers take
129
+ the message but runs no transform. Every selected handler is validated to exist — a router naming
130
+ an unknown handler (typo / renamed / removed handler) fails closed **here** (``ValueError``) rather
131
+ than producing a routed-stage row no transform worker can run; on the live path the router worker
132
+ dead-letters/NAK-equivalents it, and dry-run / ``messagefoundry check`` surface the bad name
133
+ (review M-7). The live engine's router worker calls this; the combined :func:`route_message` does too.
134
+ """
135
+ route = registry.routers[ic.router]
136
+ names = _handler_names(route(_payload(raw, ic.content_type.value)))
137
+ for hname in names:
138
+ if hname not in registry.handlers:
139
+ raise ValueError(f"router {ic.router!r} returned unknown handler {hname!r}")
140
+ return names
141
+
142
+
143
+ def transform_one(
144
+ registry: Registry, hname: str, raw: str | bytes, content_type: str = ContentType.HL7V2.value
145
+ ) -> tuple[list[DeliveryPreview], list[StateOpPreview]]:
146
+ """Run **one** Handler on its own freshly-built payload; return ``(deliveries, state_ops)``.
147
+
148
+ The **transform half** of the split routing core (ADR 0001 Step B): a single handler, its own
149
+ payload (a :class:`Message`, or a :class:`RawMessage` when ``content_type`` is non-HL7 — so one
150
+ handler's transforms can't leak into another's), with every ``Send`` target validated against the
151
+ outbound registry. An unknown outbound fails closed **here** (``ValueError``): an undeliverable
152
+ target would otherwise enqueue an outbound row no worker drains (silent accept-and-strand).
153
+
154
+ A Handler may also return :class:`~messagefoundry.config.wiring.SetState` ops (ADR 0005); they are
155
+ split out (``state_ops``) and applied exactly-once by the store inside the transform handoff (the
156
+ live transform worker passes them to ``transform_handoff(state_ops=...)``). The caller guarantees
157
+ ``hname`` is registered (:func:`route_only` validated it); the live engine's transform worker calls
158
+ this per routed-stage row.
159
+ """
160
+ handle: HandlerFn = registry.handlers[hname]
161
+ sends, ops = _partition(handle(_payload(raw, content_type)))
162
+ deliveries: list[DeliveryPreview] = []
163
+ for send in sends:
164
+ if send.to not in registry.outbound:
165
+ raise ValueError(f"handler {hname!r} sent to unknown outbound connection {send.to!r}")
166
+ payload = send.message if isinstance(send.message, str) else send.message.encode()
167
+ deliveries.append(DeliveryPreview(to=send.to, payload=payload))
168
+ state_ops = [StateOpPreview(namespace=op.namespace, key=op.key, value=op.value) for op in ops]
169
+ return deliveries, state_ops
170
+
171
+
172
+ def _dry_run_reference_view(registry: Registry) -> dict[str, Mapping[str, Any]]:
173
+ """Best-effort preview of reference snapshots for a dry-run (ADR 0006): load each FILE-backed
174
+ declaration with a literal path. DB-backed or ``env()``-path sets can't be materialized without a
175
+ store/environment, so they're omitted (a read of one then raises, as a preview error)."""
176
+ view: dict[str, Mapping[str, Any]] = {}
177
+ for spec in registry.references.values():
178
+ if spec.source.kind != "file":
179
+ continue
180
+ path = spec.source.settings.get("path")
181
+ if not isinstance(path, str): # an env() ref — unresolved in a pure dry-run
182
+ continue
183
+ try:
184
+ view[spec.name] = dict(load_code_set(path))
185
+ except CodeSetError:
186
+ continue
187
+ return view
188
+
189
+
190
+ def route_message(
191
+ registry: Registry,
192
+ ic: InboundConnection,
193
+ raw: str | bytes,
194
+ *,
195
+ ingest_time: float | None = None,
196
+ ) -> RouteOutcome:
197
+ """Run ``ic``'s Router then the named Handlers; return what they selected and would send.
198
+
199
+ ``ingest_time`` (epoch seconds) is the value a Handler's ``current_ingest_time()`` resolves to in
200
+ this preview; the CLI passes ``time.time()`` so a now-defaulting transform previews realistically. It
201
+ is ``None`` (the default) for a pure call, where ``current_ingest_time()`` returns ``None``.
202
+
203
+ Convenience recomposition of :func:`route_only` + :func:`transform_one` for the dry-run / Test
204
+ Bench / CLI preview, which want the whole routing outcome in one shot. The live **staged** engine
205
+ instead runs the two halves at *separate* stages (router worker → transform worker), so it and the
206
+ dry-run path route identically. Each handler still gets its own :class:`Message` (via
207
+ :func:`transform_one`). Router/Handler exceptions propagate to the caller.
208
+ """
209
+ # Publish the graph's code sets so a call-time code_set(...) inside a Router/Handler resolves
210
+ # during a dry-run / Test Bench / `messagefoundry check` preview (the loader only had them active
211
+ # at import time). The live staged engine activates them in its workers; this mirrors it.
212
+ #
213
+ # State (ADR 0005): there is no store/cache in a dry-run, so publish an in-memory view that
214
+ # *accumulates this run's own declared writes* — so a later handler's state_get(...) sees what an
215
+ # earlier handler in the same simulated message declared (a self-consistent preview), mirroring how
216
+ # the live cache would reflect committed writes. It is local to this call (no global side effect).
217
+ sim_state: dict[tuple[str, str], StateValue] = {}
218
+ # Reference sets (ADR 0006): there is no store/sync in a dry-run, so build a best-effort preview
219
+ # view from the graph's FILE-backed declarations (literal paths) so a reference(...) read resolves
220
+ # during `check`/Test Bench. DB-backed or env()-path sets can't be reached in a pure dry-run and are
221
+ # simply absent (a read of one then raises, surfaced as that message's preview error).
222
+ sim_reference = _dry_run_reference_view(registry)
223
+ deliveries: list[DeliveryPreview] = []
224
+ state_ops: list[StateOpPreview] = []
225
+ # Activate the same run-scoped providers the live engine uses (via the shared run_context registry),
226
+ # so router + handlers resolve identically here and in the staged engine. Dry-run runs router and
227
+ # transform in one block, so it uses the transform (superset) phase; it has no live environment, so
228
+ # active_environment=None — current_environment() then returns None, exactly as when dry-run left the
229
+ # environment unset. A provider that needs live infrastructure (db_lookup) refuses to run here.
230
+ with run_contexts(
231
+ RunContext(
232
+ code_sets=registry.code_sets,
233
+ reference_view=sim_reference,
234
+ state_view=sim_state,
235
+ active_environment=None,
236
+ ingest_time=ingest_time,
237
+ ),
238
+ phase="transform",
239
+ ):
240
+ names = route_only(registry, ic, raw)
241
+ ct = ic.content_type.value
242
+ for hname in names:
243
+ ds, ops = transform_one(registry, hname, raw, ct)
244
+ deliveries.extend(ds)
245
+ for op in ops:
246
+ sim_state[(op.namespace, op.key)] = op.value # visible to subsequent handlers
247
+ state_ops.extend(ops)
248
+ return RouteOutcome(handlers=names, deliveries=deliveries, state_ops=state_ops)
249
+
250
+
251
+ def disposition_for(outcome: RouteOutcome) -> MessageStatus:
252
+ """Classify a routing outcome for the **dry-run / Test Bench preview**.
253
+
254
+ A delivering outcome maps to ``RECEIVED`` — the preview's entry-state ("accepted; would route to
255
+ ≥1 destination"), since a pure simulation can't know the eventual delivery result. The live staged
256
+ engine records the post-router state differently for the same outcome: the ingress worker persists
257
+ ``ROUTED`` (then ``PROCESSED`` once delivered), because ``RECEIVED`` now means "committed at ingress,
258
+ awaiting routing." So this is a deliberate preview-vs-live difference, not a shared mapping — the
259
+ live path does NOT call this function (see ``RegistryRunner._ingress_worker``)."""
260
+ if outcome.deliveries:
261
+ return MessageStatus.RECEIVED
262
+ return MessageStatus.UNROUTED if not outcome.routed else MessageStatus.FILTERED
263
+
264
+
265
+ @dataclass(frozen=True)
266
+ class DryRunResult:
267
+ """Outcome of dry-running one message against an inbound connection."""
268
+
269
+ inbound: str
270
+ disposition: MessageStatus
271
+ raw: str
272
+ message_type: str | None = None
273
+ control_id: str | None = None
274
+ summary: str | None = None
275
+ handlers: list[str] = field(default_factory=list)
276
+ deliveries: list[DeliveryPreview] = field(default_factory=list)
277
+ state_ops: list[StateOpPreview] = field(default_factory=list) # declared writes (ADR 0005)
278
+ error: str | None = None
279
+
280
+
281
+ def select_inbound(registry: Registry, name: str | None = None) -> InboundConnection:
282
+ """Pick which inbound connection (Router) to simulate; defaults to the sole one."""
283
+ if name is not None:
284
+ try:
285
+ return registry.inbound[name]
286
+ except KeyError:
287
+ raise ValueError(f"no such inbound connection: {name!r}") from None
288
+ if len(registry.inbound) == 1:
289
+ return next(iter(registry.inbound.values()))
290
+ raise ValueError(
291
+ "config has multiple inbound connections; choose one: "
292
+ + ", ".join(sorted(registry.inbound))
293
+ )
294
+
295
+
296
+ def _dry_run_raw(registry: Registry, ic: InboundConnection, raw: str | bytes) -> DryRunResult:
297
+ """Dry-run a non-HL7 inbound (ADR 0004): no HL7 peek/validate; route the body as a RawMessage."""
298
+ text = raw if isinstance(raw, str) else raw.decode("utf-8")
299
+ try:
300
+ outcome = route_message(registry, ic, text, ingest_time=time.time())
301
+ except Exception as exc: # a router/handler script raised
302
+ return DryRunResult(
303
+ inbound=ic.name,
304
+ disposition=MessageStatus.ERROR,
305
+ raw=text,
306
+ message_type=ic.content_type.value,
307
+ error=f"router/handler error: {exc}",
308
+ )
309
+ return DryRunResult(
310
+ inbound=ic.name,
311
+ disposition=disposition_for(outcome),
312
+ raw=text,
313
+ message_type=ic.content_type.value,
314
+ handlers=outcome.handlers,
315
+ deliveries=outcome.deliveries,
316
+ state_ops=outcome.state_ops,
317
+ )
318
+
319
+
320
+ def dry_run(registry: Registry, raw: str | bytes, *, inbound: str | None = None) -> DryRunResult:
321
+ """Parse → (strict-validate) → route one message, returning disposition + would-send payloads.
322
+
323
+ Mirrors the engine's disposition logic with **no side effects**.
324
+ """
325
+ ic = select_inbound(registry, inbound)
326
+ if ic.content_type is not ContentType.HL7V2:
327
+ return _dry_run_raw(registry, ic, raw)
328
+ text = normalize(raw)
329
+
330
+ try:
331
+ peek = Peek.parse(text)
332
+ except HL7PeekError as exc:
333
+ return DryRunResult(
334
+ inbound=ic.name, disposition=MessageStatus.ERROR, raw=text, error=f"parse error: {exc}"
335
+ )
336
+
337
+ mt, cid, summ = peek.message_type, peek.control_id, (summarize(peek) or None)
338
+
339
+ if ic.validation.strict:
340
+ result = validate(text, expected_version=ic.validation.hl7_version)
341
+ if not result.ok:
342
+ return DryRunResult(
343
+ inbound=ic.name,
344
+ disposition=MessageStatus.ERROR,
345
+ raw=text,
346
+ message_type=mt,
347
+ control_id=cid,
348
+ summary=summ,
349
+ error="; ".join(result.errors)[:200],
350
+ )
351
+
352
+ try:
353
+ outcome = route_message(registry, ic, text, ingest_time=time.time())
354
+ except Exception as exc: # a router/handler script raised
355
+ return DryRunResult(
356
+ inbound=ic.name,
357
+ disposition=MessageStatus.ERROR,
358
+ raw=text,
359
+ message_type=mt,
360
+ control_id=cid,
361
+ summary=summ,
362
+ error=f"router/handler error: {exc}",
363
+ )
364
+
365
+ return DryRunResult(
366
+ inbound=ic.name,
367
+ disposition=disposition_for(outcome),
368
+ raw=text,
369
+ message_type=mt,
370
+ control_id=cid,
371
+ summary=summ,
372
+ handlers=outcome.handlers,
373
+ deliveries=outcome.deliveries,
374
+ state_ops=outcome.state_ops,
375
+ )
376
+
377
+
378
+ def split_messages(raw: bytes) -> list[str]:
379
+ """Split a possibly-batched HL7 payload into individual messages on ``MSH`` boundaries.
380
+
381
+ A real file connection delivers each ``MSH``-delimited message separately; mirror that so a
382
+ dry-run / commit-check sees every message in a batch file, not just the first. Delegates to the
383
+ shared :func:`messagefoundry.parsing.split.split_batch` so the live File-source ingress split
384
+ (transports/file.py) and this dry-run / ``messagefoundry check`` path stay byte-identical.
385
+ """
386
+ return split_batch(raw)
387
+
388
+
389
+ def read_messages(paths: list[str]) -> list[tuple[str, str, str]]:
390
+ """Resolve ``paths`` (files and/or directories) to ``(label, file_path, content)`` per message.
391
+
392
+ Directories contribute their ``*.hl7`` files (sorted); batch files yield one entry per message
393
+ (``"name [i]"``). Raises ``FileNotFoundError`` for a missing path and ``ValueError`` for a
394
+ directory with no ``*.hl7`` files.
395
+ """
396
+ out: list[tuple[str, str, str]] = []
397
+ for raw_path in paths:
398
+ path = Path(raw_path)
399
+ if path.is_dir():
400
+ files = sorted(path.glob("*.hl7"))
401
+ if not files:
402
+ raise ValueError(f"no *.hl7 files in {path}")
403
+ elif path.is_file():
404
+ files = [path]
405
+ else:
406
+ raise FileNotFoundError(f"no such file or directory: {path}")
407
+ for f in files:
408
+ messages = split_messages(f.read_bytes())
409
+ if len(messages) == 1:
410
+ out.append((f.name, str(f), messages[0]))
411
+ else:
412
+ out.extend((f"{f.name} [{i}]", str(f), m) for i, m in enumerate(messages, 1))
413
+ return out
414
+
415
+
416
+ def read_message_sets(
417
+ root: str | Path, inbound_names: Collection[str]
418
+ ) -> list[tuple[str, str, str, str | None]]:
419
+ """Like :func:`read_messages` but **recursive** and feed-aware, for ``messagefoundry check`` (#11).
420
+
421
+ A fixture whose top-level subdirectory under ``root`` names an inbound connection
422
+ (``root/IB_FOO/…``) is *pinned* to that inbound (its 4th tuple field is ``"IB_FOO"``), so it is
423
+ dry-run only against that feed; a fixture directly under ``root``, or under a subdirectory that
424
+ names no inbound, is *unmapped* (``None``) and the caller dry-runs it against **every** inbound —
425
+ the all-×-all fallback. Returns ``(label, file_path, content, target_inbound | None)`` per message
426
+ (a batch file yields one entry per message). A single-file ``root`` is one unmapped fixture.
427
+ Raises ``FileNotFoundError`` for a missing ``root``.
428
+ """
429
+ names = set(inbound_names)
430
+ root_path = Path(root)
431
+ pairs: list[tuple[Path, str | None]] = []
432
+ if root_path.is_dir():
433
+ for f in sorted(root_path.rglob("*.hl7")):
434
+ parts = f.relative_to(root_path).parts
435
+ # parts[0] is the top-level component under root: a feed name (pin) when it's a real
436
+ # subdir matching an inbound, else the bare filename (a top-level fixture → unmapped).
437
+ target = parts[0] if len(parts) >= 2 and parts[0] in names else None
438
+ pairs.append((f, target))
439
+ elif root_path.is_file():
440
+ pairs.append((root_path, None))
441
+ else:
442
+ raise FileNotFoundError(f"no such file or directory: {root_path}")
443
+ out: list[tuple[str, str, str, str | None]] = []
444
+ for f, target in pairs:
445
+ messages = split_messages(f.read_bytes())
446
+ if len(messages) == 1:
447
+ out.append((f.name, str(f), messages[0], target))
448
+ else:
449
+ out.extend((f"{f.name} [{i}]", str(f), m, target) for i, m in enumerate(messages, 1))
450
+ return out