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,589 @@
1
+ # SPDX-License-Identifier: AGPL-3.0-or-later
2
+ # Copyright (C) 2026 MessageFoundry Organization and contributors
3
+ """Shared, reference-driven machinery for the HL7 message generators.
4
+
5
+ Each message type (ADT, ORM, …) registers a :class:`MessageSpec` describing its
6
+ trigger→structure map, its segment builders, and which optional segments to sprinkle in.
7
+ Generation walks hl7apy's own 2.5.1 reference tree (``MESSAGES[structure]``): for each
8
+ structure we emit required segments in order plus a valid random subset of allow-listed
9
+ optionals, then gate every message through the engine's strict validator before it counts.
10
+
11
+ A type module contributes builders only for its *own* segments; the broadly shared ones
12
+ (MSH/EVN/PID/PV1/…) live here in :data:`SHARED_BUILDERS`. All data is synthetic — no real PHI.
13
+ """
14
+
15
+ from __future__ import annotations
16
+
17
+ import random
18
+ from collections.abc import Callable, Mapping
19
+ from dataclasses import dataclass, field
20
+ from datetime import datetime, timedelta
21
+ from pathlib import Path
22
+ from typing import Any
23
+
24
+ from hl7apy import v2_5_1 as _ref
25
+
26
+ from messagefoundry.parsing import validate
27
+ from messagefoundry.generators import _hl7data as d
28
+
29
+ _MESSAGES = _ref.MESSAGES
30
+ _SEGMENTS = _ref.SEGMENTS
31
+
32
+ DEFAULT_SEED = "messagefoundry-hl7-2.5.1"
33
+ # Fixed epoch so a given seed reproduces identical bytes (no wall-clock nondeterminism).
34
+ BASE_DT = datetime(2026, 1, 1, 0, 0, 0)
35
+
36
+ SegmentBuilder = Callable[[random.Random, "Ctx"], str]
37
+ GateFn = Callable[[str, str], tuple[bool, list[str]]]
38
+
39
+
40
+ # --- per-message state -------------------------------------------------------
41
+
42
+
43
+ @dataclass
44
+ class Patient:
45
+ """A fabricated patient identity, shared across a message's PID/PV1/etc."""
46
+
47
+ mrn: str
48
+ family: str
49
+ given: str
50
+ middle: str
51
+ dob: str
52
+ sex: str
53
+ street: str
54
+ city: str
55
+ state: str
56
+ zip_code: str
57
+ phone: str
58
+ patient_class: str
59
+ visit_no: str
60
+
61
+
62
+ @dataclass
63
+ class Ctx:
64
+ """Mutable per-message context threaded through the segment builders."""
65
+
66
+ message_code: str
67
+ trigger: str
68
+ structure: str
69
+ control_id: str
70
+ msg_dt: datetime
71
+ sending_app: str
72
+ sending_fac: str
73
+ receiving_app: str
74
+ receiving_fac: str
75
+ current: Patient | None = None
76
+ seq: dict[str, int] = field(default_factory=dict)
77
+
78
+
79
+ def next_seq(ctx: Ctx, name: str) -> int:
80
+ """Monotonic set-id per segment type within a message (PID-1, NK1-1, …)."""
81
+ ctx.seq[name] = ctx.seq.get(name, 0) + 1
82
+ return ctx.seq[name]
83
+
84
+
85
+ def seg(name: str, fields: dict[int, str]) -> str:
86
+ """Assemble a segment, placing each value at its 1-based field index, gaps empty."""
87
+ high = max(fields) if fields else 0
88
+ return "|".join([name, *(fields.get(i, "") for i in range(1, high + 1))])
89
+
90
+
91
+ # --- synthetic patient -------------------------------------------------------
92
+
93
+
94
+ def _birthdate(rng: random.Random) -> str:
95
+ age = rng.randint(0, 95)
96
+ return f"{2026 - age:04d}{rng.randint(1, 12):02d}{rng.randint(1, 28):02d}"
97
+
98
+
99
+ def make_patient(rng: random.Random) -> Patient:
100
+ city, state, zip_code = rng.choice(d.CITIES)
101
+ return Patient(
102
+ mrn=str(rng.randint(1_000_000, 9_999_999)),
103
+ family=rng.choice(d.FAMILY_NAMES),
104
+ given=rng.choice(d.GIVEN_NAMES),
105
+ middle=rng.choice(d.MIDDLE_INITIALS),
106
+ dob=_birthdate(rng),
107
+ sex=rng.choice(d.SEXES),
108
+ street=rng.choice(d.STREETS),
109
+ city=city,
110
+ state=state,
111
+ zip_code=zip_code,
112
+ # NANP reserved fictional range (exchange 555, subscriber 0100-0199) — never diallable
113
+ # even though the synthetic data is otherwise realistic (low-22).
114
+ phone=f"({rng.randint(200, 989)})555-{rng.randint(100, 199):04d}",
115
+ patient_class=rng.choice(d.PATIENT_CLASSES),
116
+ visit_no=f"V{rng.randint(1_000_000, 9_999_999)}",
117
+ )
118
+
119
+
120
+ def patient(ctx: Ctx, rng: random.Random) -> Patient:
121
+ """The message's current patient, creating one on first use (shared by PID/PV1/etc.)."""
122
+ if ctx.current is None:
123
+ ctx.current = make_patient(rng)
124
+ return ctx.current
125
+
126
+
127
+ # --- broadly shared segment builders -----------------------------------------
128
+
129
+
130
+ def _build_msh(rng: random.Random, ctx: Ctx) -> str:
131
+ tail = "|".join(
132
+ [
133
+ ctx.sending_app,
134
+ ctx.sending_fac,
135
+ ctx.receiving_app,
136
+ ctx.receiving_fac,
137
+ d.ts(ctx.msg_dt),
138
+ "",
139
+ f"{ctx.message_code}^{ctx.trigger}^{ctx.structure}",
140
+ ctx.control_id,
141
+ "P",
142
+ "2.5.1",
143
+ ]
144
+ )
145
+ return rf"MSH|^~\&|{tail}"
146
+
147
+
148
+ def _build_evn(rng: random.Random, ctx: Ctx) -> str:
149
+ reason = rng.choice(d.EVENT_REASONS)[0]
150
+ return seg("EVN", {1: ctx.trigger, 2: d.ts(ctx.msg_dt), 4: reason, 6: d.ts(ctx.msg_dt)})
151
+
152
+
153
+ def _build_pid(rng: random.Random, ctx: Ctx) -> str:
154
+ p = make_patient(rng)
155
+ ctx.current = p
156
+ return seg(
157
+ "PID",
158
+ {
159
+ 1: str(next_seq(ctx, "PID")),
160
+ 3: d.cx(p.mrn),
161
+ 5: d.xpn(p.family, p.given, p.middle),
162
+ 7: p.dob,
163
+ 8: p.sex,
164
+ 11: d.xad(p.street, p.city, p.state, p.zip_code),
165
+ 13: p.phone,
166
+ 18: d.cx(p.visit_no, id_type="AN"),
167
+ },
168
+ )
169
+
170
+
171
+ def _build_pd1(rng: random.Random, ctx: Ctx) -> str:
172
+ return seg(
173
+ "PD1", {3: f"{rng.choice(d.FACILITIES)} CLINIC", 4: d.xcn(*rng.choice(d.CLINICIANS))}
174
+ )
175
+
176
+
177
+ def _build_pv1(rng: random.Random, ctx: Ctx) -> str:
178
+ p = patient(ctx, rng)
179
+ return seg(
180
+ "PV1",
181
+ {
182
+ 1: str(next_seq(ctx, "PV1")),
183
+ 2: p.patient_class,
184
+ 3: d.pl(
185
+ rng.choice(d.POINTS_OF_CARE),
186
+ rng.choice(d.ROOMS),
187
+ rng.choice(d.BEDS),
188
+ rng.choice(d.FACILITIES),
189
+ ),
190
+ 7: d.xcn(*rng.choice(d.CLINICIANS)),
191
+ 10: rng.choice(d.HOSPITAL_SERVICES),
192
+ 17: d.xcn(*rng.choice(d.CLINICIANS)),
193
+ 19: d.cx(p.visit_no, id_type="VN"),
194
+ 44: d.ts(ctx.msg_dt),
195
+ },
196
+ )
197
+
198
+
199
+ def _build_pv2(rng: random.Random, ctx: Ctx) -> str:
200
+ code, text = rng.choice(d.DIAGNOSES)
201
+ return seg("PV2", {3: d.cwe(code, text, "I10")})
202
+
203
+
204
+ def _build_nk1(rng: random.Random, ctx: Ctx) -> str:
205
+ rel_code, rel_text = rng.choice(d.RELATIONSHIPS)
206
+ return seg(
207
+ "NK1",
208
+ {
209
+ 1: str(next_seq(ctx, "NK1")),
210
+ 2: d.xpn(rng.choice(d.FAMILY_NAMES), rng.choice(d.GIVEN_NAMES)),
211
+ 3: d.cwe(rel_code, rel_text, "HL70063"),
212
+ },
213
+ )
214
+
215
+
216
+ def _build_al1(rng: random.Random, ctx: Ctx) -> str:
217
+ code, text = rng.choice(d.ALLERGENS)
218
+ severity = rng.choice(d.ALLERGY_SEVERITIES)[0]
219
+ return seg(
220
+ "AL1",
221
+ {
222
+ 1: str(next_seq(ctx, "AL1")),
223
+ 2: d.cwe("DA", "Drug allergy", "HL70127"),
224
+ 3: d.cwe(code, text, "L"),
225
+ 4: severity,
226
+ },
227
+ )
228
+
229
+
230
+ def _build_dg1(rng: random.Random, ctx: Ctx) -> str:
231
+ code, text = rng.choice(d.DIAGNOSES)
232
+ return seg(
233
+ "DG1",
234
+ {
235
+ 1: str(next_seq(ctx, "DG1")),
236
+ 3: d.cwe(code, text, "I10"),
237
+ 6: rng.choice(d.DIAGNOSIS_TYPES),
238
+ },
239
+ )
240
+
241
+
242
+ def _build_obx(rng: random.Random, ctx: Ctx) -> str:
243
+ code, text, value_type, value, units = rng.choice(d.OBSERVATIONS)
244
+ return seg(
245
+ "OBX",
246
+ {
247
+ 1: str(next_seq(ctx, "OBX")),
248
+ 2: value_type,
249
+ 3: d.cwe(code, text, "LN"),
250
+ 5: value,
251
+ 6: units,
252
+ 11: "F",
253
+ },
254
+ )
255
+
256
+
257
+ def _build_orc(rng: random.Random, ctx: Ctx) -> str:
258
+ return seg(
259
+ "ORC",
260
+ {
261
+ 1: rng.choice(d.ORDER_CONTROLS),
262
+ 2: d.ei(str(rng.randint(100_000, 999_999))), # placer order number
263
+ 3: d.ei(str(rng.randint(100_000, 999_999)), "FILLER"),
264
+ 5: rng.choice(d.ORDER_STATUSES),
265
+ 9: d.ts(ctx.msg_dt),
266
+ 12: d.xcn(*rng.choice(d.CLINICIANS)),
267
+ },
268
+ )
269
+
270
+
271
+ def _build_obr(rng: random.Random, ctx: Ctx) -> str:
272
+ code, text = rng.choice(d.SERVICES)
273
+ return seg(
274
+ "OBR",
275
+ {
276
+ 1: str(next_seq(ctx, "OBR")),
277
+ 2: d.ei(str(rng.randint(100_000, 999_999))),
278
+ 3: d.ei(str(rng.randint(100_000, 999_999)), "FILLER"),
279
+ 4: d.cwe(code, text, "LN"), # universal service id (required)
280
+ 7: d.ts(ctx.msg_dt),
281
+ },
282
+ )
283
+
284
+
285
+ def _build_ft1(rng: random.Random, ctx: Ctx) -> str:
286
+ code, text = rng.choice(d.PROCEDURES)
287
+ return seg(
288
+ "FT1",
289
+ {
290
+ 1: str(next_seq(ctx, "FT1")),
291
+ 4: d.ts(ctx.msg_dt), # transaction date (required)
292
+ 6: rng.choice(d.TRANSACTION_TYPES), # transaction type (required)
293
+ 7: d.cwe(code, text, "CPT"), # transaction code (required)
294
+ },
295
+ )
296
+
297
+
298
+ def _build_pr1(rng: random.Random, ctx: Ctx) -> str:
299
+ code, text = rng.choice(d.PROCEDURES)
300
+ return seg(
301
+ "PR1",
302
+ {
303
+ 1: str(next_seq(ctx, "PR1")),
304
+ 3: d.cwe(code, text, "C4"), # procedure code (required)
305
+ 5: d.ts(ctx.msg_dt), # procedure date/time (required)
306
+ },
307
+ )
308
+
309
+
310
+ def _build_gt1(rng: random.Random, ctx: Ctx) -> str:
311
+ return seg(
312
+ "GT1",
313
+ {
314
+ 1: str(next_seq(ctx, "GT1")),
315
+ 3: d.xpn(rng.choice(d.FAMILY_NAMES), rng.choice(d.GIVEN_NAMES)), # guarantor (required)
316
+ },
317
+ )
318
+
319
+
320
+ def _build_in1(rng: random.Random, ctx: Ctx) -> str:
321
+ company_id, company = rng.choice(d.INSURANCE_COMPANIES)
322
+ code, text = rng.choice(d.INSURANCE_PLANS)
323
+ return seg(
324
+ "IN1",
325
+ {
326
+ 1: str(next_seq(ctx, "IN1")),
327
+ 2: d.cwe(code, text, "L"), # insurance plan id (required)
328
+ 3: d.cx(company_id, authority=company, id_type="NI"), # company id (required)
329
+ 4: company,
330
+ },
331
+ )
332
+
333
+
334
+ def _build_rxa(rng: random.Random, ctx: Ctx) -> str:
335
+ code, text = rng.choice(d.VACCINES)
336
+ return seg(
337
+ "RXA",
338
+ {
339
+ 1: "0", # give sub-id counter (required)
340
+ 2: "1", # administration sub-id counter (required)
341
+ 3: d.ts(ctx.msg_dt), # date/time start (required)
342
+ 4: d.ts(ctx.msg_dt), # date/time end (required)
343
+ 5: d.cwe(code, text, "CVX"), # administered code (required)
344
+ 6: "0.5", # administered amount (required)
345
+ },
346
+ )
347
+
348
+
349
+ def _build_rxr(rng: random.Random, ctx: Ctx) -> str:
350
+ code, text = rng.choice(d.ROUTES)
351
+ return seg("RXR", {1: d.cwe(code, text, "HL70162")}) # route (required)
352
+
353
+
354
+ SHARED_BUILDERS: dict[str, SegmentBuilder] = {
355
+ "MSH": _build_msh,
356
+ "EVN": _build_evn,
357
+ "PID": _build_pid,
358
+ "PD1": _build_pd1,
359
+ "PV1": _build_pv1,
360
+ "PV2": _build_pv2,
361
+ "NK1": _build_nk1,
362
+ "AL1": _build_al1,
363
+ "DG1": _build_dg1,
364
+ "OBX": _build_obx,
365
+ "ORC": _build_orc,
366
+ "OBR": _build_obr,
367
+ "FT1": _build_ft1,
368
+ "PR1": _build_pr1,
369
+ "GT1": _build_gt1,
370
+ "IN1": _build_in1,
371
+ "RXA": _build_rxa,
372
+ "RXR": _build_rxr,
373
+ }
374
+
375
+
376
+ # --- message-type registry ---------------------------------------------------
377
+
378
+
379
+ def _no_force(structure: str) -> frozenset[str]:
380
+ return frozenset()
381
+
382
+
383
+ @dataclass(frozen=True)
384
+ class MessageSpec:
385
+ """How to generate one HL7 message family.
386
+
387
+ ``builders`` are this type's own segments (merged over :data:`SHARED_BUILDERS`);
388
+ ``force`` names optional segments to always emit for a given structure; ``gate`` overrides
389
+ strict validation (e.g. ADT's two-block fallback).
390
+ """
391
+
392
+ code: str
393
+ trigger_to_structure: dict[str, str]
394
+ builders: Mapping[str, SegmentBuilder] = field(default_factory=dict)
395
+ optional_allowlist: frozenset[str] = frozenset()
396
+ force: Callable[[str], frozenset[str]] = _no_force
397
+ gate: GateFn | None = None
398
+ # Optional groups to recurse into, matched by name *suffix* (e.g. "_PATIENT") so one spec
399
+ # covers every structure of its type (ORM_O01_PATIENT, SIU_S12_PATIENT, …).
400
+ group_suffixes: frozenset[str] = frozenset()
401
+
402
+
403
+ _REGISTRY: dict[str, MessageSpec] = {}
404
+
405
+
406
+ def register(spec: MessageSpec) -> None:
407
+ _REGISTRY[spec.code] = spec
408
+
409
+
410
+ def message_codes() -> list[str]:
411
+ """Registered message types (e.g. ``["ADT", "ORM", …]``) — drives the harness GUI."""
412
+ return sorted(_REGISTRY)
413
+
414
+
415
+ def triggers_for(code: str) -> list[str]:
416
+ return sorted(_REGISTRY[code].trigger_to_structure)
417
+
418
+
419
+ def structure_for(code: str, trigger: str) -> str:
420
+ return _REGISTRY[code].trigger_to_structure[trigger]
421
+
422
+
423
+ def control_id(code: str, trigger: str, index: int) -> str:
424
+ """The deterministic synthetic control id embedded in MSH-10 by :func:`generate_message`.
425
+
426
+ Single source of truth so the harness (which labels rows and matches the engine's recorded
427
+ control ids against it) and the generator never drift apart."""
428
+ return f"MEFOR{code}{trigger}{index:05d}"
429
+
430
+
431
+ # --- reference-driven assembly ----------------------------------------------
432
+
433
+
434
+ def _emit(
435
+ children: list[Any],
436
+ rng: random.Random,
437
+ ctx: Ctx,
438
+ spec: MessageSpec,
439
+ force: frozenset[str],
440
+ out: list[str],
441
+ ) -> None:
442
+ """Walk an hl7apy reference child list, appending built segment strings to ``out``.
443
+
444
+ Required children (min>=1) are always emitted; optional segments are emitted only if
445
+ allow-listed (a random 0..N for repeating ones), or if named in ``force``. Groups recurse
446
+ only when the group itself is required.
447
+ """
448
+ for child in children:
449
+ name = child[0]
450
+ child_ref = child[1]
451
+ min_card, max_card = child[2][0], child[2][1]
452
+
453
+ if name in _SEGMENTS:
454
+ builder = spec.builders.get(name) or SHARED_BUILDERS.get(name)
455
+ if min_card >= 1 or name in force:
456
+ if builder is None:
457
+ raise RuntimeError(f"no builder for required/forced segment {name}")
458
+ out.append(builder(rng, ctx))
459
+ elif name in spec.optional_allowlist and builder is not None:
460
+ max_reps = 1 if max_card == 1 else 3
461
+ for _ in range(rng.randint(0, max_reps)):
462
+ out.append(builder(rng, ctx))
463
+ elif min_card >= 1 or any(name.endswith(s) for s in spec.group_suffixes):
464
+ _emit(child_ref[1], rng, ctx, spec, force, out)
465
+
466
+
467
+ def generate_message(code: str, trigger: str, index: int, *, seed: str = DEFAULT_SEED) -> str:
468
+ """Build one ``\\r``-delimited HL7 message (deterministic given ``code``/``trigger``/``seed``)."""
469
+ spec = _REGISTRY[code]
470
+ structure = spec.trigger_to_structure[trigger]
471
+ rng = random.Random(f"{seed}|{code}|{trigger}|{index}")
472
+ sending_app, sending_fac = rng.choice(d.SENDING_APPS)
473
+ receiving_app, receiving_fac = rng.choice(d.RECEIVING_APPS)
474
+ ctx = Ctx(
475
+ message_code=code,
476
+ trigger=trigger,
477
+ structure=structure,
478
+ control_id=control_id(code, trigger, index),
479
+ msg_dt=BASE_DT + timedelta(minutes=rng.randint(0, 525_600)),
480
+ sending_app=sending_app,
481
+ sending_fac=sending_fac,
482
+ receiving_app=receiving_app,
483
+ receiving_fac=receiving_fac,
484
+ )
485
+ out: list[str] = []
486
+ _emit(_MESSAGES[structure][1], rng, ctx, spec, spec.force(structure), out)
487
+ return "\r".join(out) + "\r"
488
+
489
+
490
+ # --- compliance gate ---------------------------------------------------------
491
+
492
+
493
+ def default_gate(msg: str, structure: str) -> tuple[bool, list[str]]:
494
+ """Strict hl7apy validation (the default for types with no special-casing)."""
495
+ result = validate(msg, expected_version="2.5.1")
496
+ return result.ok, list(result.errors)
497
+
498
+
499
+ def gate(code: str, msg: str, structure: str) -> tuple[bool, list[str]]:
500
+ """Return ``(ok, errors)`` using the type's gate override, else strict validation."""
501
+ spec = _REGISTRY[code]
502
+ if spec.gate is not None:
503
+ return spec.gate(msg, structure)
504
+ return default_gate(msg, structure)
505
+
506
+
507
+ # --- corpus writer (shared by the CLI and the per-type modules) --------------
508
+
509
+
510
+ class GenerationError(RuntimeError):
511
+ """A generated message failed strict validation — a generator bug, never a user error."""
512
+
513
+ def __init__(
514
+ self, code: str, trigger: str, index: int, structure: str, errors: list[str], message: str
515
+ ) -> None:
516
+ self.code = code
517
+ self.trigger = trigger
518
+ self.index = index
519
+ self.structure = structure
520
+ self.errors = errors
521
+ self.message = message
522
+ super().__init__(
523
+ f"{code}^{trigger} #{index} ({structure}) failed validation: {'; '.join(errors)}"
524
+ )
525
+
526
+
527
+ @dataclass(frozen=True)
528
+ class CorpusResult:
529
+ """What :func:`write_corpus` produced."""
530
+
531
+ code: str
532
+ out_dir: str
533
+ by_trigger: dict[str, int] # trigger -> messages written
534
+ total: int
535
+
536
+
537
+ def write_corpus(
538
+ code: str,
539
+ *,
540
+ triggers: list[str] | None = None,
541
+ count: int = 50,
542
+ out: str | Path,
543
+ seed: str = DEFAULT_SEED,
544
+ corpus_root: str | Path | None = None,
545
+ ) -> CorpusResult:
546
+ """Generate + strict-validate ``count`` messages per trigger and write them as
547
+ ``<out>/<TRIGGER>/NNNN.hl7`` (all-or-nothing per trigger; stale generated files are cleared first).
548
+
549
+ ``triggers`` defaults to every trigger of ``code``. The cleanup is **scoped**: it deletes only
550
+ its own ``NNNN.hl7`` files, never arbitrary ``*.hl7`` a user placed there (GEN-1). When
551
+ ``corpus_root`` is given, ``out`` must resolve **within** it — a containment guard for
552
+ programmatic callers (the CLI writes the user's explicit ``--out``). Raises :class:`ValueError`
553
+ for an out-of-root path, :class:`KeyError` for an unknown type/trigger, and
554
+ :class:`GenerationError` if any message fails validation.
555
+ """
556
+ if code not in _REGISTRY:
557
+ raise KeyError(f"unknown message type {code!r}; registered: {', '.join(message_codes())}")
558
+ chosen = list(triggers) if triggers else triggers_for(code)
559
+ unknown = [t for t in chosen if t not in _REGISTRY[code].trigger_to_structure]
560
+ if unknown:
561
+ raise KeyError(f"unknown {code} trigger(s): {', '.join(unknown)}")
562
+
563
+ out_root = Path(out).resolve()
564
+ if corpus_root is not None:
565
+ root = Path(corpus_root).resolve()
566
+ if out_root != root and root not in out_root.parents:
567
+ raise ValueError(f"refusing to write corpus outside {root}: {out_root}")
568
+ by_trigger: dict[str, int] = {}
569
+ total = 0
570
+ for trigger in chosen:
571
+ structure = structure_for(code, trigger)
572
+ # Build + validate the whole batch before writing (all-or-nothing per trigger).
573
+ messages: list[str] = []
574
+ for index in range(1, count + 1):
575
+ msg = generate_message(code, trigger, index, seed=seed)
576
+ ok, errors = gate(code, msg, structure)
577
+ if not ok:
578
+ raise GenerationError(code, trigger, index, structure, errors, msg)
579
+ messages.append(msg)
580
+ trigger_dir = out_root / trigger
581
+ trigger_dir.mkdir(parents=True, exist_ok=True)
582
+ # Only clear our own generated files (NNNN.hl7), not arbitrary *.hl7 a user may have placed.
583
+ for stale in trigger_dir.glob("[0-9][0-9][0-9][0-9].hl7"):
584
+ stale.unlink()
585
+ for index, msg in enumerate(messages, start=1):
586
+ (trigger_dir / f"{index:04d}.hl7").write_bytes(msg.encode("utf-8"))
587
+ by_trigger[trigger] = len(messages)
588
+ total += len(messages)
589
+ return CorpusResult(code=code, out_dir=str(out_root), by_trigger=by_trigger, total=total)