diff --git a/.github/dependabot.yml b/.github/dependabot.yml index fc6ef621..90263b88 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -16,6 +16,16 @@ updates: schedule: interval: weekly open-pull-requests-limit: 5 + # Updates move the locks and never a floor (#1028). `increase-if-necessary` + # leaves a range in pyproject.toml alone while it already admits the new + # release, and still rewrites an exact `==` pin in constraints/. Unset, the + # choice was Dependabot's: it raised floors (`increase`) whenever PyPI's + # summary for agents-shipgate differed from pyproject.toml's description, + # and widened ranges otherwise. `lockfile-only` is not the fix despite its + # name: Dependabot reads constraints/*.txt as requirement files, not as + # lockfiles, so it would stop proposing their updates, security fixes + # included. When a floor may move is CONTRIBUTING.md § Changing dependencies. + versioning-strategy: increase-if-necessary ignore: # `# via cyclonedx-bom`, which requires chardet!=6.*,<8,>=5.1 since 7.5.0 # (7.3.1 capped it at <6.0, which is why #407 was closed). chardet 7.x is diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index e5034269..abc631ef 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -444,6 +444,111 @@ jobs: - name: The installed command answers over stdio with five read-only tools run: python scripts/mcp_extra_requirement.py --stdio-smoke + dependency-floors: + # #1028: the runtime floors in pyproject.toml are a contract with every + # environment Agents Shipgate is installed into. While they tracked the + # newest upstream release, `pip install agents-shipgate` beside an agent + # project's ordinary pins quietly installed 0.15.0. This job holds the two + # facts that keep that from coming back: + # + # - pip installs this version beside those pins (`coinstall`: one + # `pip install --dry-run --report` per scenario); and + # - the engine works at its floors. `uv pip compile --resolution + # lowest-direct` puts every runtime dependency at exactly its floor and + # everything else at the newest release, and the checks below run there. + # + # It resolves from the index on purpose: beside an adopter's floors, their + # resolver picks the newest of everything else, and that moves. What is + # installed is still hash-checked against what this job compiled, with the + # uv that constraints/release-publish.txt pins. + # + # The tests are the ones that exercise each runtime dependency's API: the + # CLI and doctor (typer), manifests, reports, scan and verify (pydantic, + # PyYAML), manifest patches (ruamel.yaml), signed human authorization + # (cryptography) and the safety canaries, plus every sample golden byte for + # byte. `scripts/generate_schemas.py --check` is not among them: the + # committed JSON schemas are rendered by the locked pydantic, and an older + # one renders some differently (#1088), which says nothing about what the + # engine emits or accepts. + # + # Its first run took under two minutes: 19 s for the three dry runs and 55 s + # for the tests. The dry runs are the one step whose time the index sets + # (CrewAI's resolves about 140 distributions), so the cap is the usual one. + runs-on: ubuntu-latest + timeout-minutes: 15 + + steps: + - name: Checkout + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 + + - name: Set up Python + uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 + with: + python-version: "3.12" + + - name: Install the resolver and the scripts' dependencies, apart from the floors + # Their own environment, because `packaging` is one of the floors under + # test: the copy the scripts import must not be the one the tests run + # on. `uv` comes from the publication lock and `packaging` from the + # build-backend lock, both hash-checked. + run: | + python -m venv "${RUNNER_TEMP}/tools" + "${RUNNER_TEMP}/tools/bin/python" -m pip install --require-hashes --requirement constraints/release-publish.txt + "${RUNNER_TEMP}/tools/bin/python" -m pip install --require-hashes --requirement constraints/build-backend.txt + + - name: pip installs this version beside the pins agent projects carry + run: '"${RUNNER_TEMP}/tools/bin/python" scripts/dependency_floors.py coinstall' + + - name: Resolve every runtime dependency at exactly its floor + run: >- + "${RUNNER_TEMP}/tools/bin/python" scripts/dependency_floors.py compile + --uv "${RUNNER_TEMP}/tools/bin/uv" --output "${RUNNER_TEMP}/floors.txt" + + - name: Install that closure, and nothing else + # Not the project itself: pytest's `pythonpath` and the scripts put + # `src` on the path, and an editable install would need a build backend + # whose own `packaging` would move a floor under test. + run: | + python -m pip install --require-hashes --requirement "${RUNNER_TEMP}/floors.txt" + python -m pip check + python scripts/dependency_floors.py installed + + - name: The engine's dependency-heavy tests at the floors + # About 290 seconds of test time. The whole suite passed at these + # floors when they were set (#1028); here it would need the six shards + # `suite` uses. + run: >- + python -m pytest -n auto -m "not perf and not slow" + tests/test_cli.py + tests/test_environment.py + tests/test_config.py + tests/test_scan.py + tests/test_verify.py + tests/test_apply_patches.py + tests/test_declaration_authoring.py + tests/test_authorization_cli.py + tests/test_authorization_execution.py + tests/test_human_review_decision.py + tests/test_p0_safety_canaries.py + tests/test_p0_binding_canaries.py + tests/test_agent_boundary.py + tests/test_dependency_floors.py + + - name: Every sample golden, byte for byte, at the floors + run: python scripts/regenerate_goldens.py --check + + - name: doctor finds every dependency at or above its floor + # The floors differ from the lock the other jobs install, so the only + # dependency code doctor may report here is the lock-drift warning. + run: | + ./shipgate doctor --config samples/support_refund_agent/shipgate.yaml --json > doctor.json + python - <<'PY' + import json, pathlib + environment = json.loads(pathlib.Path("doctor.json").read_text())[0]["environment"] + codes = {entry["code"] for entry in environment["mismatches"]} + assert codes <= {"dependency_differs_from_lock"}, environment["mismatches"] + PY + clean-checkout-launcher: # `./shipgate` has to work on a checkout where Agents Shipgate itself is # not installed: no console script on `PATH`, no editable install, no diff --git a/AGENTS.md b/AGENTS.md index 90919c35..a0a44597 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -427,7 +427,7 @@ Every `doctor --json` payload carries an `environment` block, and so does every | `source_tree` | The enclosing Agents Shipgate checkout, if any: `root`, `version`, `launcher`, and `contains_import`. | | `mismatches[]` | `code`, `severity` (`error` / `warning`), `detail`, and — when one exists — a runnable `command` spelled for this invocation. Empty is the normal state. | -`mismatches[]` codes: `interpreter_unsupported`, `import_outside_source_tree`, `source_tree_version_differs`, `installed_version_differs`, `console_script_interpreter_missing`, `console_script_runs_other_interpreter`. Nothing here executes an interpreter or a console script to find out — a stale wrapper is identified by reading it, because a wrapper that cannot start is exactly the one that cannot report on itself. +`mismatches[]` codes: `interpreter_unsupported`, `import_outside_source_tree`, `source_tree_version_differs`, `installed_version_differs`, `console_script_interpreter_missing`, `console_script_runs_other_interpreter`, `dependency_below_declared_floor` (a runtime dependency below the floor the running build declares), `dependency_differs_from_lock` (a source checkout's dependency off `constraints/dev.txt`, so CI may not reproduce the result). Nothing here executes an interpreter or a console script to find out — a stale wrapper is identified by reading it, because a wrapper that cannot start is exactly the one that cannot report on itself. ### One control vocabulary across the commands diff --git a/CHANGELOG.md b/CHANGELOG.md index ac9ee4d0..d8110cc4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -51,6 +51,11 @@ - **The published verifier `0.21` and agent-boundary-result `v3` schemas now declare a capability row closed.** The locked development closure moves to pydantic 2.14, which reflects an enclosing model's `extra="forbid"` in the JSON Schema of a standard-library dataclass it contains; `CapabilityDiffRow` therefore gains `additionalProperties: false` in both files. Validation is unchanged: the same model already rejected an unknown row key under pydantic 2.13, and no emitted artifact, schema version or contract version moves. A consumer that validates a row against these schemas and relied on the object being open now rejects an unknown key, as the producing model always did. - Move the published-release pins, examples and adoption prompts to `v1.2.0` (contract 41) now that it is published, re-capture the README and quickstart `diff` answers from the published `1.2.0`, and re-measure the pilot ledger's Route H dry run on it. No schema or contract change. (#778) +### Packaging + +- **`pip install agents-shipgate` installs this release beside the older pins agent projects carry, instead of quietly installing 0.15.0.** The runtime floors had tracked the newest upstream release (`cryptography>=50.0.1,<51`, `packaging>=26.3,<27`, `pydantic>=2.13.4`, `pyyaml>=6.0.3`, `ruamel.yaml>=0.19.1`, `typer>=0.27.0`). An environment that already held `cryptography==46.0.7` or `packaging==24.2` therefore got 0.15.0 from pip with no warning, an engine without the verifier and `diff` this documentation describes, and one with CrewAI 1.15 (`pydantic<2.13`) got an older release or `ResolutionImpossible`. Each floor is now the oldest version the engine is tested on: `cryptography>=44.0.3`, `packaging>=24.2`, `pydantic>=2.12.5,<3`, `pyyaml>=6.0.2,<7`, `ruamel.yaml>=0.18.10,<1` and `typer>=0.20.0,<1`. The `cryptography` and `packaging` caps are gone: neither has a known break, and each new major would have repeated the fallback. The lower `cryptography` floor admits releases with published advisories; none of them is in the Ed25519 signature verification that is all Agents Shipgate uses it for, and a fresh install still resolves the newest release. A new CI job, `dependency-floors`, resolves every runtime dependency at exactly its floor (`uv pip compile --resolution lowest-direct`), runs the dependency-heavy tests and every sample golden there, and checks with `pip install --dry-run --report` that this version installs beside those three pins. Dependabot now moves the locks and never a floor (`versioning-strategy: increase-if-necessary`), and `CONTRIBUTING.md` § Changing dependencies says when a floor may move. An environment that already resolved a 1.x release is unaffected. (#1028) +- **`doctor --json` reports a runtime dependency below its floor.** The `environment` block compared Shipgate's own versions but never its dependencies, so a virtualenv below the declared floors (typer 0.25.1 under a 0.27.0 floor) reported `mismatches: []`. Two codes are added to `mismatches[]`: `dependency_below_declared_floor` (`error`), when a runtime dependency is installed below the floor the running build declares, with a reinstall `command`; and `dependency_differs_from_lock` (`warning`), in a source checkout only, when one differs from `constraints/dev.txt`, the closure CI tests the tree against. Versions are read from installed metadata; nothing is executed. The block's fields are unchanged, and a consumer that ignores unknown codes is unaffected. (#1028) + ## 1.2.0 - 2026-09-30 An advisory-channel minor release. It adds `diff --application`, which compares diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 54e2cf22..d75b100f 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -125,11 +125,61 @@ stale fails there by name rather than as a puzzling import error. See [`docs/release-runbook.md`](docs/release-runbook.md#the-environment-is-locked-and-it-is-cis) for which job installs which lock. +### Runtime floors + +The `>=` floor of each `[project].dependencies` entry is the oldest version +Agents Shipgate is tested on, not the newest one released. It is a contract +with every environment the package is installed into. While the floors tracked +the newest upstream release, `pip install agents-shipgate` in an agent project +that already pinned `cryptography==46.0.7` or `packaging==24.2` quietly +installed 0.15.0, and beside CrewAI (`pydantic<2.13`) it installed an older +release or failed (#1028). + +A floor moves only when: + +- the code starts to need an API or a fix that the floor version lacks; or +- a version at or above the floor is known to break Agents Shipgate. A + vulnerability counts when it is reachable through what Agents Shipgate calls. + An advisory in code it never calls does not: the adopter's resolver decides + whether a vulnerable release is installed, and a higher floor only takes the + newer Agents Shipgate away. Every `cryptography` release before 50.0.0 carries + advisories, and none of them is in the raw Ed25519 verification, which is all + this package uses `cryptography` for. + +A Dependabot pull request, a lock refresh or a new upstream release is never a +reason to move a floor. An upper cap is declared only where a break is known. +`tests/test_dependency_floors.py` lists the caps in force and the break each +one stands for. + +CI's `dependency-floors` job proves the floors on every pull request. It +resolves the runtime requirements with `uv pip compile --resolution +lowest-direct` and fails unless each one lands exactly on its floor. It then +runs the tests that exercise those dependencies, and every sample golden, in +that closure, and checks with `pip install --dry-run --report` that pip still +installs this version beside the pins in `scripts/dependency_floors.py`. To +check a floor change before pushing: + +```bash +python scripts/dependency_floors.py coinstall +python scripts/dependency_floors.py compile --uv "$(command -v uv)" --output floors.txt +``` + +`doctor --json` reports `dependency_below_declared_floor` when the interpreter it +runs in has a runtime dependency below its floor. In a source checkout it also +reports `dependency_differs_from_lock` when one differs from +`constraints/dev.txt`, the closure CI tests the tree against. + +### Dependabot + Dependabot groups each week's minor and patch version updates into one pull request per ecosystem. Major bumps, security updates, `uv` and the harness's -`claude-agent-sdk` still arrive individually. Dependabot edits the hash locks -as plain requirement files, so a grouped pip PR is a request, never merged -as-is. To act on it, raise any floor it changes in `pyproject.toml` or a +`claude-agent-sdk` still arrive individually. Its pip updates use +`versioning-strategy: increase-if-necessary`, so a range in `pyproject.toml` +that already admits the new release is left alone: its pull requests move the +locks, never a floor, and the harness's `claude-agent-sdk` range is proposed +for change only when a release falls outside it. Dependabot edits the hash +locks as plain requirement files, so a grouped pip PR is a request, never +merged as-is. To act on it, move any exact toolchain pin it changes in a `constraints/*.in`, then recompile only the locks it touches, for example `python scripts/update_locks.py constraints/dev.txt`, with the `uv` version `constraints/release-publish.in` pinned before the PR. A recompile refreshes diff --git a/constraints/dev.txt b/constraints/dev.txt index ceb0ca21..86fab9ca 100644 --- a/constraints/dev.txt +++ b/constraints/dev.txt @@ -39,22 +39,22 @@ # changed extra, marker, URL or range cannot slip past without recompiling. # # declares: build<2,>=1.6.0 -# declares: cryptography<51,>=50.0.1 +# declares: cryptography>=44.0.3 # declares: cyclonedx-bom<8,>=7.3.1 # declares: hypothesis<7,>=6.165.10 # declares: jsonschema<5,>=4.26.0 -# declares: packaging<27,>=26.3 +# declares: packaging>=24.2 # declares: pip-audit<3,>=2.10.1 -# declares: pydantic<3,>=2.13.4 +# declares: pydantic<3,>=2.12.5 # declares: pytest-cov<8,>=7.1.0 # declares: pytest-xdist<4,>=3.8 # declares: pytest<10,>=9.1.1 -# declares: pyyaml<7,>=6.0.3 -# declares: ruamel-yaml<1,>=0.19.1 +# declares: pyyaml<7,>=6.0.2 +# declares: ruamel-yaml<1,>=0.18.10 # declares: ruff<1,>=0.16.1 # declares: sigstore<5,>=4.5.0 # declares: twine<8,>=7.0.0 -# declares: typer<1,>=0.27.0 +# declares: typer<1,>=0.20.0 # annotated-doc==0.0.5 \ --hash=sha256:117bac03a25ede5df5440e855b32d556049ca169ead221505badf432fed4b101 \ diff --git a/docs/diagnostics.md b/docs/diagnostics.md index 24da1c08..a6f54c5f 100644 --- a/docs/diagnostics.md +++ b/docs/diagnostics.md @@ -337,6 +337,13 @@ dies before a line of Shipgate runs. | `installed_version_differs` | warning | Two *installed* copies shadow each other, so which one answers depends on path order. Not raised when a source checkout out-votes an install: that is intended, and an editable install's metadata lags every version bump by design. | | `console_script_interpreter_missing` | error | A wrapper on `PATH` names an interpreter that no longer exists, so it fails before Shipgate starts. | | `console_script_runs_other_interpreter` | warning | A bare `agents-shipgate` would execute a different installation than this command did. | +| `dependency_below_declared_floor` | error | A runtime dependency is installed below the floor the running build declares: its `pyproject.toml` in a source checkout, its `Requires-Dist` when installed. Nothing tests that version. The `command` reinstalls: the checkout's `constraints/dev.txt` with hashes, or else the declared requirement. | +| `dependency_differs_from_lock` | warning | Source checkout only. A runtime dependency differs from the version `constraints/dev.txt` pins, the closure CI tests the tree against, so a result here may not reproduce in CI. | + +Dependency versions are what `importlib.metadata` records for the running +interpreter. A dependency with no recorded metadata, a requirement behind an +environment marker, and a version the standard-library comparison cannot read +(an epoch) are left unjudged rather than guessed at. Nothing here runs an interpreter or executes a console script to find out. That is the trust-model invariant (`tests/test_adapter_static_only.py` bans diff --git a/llms-full.txt b/llms-full.txt index 713ec875..46ffefc1 100644 --- a/llms-full.txt +++ b/llms-full.txt @@ -452,7 +452,7 @@ Every `doctor --json` payload carries an `environment` block, and so does every | `source_tree` | The enclosing Agents Shipgate checkout, if any: `root`, `version`, `launcher`, and `contains_import`. | | `mismatches[]` | `code`, `severity` (`error` / `warning`), `detail`, and — when one exists — a runnable `command` spelled for this invocation. Empty is the normal state. | -`mismatches[]` codes: `interpreter_unsupported`, `import_outside_source_tree`, `source_tree_version_differs`, `installed_version_differs`, `console_script_interpreter_missing`, `console_script_runs_other_interpreter`. Nothing here executes an interpreter or a console script to find out — a stale wrapper is identified by reading it, because a wrapper that cannot start is exactly the one that cannot report on itself. +`mismatches[]` codes: `interpreter_unsupported`, `import_outside_source_tree`, `source_tree_version_differs`, `installed_version_differs`, `console_script_interpreter_missing`, `console_script_runs_other_interpreter`, `dependency_below_declared_floor` (a runtime dependency below the floor the running build declares), `dependency_differs_from_lock` (a source checkout's dependency off `constraints/dev.txt`, so CI may not reproduce the result). Nothing here executes an interpreter or a console script to find out — a stale wrapper is identified by reading it, because a wrapper that cannot start is exactly the one that cannot report on itself. ### One control vocabulary across the commands diff --git a/pyproject.toml b/pyproject.toml index 6a9eecf3..9f02bd0b 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -52,13 +52,20 @@ classifiers = [ "Environment :: Console", "Typing :: Typed", ] +# Each floor is the oldest version CI's `dependency-floors` job tests, never +# the newest release: a floor above a version an agent project already pins +# makes pip install an old Agents Shipgate in its place, without a warning +# (#1028). A floor moves only when the code needs a newer version or a version +# it admits is known to break it, and a cap only where a break is known. +# CONTRIBUTING.md § Changing dependencies has the rule; +# `scripts/dependency_floors.py` enforces the shape. dependencies = [ - "cryptography>=50.0.1,<51", - "packaging>=26.3,<27", - "pydantic>=2.13.4,<3", - "pyyaml>=6.0.3,<7", - "ruamel.yaml>=0.19.1,<1", - "typer>=0.27.0,<1", + "cryptography>=44.0.3", + "packaging>=24.2", + "pydantic>=2.12.5,<3", + "pyyaml>=6.0.2,<7", + "ruamel.yaml>=0.18.10,<1", + "typer>=0.20.0,<1", ] [project.urls] diff --git a/scripts/dependency_floors.py b/scripts/dependency_floors.py new file mode 100644 index 00000000..a7eec3bc --- /dev/null +++ b/scripts/dependency_floors.py @@ -0,0 +1,481 @@ +#!/usr/bin/env python3 +"""Hold the runtime dependency floors to versions that have been tested (#1028). + +The floors in ``pyproject.toml``'s ``[project].dependencies`` are a contract +with every environment Agents Shipgate is installed into, not a record of what +CI happens to run. When they tracked the newest upstream release, an agent +project that already pinned ``cryptography==46.0.7`` or ``packaging==24.2`` +did not get an error from ``pip install agents-shipgate``: pip quietly chose +0.15.0, the newest release whose metadata still admitted those pins, an engine +without the verifier and diff surfaces the documentation describes. + +So a floor is held to two facts, each checked by a subcommand that CI's +``dependency-floors`` job runs on every pull request: + +``coinstall`` + ``pip install --dry-run --report`` of this tree's declarations next to + pins real agent projects carry. Each scenario must resolve *this* version + of Agents Shipgate together with the pin, not fall back or fail. Needs the + package index. + +``compile`` / ``installed`` + ``uv pip compile --resolution lowest-direct`` of the runtime requirements + and the test tools the job runs, with hashes: every direct requirement at + the lowest version it admits, everything else at the newest. It fails + unless each runtime dependency resolved to exactly its declared floor — a + floor that some other requirement silently raised would otherwise be + "tested" at a version it is not. ``installed`` then re-checks the + interpreter the tests will run in. + +A floor moves only when the code needs something the old version lacks, or a +version at or above it is known to break Agents Shipgate (CONTRIBUTING.md +§ Changing dependencies). Upper caps are kept only where a break is known. + + python scripts/dependency_floors.py coinstall + python scripts/dependency_floors.py compile --uv "$(command -v uv)" --output floors.txt + python scripts/dependency_floors.py installed +""" + +from __future__ import annotations + +import argparse +import base64 +import hashlib +import json +import subprocess # noqa: S404 - drives pip and uv, explicit developer tools +import sys +import tempfile +import tomllib +import zipfile +from dataclasses import dataclass +from importlib import metadata +from pathlib import Path + +from packaging.requirements import InvalidRequirement, Requirement +from packaging.utils import canonicalize_name +from packaging.version import InvalidVersion, Version + +if __package__: + from scripts._release_support import ReleaseError + from scripts.verify_dependency_lock import parse_lock +else: # ``python scripts/dependency_floors.py`` + from _release_support import ReleaseError + from verify_dependency_lock import parse_lock + +REPO_ROOT = Path(__file__).resolve().parent.parent + +#: The interpreter the resolutions target: `requires-python`'s floor, which is +#: also the version CI runs. Every scenario below must hold there. +TARGET_PYTHON = "3.12" + +#: The test tools the floors job installs beside the runtime closure, pinned to +#: the versions `constraints/dev.txt` locks, so the tools are not a variable: +#: what differs from the ordinary suite is the runtime dependencies under test +#: and what they pull in. +TEST_TOOLS = ("pytest", "pytest-xdist", "hypothesis", "jsonschema") +DEV_LOCK = "constraints/dev.txt" + + +@dataclass(frozen=True) +class Scenario: + """One environment an adopter already has, and must still install into.""" + + name: str + pins: tuple[str, ...] + why: str + + +SCENARIOS: tuple[Scenario, ...] = ( + Scenario( + name="cryptography 46", + pins=("cryptography==46.0.7",), + why=( + "with the floor at 50.0.1, an environment holding this pin got " + "agents-shipgate 0.15.0 from pip, with no warning" + ), + ), + Scenario( + name="packaging 24", + pins=("packaging==24.2",), + why=( + "langchain-core 0.3 to 1.0 cap packaging<26, so agent projects " + "carry 24.x and 25.x; the 26.3 floor sent them to 0.15.0" + ), + ), + Scenario( + name="CrewAI", + pins=("crewai==1.15.27",), + why=( + "CrewAI requires pydantic<2.13; the 2.13.4 floor made pip fall back " + "to an old release or fail with ResolutionImpossible" + ), + ), +) + + +# --------------------------------------------------------------------------- +# The declarations +# --------------------------------------------------------------------------- + + +@dataclass(frozen=True) +class Floor: + """One runtime requirement and the lowest version it admits.""" + + requirement: Requirement + version: Version + + @property + def name(self) -> str: + return canonicalize_name(self.requirement.name) + + +def project_table(root: Path = REPO_ROOT) -> dict: + pyproject = root / "pyproject.toml" + project = tomllib.loads(pyproject.read_text(encoding="utf-8")).get("project") + if not isinstance(project, dict): + raise ReleaseError(f"{pyproject} has no [project] table.") + return project + + +def runtime_floors(root: Path = REPO_ROOT) -> list[Floor]: + """Every runtime requirement, each with the one floor it must declare. + + The shape is a policy, so it is enforced rather than assumed: a ``>=`` + floor, an optional ``<`` cap, no marker, extra or URL. Anything else would + make "the lowest version this admits" a question this script answers by + guessing — and the lowest-direct job would then test something other than + what an adopter's resolver can pick. + """ + + floors: list[Floor] = [] + for spec in project_table(root).get("dependencies") or []: + try: + requirement = Requirement(spec) + except InvalidRequirement as exc: + raise ReleaseError( + f"pyproject.toml declares an unparsable requirement {spec!r}: {exc}" + ) from exc + problems = [] + if requirement.marker is not None: + problems.append("an environment marker") + if requirement.extras: + problems.append("extras") + if requirement.url: + problems.append("a direct URL") + lower = [item for item in requirement.specifier if item.operator == ">="] + other = [item for item in requirement.specifier if item.operator not in {">=", "<"}] + if len(lower) != 1: + problems.append(f"{len(lower)} `>=` floors instead of one") + if other: + problems.append(f"the operator(s) {', '.join(str(item) for item in other)}") + if problems: + raise ReleaseError( + f"pyproject.toml declares {spec!r} with {', '.join(problems)}. A runtime " + "requirement is a `>=` floor and, only where a break is known, a `<` cap." + ) + try: + version = Version(lower[0].version) + except InvalidVersion as exc: + raise ReleaseError( + f"pyproject.toml declares an invalid floor in {spec!r}: {exc}" + ) from exc + floors.append(Floor(requirement=requirement, version=version)) + if not floors: + raise ReleaseError("pyproject.toml declares no runtime dependencies.") + return floors + + +# --------------------------------------------------------------------------- +# coinstall +# --------------------------------------------------------------------------- + + +def _record_line(name: str, data: bytes) -> str: + digest = base64.urlsafe_b64encode(hashlib.sha256(data).digest()).rstrip(b"=").decode() + return f"{name},sha256={digest},{len(data)}" + + +def metadata_wheel(directory: Path, root: Path = REPO_ROOT) -> tuple[Path, str]: + """A wheel carrying only this tree's resolution metadata. + + A resolver reads four facts from a wheel — name, version, `Requires-Python` + and `Requires-Dist` — and the build backend copies each of them from + `[project]` verbatim. Writing them directly asks pip exactly the question + the published wheel will ask, without a build backend whose own + dependencies (`packaging`, for one) would have to be installed beside the + floors under test. + """ + + project = project_table(root) + name = str(project["name"]) + version = str(project["version"]) + stem = f"{canonicalize_name(name).replace('-', '_')}-{version}" + lines = [ + "Metadata-Version: 2.1", + f"Name: {name}", + f"Version: {version}", + ] + if project.get("requires-python"): + lines.append(f"Requires-Python: {project['requires-python']}") + lines += [f"Requires-Dist: {spec}" for spec in project.get("dependencies") or []] + files = { + f"{stem}.dist-info/METADATA": ("\n".join(lines) + "\n").encode(), + f"{stem}.dist-info/WHEEL": ( + b"Wheel-Version: 1.0\nGenerator: scripts/dependency_floors.py\n" + b"Root-Is-Purelib: true\nTag: py3-none-any\n" + ), + } + record_name = f"{stem}.dist-info/RECORD" + record = "\n".join([*(_record_line(path, data) for path, data in files.items()), f"{record_name},,"]) + files[record_name] = (record + "\n").encode() + wheel = directory / f"{stem}-py3-none-any.whl" + with zipfile.ZipFile(wheel, "w") as archive: + for path, data in files.items(): + archive.writestr(path, data) + return wheel, version + + +@dataclass(frozen=True) +class Resolution: + scenario: Scenario + problems: tuple[str, ...] + installed: dict[str, str] + + +def judge_report( + scenario: Scenario, report: dict, *, distribution: str, version: str +) -> Resolution: + """Whether a `pip --report` resolution kept this release and each pin.""" + + installed = { + canonicalize_name(item["metadata"]["name"]): item["metadata"]["version"] + for item in report.get("install") or [] + } + problems = [] + resolved = installed.get(canonicalize_name(distribution)) + if resolved != version: + problems.append( + f"{distribution} resolved to {resolved or 'nothing'}, not {version}" + ) + for pin in scenario.pins: + requirement = Requirement(pin) + name = canonicalize_name(requirement.name) + got = installed.get(name) + if got is None or not requirement.specifier.contains(got, prereleases=True): + problems.append(f"{pin} was not kept: resolved {name}=={got or 'nothing'}") + return Resolution(scenario=scenario, problems=tuple(problems), installed=installed) + + +def resolve( + scenario: Scenario, + wheel: Path, + *, + version: str, + report_path: Path, + python_version: str = TARGET_PYTHON, + distribution: str = "agents-shipgate", +) -> Resolution: + """One `pip install --dry-run --report`, from the index, ignoring what is installed.""" + + command = [ + sys.executable, + "-m", + "pip", + "install", + "--dry-run", + "--ignore-installed", + "--disable-pip-version-check", + "--quiet", + "--python-version", + python_version, + "--only-binary=:all:", + "--report", + str(report_path), + str(wheel), + *scenario.pins, + ] + result = subprocess.run( # noqa: S603 - argument list built here + command, capture_output=True, text=True, check=False + ) + if result.returncode != 0: + tail = "\n".join(result.stderr.strip().splitlines()[-6:]) + return Resolution( + scenario=scenario, + problems=(f"pip could not resolve it (exit {result.returncode}):\n{tail}",), + installed={}, + ) + report = json.loads(report_path.read_text(encoding="utf-8")) + return judge_report(scenario, report, distribution=distribution, version=version) + + +def coinstall(*, python_version: str = TARGET_PYTHON, root: Path = REPO_ROOT) -> int: + distribution = str(project_table(root)["name"]) + failed = 0 + with tempfile.TemporaryDirectory(prefix="shipgate-floors-") as raw: + directory = Path(raw) + wheel, version = metadata_wheel(directory, root) + for index, scenario in enumerate(SCENARIOS): + resolution = resolve( + scenario, + wheel, + version=version, + report_path=directory / f"report-{index}.json", + python_version=python_version, + distribution=distribution, + ) + label = f"{scenario.name} ({' '.join(scenario.pins)})" + if resolution.problems: + failed += 1 + lines = [f"FAIL: {label}: {scenario.why}"] + lines += [f" - {problem}" for problem in resolution.problems] + else: + lines = [f"OK: {label} resolves {distribution} {version} on Python {python_version}"] + # Flushed per scenario: each is a resolution against the index, and + # a CI log that shows nothing until the last one ends hides which + # scenario is slow. + print("\n".join(lines), flush=True) + return 1 if failed else 0 + + +# --------------------------------------------------------------------------- +# compile / installed +# --------------------------------------------------------------------------- + + +def test_tool_pins(root: Path = REPO_ROOT) -> list[str]: + pins = parse_lock(root / DEV_LOCK) + requirements = [] + for tool in TEST_TOOLS: + branches = pins.get(canonicalize_name(tool), []) + unconditional = [branch for branch in branches if not branch.marker and branch.version] + if len(unconditional) != 1: + raise ReleaseError( + f"{DEV_LOCK} does not pin {tool} exactly once without a marker; " + "the floors job takes its test tools from there." + ) + requirements.append(f"{tool}=={unconditional[0].version}") + return requirements + + +def compile_input(root: Path = REPO_ROOT) -> str: + floors = runtime_floors(root) + lines = [ + "# Generated by scripts/dependency_floors.py: the runtime requirements as", + "# pyproject.toml declares them, and the test tools at their locked versions.", + *(str(floor.requirement) for floor in floors), + *test_tool_pins(root), + ] + return "\n".join(lines) + "\n" + + +def compile_command(source: Path, *, uv: str, python_version: str) -> list[str]: + return [ + uv, + "pip", + "compile", + "--resolution", + "lowest-direct", + "--generate-hashes", + "--no-header", + "--python-version", + python_version, + str(source), + ] + + +def floor_problems(lock: Path, floors: list[Floor]) -> list[str]: + """Each runtime dependency the lowest-direct resolution did not hold at its floor.""" + + pins = parse_lock(lock) + problems = [] + for floor in floors: + branches = pins.get(floor.name, []) + if len(branches) != 1 or branches[0].version is None: + problems.append(f"{floor.name} is not pinned exactly once in the resolution") + continue + (branch,) = branches + if Version(branch.version) != floor.version: + raisers = ", ".join(branch.requesters) or "an unrecorded requester" + problems.append( + f"{floor.name} resolved to {branch.version}, not its floor {floor.version} " + f"(requested by {raisers}). Something else requires more than the floor, " + "so the job would not be testing the version pyproject.toml admits." + ) + return problems + + +def compile_floors( + output: Path, *, uv: str, python_version: str = TARGET_PYTHON, root: Path = REPO_ROOT +) -> int: + floors = runtime_floors(root) + with tempfile.TemporaryDirectory(prefix="shipgate-floors-") as raw: + source = Path(raw) / "floors.in" + source.write_text(compile_input(root), encoding="utf-8") + result = subprocess.run( # noqa: S603 - argument list built here + compile_command(source, uv=uv, python_version=python_version), + capture_output=True, + text=True, + check=False, + ) + if result.returncode != 0: + raise ReleaseError(f"uv pip compile --resolution lowest-direct failed:\n{result.stderr}") + output.write_text(result.stdout, encoding="utf-8") + problems = floor_problems(output, floors) + if problems: + raise ReleaseError("\n - ".join(["The floors did not resolve as declared:", *problems])) + for floor in floors: + sys.stdout.write(f"OK: {floor.name}=={floor.version} ({floor.requirement})\n") + return 0 + + +def installed_problems(floors: list[Floor]) -> list[str]: + problems = [] + for floor in floors: + try: + found = metadata.version(floor.name) + except metadata.PackageNotFoundError: + problems.append(f"{floor.name} is not installed") + continue + if Version(found) != floor.version: + problems.append(f"{floor.name} is {found}, not its floor {floor.version}") + return problems + + +def check_installed(root: Path = REPO_ROOT) -> int: + floors = runtime_floors(root) + problems = installed_problems(floors) + if problems: + raise ReleaseError( + "\n - ".join(["This interpreter is not at the declared floors:", *problems]) + ) + for floor in floors: + sys.stdout.write(f"OK: {floor.name}=={floor.version} installed\n") + return 0 + + +def main(argv: list[str] | None = None) -> int: + parser = argparse.ArgumentParser(description=__doc__.splitlines()[0]) + commands = parser.add_subparsers(dest="command", required=True) + coinstall_parser = commands.add_parser("coinstall", help="pip dry-run co-installation scenarios") + coinstall_parser.add_argument("--python-version", default=TARGET_PYTHON) + compile_parser = commands.add_parser("compile", help="lowest-direct resolution of the floors") + compile_parser.add_argument("--uv", default="uv", help="path to the uv executable") + compile_parser.add_argument("--output", type=Path, required=True) + compile_parser.add_argument("--python-version", default=TARGET_PYTHON) + commands.add_parser("installed", help="check this interpreter is at the floors") + args = parser.parse_args(argv) + + try: + if args.command == "coinstall": + return coinstall(python_version=args.python_version) + if args.command == "compile": + return compile_floors(args.output, uv=args.uv, python_version=args.python_version) + return check_installed() + except (ReleaseError, OSError) as exc: + sys.stderr.write(f"Dependency floor error: {exc}\n") + return 1 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/src/agents_shipgate/environment.py b/src/agents_shipgate/environment.py index 51797b0b..6d5d5ea6 100644 --- a/src/agents_shipgate/environment.py +++ b/src/agents_shipgate/environment.py @@ -8,6 +8,13 @@ take, or a console script that dies with ``ModuleNotFoundError`` before a line of Shipgate runs (#334, and the epic's step 5 in #338). +The dependencies underneath diverge just as quietly. A virtualenv the launcher +selected can sit below the floors the running build declares, or away from the +lock CI tests a checkout against, and a change then passes locally and fails in +CI or for an adopter. So the running build's runtime requirements are compared +with what this interpreter has installed, and, in a source checkout, with +``constraints/dev.txt`` (#1028). + Everything here is answered by *reading*, never by running: no interpreter is spawned to interrogate it, no console script is executed to see what it does. That is not only the trust-model invariant the whole package is held to @@ -26,6 +33,7 @@ from __future__ import annotations +import importlib.metadata as _metadata import os import re import sys @@ -40,14 +48,17 @@ from agents_shipgate.invocation import ( CONSOLE_SCRIPTS, Invocation, + join_argv, render_command, resolve_invocation, ) __all__ = [ + "DEVELOPMENT_LOCK", "DISTRIBUTION_NAME", "MINIMUM_PYTHON", "REPOSITORY_LAUNCHER", + "DependencyFact", "EnvironmentProbe", "describe_environment", "environment_report", @@ -70,6 +81,10 @@ #: ``PATH``. REPOSITORY_LAUNCHER = "shipgate" +#: The hash-locked closure CI installs, relative to a source checkout's root: +#: the dependency versions a source tree is actually tested against. +DEVELOPMENT_LOCK = "constraints/dev.txt" + # Enough of a wrapper to hold its shebang and, for the shell trampoline below, # the ``exec`` line naming an absolute interpreter path. Console-script wrappers # on Windows are compiled binaries, so this is read defensively and may decode @@ -94,6 +109,43 @@ # with ``re.match``, so ``exec`` has to be the command, not a mention of one. _EXEC_TARGET = re.compile(r"""['"]*exec['"]*\s+(?:"([^"]+)"|'([^']+)'|(\S+))""") +# A declared requirement: a name, optional extras, then specifiers up to an +# optional marker. Spelled for the declarations this project writes; whatever +# it cannot read is left unjudged rather than guessed at. +_REQUIREMENT = re.compile( + r"^\s*(?P[A-Za-z0-9](?:[A-Za-z0-9._-]*[A-Za-z0-9])?)\s*(?:\[[^\]]*\])?" + r"\s*\(?(?P[^;()]*)\)?\s*(?P;.*)?$" +) + +# One pin in a `uv pip compile` lock: `name==version`, optionally `; marker`, +# and the line continuation before its hashes. +_LOCK_PIN = re.compile( + r"^(?P[A-Za-z0-9][A-Za-z0-9._-]*)\s*(?:\[[^\]]*\])?\s*==\s*(?P[^\s;\\]+)" + r"\s*(?P;[^\\]*)?\\?\s*$" +) + +# The release segment of a version, and whatever follows it. +_RELEASE = re.compile(r"^\s*v?(?P\d+(?:\.\d+)*)(?P.*)$", re.IGNORECASE) + +# A pre- or development-release suffix: the one kind that sorts a version +# *below* the release it is attached to. +_PRE_RELEASE = re.compile(r"^[-_.]?(?:a|b|c|rc|alpha|beta|pre|preview|dev)", re.IGNORECASE) + + +@dataclass(frozen=True) +class DependencyFact: + """One runtime requirement of the build that is running, and this interpreter's copy. + + ``requirement`` is the declaration verbatim. ``installed`` is what + ``importlib.metadata`` records for it in this interpreter, ``None`` when it + records nothing. ``locked`` is the development lock's pin, read only for a + source checkout: that lock is the environment CI tests the tree in. + """ + + requirement: str + installed: str | None + locked: str | None = None + @dataclass(frozen=True) class EnvironmentProbe: @@ -117,6 +169,12 @@ class EnvironmentProbe: #: Suffixes that make a file executable, from ``PATHEXT``. Empty on POSIX, #: where the execute bit decides instead. path_extensions: tuple[str, ...] = () + #: The imported build's runtime requirements, each with the version this + #: interpreter has installed (#1028). Empty when they cannot be read. + dependencies: tuple[DependencyFact, ...] = () + #: The development lock those requirements' ``locked`` pins came from, for a + #: source checkout that has one. + dependency_lock: str | None = None def probe_environment( @@ -131,10 +189,12 @@ def probe_environment( cwd = Path.cwd() except OSError: # pragma: no cover - deleted working directory cwd = Path(os.sep) + package_file = getattr(agents_shipgate, "__file__", None) + dependencies, dependency_lock = _probe_dependencies(package_file) return EnvironmentProbe( executable=sys.executable, python_version=sys.version_info[:3], - package_file=getattr(agents_shipgate, "__file__", None), + package_file=package_file, imported_version=getattr(agents_shipgate, "__version__", None), installed_version=_installed_version(), path_entries=tuple( @@ -146,6 +206,8 @@ def probe_environment( # on. Searched before the imported package's own root — see # `find_source_checkout` for why that order is load-bearing. search_from=tuple(path for path in (workspace, cwd) if path is not None), + dependencies=dependencies, + dependency_lock=dependency_lock, ) @@ -179,6 +241,165 @@ def _installed_version() -> str | None: return None +def _probe_dependencies( + package_file: str | None, +) -> tuple[tuple[DependencyFact, ...], str | None]: + """The imported build's runtime requirements and this interpreter's copies. + + The requirements come from the build that is *running*, not from whatever + distribution metadata happens to be installed: a source checkout declares + them in its own ``pyproject.toml`` (an editable install's recorded metadata + lags every edit to a floor), and a built distribution in its + ``Requires-Dist``. Installed versions are read through ``importlib.metadata`` + at call time, so a test can state them by patching that module. + """ + + declared, lock = _declared_dependencies(_describe_import_source(package_file)) + locked = _locked_versions(lock) if lock is not None else {} + facts = [] + for requirement in declared: + parsed = _requirement_floor(requirement) + if parsed is None: + continue + name, _floor = parsed + facts.append( + DependencyFact( + requirement=requirement, + installed=_installed_distribution_version(name), + locked=locked.get(name), + ) + ) + return tuple(facts), (str(lock) if lock is not None else None) + + +def _declared_dependencies( + import_source: Mapping[str, object], +) -> tuple[list[str], Path | None]: + """The running build's runtime requirements, and its development lock if any. + + Only unconditional requirements are kept from a built distribution: an + extra's entries carry an ``extra ==`` marker, and this module evaluates no + markers. ``import_source`` is :func:`_describe_import_source`'s answer, so + "source checkout" means the same thing here as in the report. + """ + + root = import_source.get("root") + kind = import_source.get("kind") + if kind == "source_checkout" and root: + checkout = Path(str(root)).parent + try: + parsed = tomllib.loads((checkout / "pyproject.toml").read_text(encoding="utf-8")) + except (OSError, UnicodeDecodeError, tomllib.TOMLDecodeError): + return [], None + project = parsed.get("project") + declared = project.get("dependencies") if isinstance(project, dict) else None + lock = checkout / DEVELOPMENT_LOCK + return ( + [item for item in declared or [] if isinstance(item, str)], + lock if lock.is_file() else None, + ) + if kind == "installed": + try: + declared = _metadata.requires(DISTRIBUTION_NAME) or [] + except Exception: # noqa: BLE001 - absent or corrupt metadata is a fact, not a fault + return [], None + return [item for item in declared if ";" not in item], None + return [], None + + +def _locked_versions(lock: Path) -> dict[str, str]: + """The lock's unconditional pins; a name forked across markers is left out.""" + + try: + text = lock.read_text(encoding="utf-8") + except (OSError, UnicodeDecodeError): + return {} + pins: dict[str, list[str | None]] = {} + for line in text.splitlines(): + match = _LOCK_PIN.match(line) + if match is not None: + pins.setdefault(_canonical_name(match.group("name")), []).append( + None if match.group("marker") else match.group("version") + ) + return { + name: versions[0] + for name, versions in pins.items() + if len(versions) == 1 and versions[0] is not None + } + + +def _installed_distribution_version(name: str) -> str | None: + try: + return _metadata.version(name) + except _metadata.PackageNotFoundError: + return None + except Exception: # noqa: BLE001 - a corrupt metadata directory + return None + + +def _canonical_name(name: str) -> str: + """PEP 503 normalization, so ``ruamel.yaml`` and ``ruamel-yaml`` are one name.""" + + return re.sub(r"[-_.]+", "-", name).lower() + + +def _requirement_floor(requirement: str) -> tuple[str, str | None] | None: + """An unconditional requirement's canonical name and its single ``>=`` floor.""" + + match = _REQUIREMENT.match(requirement) + if match is None or match.group("marker"): + return None + floors = [ + part.strip()[2:].strip() + for part in match.group("specifiers").split(",") + if part.strip().startswith(">=") + ] + return _canonical_name(match.group("name")), floors[0] if len(floors) == 1 else None + + +def _release(version: str) -> tuple[tuple[int, ...], str] | None: + """A version's release segment, trailing zeros dropped, and its suffix. + + ``None`` for an epoch (``1!2.0``): an epoch outranks every release segment, + and reading ``1`` as the release would invert the comparison. + """ + + if "!" in version: + return None + match = _RELEASE.match(version) + if match is None: + return None + parts = [int(part) for part in match.group("release").split(".")] + while len(parts) > 1 and parts[-1] == 0: + parts.pop() + return tuple(parts), match.group("rest").strip().lower() + + +def _below(installed: str, floor: str) -> bool: + """Whether ``installed`` sorts below ``floor`` under PEP 440, when that is certain. + + Standard library only, so this compares release segments and handles the + one suffix that changes the answer at an equal release: a pre- or + development release of the floor sorts below it. A version this cannot + read (an epoch, say) is never called low: a false "below the floor" would + send someone to reinstall a working environment. + """ + + have, want = _release(installed), _release(floor) + if have is None or want is None: + return False + if have[0] != want[0]: + return have[0] < want[0] + return bool(_PRE_RELEASE.match(have[1])) and not want[1] + + +def _same_version(left: str, right: str) -> bool: + first, second = _release(left), _release(right) + if first is None or second is None: + return left.strip().lower() == right.strip().lower() + return first == second + + def environment_report( *, workspace: Path | None = None, @@ -666,4 +887,64 @@ def _mismatches( ) ) + found.extend(_dependency_mismatches(probe)) + return found + + +def _dependency_mismatches(probe: EnvironmentProbe) -> list[dict[str, object]]: + """Runtime dependencies below the build's floor, or off the tree's lock (#1028). + + Below a floor is an ``error``: the build declares it does not support that + version, so a command can fail or behave as it does nowhere CI looks. Off + the development lock is a ``warning``, and only for a source checkout: the + version is supported, but a result here may not reproduce in CI. A missing + distribution is never reported — it is the launcher's import failure to + explain, and absent metadata is not evidence of an absent package. + """ + + found: list[dict[str, object]] = [] + lock = probe.dependency_lock + lock_install = ( + join_argv( + [probe.executable, "-m", "pip", "install", "--require-hashes", "--requirement", lock] + ) + if lock + else None + ) + for fact in probe.dependencies: + parsed = _requirement_floor(fact.requirement) + if parsed is None or fact.installed is None: + continue + name, floor = parsed + if floor is not None and _below(fact.installed, floor): + found.append( + _mismatch( + "dependency_below_declared_floor", + severity="error", + detail=( + f"{name} {fact.installed} is installed in {probe.executable}, " + f"below the {floor} floor this build of Agents Shipgate " + f"declares ({fact.requirement}). No version below the " + "floor is tested, so a command can fail on it or behave " + "differently than it does in CI." + ), + command=lock_install + or join_argv( + [probe.executable, "-m", "pip", "install", fact.requirement] + ), + ) + ) + elif fact.locked is not None and not _same_version(fact.installed, fact.locked): + found.append( + _mismatch( + "dependency_differs_from_lock", + severity="warning", + detail=( + f"{name} {fact.installed} is installed in {probe.executable}, " + f"but {lock} pins {fact.locked}, the version CI tests this " + "checkout against. A result here may not reproduce in CI." + ), + command=lock_install, + ) + ) return found diff --git a/tests/shard_seconds.json b/tests/shard_seconds.json index ffb6532d..bf9b6331 100644 --- a/tests/shard_seconds.json +++ b/tests/shard_seconds.json @@ -436,7 +436,8 @@ "tests/test_verification_receipt_consumers.py": 3.7, "tests/test_verify_base_and_gate_removal.py": 17.9, "tests/test_verify_pr_controlled_inputs.py": 23.4, - "tests/test_claude_command_settings.py": 21.0 + "tests/test_claude_command_settings.py": 21.0, + "tests/test_dependency_floors.py": 0.1 }, "measured_at": "cbff1ef0b101", "measurement": { @@ -557,6 +558,17 @@ "case_count": 14, "command": "python -m pytest tests/test_claude_command_settings.py --junitxml=command-settings.xml", "time_basis": "JUnit testcase time; original full-suite measurements retained" + }, + { + "source": "junit", + "measured_at": "4e1f0474 with issue #1028 dependency floors increment", + "report_sha256": "f22986dde00b5ae55c287e937ebb9a830dd839d13ced5134e4cac43fa2949400", + "files": { + "tests/test_dependency_floors.py": 0.1 + }, + "case_count": 22, + "command": "python -m pytest -m \"not slow\" tests/test_dependency_floors.py --junitxml=dependency-floors.xml", + "time_basis": "JUnit testcase time; original full-suite measurements retained" } ] } diff --git a/tests/test_dependency_floors.py b/tests/test_dependency_floors.py new file mode 100644 index 00000000..6debae92 --- /dev/null +++ b/tests/test_dependency_floors.py @@ -0,0 +1,373 @@ +"""The runtime floors are tested versions, and pip installs this release beside them (#1028). + +With each floor at the newest upstream release, `pip install agents-shipgate` +in an agent project that pinned `cryptography==46.0.7` or `packaging==24.2`, or +used CrewAI (`pydantic<2.13`), quietly installed 0.15.0. `scripts/dependency_floors.py` +is what CI's `dependency-floors` job runs to keep that from coming back; these +tests hold its parts offline, and one resolves the real scenarios against the +package index when the index can be reached. +""" + +from __future__ import annotations + +import base64 +import hashlib +import socket +import tomllib +import zipfile +from email.parser import BytesParser +from email.policy import default as email_policy +from pathlib import Path + +import pytest +import yaml +from packaging.requirements import Requirement +from packaging.utils import canonicalize_name + +from scripts import dependency_floors as floors +from scripts._release_support import ReleaseError +from scripts.verify_dependency_lock import parse_lock + +REPO_ROOT = Path(__file__).resolve().parent.parent + +# The runtime dependencies that keep an upper cap, and the break each one +# stands for. Every other runtime dependency is uncapped: a cap with no known +# break only means that the next routine major sends pip back to an old +# Agents Shipgate (CONTRIBUTING.md § Changing dependencies). +CAPPED = { + "pydantic": "Pydantic 3 is announced as a breaking release", + "pyyaml": "a new PyYAML major is a declared break", + "ruamel-yaml": "ruamel.yaml's 0.x series changes its loader API between minors", + "typer": "Typer is 0.x; 1.0 is its declared break", +} + + +def _declared(name: str) -> Requirement: + project = tomllib.loads((REPO_ROOT / "pyproject.toml").read_text(encoding="utf-8")) + for spec in project["project"]["dependencies"]: + requirement = Requirement(spec) + if canonicalize_name(requirement.name) == canonicalize_name(name): + return requirement + raise AssertionError(f"pyproject.toml declares no {name}") + + +def _project(tmp_path: Path, dependencies: list[str], *, version: str = "9.9.9") -> Path: + listed = ", ".join(f'"{spec}"' for spec in dependencies) + (tmp_path / "pyproject.toml").write_text( + "[project]\n" + 'name = "agents-shipgate"\n' + f'version = "{version}"\n' + 'requires-python = ">=3.12"\n' + f"dependencies = [{listed}]\n", + encoding="utf-8", + ) + return tmp_path + + +# --------------------------------------------------------------------------- +# The declarations +# --------------------------------------------------------------------------- + + +def test_every_runtime_dependency_declares_one_floor_and_a_cap_only_where_a_break_is_known(): + declared = floors.runtime_floors() + + assert {floor.name for floor in declared} == { + "cryptography", + "packaging", + "pydantic", + "pyyaml", + "ruamel-yaml", + "typer", + } + capped = { + floor.name + for floor in declared + if any(item.operator == "<" for item in floor.requirement.specifier) + } + assert capped == set(CAPPED), ( + "An upper cap is declared only where a break is known; record the break " + "in CAPPED, or drop the cap." + ) + + +@pytest.mark.parametrize("scenario", floors.SCENARIOS, ids=lambda scenario: scenario.name) +def test_the_floors_admit_each_scenarios_direct_pins(scenario: floors.Scenario): + """The offline half of the co-installation check, and the part #1028 broke. + + A scenario pin on a package Agents Shipgate also declares must satisfy that + declaration, or pip can only answer with an older Agents Shipgate. The + transitive half (CrewAI's `pydantic<2.13`) needs the index, below. + """ + + for pin in scenario.pins: + requirement = Requirement(pin) + (version,) = [item.version for item in requirement.specifier] + try: + declared = _declared(requirement.name) + except AssertionError: + continue + assert declared.specifier.contains(version, prereleases=True), ( + f"{declared} excludes {pin}: {scenario.why}" + ) + + +def test_the_floors_admit_crewais_pydantic_range(): + assert _declared("pydantic").specifier.contains("2.12.5") + + +@pytest.mark.parametrize( + ("spec", "problem"), + [ + ("typer", "0 `>=` floors"), + ("typer<1", "0 `>=` floors"), + ("typer==0.16.0", "0 `>=` floors"), + ("typer>=0.16.0,!=0.17.0", "the operator(s) !=0.17.0"), + ("typer>=0.16.0; python_version < '3.13'", "an environment marker"), + ("typer[all]>=0.16.0", "extras"), + ], +) +def test_a_runtime_requirement_without_exactly_one_floor_is_refused( + tmp_path: Path, spec: str, problem: str +): + root = _project(tmp_path, [spec]) + + with pytest.raises(ReleaseError, match="A runtime requirement is a `>=` floor") as caught: + floors.runtime_floors(root) + + assert problem in str(caught.value) + + +def test_dependabot_moves_the_locks_and_never_a_floor(): + """`increase-if-necessary`, not Dependabot's library-detection default. + + Unset, Dependabot raised a floor (`increase`) whenever PyPI's summary for + agents-shipgate differed from pyproject.toml's description, which every + description edit between releases arranged. `lockfile-only` would stop it + proposing updates to constraints/*.txt at all, because it reads them as + requirement files rather than lockfiles. + """ + + config = yaml.safe_load((REPO_ROOT / ".github/dependabot.yml").read_text(encoding="utf-8")) + (pip,) = [entry for entry in config["updates"] if entry["package-ecosystem"] == "pip"] + + assert pip["versioning-strategy"] == "increase-if-necessary" + + +def test_pull_request_ci_resolves_the_floors_and_runs_the_engine_there(): + """The job that makes a floor a tested version: resolve, install hashed, check, test.""" + + workflow = yaml.safe_load((REPO_ROOT / ".github/workflows/ci.yml").read_text(encoding="utf-8")) + triggers = workflow.get("on", workflow.get(True)) + runs = [ + str(step.get("run", "")) + for step in workflow["jobs"]["dependency-floors"]["steps"] + ] + + assert "pull_request" in triggers + for needle in ( + "scripts/dependency_floors.py coinstall", + "scripts/dependency_floors.py compile", + "--require-hashes --requirement \"${RUNNER_TEMP}/floors.txt\"", + "scripts/dependency_floors.py installed", + "python -m pytest", + "scripts/regenerate_goldens.py --check", + ): + assert any(needle in run for run in runs), needle + + +def test_the_floor_is_the_lowest_version_the_requirement_admits(tmp_path: Path): + root = _project(tmp_path, ["typer>=0.16.0,<1", "cryptography>=44.0.3"]) + + declared = {floor.name: str(floor.version) for floor in floors.runtime_floors(root)} + + assert declared == {"typer": "0.16.0", "cryptography": "44.0.3"} + + +# --------------------------------------------------------------------------- +# coinstall +# --------------------------------------------------------------------------- + + +def test_the_metadata_wheel_states_exactly_what_the_resolver_reads(tmp_path: Path): + """Name, version, Requires-Python and Requires-Dist, copied from `[project]`.""" + + wheel, version = floors.metadata_wheel(tmp_path) + project = tomllib.loads((REPO_ROOT / "pyproject.toml").read_text(encoding="utf-8"))["project"] + + assert version == project["version"] + assert wheel.name == f"agents_shipgate-{version}-py3-none-any.whl" + with zipfile.ZipFile(wheel) as archive: + names = archive.namelist() + message = BytesParser(policy=email_policy).parsebytes( + archive.read(f"agents_shipgate-{version}.dist-info/METADATA") + ) + record = archive.read(f"agents_shipgate-{version}.dist-info/RECORD").decode() + for line in record.splitlines(): + path, digest, size = line.split(",") + if path.endswith("RECORD"): + assert (digest, size) == ("", "") + continue + data = archive.read(path) + expected = base64.urlsafe_b64encode(hashlib.sha256(data).digest()).rstrip(b"=") + assert digest == f"sha256={expected.decode()}" + assert int(size) == len(data) + + assert sorted(names) == sorted( + f"agents_shipgate-{version}.dist-info/{name}" for name in ("METADATA", "WHEEL", "RECORD") + ) + assert message["Name"] == "agents-shipgate" + assert message["Version"] == version + assert message["Requires-Python"] == project["requires-python"] + assert message.get_all("Requires-Dist") == project["dependencies"] + + +def _report(**installed: str) -> dict: + return { + "install": [ + {"metadata": {"name": name.replace("_", "-"), "version": version}} + for name, version in installed.items() + ] + } + + +_CRYPTOGRAPHY = floors.Scenario(name="example", pins=("cryptography==46.0.7",), why="") + + +def test_a_resolution_that_keeps_this_release_and_the_pin_passes(): + resolution = floors.judge_report( + _CRYPTOGRAPHY, + _report(agents_shipgate="1.3.0", cryptography="46.0.7"), + distribution="agents-shipgate", + version="1.3.0", + ) + + assert resolution.problems == () + + +def test_a_resolution_that_falls_back_to_an_old_release_fails(): + """The #1028 shape: pip kept the pin by choosing an older Agents Shipgate.""" + + resolution = floors.judge_report( + _CRYPTOGRAPHY, + _report(agents_shipgate="0.15.0", cryptography="46.0.7"), + distribution="agents-shipgate", + version="1.3.0", + ) + + assert resolution.problems == ("agents-shipgate resolved to 0.15.0, not 1.3.0",) + + +def test_a_resolution_that_moves_the_projects_pin_fails(): + resolution = floors.judge_report( + _CRYPTOGRAPHY, + _report(agents_shipgate="1.3.0", cryptography="50.0.2"), + distribution="agents-shipgate", + version="1.3.0", + ) + + assert resolution.problems == ( + "cryptography==46.0.7 was not kept: resolved cryptography==50.0.2", + ) + + +def _index_reachable() -> bool: + try: + with socket.create_connection(("pypi.org", 443), timeout=3): + return True + except OSError: + return False + + +@pytest.mark.slow +def test_pip_installs_this_release_beside_the_pins_agent_projects_carry( + capsys: pytest.CaptureFixture[str], +): + """The scripted `pip install --dry-run --report` check, against the real index. + + Skipped, not failed, when the index cannot be reached: an offline run has + nothing to say about resolution. CI's `dependency-floors` job runs the same + command on every pull request, where the index is always reachable. + """ + + if not _index_reachable(): + pytest.skip("pypi.org is not reachable; resolution needs the package index") + + status = floors.coinstall() + + output = capsys.readouterr().out + assert status == 0, output + assert output.count("OK: ") == len(floors.SCENARIOS), output + + +# --------------------------------------------------------------------------- +# compile / installed +# --------------------------------------------------------------------------- + + +def test_the_lowest_direct_input_is_the_declarations_and_the_locked_test_tools(): + text = floors.compile_input() + lines = [line for line in text.splitlines() if line and not line.startswith("#")] + locked = parse_lock(REPO_ROOT / floors.DEV_LOCK) + + runtime = [str(floor.requirement) for floor in floors.runtime_floors()] + assert lines[: len(runtime)] == runtime + assert lines[len(runtime) :] == [ + f"{tool}=={locked[canonicalize_name(tool)][0].version}" for tool in floors.TEST_TOOLS + ] + + +def _lock(tmp_path: Path, body: str) -> Path: + path = tmp_path / "floors.txt" + path.write_text(body, encoding="utf-8") + return path + + +def test_a_floor_that_something_else_raised_is_named_with_what_raised_it(tmp_path: Path): + root = _project(tmp_path, ["cryptography>=44.0.3", "typer>=0.16.0,<1"]) + lock = _lock( + tmp_path, + "cryptography==46.0.7 \\\n" + " --hash=sha256:aaaa\n" + " # via\n" + " # -r floors.in\n" + " # example-tool\n" + "typer==0.16.0 \\\n" + " --hash=sha256:bbbb\n" + " # via -r floors.in\n", + ) + + (problem,) = floors.floor_problems(lock, floors.runtime_floors(root)) + + assert problem.startswith("cryptography resolved to 46.0.7, not its floor 44.0.3") + assert "example-tool" in problem + + +def test_a_resolution_at_every_floor_has_no_problems(tmp_path: Path): + root = _project(tmp_path, ["cryptography>=44.0.3", "typer>=0.16.0,<1"]) + lock = _lock( + tmp_path, + "cryptography==44.0.3 \\\n --hash=sha256:aaaa\n" + "typer==0.16.0 \\\n --hash=sha256:bbbb\n", + ) + + assert floors.floor_problems(lock, floors.runtime_floors(root)) == [] + + +def test_the_installed_check_reads_each_floor_from_importlib_metadata( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +): + root = _project(tmp_path, ["cryptography>=44.0.3", "typer>=0.16.0,<1", "pyyaml>=6.0.2,<7"]) + installed = {"cryptography": "44.0.3", "typer": "0.27.3"} + + def version(name: str) -> str: + if name not in installed: + raise floors.metadata.PackageNotFoundError(name) + return installed[name] + + monkeypatch.setattr(floors.metadata, "version", version) + + assert floors.installed_problems(floors.runtime_floors(root)) == [ + "typer is 0.27.3, not its floor 0.16.0", + "pyyaml is not installed", + ] diff --git a/tests/test_environment.py b/tests/test_environment.py index 146fc4e2..1ccc086d 100644 --- a/tests/test_environment.py +++ b/tests/test_environment.py @@ -16,6 +16,7 @@ from __future__ import annotations +import importlib.metadata import json import os import shlex @@ -27,11 +28,16 @@ from pathlib import Path import pytest +from packaging.requirements import Requirement +from packaging.utils import canonicalize_name from agents_shipgate import __version__ from agents_shipgate.environment import ( + DEVELOPMENT_LOCK, MINIMUM_PYTHON, + DependencyFact, EnvironmentProbe, + _probe_dependencies, describe_environment, environment_report, same_interpreter, @@ -39,8 +45,10 @@ from agents_shipgate.invocation import ( CLI_OVERRIDE_ENV_VAR, Invocation, + join_argv, render_cli_override, ) +from scripts.verify_dependency_lock import parse_lock REPO_ROOT = Path(__file__).resolve().parent.parent LAUNCHER = REPO_ROOT / "shipgate" @@ -1227,3 +1235,237 @@ def test_a_mention_of_exec_inside_the_script_body_is_not_a_handoff( (described,) = report["launcher"]["console_scripts"] assert described["interpreter"] is None assert report["mismatches"] == [] + + +# --------------------------------------------------------------------------- +# Runtime dependencies against the build's floors and the tree's lock (#1028) +# --------------------------------------------------------------------------- + + +def _codes(report: dict) -> list[str]: + return [entry["code"] for entry in report["mismatches"]] + + +def test_a_dependency_below_its_declared_floor_is_an_error_with_a_reinstall() -> None: + """The adopter's case: a built distribution, after a `--no-deps` or forced downgrade.""" + + report = describe_environment( + _probe(dependencies=(DependencyFact("typer>=0.16.0,<1", installed="0.15.4"),)) + ) + + (mismatch,) = report["mismatches"] + assert mismatch["code"] == "dependency_below_declared_floor" + assert mismatch["severity"] == "error" + assert mismatch["detail"].startswith("typer 0.15.4 is installed in /venv/bin/python") + assert "0.16.0 floor" in mismatch["detail"] + assert mismatch["command"] == "/venv/bin/python -m pip install 'typer>=0.16.0,<1'" + + +def test_a_source_checkout_below_a_floor_is_sent_to_its_lock() -> None: + """Below the floor is reported once, as the error, not also as lock drift.""" + + report = describe_environment( + _probe( + dependencies=( + DependencyFact("typer>=0.16.0,<1", installed="0.15.4", locked="0.27.3"), + ), + dependency_lock="/work/my checkout/constraints/dev.txt", + ) + ) + + (mismatch,) = report["mismatches"] + assert mismatch["code"] == "dependency_below_declared_floor" + assert mismatch["command"] == ( + "/venv/bin/python -m pip install --require-hashes --requirement " + "'/work/my checkout/constraints/dev.txt'" + ) + + +def test_a_dependency_off_the_development_lock_is_a_warning() -> None: + """#1028's contributor case: supported, but not what CI tests this tree on.""" + + lock = "/checkout/constraints/dev.txt" + report = describe_environment( + _probe( + dependencies=( + DependencyFact("typer>=0.16.0,<1", installed="0.25.1", locked="0.27.3"), + DependencyFact("pyyaml>=6.0.2,<7", installed="6.0.3", locked="6.0.3"), + DependencyFact("packaging>=24.2", installed="26.0", locked="26"), + ), + dependency_lock=lock, + ) + ) + + (mismatch,) = report["mismatches"] + assert mismatch["code"] == "dependency_differs_from_lock" + assert mismatch["severity"] == "warning" + assert mismatch["detail"].startswith("typer 0.25.1 is installed in /venv/bin/python") + assert f"{lock} pins 0.27.3" in mismatch["detail"] + assert mismatch["command"] == ( + f"/venv/bin/python -m pip install --require-hashes --requirement {lock}" + ) + + +@pytest.mark.parametrize( + ("installed", "floor", "below"), + [ + ("0.15.4", "0.16.0", True), + ("44.0.3", "50.0.1", True), + ("0.9.9", "0.16.0", True), + ("0.16.0rc1", "0.16.0", True), + ("0.16.0.dev3", "0.16.0", True), + ("0.16.0", "0.16.0", False), + ("0.16", "0.16.0", False), + ("0.16.0.post1", "0.16.0", False), + ("0.16.0+local", "0.16.0", False), + ("0.16.1", "0.16.0", False), + ("1.0.0", "0.16.0", False), + ("0.17.0rc1", "0.16.0", False), + # An epoch outranks any release segment. It is beyond what this reads, + # so it is never called low, even where its digits look lower. + ("1!0.1", "2.0", False), + ], +) +def test_below_the_floor_follows_pep_440_order(installed: str, floor: str, below: bool) -> None: + report = describe_environment( + _probe(dependencies=(DependencyFact(f"typer>={floor}", installed=installed),)) + ) + + assert _codes(report) == (["dependency_below_declared_floor"] if below else []) + + +def test_what_cannot_be_judged_is_not_reported() -> None: + """No metadata, a marker, no floor, or no lock pin: each is silence, not a guess.""" + + report = describe_environment( + _probe( + dependencies=( + DependencyFact("typer>=0.16.0,<1", installed=None, locked="0.27.3"), + DependencyFact("tomli>=2; python_version < '3.11'", installed="1.0"), + DependencyFact("pydantic<3", installed="1.10.0"), + DependencyFact("pyyaml>=6.0.2,<7", installed="6.0.2", locked=None), + ), + dependency_lock="/checkout/constraints/dev.txt", + ) + ) + + assert report["mismatches"] == [] + + +def _version_from(installed: dict[str, str]): + """A stand-in for `importlib.metadata.version` that knows only `installed`.""" + + def version(name: str) -> str: + key = canonicalize_name(name) + if key not in installed: + raise importlib.metadata.PackageNotFoundError(name) + return installed[key] + + return version + + +def test_doctor_reads_installed_versions_through_importlib_metadata( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """The acceptance criterion, on this checkout's real floors and lock. + + Every runtime dependency `pyproject.toml` declares is stated at the version + `constraints/dev.txt` pins, except `typer`, stated below its floor. Only + `typer` may be reported, and as the error: lock drift is the lesser fact. + """ + + project = tomllib.loads((REPO_ROOT / "pyproject.toml").read_text(encoding="utf-8")) + lock = REPO_ROOT / DEVELOPMENT_LOCK + pins = parse_lock(lock) + installed = {} + for spec in project["project"]["dependencies"]: + name = canonicalize_name(Requirement(spec).name) + (branch,) = pins[name] + installed[name] = branch.version + installed["typer"] = "0.15.4" + monkeypatch.setattr(importlib.metadata, "version", _version_from(installed)) + + report = environment_report(workspace=REPO_ROOT) + + dependency_mismatches = [ + entry for entry in report["mismatches"] if entry["code"].startswith("dependency_") + ] + (mismatch,) = dependency_mismatches + assert mismatch["code"] == "dependency_below_declared_floor" + assert mismatch["detail"].startswith(f"typer 0.15.4 is installed in {sys.executable}") + assert mismatch["command"] == join_argv( + [sys.executable, "-m", "pip", "install", "--require-hashes", "--requirement", str(lock)] + ) + + +def test_a_built_distribution_is_judged_by_its_own_requires_dist( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Outside a checkout the floors are the installed wheel's, and extras are skipped.""" + + requires = { + "agents-shipgate": [ + "typer<1,>=0.16.0", + "pyyaml<7,>=6.0.2", + 'weasyprint<71,>=70.0; extra == "pdf"', + ] + } + monkeypatch.setattr(importlib.metadata, "requires", lambda name: requires.get(name)) + monkeypatch.setattr( + importlib.metadata, + "version", + _version_from({"typer": "0.15.4", "pyyaml": "6.0.3", "weasyprint": "60.0"}), + ) + + facts, lock = _probe_dependencies( + "/venv/lib/python3.12/site-packages/agents_shipgate/__init__.py" + ) + + assert lock is None + assert facts == ( + DependencyFact("typer<1,>=0.16.0", installed="0.15.4"), + DependencyFact("pyyaml<7,>=6.0.2", installed="6.0.3"), + ) + report = describe_environment(_probe(dependencies=facts)) + assert _codes(report) == ["dependency_below_declared_floor"] + assert report["mismatches"][0]["command"] == ( + "/venv/bin/python -m pip install 'typer<1,>=0.16.0'" + ) + + +def test_a_checkouts_lock_pins_are_read_and_a_forked_pin_is_left_out( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + root = tmp_path / "checkout" + (root / "src" / "agents_shipgate").mkdir(parents=True) + (root / "pyproject.toml").write_text( + '[project]\nname = "agents-shipgate"\nversion = "1.2.3"\n' + 'dependencies = ["typer>=0.16.0,<1", "ruamel.yaml>=0.18.10,<1", "forked>=1"]\n', + encoding="utf-8", + ) + (root / "constraints").mkdir() + (root / "constraints" / "dev.txt").write_text( + "# a header line\n" + "typer==0.27.3 \\\n --hash=sha256:aaaa\n" + "ruamel-yaml==0.19.1 \\\n --hash=sha256:bbbb\n" + "forked==1.0 ; python_version < '3.13' \\\n --hash=sha256:cccc\n" + "forked==2.0 ; python_version >= '3.13' \\\n --hash=sha256:dddd\n", + encoding="utf-8", + ) + monkeypatch.setattr( + importlib.metadata, + "version", + _version_from({"typer": "0.27.3", "ruamel-yaml": "0.18.10", "forked": "1.0"}), + ) + + facts, lock = _probe_dependencies(str(root / "src" / "agents_shipgate" / "__init__.py")) + + assert lock == str(root / "constraints" / "dev.txt") + assert facts == ( + DependencyFact("typer>=0.16.0,<1", installed="0.27.3", locked="0.27.3"), + DependencyFact("ruamel.yaml>=0.18.10,<1", installed="0.18.10", locked="0.19.1"), + DependencyFact("forked>=1", installed="1.0", locked=None), + ) + report = describe_environment(_probe(dependencies=facts, dependency_lock=lock)) + assert _codes(report) == ["dependency_differs_from_lock"] + assert report["mismatches"][0]["detail"].startswith("ruamel-yaml 0.18.10") diff --git a/tests/test_packaging.py b/tests/test_packaging.py index 8573555d..d996df53 100644 --- a/tests/test_packaging.py +++ b/tests/test_packaging.py @@ -13,6 +13,7 @@ import pytest from packaging.requirements import Requirement +from packaging.utils import canonicalize_name REPO_ROOT = Path(__file__).resolve().parent.parent @@ -407,6 +408,44 @@ def test_wheel_declares_verification_identity_runtime_dependencies( assert "packaging" in requirements +def _resolution_metadata(wheel: Path) -> tuple[object, ...]: + """The facts a resolver reads from a wheel, unconditional requirements only.""" + + with zipfile.ZipFile(wheel) as archive: + name = next(item for item in archive.namelist() if item.endswith(".dist-info/METADATA")) + metadata = message_from_bytes(archive.read(name)) + requirements = [Requirement(value) for value in metadata.get_all("Requires-Dist", [])] + # Compared as a resolver reads them: `ruamel.yaml` and `ruamel-yaml` are one + # name, and the backend writes the second. + return ( + canonicalize_name(metadata["Name"]), + metadata["Version"], + metadata["Requires-Python"], + sorted( + (canonicalize_name(requirement.name), str(requirement.specifier)) + for requirement in requirements + if requirement.marker is None + ), + ) + + +def test_the_floors_check_resolves_the_metadata_the_wheel_ships( + built_wheel: Path, tmp_path: Path +) -> None: + """`scripts/dependency_floors.py coinstall` resolves a metadata-only wheel (#1028). + + That keeps a build backend, and the `packaging` it brings, out of CI's + floors job. It is only a valid stand-in while it states exactly what the + built wheel states to a resolver. + """ + + from scripts.dependency_floors import metadata_wheel + + stand_in, _version = metadata_wheel(tmp_path) + + assert _resolution_metadata(stand_in) == _resolution_metadata(built_wheel) + + def test_wheel_includes_org_policy_templates(built_wheel: Path) -> None: with zipfile.ZipFile(built_wheel) as archive: names = set(archive.namelist())