Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
18 commits
Select commit Hold shift + click to select a range
697edf8
Dependabot: restate the pynetdicom, pydicom and webauthn caps as igno…
Oct 1, 2026
16306ca
feat(generators): emit one alternative of a choice group; ORM^O01 get…
Oct 1, 2026
e965f7b
Dependabot cap test: simplify, derive group-pin exemptions, point not…
Oct 1, 2026
8e9134a
Dependabot cap test: review round 1, exact ranges and build-system (B…
Oct 1, 2026
a593b56
perf(cli,tray): defer startup imports with __lazy_modules__ (BACKLOG …
Oct 1, 2026
32b6d53
fix(generators): OBR repeats its ORC's order numbers; choice predicat…
Oct 1, 2026
643c59c
Dependabot: restate the pynetdicom, pydicom and webauthn caps as igno…
Oct 1, 2026
8ef470f
Dependabot: restate the pydicom cap as an ignore range, and test ever…
Oct 1, 2026
8b079cd
Dependabot: restate the pydicom cap as an ignore range, and test ever…
Oct 1, 2026
230cf26
test(startup): keep the probe's exit code, cover tray.branding, strip…
Oct 1, 2026
602673a
test(generators): pin the ORC-to-OBR number hand-over; changelog spli…
Oct 1, 2026
b01af4b
Dependabot: restate the pynetdicom, pydicom and webauthn caps as igno…
Oct 1, 2026
ad67091
Dependabot: restate the pynetdicom, pydicom and webauthn caps as igno…
Oct 1, 2026
c4aa71c
Merge remote-tracking branch 'origin/b2514-lazy-modules' into batch/d…
Oct 1, 2026
a1dc163
Merge remote-tracking branch 'origin/b2499-2497-choice-groups' into b…
Oct 1, 2026
5fa55dd
Merge remote-tracking branch 'origin/b2505-cap-ignores' into batch/de…
Oct 1, 2026
a06541b
Merge remote-tracking branch 'origin/main' into fix-1883
Oct 1, 2026
80bd731
test(generators): import all_types so the order-number test registers…
Oct 1, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
44 changes: 33 additions & 11 deletions .github/dependabot.yml
Original file line number Diff line number Diff line change
Expand Up @@ -57,14 +57,14 @@ updates:
default-days: 5
semver-major-days: 7
# Dependabot WIDENS a declared cap instead of respecting it, so a load-bearing upper bound in
# pyproject.toml must be restated here as a version range or the cap is decorative. PR #66 rewrote
# pyproject.toml must be restated here as a version range or the cap is decorative, unless it is
# a named exemption (THE RULE, below). PR #66 rewrote
# `annotated-types<0.8` -> `<0.9` and `ruff>=0.4,<0.16` -> `<0.17`; the
# resync then propagated 0.8.0/0.16.0 into constraints.lock, so ci.yml's `--constraint` pinned CI
# *to* the broken versions. `python-deps` groups every minor and patch on `*`, and both of those
# bumps are minors, so they red the WHOLE batch and hold every benign bump in it hostage. WHY
# each is capped lives beside the cap in pyproject.toml (ruff in [dev]; uvicorn in
# [project.dependencies]) — do not
# restate it here; lift a cap and delete its entry in the SAME PR.
# bumps are minors, so they red the WHOLE batch and hold every benign bump in it hostage. Where
# pyproject.toml records WHY a package is capped, it sits beside the cap; do not restate it here.
# Lift a cap and delete its entry in the SAME PR.
# TRADE-OFF, accepted: `ignore` suppresses the SECURITY track for the named RANGE as well as the
# routine one (`update-types` is the version-only knob and cannot express a range). A ruff
# 0.15.x or uvicorn 0.49.x advisory fix still flows. Detection is untouched — security.yml's
Expand Down Expand Up @@ -112,12 +112,25 @@ updates:
# security.yml runs pip-audit over ci/locks/release-tools.lock. Lift this entry in the same PR
# that moves the pyproject spec to a validated 7.4.
#
# `uvicorn` is the same WIDENED-CAP case. Dependabot proposed widening `uvicorn[standard]`'s
# `<0.50` to `<0.54` in PR 1773, which the Lander closed on 2026-09-28: 0.50 and later pick a
# websockets protocol the protocol-header floor refuses (BACKLOG #1120 arm (b)), so serve
# exits 2. Why the cap exists is beside it in pyproject.toml; read it there. The bare name
# matches `uvicorn[standard]` because dependabot-core's Python NameNormaliser strips extras
# before it compares names (read 2026-09-30). Lift this entry in the same PR that lifts the cap.
# `uvicorn` is the same WIDENED-CAP case. Dependabot proposed widening uvicorn's `<0.50` to
# `<0.54` in PR 1773, which the Lander closed on 2026-09-28: 0.50 and later pick a websockets
# protocol the protocol-header floor refuses (BACKLOG #1120 arm (b)), so serve exits 2. Why the
# cap exists is beside it in pyproject.toml; read it there. pyproject.toml declares plain
# `uvicorn`, not `uvicorn[standard]`, so the bare name here matches it as written. Lift this
# entry in the same PR that lifts the cap.
#
# `pydicom`, `webauthn` and `pynetdicom` are the same WIDENED-CAP case (BACKLOG #2505). A major
# cap needs its entry as much as a minor one: `python-deps-major` is one batch on `*`, so a
# widened major cap holds that batch hostage too. pyproject.toml's [dicom] comment gives
# pynetdicom's own pydicom>=3,<4 requirement but no reason for `pynetdicom<4` itself; the cap is
# already in 5fa6db9f42, the 2026-07-06 history-reset snapshot, so the entry mirrors it as-is.
#
# THE RULE (BACKLOG #2505): every upper bound in pyproject.toml has an entry here, exactly `>=`
# the version the test derives (a `<` cap's bound; for an exact `==` pin, the next minor), unless
# it is a NAMED exemption. A named exemption must NOT have an entry. The list, with each reason,
# is `_EXEMPT` in tests/test_dependabot_cap_ignores.py; it holds at least `hvac`, `hatchling` and
# the exact group pins under "NOT IGNORED, deliberately" above other than `sigstore`. The test
# also holds every entry here to a cap that still exists.
ignore:
- dependency-name: "ruff"
versions: [">=0.16.0"]
Expand All @@ -127,6 +140,15 @@ updates:
versions: [">=7.4.0"]
- dependency-name: "uvicorn"
versions: [">=0.50.0"]
# Why: the comment above the [dicom] extra in pyproject.toml.
- dependency-name: "pydicom"
versions: [">=3.1.0"]
# Why: the comment above the [webauthn] extra in pyproject.toml.
- dependency-name: "webauthn"
versions: [">=4.0.0"]
# No reason for this cap is recorded in pyproject.toml; see the paragraph above.
- dependency-name: "pynetdicom"
versions: [">=4.0.0"]
groups:
# Version-update grouping (applies-to defaults to version-updates), SPLIT BY UPDATE TYPE (owner
# decision 2026-09-30) so one bad major cannot hold the routine minors and patches hostage.
Expand Down
6 changes: 6 additions & 0 deletions changelog.d/2497.changed.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
- **`messagefoundry generate` now gives every ORM^O01 an order detail.** The generator used to
leave ORDER_DETAIL out, because it emitted every required child of a group, and the order
detail's OBR/RQD/RQ1/RXO/ODS/ODT group is a choice. It now emits one alternative of a choice
group, OBR by default. Each generated ORM^O01 now ends ORC then OBR and passes strict
validation, so a corpus generated before this change differs in its ORM files.
(`BACKLOG #2497`)
4 changes: 4 additions & 0 deletions changelog.d/2497.fixed.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
- **A generated OBR now repeats its order's placer and filler numbers.** OBR-2 and OBR-3 drew
fresh numbers instead of copying ORC-2 and ORC-3, as HL7 requires. In generated OML, MDM and
ORU messages that carry an ORC then an OBR, only those two fields change, so a corpus
generated before this change differs there. (`BACKLOG #2497`)
5 changes: 5 additions & 0 deletions messagefoundry/__main__.py
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,11 @@

from __future__ import annotations

# PEP 810 (BACKLOG #2514; inert on 3.14, see tests/test_startup_import_budget.py). Only commands that
# open a store or read a service TOML use these. The rest below runs on every command or is the
# logging chain, so it stays eager. The heavy import is deferred in config/__init__.py.
__lazy_modules__ = ["sqlite3", "tomllib"]

import argparse
import functools
import json
Expand Down
5 changes: 5 additions & 0 deletions messagefoundry/config/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,11 @@

from __future__ import annotations

# PEP 810 (BACKLOG #2514; inert on 3.14, see tests/test_startup_import_budget.py). Importing the
# leaf `config.tls_policy`, which every CLI command reaches through `logging_setup`, then no longer
# loads pydantic and the models: about 80 modules. A use of a re-exported name loads them.
__lazy_modules__ = ["messagefoundry.config.models"]

from messagefoundry.config.models import (
AckMode,
ConnectorType,
Expand Down
63 changes: 54 additions & 9 deletions messagefoundry/generators/_core.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,8 @@
trigger→structure map, its segment builders, and which optional segments to sprinkle in.
Generation walks hl7apy's own 2.5.1 reference tree (``MESSAGES[structure]``): for each
structure we emit required segments in order plus a valid random subset of allow-listed
optionals, then gate every message through the engine's strict validator before it counts.
optionals, and one alternative of each choice group, then gate every message through the
engine's strict validator before it counts.

A type module contributes builders only for its *own* segments; the broadly shared ones
(MSH/EVN/PID/PV1/…) live here in :data:`SHARED_BUILDERS`. All data is synthetic — no real PHI.
Expand All @@ -26,6 +27,9 @@
from messagefoundry.generators import _hl7data as d
from messagefoundry.parsing import validate

# The strict validator's own choice test, so generation and validation agree on what a choice is.
from messagefoundry.parsing.validate import is_choice_group

_MESSAGES = _ref.MESSAGES
_SEGMENTS = _ref.SEGMENTS

Expand Down Expand Up @@ -74,6 +78,8 @@ class Ctx:
receiving_fac: str
current: Patient | None = None
seq: dict[str, int] = field(default_factory=dict)
# The current ORC's placer and filler order numbers (ORC-2, ORC-3), which its OBR repeats.
order_numbers: tuple[str, str] | None = None


def next_seq(ctx: Ctx, name: str) -> int:
Expand Down Expand Up @@ -255,12 +261,16 @@ def _build_obx(rng: random.Random, ctx: Ctx) -> str:


def _build_orc(rng: random.Random, ctx: Ctx) -> str:
control = rng.choice(d.ORDER_CONTROLS)
placer = d.ei(str(rng.randint(100_000, 999_999))) # placer order number
filler = d.ei(str(rng.randint(100_000, 999_999)), "FILLER")
ctx.order_numbers = (placer, filler)
return seg(
"ORC",
{
1: rng.choice(d.ORDER_CONTROLS),
2: d.ei(str(rng.randint(100_000, 999_999))), # placer order number
3: d.ei(str(rng.randint(100_000, 999_999)), "FILLER"),
1: control,
2: placer,
3: filler,
5: rng.choice(d.ORDER_STATUSES),
9: d.ts(ctx.msg_dt),
12: d.xcn(*rng.choice(d.CLINICIANS)),
Expand All @@ -270,12 +280,20 @@ def _build_orc(rng: random.Random, ctx: Ctx) -> str:

def _build_obr(rng: random.Random, ctx: Ctx) -> str:
code, text = rng.choice(d.SERVICES)
# Drawn even when an ORC supplies them, so every later value keeps its place in the stream.
placer = d.ei(str(rng.randint(100_000, 999_999)))
filler = d.ei(str(rng.randint(100_000, 999_999)), "FILLER")
if ctx.order_numbers is not None:
# OBR-2/OBR-3 repeat the order's ORC-2/ORC-3. Consumed, so an OBR in a later order
# without its own ORC does not borrow this one's numbers.
placer, filler = ctx.order_numbers
ctx.order_numbers = None
return seg(
"OBR",
{
1: str(next_seq(ctx, "OBR")),
2: d.ei(str(rng.randint(100_000, 999_999))),
3: d.ei(str(rng.randint(100_000, 999_999)), "FILLER"),
2: placer,
3: filler,
4: d.cwe(code, text, "LN"), # universal service id (required)
7: d.ts(ctx.msg_dt),
},
Expand Down Expand Up @@ -398,6 +416,12 @@ class MessageSpec:
# Optional groups to recurse into, matched by name *suffix* (e.g. "_PATIENT") so one spec
# covers every structure of its type (ORM_O01_PATIENT, SIU_S12_PATIENT, …).
group_suffixes: frozenset[str] = frozenset()
# The alternative a choice group emits when it offers this one (see ``_pick_alternative``).
preferred_alternative: str = "OBR"


def _builder_for(spec: MessageSpec, name: str) -> SegmentBuilder | None:
return spec.builders.get(name) or SHARED_BUILDERS.get(name)


_REGISTRY: dict[str, MessageSpec] = {}
Expand Down Expand Up @@ -431,6 +455,22 @@ def control_id(code: str, trigger: str, index: int) -> str:
# --- reference-driven assembly ----------------------------------------------


def _pick_alternative(group: str, alternatives: Any, rng: random.Random, spec: MessageSpec) -> Any:
"""The one alternative of choice group ``group`` to emit.

``spec.preferred_alternative`` (OBR by default) when the group offers it and it can be built,
without drawing from ``rng``. Otherwise a seeded pick among the segment alternatives that
can be built, so a seed reproduces its bytes for a given set of builders; adding a builder
can change that pick. No shipped hl7apy choice group has a group as an alternative, so only
segments are candidates.
"""
usable = [alt for alt in alternatives if alt[3] == "SEG" and _builder_for(spec, alt[0])]
if not usable:
raise RuntimeError(f"no builder for any alternative of choice group {group}")
preferred = next((alt for alt in usable if alt[0] == spec.preferred_alternative), None)
return preferred if preferred is not None else rng.choice(usable)


def _emit(
children: list[Any],
rng: random.Random,
Expand All @@ -443,15 +483,17 @@ def _emit(

Required children (min>=1) are always emitted; optional segments are emitted only if
allow-listed (a random 0..N for repeating ones), or if named in ``force``. Groups recurse
only when the group itself is required.
only when the group itself is required or matches ``spec.group_suffixes``. A choice group
means "exactly one of", so it emits one alternative rather than every required child. The
choice test is the strict validator's, which keeps the sequences hl7apy mislabels as choices.
"""
for child in children:
name = child[0]
child_ref = child[1]
min_card, max_card = child[2][0], child[2][1]

if name in _SEGMENTS:
builder = spec.builders.get(name) or SHARED_BUILDERS.get(name)
builder = _builder_for(spec, name)
if min_card >= 1 or name in force:
if builder is None:
raise RuntimeError(f"no builder for required/forced segment {name}")
Expand All @@ -461,7 +503,10 @@ def _emit(
for _ in range(rng.randint(0, max_reps)):
out.append(builder(rng, ctx))
elif min_card >= 1 or any(name.endswith(s) for s in spec.group_suffixes):
_emit(child_ref[1], rng, ctx, spec, force, out)
group_children = child_ref[1]
if is_choice_group(name, child_ref):
group_children = [_pick_alternative(name, group_children, rng, spec)]
_emit(group_children, rng, ctx, spec, force, out)


def generate_message(code: str, trigger: str, index: int, *, seed: str = DEFAULT_SEED) -> str:
Expand Down
9 changes: 4 additions & 5 deletions messagefoundry/generators/orm.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,10 +2,9 @@
# Copyright (C) 2026 MessageFoundry Foundation, LLC and contributors
"""Generate conformant HL7 v2.5.1 **ORM** (general order) messages.

ORM_O01 only *requires* MSH + ORC; we include the optional PATIENT group (PID/PV1) for realism.
We deliberately omit ORDER_DETAIL: hl7apy models its OBR/RQD/RQ1/RXO/ODS/ODT subgroup as
all-required rather than a choice, so it can't be populated sensibly — OBR-based orders are
better expressed via OML (the modern lab order) instead.
ORM_O01 only *requires* MSH + ORC; we include the optional PATIENT group (PID/PV1) for realism,
and an ORDER_DETAIL after each ORC. Its OBR/RQD/RQ1/RXO/ODS/ODT group is a choice, so the
generator emits exactly one alternative, OBR (see ``_core._pick_alternative``).
"""

from __future__ import annotations
Expand All @@ -18,6 +17,6 @@
code="ORM",
trigger_to_structure={"O01": "ORM_O01"},
optional_allowlist=frozenset({"PD1", "PV2"}),
group_suffixes=frozenset({"_PATIENT", "_PATIENT_VISIT"}),
group_suffixes=frozenset({"_PATIENT", "_PATIENT_VISIT", "_ORDER_DETAIL"}),
)
)
16 changes: 12 additions & 4 deletions messagefoundry/parsing/validate.py
Original file line number Diff line number Diff line change
Expand Up @@ -48,7 +48,7 @@
normalize,
)

__all__ = ["ValidationResult", "validate"]
__all__ = ["ValidationResult", "is_choice_group", "validate"]

logger = logging.getLogger(__name__)

Expand Down Expand Up @@ -116,6 +116,8 @@ def _choice_fix_needed() -> bool:
release that accepts the valid probe only by no longer checking choice groups at all, and
for one that merges PR 152 as written and so rejects the labelled-choice probe.
``tests/test_validate_choice_groups.py`` goes red on a fixed release, so the shim gets deleted.
Keep :func:`is_choice_group` and ``_SEQUENCES_LABELLED_CHOICE`` when it is: the generator
uses them.
"""
from hl7apy.consts import VALIDATION_LEVEL
from hl7apy.exceptions import HL7apyException
Expand Down Expand Up @@ -150,7 +152,13 @@ def accepted(raw: str) -> bool:
_rewrite_failures: set[tuple[str, str | None]] = set()


def _is_choice(name: str, ref: _Reference) -> bool:
def is_choice_group(name: str, ref: _Reference) -> bool:
"""True if hl7apy table entry ``ref`` for group ``name`` is a real "exactly one of" choice.

The synthetic-message generator uses this too, so it emits one alternative where strict
validation wants one. This predicate and ``_SEQUENCES_LABELLED_CHOICE`` describe hl7apy's
tables, not issue 151, so they outlive the shim: deleting the shim must keep them.
"""
return ref[0] == "choice" and name not in _SEQUENCES_LABELLED_CHOICE


Expand All @@ -164,7 +172,7 @@ def _as_sequence(ref: _Reference, *, choice: bool = False) -> _Reference:
children = []
for name, sub, (low, high), marker in ref[1]:
if marker == "GRP":
sub = _as_sequence(sub, choice=_is_choice(name, sub))
sub = _as_sequence(sub, choice=is_choice_group(name, sub))
children.append([name, sub, (0, high) if choice else (low, high), marker])
return ("sequence", tuple(children), *ref[2:])

Expand Down Expand Up @@ -214,7 +222,7 @@ def _first_choice_error(element: Any, ref: _Reference) -> str | None:
if marker != "GRP":
continue
for group in _occurrences(element, name):
error = _choice_error(group, sub) if _is_choice(name, sub) else None
error = _choice_error(group, sub) if is_choice_group(name, sub) else None
if error is None:
error = _first_choice_error(group, sub)
if error is not None:
Expand Down
5 changes: 5 additions & 0 deletions messagefoundry/tray/__main__.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,11 @@

from __future__ import annotations

# PEP 810 (BACKLOG #2514; inert on 3.14, see tests/test_startup_import_budget.py). The first process
# returns after relaunch_branded() and never takes the mutex. logscrub stays eager on purpose: it is
# tray.log's PHI and credential filter, and the log-scrub chain is kept out of every lazy list.
__lazy_modules__ = ["messagefoundry.tray.instance"]

import logging
import logging.handlers
import sys
Expand Down
5 changes: 5 additions & 0 deletions messagefoundry/tray/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,11 @@

from __future__ import annotations

# PEP 810 (BACKLOG #2514; inert on 3.14, see tests/test_startup_import_budget.py). The tray's first
# process only calls default_config_dir() before it re-execs as the branded child, so it need not
# load service_status (asyncio, subprocess, ctypes) or tomllib.
__lazy_modules__ = ["messagefoundry.service_status", "tomllib"]

import re
import sys
import tomllib
Expand Down
Loading
Loading