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