diff --git a/.github/dependabot.yml b/.github/dependabot.yml index fc7039dd..d066a73d 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -23,9 +23,8 @@ updates: day: "monday" commit-message: prefix: "chore(deps)" - # Ineffective for the uv ecosystem as of 2026-08-31: Dependabot widens - # `~=X.Y.Z` to a range regardless (#134 twice, #138). The pin is enforced - # by .github/scripts/check_runtime_pins.py, not by this setting. + # Ineffective for uv: Dependabot widens `~=X.Y.Z` to a range regardless. + # check_runtime_pins.py enforces the pin; dependabot-pins.yml restores it. versioning-strategy: "increase" groups: dev-tooling: diff --git a/.github/scripts/check_ruff_sync.py b/.github/scripts/check_ruff_sync.py deleted file mode 100644 index 6118b4c6..00000000 --- a/.github/scripts/check_ruff_sync.py +++ /dev/null @@ -1,55 +0,0 @@ -"""The ruff-pre-commit rev must match the ruff version uv.lock resolves.""" - -from __future__ import annotations - -import re -import sys -import tomllib -from pathlib import Path - -REV = re.compile( - r"-\s+repo:\s*https://github\.com/astral-sh/ruff-pre-commit.*?^\s*rev:\s*v?([\d.]+)", - re.S | re.M, -) - - -def versions(root: Path) -> tuple[str | None, str | None]: - """Return (pre-commit rev, uv.lock version) for ruff.""" - config = (root / ".pre-commit-config.yaml").read_text(encoding="utf-8") - match = REV.search(config) - - lock = tomllib.loads((root / "uv.lock").read_text(encoding="utf-8")) - packages = lock.get("package", []) - assert isinstance(packages, list) - locked = next( - (p["version"] for p in packages if isinstance(p, dict) and p.get("name") == "ruff"), - None, - ) - return (match.group(1) if match else None, locked) - - -def main() -> int: - hook, locked = versions(Path(__file__).resolve().parents[2]) - - if hook is None or locked is None: - print("::error::could not read the ruff version from both files") - return 1 - - if hook != locked: - print( - f"::error::ruff drift, .pre-commit-config.yaml pins v{hook}, uv.lock resolves {locked}" - ) - print( - "\nThe hook and CI must run the same ruff. Drift means the hook " - "reformats\nfiles that `uv run ruff format --check .` then rejects, or " - "crashes on code\nCI accepts. Bump `rev:` to match in the same PR as the " - "lock change." - ) - return 1 - - print(f"ruff in sync: v{locked}") - return 0 - - -if __name__ == "__main__": - sys.exit(main()) diff --git a/.github/scripts/check_runtime_pins.py b/.github/scripts/check_runtime_pins.py index 541ba472..c5780de8 100644 --- a/.github/scripts/check_runtime_pins.py +++ b/.github/scripts/check_runtime_pins.py @@ -1,4 +1,8 @@ -"""Every user-facing dependency pins the minor: `~=X.Y.Z`, never `~=X.Y` or a range.""" +"""Every user-facing dependency pins the minor: `~=X.Y.Z`, never `~=X.Y` or a range. + +`--fix` rewrites each offender to `~=` the version uv.lock resolves, which is how a +Dependabot PR (its uv ecosystem always widens to a range) gets its pin back. +""" from __future__ import annotations @@ -9,9 +13,12 @@ # Three-component compatible-release, extras allowed: `termaid[rich]~=0.6.1`. PIN = re.compile(r"^[A-Za-z0-9._-]+(\[[A-Za-z0-9,._-]+\])?~=\d+\.\d+\.\d+$") +NAME = re.compile(r"^([A-Za-z0-9._-]+)(\[[A-Za-z0-9,._-]+\])?") +# A final release (post-releases included): `~=X.Y.Z` excludes a locked pre-release. +RELEASE = re.compile(r"^(\d+)\.(\d+)(?:\.(\d+)(?:\.\d+)*)?(?:\.post\d+)?$") -# `dev` never reaches a user install; every other group here does. -USER_FACING_GROUPS = ("pdf-structure",) +# `dev` never reaches a user install; these groups and every extra do. +USER_FACING_GROUPS = ("pdf-structure", "semantic") def offenders(pyproject: dict[str, object]) -> list[tuple[str, str]]: @@ -20,16 +27,84 @@ def offenders(pyproject: dict[str, object]) -> list[tuple[str, str]]: assert isinstance(project, dict) checked = [("project.dependencies", d) for d in project["dependencies"]] + extras = project.get("optional-dependencies", {}) + assert isinstance(extras, dict) + for name, deps in extras.items(): + checked += [(f"project.optional-dependencies.{name}", d) for d in deps] + groups = pyproject.get("dependency-groups", {}) assert isinstance(groups, dict) for name in USER_FACING_GROUPS: checked += [(f"dependency-groups.{name}", d) for d in groups.get(name, [])] - return [(where, spec) for where, spec in checked if not PIN.match(spec)] + return [(where, spec) for where, spec in checked if not PIN.match(_requirement(spec))] + + +def _requirement(spec: str) -> str: + return spec.partition(";")[0].strip() + + +def _normalise(name: str) -> str: + return re.sub(r"[-_.]+", "-", name).lower() + + +def pinned(spec: str, lock: dict[str, object]) -> str: + """Return `spec` rewritten as `name[extras]~=X.Y.Z; marker` at the version uv.lock resolves.""" + match = NAME.match(_requirement(spec)) + if match is None: + raise SystemExit(f"::error::cannot read a package name from {spec!r}") + name, extras = match.group(1), match.group(2) or "" + marker = spec.partition(";")[2].strip() + + packages = lock.get("package", []) + assert isinstance(packages, list) + # A forked resolution locks one entry per platform; the pin must cover them all. + releases = [ + RELEASE.match(str(p["version"])) + for p in packages + if isinstance(p, dict) and _normalise(str(p.get("name"))) == _normalise(name) + ] + if not releases or None in releases: + raise SystemExit(f"::error::uv.lock resolves no release version for {name}") + versions = sorted( + (int(r.group(1)), int(r.group(2)), int(r.group(3) or 0)) for r in releases if r is not None + ) + if len({v[:2] for v in versions}) > 1: + raise SystemExit( + f"::error::uv.lock resolves {name} to several minors; no `~=` pin covers them" + ) + + major, minor, micro = versions[0] + return f"{name}{extras}~={major}.{minor}.{micro}" + (f"; {marker}" if marker else "") + + +def fix(root: Path) -> list[tuple[str, str]]: + """Rewrite every offender in pyproject.toml in place; return (old, new) per change.""" + path = root / "pyproject.toml" + text = path.read_text(encoding="utf-8") + lock = tomllib.loads((root / "uv.lock").read_text(encoding="utf-8")) + + # An extra mirrored as a group holds the same spec twice; both copies move together. + specs = dict.fromkeys(spec for _, spec in offenders(tomllib.loads(text))) + changes = [(spec, pinned(spec, lock)) for spec in specs] + for old, new in changes: + quoted = f'"{old}"' + if quoted not in text: + raise SystemExit(f"::error::{quoted} is not written that way in pyproject.toml") + text = text.replace(quoted, f'"{new}"') -def main() -> int: + path.write_text(text, encoding="utf-8") + return changes + + +def main(argv: list[str]) -> int: root = Path(__file__).resolve().parents[2] + + if argv == ["--fix"]: + for old, new in fix(root): + print(f"{old} -> {new}") + pyproject = tomllib.loads((root / "pyproject.toml").read_text(encoding="utf-8")) bad = offenders(pyproject) @@ -41,7 +116,8 @@ def main() -> int: f"\n{len(bad)} user-facing dependency specifier(s) are not pinned.\n" "uv.lock reaches no installer, so these specifiers are what every user\n" "resolves. A widened range ships versions CI never ran.\n" - "Dependabot widens by default: correct the specifier before merging." + "Dependabot widens by default: `dependabot-pins.yml` restores the pin,\n" + "or run this script with `--fix` and then `uv lock`." ) return 1 @@ -50,4 +126,4 @@ def main() -> int: if __name__ == "__main__": - sys.exit(main()) + sys.exit(main(sys.argv[1:])) diff --git a/.github/scripts/new_vulns.py b/.github/scripts/new_vulns.py new file mode 100644 index 00000000..d08055d4 --- /dev/null +++ b/.github/scripts/new_vulns.py @@ -0,0 +1,70 @@ +"""Fail on the vulnerabilities a PR's pip-audit JSON report has that the base branch's lacks. + +A finding is inherited only when the base reports the same package with an advisory +sharing an ID or alias. Push and cron audits on main stay strict, so inherited +findings still surface there. +""" + +from __future__ import annotations + +import json +import re +import sys +from pathlib import Path + +Finding = tuple[str, str, frozenset[str]] + + +def findings(report: dict[str, object]) -> list[Finding]: + """Return (package, version, advisory IDs and aliases) for every vulnerability in a report.""" + found: list[Finding] = [] + deps = report.get("dependencies", []) + assert isinstance(deps, list) + for dep in deps: + assert isinstance(dep, dict) + name = re.sub(r"[-_.]+", "-", str(dep["name"])).lower() + for vuln in dep.get("vulns", []): + found.append( + (name, str(dep["version"]), frozenset([vuln["id"], *vuln.get("aliases", [])])) + ) + return found + + +def introduced(base: list[Finding], pr: list[Finding]) -> list[Finding]: + """Return the PR findings no base finding matches by package and a shared advisory ID.""" + return [ + (package, version, ids) + for package, version, ids in pr + if not any(package == b_package and ids & b_ids for b_package, _, b_ids in base) + ] + + +def _load(path: Path) -> list[Finding] | None: + if not path.is_file(): + return None + return findings(json.loads(path.read_text(encoding="utf-8"))) + + +def main(base_path: str, pr_path: str) -> int: + pr = _load(Path(pr_path)) + if pr is None: + print(f"::error::the PR audit wrote no report at {pr_path}; see the step log") + return 1 + # No base report means the base audit errored; treating it as clean keeps the PR strict. + base = _load(Path(base_path)) + if base is None: + print(f"::warning::no base audit at {base_path}; auditing the PR strictly") + base = [] + + new = introduced(base, pr) + for package, version, ids in pr: + level = "error" if (package, version, ids) in new else "warning" + note = "introduced by this PR" if level == "error" else "already on the base branch" + print(f"::{level}::{package} {version} {', '.join(sorted(ids))}: {note}") + + print(f"{len(new)} new, {len(pr) - len(new)} inherited") + return 1 if new else 0 + + +if __name__ == "__main__": + sys.exit(main(sys.argv[1], sys.argv[2])) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index bda33abd..f0248c2f 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -74,8 +74,6 @@ jobs: - run: uv sync --frozen --group dev - name: Ruff format (check only) run: uv run ruff format --check . - - name: pre-commit and CI run the same ruff - run: python3 .github/scripts/check_ruff_sync.py typecheck: name: pyright (strict) diff --git a/.github/workflows/dependabot-pins.yml b/.github/workflows/dependabot-pins.yml new file mode 100644 index 00000000..a3433de1 --- /dev/null +++ b/.github/workflows/dependabot-pins.yml @@ -0,0 +1,90 @@ +name: dependabot-pins + +# Dependabot's uv ecosystem widens `~=X.Y.Z` to a range whatever +# `versioning-strategy` says; this re-pins its PR and re-locks. + +on: + pull_request: + branches: [main] + paths: [pyproject.toml] + +permissions: + contents: read + +concurrency: + group: dependabot-pins-${{ github.event.pull_request.number }} + cancel-in-progress: true + +jobs: + repin: + name: restore runtime pins + # Holds no secret: the only job that resolves packages. + if: >- + github.actor == 'dependabot[bot]' && + github.event.pull_request.user.login == 'dependabot[bot]' && + github.event.pull_request.head.repo.full_name == github.repository + runs-on: ubuntu-latest + outputs: + changed: ${{ steps.diff.outputs.changed }} + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 + with: + ref: ${{ github.head_ref }} + persist-credentials: false + - uses: astral-sh/setup-uv@1c37ad07a6a961277cf70c0d37d6f313000f5884 # v10.3.0 + - run: uv python install 3.13 + - run: python3 .github/scripts/check_runtime_pins.py --fix + - run: uv lock --no-build + - id: diff + run: | + git diff --binary -- pyproject.toml uv.lock > repin.patch + # A lock-only diff is a uv format difference, not a restored pin. + if ! git diff --quiet -- pyproject.toml; then echo "changed=true" >> "$GITHUB_OUTPUT"; fi + - if: steps.diff.outputs.changed == 'true' + uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7 + with: + name: repin-patch + path: repin.patch + retention-days: 1 + + push: + name: push restored pins + # Runs only git, hooks off; the deploy key cannot bypass the main ruleset. + needs: repin + if: needs.repin.outputs.changed == 'true' + runs-on: ubuntu-latest + steps: + - name: Require the deploy key + # Without it checkout falls back to the read-only GITHUB_TOKEN. + env: + KEY: ${{ secrets.PIN_FIX_DEPLOY_KEY }} + run: | + if [ -z "$KEY" ]; then + echo "::error::PIN_FIX_DEPLOY_KEY is not set as a Dependabot secret" + exit 1 + fi + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 + with: + ref: ${{ github.head_ref }} + ssh-key: ${{ secrets.PIN_FIX_DEPLOY_KEY }} + - uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8 + with: + name: repin-patch + path: ${{ runner.temp }} + - name: Apply and push + env: + PATCH: ${{ runner.temp }}/repin.patch + HEAD_REF: ${{ github.head_ref }} + run: | + git apply "$PATCH" + # Porcelain -z lists every path whole (rename sources too); --summary adds mode changes. + extra="$(git status --porcelain -z | tr '\0' '\n' | grep -vxE ' M (pyproject\.toml|uv\.lock)' || true)$(git diff --summary)" + if [ -n "$extra" ]; then + echo "::error::the re-pin patch changes more than pyproject.toml and uv.lock:" + echo "$extra" + exit 1 + fi + git config user.name "github-actions[bot]" + git config user.email "41898282+github-actions[bot]@users.noreply.github.com" + git -c core.hooksPath=/dev/null commit --no-verify -m "chore(deps): restore the ~=X.Y.Z runtime pin" -- pyproject.toml uv.lock + git push origin "HEAD:${HEAD_REF}" diff --git a/.github/workflows/security.yml b/.github/workflows/security.yml index a181ca61..529554eb 100644 --- a/.github/workflows/security.yml +++ b/.github/workflows/security.yml @@ -39,13 +39,36 @@ jobs: # 0 of the publish runbook lands `fnd` on PyPI, the flag stays: # we don't need to audit ourselves, only our deps. run: | - uv export \ + uv export --frozen \ --format requirements-txt \ --no-hashes \ --no-emit-project \ -o requirements.frozen.txt + - name: Check out the base branch + if: github.event_name == 'pull_request' + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 + with: + ref: ${{ github.event.pull_request.base.sha }} + path: base + persist-credentials: false + - name: Audit the base branch + # Its findings are what main already carries; the PR audit skips them. + if: github.event_name == 'pull_request' + run: | + uv export --frozen --project base \ + --format requirements-txt \ + --no-hashes \ + --no-emit-project \ + -o base.frozen.txt + uv tool run pip-audit -r base.frozen.txt --strict --format json -o "$RUNNER_TEMP/base-audit.json" || true - name: Audit + if: github.event_name != 'pull_request' run: uv tool run pip-audit -r requirements.frozen.txt --strict + - name: Audit what the PR introduces + if: github.event_name == 'pull_request' + run: | + uv tool run pip-audit -r requirements.frozen.txt --strict --format json -o "$RUNNER_TEMP/pr-audit.json" || true + python3 .github/scripts/new_vulns.py "$RUNNER_TEMP/base-audit.json" "$RUNNER_TEMP/pr-audit.json" ruff-security: name: ruff (bandit ruleset) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index c9a0103d..e1c98b0a 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -13,15 +13,21 @@ repos: - id: check-added-large-files args: ["--maxkb=2000"] - - repo: https://github.com/astral-sh/ruff-pre-commit - # Keep in sync with the ruff pin in pyproject.toml dev deps; CI runs - # `uv run ruff format --check .` so drift here will reformat files - # locally that CI then rejects. - rev: v0.17.0 + - repo: local + # The ruff uv.lock resolves, so the hook and CI cannot run different versions. hooks: - id: ruff-format + name: ruff format + entry: uv run --frozen ruff format --force-exclude + language: system + types_or: [python, pyi, jupyter] + require_serial: true - id: ruff - args: ["--fix"] + name: ruff check + entry: uv run --frozen ruff check --force-exclude --fix + language: system + types_or: [python, pyi, jupyter] + require_serial: true - repo: local # Each pre-push check skips a tree `make batch-close` already passed diff --git a/SECURITY.md b/SECURITY.md index a6fec041..169ed7cb 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -81,8 +81,10 @@ Out of scope (today): `deps-latest.yml` is the deliberate exception: it runs `uv lock --upgrade` first, precisely to test outside the lock. - CI runs `pip-audit --strict` against the exported lockfile on every - push and weekly on a cron (`.github/workflows/security.yml`). The - build fails on any HIGH/CRITICAL CVE in a direct dep. + push to main and weekly on a cron (`.github/workflows/security.yml`). + On a PR it fails only on advisories the PR introduces: findings the + base branch already carries are listed as warnings and left to the + main audit and Dependabot security updates. - `.github/workflows/ci.yml` runs `check_runtime_pins.py` on every PR and it is a required check: a user-facing dependency that is not pinned `~=X.Y.Z` fails the build. Dependabot rewrites these pins into @@ -90,7 +92,10 @@ Out of scope (today): stop it, so this guard, not the Dependabot config, is what holds the convention. A patch bump is lock-only and passes untouched; a minor or major bump falls outside the pin, so Dependabot rewrites the range - and the specifier needs correcting by hand in that PR. + and `.github/workflows/dependabot-pins.yml` pushes the `~=` pin back + onto that PR (it needs the `PIN_FIX_DEPLOY_KEY` Dependabot secret). + Dependabot then treats the PR as edited and stops rebasing it; use + `@dependabot recreate`, after which the pin is restored again. - `.github/workflows/deps-latest.yml` runs weekly, resolves every dependency at its newest allowed version ignoring `uv.lock`, and runs the suite against it. With `~=X.Y.Z` pins that is the patch float: diff --git a/tests/test_a_pr_audit_skips_what_main_already_carries.py b/tests/test_a_pr_audit_skips_what_main_already_carries.py new file mode 100644 index 00000000..bff7f307 --- /dev/null +++ b/tests/test_a_pr_audit_skips_what_main_already_carries.py @@ -0,0 +1,61 @@ +"""`new_vulns.py` fails a PR only on a package and advisory pair the base branch lacks.""" + +from __future__ import annotations + +import importlib.util +import json +from collections.abc import Mapping, Sequence +from pathlib import Path + +_ROOT = Path(__file__).resolve().parents[1] +_SPEC = importlib.util.spec_from_file_location( + "new_vulns", _ROOT / ".github" / "scripts" / "new_vulns.py" +) +assert _SPEC is not None +assert _SPEC.loader is not None +new_vulns = importlib.util.module_from_spec(_SPEC) +_SPEC.loader.exec_module(new_vulns) + + +def _report(path: Path, *deps: tuple[str, str, Sequence[Mapping[str, object]]]) -> str: + body = [{"name": n, "version": v, "vulns": vulns} for n, v, vulns in deps] + body.append({"name": "fndr", "skip_reason": "not on PyPI"}) + path.write_text(json.dumps({"dependencies": body, "fixes": []}), encoding="utf-8") + return str(path) + + +_FILELOCK = ("filelock", "3.29.0", [{"id": "CVE-1", "aliases": ["GHSA-a"]}]) + + +def test_a_finding_the_base_carries_passes(tmp_path: Path) -> None: + base = _report(tmp_path / "base.json", _FILELOCK) + renamed = ("FileLock", "3.29.1", [{"id": "GHSA-a", "aliases": []}]) + pr = _report(tmp_path / "pr.json", renamed) + + assert new_vulns.main(base, pr) == 0 + + +def test_a_new_finding_fails(tmp_path: Path) -> None: + base = _report(tmp_path / "base.json", _FILELOCK) + pr = _report(tmp_path / "pr.json", _FILELOCK, ("virtualenv", "21.7.13", [{"id": "CVE-2"}])) + + assert new_vulns.main(base, pr) == 1 + + +def test_a_shared_advisory_on_another_package_fails(tmp_path: Path) -> None: + base = _report(tmp_path / "base.json", _FILELOCK) + pr = _report(tmp_path / "pr.json", _FILELOCK, ("py-filelock", "1.0", [{"id": "CVE-1"}])) + + assert new_vulns.main(base, pr) == 1 + + +def test_a_missing_base_report_audits_strictly(tmp_path: Path) -> None: + pr = _report(tmp_path / "pr.json", _FILELOCK) + + assert new_vulns.main(str(tmp_path / "absent.json"), pr) == 1 + + +def test_a_missing_pr_report_fails(tmp_path: Path) -> None: + base = _report(tmp_path / "base.json") + + assert new_vulns.main(base, str(tmp_path / "absent.json")) == 1 diff --git a/tests/test_a_widened_runtime_pin_is_restored.py b/tests/test_a_widened_runtime_pin_is_restored.py new file mode 100644 index 00000000..9fbd8a5c --- /dev/null +++ b/tests/test_a_widened_runtime_pin_is_restored.py @@ -0,0 +1,152 @@ +"""`check_runtime_pins.py --fix` turns a Dependabot range back into `~=` the locked version.""" + +from __future__ import annotations + +import importlib.util +import tomllib +from pathlib import Path + +import pytest + +_ROOT = Path(__file__).resolve().parents[1] +_SPEC = importlib.util.spec_from_file_location( + "check_runtime_pins", _ROOT / ".github" / "scripts" / "check_runtime_pins.py" +) +assert _SPEC is not None +assert _SPEC.loader is not None +check_runtime_pins = importlib.util.module_from_spec(_SPEC) +_SPEC.loader.exec_module(check_runtime_pins) + +_PYPROJECT = """\ +[project] +name = "demo" +dependencies = [ + "tantivy>=0.26.2,<0.28.0", + "Tomli_W>=1.2,<1.4", + "termaid[rich]>=0.9,<0.11", + "rich~=15.0.0", +] + +[project.optional-dependencies] +semantic = ["onnxruntime>=1.31.0,<1.33"] +cli = ["typer>=0.27,<0.29"] + +[dependency-groups] +pdf-structure = ["pymupdf4llm>=1.28.2,<1.30"] +semantic = ["onnxruntime>=1.31.0,<1.33"] +dev = ["ruff>=0.17.0"] +""" + +_LOCK = """\ +version = 1 +revision = 5 + +[[package]] +name = "tantivy" +version = "0.27.0" + +[[package]] +name = "tomli-w" +version = "1.3" + +[[package]] +name = "termaid" +version = "0.10.1" + +[[package]] +name = "rich" +version = "15.0.4" + +[[package]] +name = "pymupdf4llm" +version = "1.29.0.post1" + +[[package]] +name = "onnxruntime" +version = "1.32.1" + +[[package]] +name = "typer" +version = "0.28.2" +""" + + +def _project(tmp_path: Path, pyproject: str) -> Path: + (tmp_path / "pyproject.toml").write_text(pyproject, encoding="utf-8") + (tmp_path / "uv.lock").write_text(_LOCK, encoding="utf-8") + return tmp_path + + +def test_every_widened_pin_returns_to_the_locked_minor(tmp_path: Path) -> None: + root = _project(tmp_path, _PYPROJECT) + + changes = check_runtime_pins.fix(root) + + text = (root / "pyproject.toml").read_text(encoding="utf-8") + assert '"tantivy~=0.27.0"' in text + assert '"Tomli_W~=1.3.0"' in text + assert '"termaid[rich]~=0.10.1"' in text + assert '"pymupdf4llm~=1.29.0"' in text + assert text.count('"onnxruntime~=1.32.1"') == 2 + assert '"typer~=0.28.2"' in text + assert '"rich~=15.0.0"' in text + assert '"ruff>=0.17.0"' in text + assert len(changes) == 6 + + +def test_a_pinned_project_is_left_byte_identical(tmp_path: Path) -> None: + pinned = _PYPROJECT.replace("tantivy>=0.26.2,<0.28.0", "tantivy~=0.27.0") + pinned = pinned.replace("Tomli_W>=1.2,<1.4", "Tomli_W~=1.3.0") + pinned = pinned.replace("termaid[rich]>=0.9,<0.11", "termaid[rich]~=0.10.1") + pinned = pinned.replace('["pymupdf4llm>=1.28.2,<1.30"]', '["pymupdf4llm~=1.29.0"]') + pinned = pinned.replace("onnxruntime>=1.31.0,<1.33", "onnxruntime~=1.32.1") + pinned = pinned.replace("typer>=0.27,<0.29", "typer~=0.28.2") + root = _project(tmp_path, pinned) + + assert check_runtime_pins.fix(root) == [] + assert (root / "pyproject.toml").read_text(encoding="utf-8") == pinned + + +def test_an_unlocked_dependency_refuses_rather_than_guessing(tmp_path: Path) -> None: + root = _project(tmp_path, _PYPROJECT.replace("tantivy>=", "notlocked>=")) + + with pytest.raises(SystemExit, match="notlocked"): + check_runtime_pins.fix(root) + + +def test_a_marked_requirement_keeps_its_marker(tmp_path: Path) -> None: + marked = _PYPROJECT.replace('"rich~=15.0.0",', "\"rich>=15.0,<17 ; sys_platform == 'darwin'\",") + root = _project(tmp_path, marked) + + check_runtime_pins.fix(root) + + text = (root / "pyproject.toml").read_text(encoding="utf-8") + assert "\"rich~=15.0.4; sys_platform == 'darwin'\"" in text + assert check_runtime_pins.offenders(tomllib.loads(text)) == [] + + +def _forked(*versions: str) -> dict[str, object]: + return {"package": [{"name": "onnxruntime", "version": v} for v in versions]} + + +def test_a_forked_lock_pins_the_lowest_patch_of_its_one_minor() -> None: + lock = _forked("1.32.4", "1.32.1") + + assert check_runtime_pins.pinned("onnxruntime>=1.31,<1.33", lock) == "onnxruntime~=1.32.1" + + +def test_a_four_part_release_pins_its_first_three() -> None: + assert ( + check_runtime_pins.pinned("onnxruntime>=1.31", _forked("1.32.1.4")) == "onnxruntime~=1.32.1" + ) + + +def test_a_forked_lock_across_minors_refuses() -> None: + with pytest.raises(SystemExit, match="several minors"): + check_runtime_pins.pinned("onnxruntime>=1.31,<1.34", _forked("1.32.1", "1.33.0")) + + +@pytest.mark.parametrize("version", ["2.0.0rc1", "2.0.0.dev3", "1.2.3+cpu", "1!2.0.0"]) +def test_a_locked_non_final_release_refuses(version: str) -> None: + with pytest.raises(SystemExit, match="no release version"): + check_runtime_pins.pinned("onnxruntime>=1.31", _forked(version))