diff --git a/.github/dependabot.yml b/.github/dependabot.yml index 05cd9c446..6df5627cc 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -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 @@ -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"] @@ -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. diff --git a/changelog.d/2497.changed.md b/changelog.d/2497.changed.md new file mode 100644 index 000000000..ceb0cb772 --- /dev/null +++ b/changelog.d/2497.changed.md @@ -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`) diff --git a/changelog.d/2497.fixed.md b/changelog.d/2497.fixed.md new file mode 100644 index 000000000..3e12ae861 --- /dev/null +++ b/changelog.d/2497.fixed.md @@ -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`) diff --git a/messagefoundry/__main__.py b/messagefoundry/__main__.py index c3b952a95..e6ab1655d 100644 --- a/messagefoundry/__main__.py +++ b/messagefoundry/__main__.py @@ -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 diff --git a/messagefoundry/config/__init__.py b/messagefoundry/config/__init__.py index a9f326ec8..965bc5dec 100644 --- a/messagefoundry/config/__init__.py +++ b/messagefoundry/config/__init__.py @@ -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, diff --git a/messagefoundry/generators/_core.py b/messagefoundry/generators/_core.py index 5640b2f76..8d7166c6b 100644 --- a/messagefoundry/generators/_core.py +++ b/messagefoundry/generators/_core.py @@ -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. @@ -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 @@ -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: @@ -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)), @@ -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), }, @@ -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] = {} @@ -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, @@ -443,7 +483,9 @@ 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] @@ -451,7 +493,7 @@ def _emit( 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}") @@ -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: diff --git a/messagefoundry/generators/orm.py b/messagefoundry/generators/orm.py index 17920989f..6fb4246a4 100644 --- a/messagefoundry/generators/orm.py +++ b/messagefoundry/generators/orm.py @@ -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 @@ -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"}), ) ) diff --git a/messagefoundry/parsing/validate.py b/messagefoundry/parsing/validate.py index 920c3876a..f51a2780b 100644 --- a/messagefoundry/parsing/validate.py +++ b/messagefoundry/parsing/validate.py @@ -48,7 +48,7 @@ normalize, ) -__all__ = ["ValidationResult", "validate"] +__all__ = ["ValidationResult", "is_choice_group", "validate"] logger = logging.getLogger(__name__) @@ -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 @@ -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 @@ -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:]) @@ -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: diff --git a/messagefoundry/tray/__main__.py b/messagefoundry/tray/__main__.py index ba9235082..b1b99fdf6 100644 --- a/messagefoundry/tray/__main__.py +++ b/messagefoundry/tray/__main__.py @@ -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 diff --git a/messagefoundry/tray/config.py b/messagefoundry/tray/config.py index 26daf3936..0469145f0 100644 --- a/messagefoundry/tray/config.py +++ b/messagefoundry/tray/config.py @@ -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 diff --git a/tests/test_dependabot_cap_ignores.py b/tests/test_dependabot_cap_ignores.py new file mode 100644 index 000000000..b344b4076 --- /dev/null +++ b/tests/test_dependabot_cap_ignores.py @@ -0,0 +1,437 @@ +# SPDX-License-Identifier: AGPL-3.0-or-later +# Copyright (C) 2026 MessageFoundry Foundation, LLC and contributors +"""Every upper bound in pyproject.toml has a Dependabot ignore range, or a named exemption. + +Dependabot WIDENS a declared cap instead of respecting it; ``.github/dependabot.yml`` says so above +its uv ``ignore`` list, with the history. Until BACKLOG #2505 nothing held the two files together, +and three caps had no entry. + +THE CONTRACT, both directions: + +* Every upper bound in ``[build-system].requires``, ``[project.dependencies]``, + ``[project.optional-dependencies]`` and ``[dependency-groups]`` (``<``, ``~=``, ``==``, or an + ``==X.*`` wildcard) has a uv-ecosystem ``ignore`` entry whose range is exactly ``>=`` the version + ``_cap_of`` derives from the package's tightest cap. Or the package is named in ``_EXEMPT`` + below, in the table it names, with its reason. +* A package named in ``_EXEMPT`` must NOT have an entry. That is the must-not list: hvac's cap is + deliberately not mirrored, and pyproject.toml says why. +* Every uv ``ignore`` entry names a package that pyproject.toml caps. dependabot.yml says to lift a + cap and delete its entry in the same PR; this is what makes that rule fail when skipped. It is + also this test's positive control: a parser that found no caps would red on every real entry, so + the first test cannot pass vacuously. + +For a ``<`` cap the derived version is the bound itself. For an exact ``==`` pin it is the next +minor, which is the scope dependabot.yml's sigstore note records: the pin's patch track stays open. + +The mutation arms break a made-up cap and entry pair added to copies of the real files, never a +real one, so lifting a real cap cannot break an arm. +""" + +from __future__ import annotations + +import copy +import functools +import tomllib +from collections.abc import Callable +from pathlib import Path +from typing import Any, NamedTuple + +import pytest +import yaml +from packaging.requirements import Requirement +from packaging.specifiers import InvalidSpecifier, Specifier, SpecifierSet +from packaging.utils import canonicalize_name +from packaging.version import Version + +from tests.test_ci_venv_pinning import EXACT_GROUP_PINS + +_ROOT = Path(__file__).resolve().parents[1] +_PYPROJECT = _ROOT / "pyproject.toml" +_DEPENDABOT = _ROOT / ".github" / "dependabot.yml" +_GROUPS = "[dependency-groups]" + +#: The exact ``==`` pins in ``[dependency-groups]`` that are deliberately NOT ignored. ``sigstore`` +#: is the one exact group pin that IS ignored, by owner ruling; dependabot.yml's sigstore note says +#: why. If that ruling is lifted and its entry deleted, drop sigstore from this subtraction too. +_GROUP_PINS = frozenset(EXACT_GROUP_PINS) - {"sigstore"} +_GROUP_PIN_REASON = ( + ".github/dependabot.yml, 'NOT IGNORED, deliberately': ignoring the exact pins in " + "[dependency-groups] would freeze the hash-pinned CI toolchain" +) + +#: Every cap with no ignore entry, by name: package -> (its table, the reason). Each is also a +#: MUST-NOT: an ignore entry for it reds. The table is a prefix of ``Cap.where``, so a cap on the +#: same package anywhere else is not exempt. An exemption in ``[dependency-groups]`` must also be an +#: exact ``==`` pin. Read each reason where it points. +_EXEMPT: dict[str, tuple[str, str]] = { + **dict.fromkeys(_GROUP_PINS, (_GROUPS, _GROUP_PIN_REASON)), + "hvac": ( + "[project.optional-dependencies].vault", + "pyproject.toml's comment above the [vault] extra says hvac is deliberately not mirrored", + ), + "hatchling": ( + "[build-system].requires", + "pyproject.toml's [build-system] comment expects Dependabot to bump this pin", + ), +} + + +class Cap(NamedTuple): + """One upper bound: where it sits, and the version its ignore range must start at.""" + + package: str + where: str + spec: str + exact_pin: bool + ignore_from: Version + + +def _is_pin(spec: Specifier) -> bool: + return spec.operator == "==" and not spec.version.endswith(".*") + + +def _bump(release: tuple[int, ...]) -> Version: + """Increment the last component of ``release``: (7, 3) -> 7.4, (4,) -> 5.""" + return Version(".".join(str(n) for n in (*release[:-1], release[-1] + 1))) + + +def _cap_of(spec: Specifier) -> Version | None: + """Where an upper-bound specifier's ignore range starts, or None for a floor or ``!=``.""" + op, raw = spec.operator, spec.version + if op in (">", ">=", "!="): + return None + if op == "<": + return Version(raw) + if _is_pin(spec): + pinned = Version(raw) + return Version(f"{pinned.major}.{pinned.minor + 1}.0") + if op in ("~=", "=="): # `==` here is the `==X.*` wildcard + release = Version(raw.removesuffix(".*")).release + return _bump(release if op == "==" else release[:-1]) + # `<=` and `===` have no use in pyproject.toml today. Model one deliberately when it arrives. + raise AssertionError(f"unmodelled upper-bound operator {op!r} in {spec}") + + +def _caps(pyproject: dict[str, Any]) -> list[Cap]: + """The tightest upper bound of every capped requirement in the tables Dependabot's uv reads.""" + project = pyproject["project"] + tables: list[tuple[str, list[Any]]] = [ + ("[build-system].requires", pyproject.get("build-system", {}).get("requires", [])), + ("[project.dependencies]", project.get("dependencies", [])), + ] + tables += [ + (f"[project.optional-dependencies].{name}", reqs) + for name, reqs in project.get("optional-dependencies", {}).items() + ] + tables += [ + (f"{_GROUPS}.{name}", reqs) for name, reqs in pyproject.get("dependency-groups", {}).items() + ] + caps: list[Cap] = [] + for where, reqs in tables: + for raw in reqs: + if not isinstance(raw, str): # an `{include-group = ...}` table + continue + req = Requirement(raw) # markers parse apart from the specifier, so never read as caps + bounds = [b for s in req.specifier if (b := _cap_of(s)) is not None] + if bounds: + pin = any(_is_pin(s) for s in req.specifier) + caps.append(Cap(canonicalize_name(req.name), where, str(req), pin, min(bounds))) + return caps + + +def _uv_entry(dependabot: dict[str, Any]) -> dict[str, Any]: + """The one uv-ecosystem update entry for the repository root.""" + uv = [ + u + for u in dependabot["updates"] + if u["package-ecosystem"] == "uv" and u.get("directory") == "/" + ] + assert len(uv) == 1, f"expected one uv update entry with `directory: /`, found {len(uv)}" + return uv[0] + + +def _uv_ignores(dependabot: dict[str, Any]) -> dict[str, list[dict[str, Any]]]: + """The root uv entry's ignore list, keyed by canonical package name.""" + ignores: dict[str, list[dict[str, Any]]] = {} + for entry in _uv_entry(dependabot).get("ignore", []): + ignores.setdefault(canonicalize_name(entry["dependency-name"]), []).append(entry) + return ignores + + +def _ranges(entry: dict[str, Any]) -> list[Any]: + versions = entry.get("versions", []) + return versions if isinstance(versions, list) else [versions] + + +def _is_exactly_from(rng: Any, start: Version) -> bool: + """Whether ``rng`` is the single specifier ``>=start``.""" + if not isinstance(rng, str): # an unquoted YAML number, say + return False + try: + specs = list(SpecifierSet(rng)) + except InvalidSpecifier: + return False + return len(specs) == 1 and specs[0].operator == ">=" and Version(specs[0].version) == start + + +def _violations(pyproject: dict[str, Any], dependabot: dict[str, Any]) -> list[str]: + caps = _caps(pyproject) + ignores = _uv_ignores(dependabot) + problems: list[str] = [] + tightest: dict[str, Version] = {} + for cap in caps: + tightest[cap.package] = min(cap.ignore_from, tightest.get(cap.package, cap.ignore_from)) + + for cap in caps: + label = f"{cap.where}: {cap.spec}" + want = f">={cap.ignore_from}" + if cap.package in _EXEMPT: + table, reason = _EXEMPT[cap.package] + if cap.where != table and not cap.where.startswith(f"{table}."): + problems.append(f"{label} is exempt only in {table}, and this cap is elsewhere") + elif table == _GROUPS and not cap.exact_pin: + problems.append(f"{label} is exempt as a group `==` pin, but is not one") + elif cap.package in ignores: + problems.append(f"{label} is exempt ({reason}) yet has an ignore entry") + continue + if cap.ignore_from != tightest[cap.package]: + continue # one ignore entry serves a package; the tightest cap is the one it restates + entries = ignores.get(cap.package) + if not entries: + problems.append( + f"{label} has no uv ignore entry in {_DEPENDABOT.name}; add one with " + f'versions ["{want}"], or a named exemption with its reason in {Path(__file__).name}' + ) + continue + if any("update-types" in e for e in entries): + problems.append( + f"{label}: an `update-types` ignore cannot restate a range; use `versions` only" + ) + ranges = [rng for e in entries for rng in _ranges(e)] + if not ranges or not all(_is_exactly_from(rng, cap.ignore_from) for rng in ranges): + problems.append(f"{label}: its ignore range must be exactly {want!r}; found {ranges}") + + capped = {cap.package for cap in caps} + for name in sorted(set(ignores) - capped): + problems.append( + f"{_DEPENDABOT.name} ignores {name!r}, which pyproject.toml does not cap. Name the bare " + "package; an entry here restates a cap, so delete it in the PR that lifts the cap, " + f"or model the new kind of entry in {Path(__file__).name}" + ) + for name in sorted(set(_EXEMPT) - capped): + problems.append(f"exemption for {name!r} names no capped requirement in pyproject.toml") + return problems + + +@functools.cache +def _load() -> tuple[dict[str, Any], dict[str, Any]]: + """Parsed once per run. Callers that mutate must deepcopy first, as ``_fixture`` does.""" + pyproject = tomllib.loads(_PYPROJECT.read_text(encoding="utf-8")) + dependabot = yaml.safe_load(_DEPENDABOT.read_text(encoding="utf-8")) + return pyproject, dependabot + + +def test_every_cap_has_an_ignore_entry_or_a_stated_exemption() -> None: + """RED when: a cap has no exact ignore range, an exempt package has one, or one outlives its cap.""" + problems = _violations(*_load()) + assert not problems, "\n".join(problems) + + +@pytest.mark.parametrize( + ("spec", "start"), + [ + ("<0.50", "0.50"), + ("<4", "4"), + ("<3.1", "3.1"), + ("~=7.3.1", "7.4"), + ("~=7.3", "8"), + ("==4.4.0", "4.5.0"), + ("==1.2.*", "1.3"), + (">=1.0", None), + ("!=1.2.*", None), + ], +) +def test_cap_arithmetic(spec: str, start: str | None) -> None: + assert _cap_of(Specifier(spec)) == (Version(start) if start else None) + + +# A made-up cap and entry pair for each shape the mutation arms break. No real package is named, +# so a legitimate cap lift in pyproject.toml cannot crash an arm. +_FAKE_CAP = "mefor-fake-cap>=1.0,<2.5" +_FAKE_PIN = "mefor-fake-pin==3.2.1" + + +def _fake_entry(dependabot: dict[str, Any], name: str) -> dict[str, Any]: + return next(e for e in _uv_entry(dependabot)["ignore"] if e["dependency-name"] == name) + + +def _fixture() -> tuple[dict[str, Any], dict[str, Any]]: + """Copies of the real files, plus the two made-up caps with correct entries.""" + pyproject, dependabot = copy.deepcopy(_load()) + pyproject["project"]["dependencies"].append(_FAKE_CAP) + pyproject.setdefault("dependency-groups", {})["mefor-fake"] = [_FAKE_PIN] + _uv_entry(dependabot).setdefault("ignore", []).extend( + [ + {"dependency-name": "mefor-fake-cap", "versions": [">=2.5.0"]}, + {"dependency-name": "mefor-fake-pin", "versions": [">=3.3.0"]}, + ] + ) + return pyproject, dependabot + + +_Mutation = Callable[[dict[str, Any], dict[str, Any]], None] + + +def _looser_cap_elsewhere(p: dict[str, Any], _d: dict[str, Any]) -> None: + p["dependency-groups"]["mefor-fake"].append("mefor-fake-cap<4") + + +@pytest.mark.parametrize( + "mutate", + [None, _looser_cap_elsewhere], + ids=["unbroken", "looser-cap-on-same-package"], +) +def test_the_mutation_fixture_is_clean(mutate: _Mutation | None) -> None: + """Control: the arms below discriminate only if the unbroken fixture passes. A looser second cap + on a package is matched by nothing; its tightest cap's entry serves it.""" + pyproject, dependabot = _fixture() + if mutate: + mutate(pyproject, dependabot) + problems = _violations(pyproject, dependabot) + assert not problems, "\n".join(problems) + + +def _drop_entry(_p: dict[str, Any], d: dict[str, Any]) -> None: + uv = _uv_entry(d) + uv["ignore"] = [e for e in uv["ignore"] if e["dependency-name"] != "mefor-fake-cap"] + + +def _set_entry(name: str, key: str, value: Any) -> _Mutation: + def mutate(_p: dict[str, Any], d: dict[str, Any]) -> None: + _fake_entry(d, name)[key] = value + + return mutate + + +def _to_update_types(_p: dict[str, Any], d: dict[str, Any]) -> None: + entry = _fake_entry(d, "mefor-fake-cap") + del entry["versions"] + entry["update-types"] = ["version-update:semver-major"] + + +def _add_major_cap(p: dict[str, Any], _d: dict[str, Any]) -> None: + p["project"]["dependencies"].append("mefor-fake-major>=1.0,<3") + + +def _lift_cap(p: dict[str, Any], _d: dict[str, Any]) -> None: + deps = p["project"]["dependencies"] + deps[deps.index(_FAKE_CAP)] = "mefor-fake-cap>=1.0" + + +_EXACT = "must be exactly" +_CAP_VERSIONS = "mefor-fake-cap", "versions" + + +@pytest.mark.parametrize( + ("mutate", "expect"), + [ + pytest.param(_drop_entry, "no uv ignore entry", id="entry-deleted"), + pytest.param(_add_major_cap, "no uv ignore entry", id="major-cap-without-entry"), + pytest.param(_set_entry(*_CAP_VERSIONS, [">=2.6.0"]), _EXACT, id="gap-at-cap"), + pytest.param(_set_entry(*_CAP_VERSIONS, [">=2.4.0"]), _EXACT, id="freezes"), + pytest.param( + _set_entry(*_CAP_VERSIONS, ["==2.5.*", ">=3.0.0"]), _EXACT, id="hole-above-cap" + ), + pytest.param( + _set_entry(*_CAP_VERSIONS, [">=1.2.0,<1.4.0", ">=2.5.0"]), + _EXACT, + id="blocks-an-allowed-range", + ), + pytest.param(_set_entry(*_CAP_VERSIONS, ["2.5.0"]), _EXACT, id="not-a-specifier"), + pytest.param(_set_entry(*_CAP_VERSIONS, [2.5]), _EXACT, id="not-a-string"), + pytest.param(_set_entry(*_CAP_VERSIONS, 4), _EXACT, id="not-a-list"), + pytest.param( + _set_entry("mefor-fake-pin", "versions", [">=3.2.0"]), _EXACT, id="blocks-pin" + ), + pytest.param(_set_entry("mefor-fake-pin", "versions", [">=4.0.0"]), _EXACT, id="pin-gap"), + pytest.param(_to_update_types, "update-types", id="update-types"), + pytest.param(_lift_cap, "does not cap", id="stale-entry"), + pytest.param( + _set_entry("mefor-fake-cap", "dependency-name", "mefor-fake-cap[extra]"), + "does not cap", + id="name-with-extras", + ), + ], +) +def test_the_checker_reds_on_a_mutated_config(mutate: _Mutation, expect: str) -> None: + """Mutation arm: each broken copy must produce a violation naming what broke.""" + pyproject, dependabot = _fixture() + mutate(pyproject, dependabot) + problems = _violations(pyproject, dependabot) + assert any(expect in p for p in problems), f"no violation mentioning {expect!r}: {problems}" + + +@pytest.mark.parametrize( + ("name", "table", "added", "expect"), + [ + pytest.param( + "mefor-fake-cap", + "[project.dependencies]", + None, + "yet has an ignore entry", + id="exempt-with-entry", + ), + pytest.param( + "mefor-never-capped", "[project.dependencies]", None, "names no", id="dead-exemption" + ), + pytest.param( + "mefor-fake-cap", "[build-system]", None, "is exempt only in", id="exempt-elsewhere" + ), + pytest.param( + "mefor-gp", + _GROUPS, + ("dependencies", "mefor-gp==1.0.0"), + "only in", + id="pin-not-in-group", + ), + pytest.param( + "mefor-gp", _GROUPS, ("group", "mefor-gp>=1,<2"), "is not one", id="group-cap-not-a-pin" + ), + pytest.param( + "mefor-gp", + "[project.optional-dependencies].fake", + ("extra", "mefor-gp>=1,<2"), + "is exempt only in", + id="extra-name-prefix-is-not-the-table", + ), + ], +) +def test_the_checker_reds_on_a_broken_exemption( + monkeypatch: pytest.MonkeyPatch, + name: str, + table: str, + added: tuple[str, str] | None, + expect: str, +) -> None: + """Mutation arm for the exemption table, over made-up packages only.""" + monkeypatch.setitem(_EXEMPT, name, (table, "test reason")) + pyproject, dependabot = _fixture() + if added: + where, req = added + if where == "group": + pyproject["dependency-groups"]["mefor-fake"].append(req) + elif where == "extra": + pyproject["project"].setdefault("optional-dependencies", {})["fake-legacy"] = [req] + else: + pyproject["project"]["dependencies"].append(req) + problems = _violations(pyproject, dependabot) + assert any(expect in p for p in problems), f"no violation mentioning {expect!r}: {problems}" + + +@pytest.mark.parametrize("name", ["hvac", "hatchling"]) # explicit, so dropping one reds here +def test_a_named_exemption_must_not_have_an_entry(name: str) -> None: + """Mutation arm for the must-not list, over the real named exemptions (hvac, hatchling).""" + pyproject, dependabot = _fixture() + _uv_entry(dependabot)["ignore"].append({"dependency-name": name, "versions": [">=999"]}) + problems = _violations(pyproject, dependabot) + assert any(name in p and "yet has an ignore entry" in p for p in problems), problems diff --git a/tests/test_generators_choice.py b/tests/test_generators_choice.py new file mode 100644 index 000000000..5c8a082d4 --- /dev/null +++ b/tests/test_generators_choice.py @@ -0,0 +1,134 @@ +# SPDX-License-Identifier: AGPL-3.0-or-later +# Copyright (C) 2026 MessageFoundry Foundation, LLC and contributors +"""The generator emits exactly one alternative of a choice group (BACKLOG #2497). + +ORM^O01's order detail holds a choice of OBR, RQD, RQ1, RXO, ODS or ODT. Strict validation +needs exactly one, so the generator picks one instead of emitting every required child. All +data is synthetic. +""" + +from __future__ import annotations + +import random +from typing import Any + +import pytest + +from messagefoundry.generators import _core, all_types # noqa: F401 (registers ORM and OML) +from messagefoundry.parsing import Peek +from messagefoundry.parsing.validate import _SEQUENCES_LABELLED_CHOICE + +_ORDER_ALTERNATIVES = ("OBR", "RQD", "RQ1", "RXO", "ODS", "ODT") + + +def _alternatives(*names: str) -> list[list[Any]]: + return [[name, ("sequence", ()), (1, 1), "SEG"] for name in names] + + +def _spec(*buildable: str) -> _core.MessageSpec: + builders = { + name: (lambda rng, ctx, name=name: f"{name}|{rng.randint(1, 9)}") for name in buildable + } + return _core.MessageSpec(code="T", trigger_to_structure={}, builders=builders) + + +# The generator writes v2.5.1 only (MSH-12), so that is the version strict validation checks. +# A few indices under two seeds, because the optional PD1/PV2 around the order vary with both. +@pytest.mark.parametrize("seed", [_core.DEFAULT_SEED, "another-seed"]) +@pytest.mark.parametrize("index", range(1, 4)) +def test_generated_orm_o01_has_one_order_detail_and_is_strictly_valid( + index: int, seed: str +) -> None: + msg = _core.generate_message("ORM", "O01", index, seed=seed) + peek = Peek.parse(msg) + assert peek.version == "2.5.1" + names = peek.segments() + assert names[-2:] == ["ORC", "OBR"], names + assert [n for n in names if n in _ORDER_ALTERNATIVES] == ["OBR"], names + # HL7 says OBR-2 and OBR-3 are identical to the order's ORC-2 and ORC-3. + assert peek.field("OBR-2") == peek.field("ORC-2") + assert peek.field("OBR-3") == peek.field("ORC-3") + ok, errors = _core.gate("ORM", msg, "ORM_O01") + assert ok, errors + + +def test_an_obr_takes_its_orc_numbers_once() -> None: + """The ORC hands its numbers to the next OBR only; a second OBR draws its own.""" + rng = random.Random(0) + ctx = _core.Ctx("OML", "O21", "OML_O21", "CID", _core.BASE_DT, "A", "F", "B", "G") + orc = _core._build_orc(rng, ctx).split("|") + first = _core._build_obr(rng, ctx).split("|") + second = _core._build_obr(rng, ctx).split("|") + assert first[2:4] == orc[2:4] + assert second[2:4] != orc[2:4] + + +def test_handing_over_order_numbers_moves_no_other_value(monkeypatch: pytest.MonkeyPatch) -> None: + """The OBR still draws its own numbers when an ORC supplies them, so the random stream, and + every later value of OML, MDM and ORU messages, stays where it was. Only OBR-2/3 move.""" + real_orc = _core.SHARED_BUILDERS["ORC"] + now = _core.generate_message("OML", "O21", 1) + + def orc_handing_nothing(rng: random.Random, ctx: _core.Ctx) -> str: + text = real_orc(rng, ctx) + ctx.order_numbers = None + return text + + monkeypatch.setitem(_core.SHARED_BUILDERS, "ORC", orc_handing_nothing) + before = _core.generate_message("OML", "O21", 1) + changed = [ + (a.split("|"), b.split("|")) + for a, b in zip(now.split("\r"), before.split("\r"), strict=True) + if a != b + ] + assert [a[0] for a, _ in changed] == ["OBR"] # the control: the hand-over did change it + assert [a[:2] + a[4:] for a, _ in changed] == [b[:2] + b[4:] for _, b in changed] + + +def test_obr_is_picked_without_drawing_from_the_seed() -> None: + """OBR leaves the random stream alone, so adding a choice moves no other generated value.""" + rng = random.Random("seed") + before = rng.getstate() + alt = _core._pick_alternative("G", _alternatives(*_ORDER_ALTERNATIVES), rng, _spec("RXO")) + assert alt[0] == "OBR" + assert rng.getstate() == before + + +def test_without_obr_the_pick_is_seeded_and_buildable() -> None: + spec = _spec("RXO", "ODS") + alternatives = _alternatives("RQD", "RXO", "ODS") + picks = { + seed: _core._pick_alternative("G", alternatives, random.Random(seed), spec)[0] + for seed in range(40) + } + assert set(picks.values()) == {"RXO", "ODS"} # never RQD, which has no builder + for seed, pick in picks.items(): # the same seed always picks the same alternative + assert _core._pick_alternative("G", alternatives, random.Random(seed), spec)[0] == pick + + +def test_a_choice_with_no_buildable_alternative_names_the_group() -> None: + with pytest.raises(RuntimeError, match="choice group G_SUPPGRP"): + _core._pick_alternative("G_SUPPGRP", _alternatives("RQD", "RQ1"), random.Random(0), _spec()) + + +def _emitted(group: str) -> list[str]: + ctx = _core.Ctx("ORM", "O01", "ORM_O01", "CID", _core.BASE_DT, "A", "F", "B", "G") + group_ref = ("choice", tuple(_alternatives("PID", "PV1"))) + out: list[str] = [] + _core._emit( + [[group, group_ref, (1, 1), "GRP"]], random.Random(0), ctx, _spec(), frozenset(), out + ) + return [segment[:3] for segment in out] + + +def test_a_choice_group_emits_one_alternative() -> None: + assert len(_emitted("ORM_O01_OBRRQDRQ1RXOODSODT_SUPPGRP")) == 1 + + +def test_a_sequence_hl7apy_labels_choice_emits_every_part() -> None: + """The control arm: QBP_E22_QUERY is QPD then RCP in HL7, though hl7apy labels it a choice. + The generator follows the validator's list of such groups, so it still emits both parts. + The generator walks only v2.5.1 today, where no such group occurs; this pins the rule for + the day it walks a later version.""" + assert "QBP_E22_QUERY" in _SEQUENCES_LABELLED_CHOICE + assert _emitted("QBP_E22_QUERY") == ["PID", "PV1"] diff --git a/tests/test_startup_import_budget.py b/tests/test_startup_import_budget.py new file mode 100644 index 000000000..e977857f8 --- /dev/null +++ b/tests/test_startup_import_budget.py @@ -0,0 +1,159 @@ +# SPDX-License-Identifier: AGPL-3.0-or-later +# Copyright (C) 2026 MessageFoundry Foundation, LLC and contributors +"""Startup import budget for the CLI and the tray (BACKLOG #2514, PEP 810). + +Four modules carry a `__lazy_modules__` list: `__main__.py`, `config/__init__.py`, `tray/__main__.py` +and `tray/config.py`. On Python 3.15 the imports they name bind lazily and load on first use. On +3.14, the project's floor, the list is an ordinary variable and nothing changes, so the budget is +ADVISORY there: it skips. The `lazy` keyword is not used because it is a SyntaxError on 3.14. + +The budget is a set of modules, never a wall-clock time, so it cannot flake on a loaded runner. +On 3.15.0b3 the lists take the CLI from 273 modules to about 186 and the tray's first process from +204 to 120, counting the interpreter's own modules. + +Each probe runs in a FRESH interpreter, because the pytest process has already imported most of +the engine. The CLI probe runs the package through runpy, as `python -m messagefoundry` does, so +`messagefoundry.__main__` is never in `sys.modules`, and it keeps the CLI's exit code, so a refused +argument fails the probe. The tray probe imports the entry module and `tray.branding`, which is +what the first process loads before `main()` re-execs as the branded child. + +The control arm runs the same probe with every import forced eager: natively on 3.14, and through +`sys.set_lazy_imports_filter` on 3.15. It asserts the deferred modules DO load there, so a +misspelled name cannot pass the budget by never loading at all. If the control fails because a +module no longer loads even eagerly, its lazy entry is dead: remove it from both places. +""" + +from __future__ import annotations + +import json +import os +import subprocess +import sys +from pathlib import Path +from typing import NamedTuple + +import pytest + +_LAZY_ARM_SKIP = "advisory until the floor is 3.15: __lazy_modules__ has no effect on 3.14 (#2478)" + + +class _Probe(NamedTuple): + kind: str + args: tuple[str, ...] + #: Must NOT load on 3.15, and must load when forced eager. + deferred: frozenset[str] + #: The exact engine modules it may load on 3.15, measured on 3.15.0b3. Exact rather than a + #: ceiling, so one new eager engine import on the startup path fails here instead of being + #: absorbed by headroom. If you add one on purpose, add it here and say why in the commit. + engine: frozenset[str] + + +#: The CLI reaches `config.tls_policy` through `logging_setup`. Before #2514, loading that leaf ran +#: `config/__init__.py`, which loaded pydantic and every model. +_CLI_DEFERRED = frozenset({"pydantic", "messagefoundry.config.models", "sqlite3", "tomllib"}) +_CLI_ENGINE = frozenset( + { + "messagefoundry", + "messagefoundry.cli_common", + "messagefoundry.cli_surface", + "messagefoundry.config", + "messagefoundry.config.tls_policy", + "messagefoundry.console_streams", + "messagefoundry.controlchars", + "messagefoundry.keywrap", + "messagefoundry.last_resort", + "messagefoundry.log_spool", + "messagefoundry.logging_guard", + "messagefoundry.logging_setup", + "messagefoundry.odbc_env", + "messagefoundry.redaction", + "messagefoundry.secretscrub", + } +) + +#: `--help` stops once the parser is built. `_build_parser` builds every subparser, so `check`, +#: `verify` and `serve --help` load the same set today; one stands for all three. Add the others +#: back if parser building ever becomes per-subcommand. +_PROBES = { + "cli-version": _Probe("cli", ("--version",), _CLI_DEFERRED, _CLI_ENGINE), + "cli-check-help": _Probe("cli", ("check", "--help"), _CLI_DEFERRED, _CLI_ENGINE), + "tray-first-process": _Probe( + "tray", + (), + frozenset( + {"messagefoundry.service_status", "messagefoundry.tray.instance", "asyncio", "tomllib"} + ), + frozenset( + { + "messagefoundry", + "messagefoundry.api_tls_source", + "messagefoundry.controlchars", + "messagefoundry.redaction", + "messagefoundry.secretscrub", + "messagefoundry.tray", + "messagefoundry.tray.__main__", + "messagefoundry.tray.branding", + "messagefoundry.tray.config", + "messagefoundry.tray.logscrub", + } + ), + ), +} + +_PROBE_CODE = """ +import json, sys +out, mode, kind, args = sys.argv[1], sys.argv[2], sys.argv[3], sys.argv[4:] +if mode == "eager" and hasattr(sys, "set_lazy_imports_filter"): + sys.set_lazy_imports_filter(lambda *_: False) +try: + if kind == "cli": + import runpy + sys.argv = ["messagefoundry", *args] + runpy.run_module("messagefoundry", run_name="__main__") + else: + import messagefoundry.tray.__main__ + import messagefoundry.tray.branding # main() imports it before the re-exec + code = 0 +except SystemExit as exc: + code = exc.code +finally: + with open(out, "w", encoding="utf-8") as fh: + json.dump(sorted(sys.modules), fh) +sys.exit(code) +""" + + +def _loaded(tmp_path: Path, probe: _Probe, *, mode: str) -> set[str]: + out = tmp_path / "modules.json" + # PYTHON_LAZY_IMPORTS would override the lists in both arms, so the probe never inherits it. + env = {k: v for k, v in os.environ.items() if k != "PYTHON_LAZY_IMPORTS"} + proc = subprocess.run( + [sys.executable, "-c", _PROBE_CODE, str(out), mode, probe.kind, *probe.args], + capture_output=True, + text=True, + env=env, + # Under the suite's 60 s pytest-timeout, so a hung probe still reports its stderr. + timeout=50, + ) + assert proc.returncode == 0 and out.exists(), proc.stderr[-2000:] + return set(json.loads(out.read_text(encoding="utf-8"))) + + +@pytest.mark.parametrize("name", list(_PROBES)) +def test_control_arm_loads_every_deferred_module_when_eager(tmp_path: Path, name: str) -> None: + probe = _PROBES[name] + missing = probe.deferred - _loaded(tmp_path, probe, mode="eager") + assert not missing, f"never loaded even eagerly, so the lazy entry is dead: {sorted(missing)}" + + +@pytest.mark.skipif(sys.version_info < (3, 15), reason=_LAZY_ARM_SKIP) +@pytest.mark.parametrize("name", list(_PROBES)) +def test_startup_stays_within_budget(tmp_path: Path, name: str) -> None: + probe = _PROBES[name] + loaded = _loaded(tmp_path, probe, mode="lazy") + early = probe.deferred & loaded + assert not early, f"deferred module loaded at startup: {sorted(early)}" + engine = {m for m in loaded if m.partition(".")[0] == "messagefoundry"} + assert engine == probe.engine, ( + f"added: {sorted(engine - probe.engine)}, removed: {sorted(probe.engine - engine)}" + ) diff --git a/tests/test_validate_choice_groups.py b/tests/test_validate_choice_groups.py index dbf3ee3b0..abe0f1b3c 100644 --- a/tests/test_validate_choice_groups.py +++ b/tests/test_validate_choice_groups.py @@ -155,7 +155,8 @@ def test_an_empty_choice_group_is_reported() -> None: def test_hl7apy_still_has_the_bug_so_the_shim_is_still_on() -> None: - """Goes red on the first hl7apy release that fixes issue 151. Then delete the shim. + """Goes red on the first hl7apy release that fixes issue 151. Then delete the shim, but keep + ``is_choice_group`` and ``_SEQUENCES_LABELLED_CHOICE``: the generator uses them. The shim switches itself off on such a release, since :func:`_choice_fix_needed` asks hl7apy rather than its version number. The code it leaves behind is dead, and this test is diff --git a/tests/tooling_manifest.txt b/tests/tooling_manifest.txt index 4802fd268..4df4080a9 100644 --- a/tests/tooling_manifest.txt +++ b/tests/tooling_manifest.txt @@ -114,6 +114,7 @@ tests/test_dast_claims.py tests/test_defaulted_credential_lint.py tests/test_dep1_lock_resync_lockstep.py tests/test_dependabot_automerge_guardrails.py +tests/test_dependabot_cap_ignores.py tests/test_docs_runbooks.py tests/test_failure_signal.py tests/test_feature_map_claims.py