Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 7 additions & 4 deletions .github/workflows/lock-refresh.yml
Original file line number Diff line number Diff line change
Expand Up @@ -4,8 +4,9 @@
# rule 3), so tool and dependency upgrades must arrive as a PR that moves the
# lock, never overnight on their own. This workflow is that PR's author: after
# a green `Nightly matrix` run on `develop` (the fresh-resolve canary, ADR 0007
# rule 4), it reruns scripts/compile_constraints.py and, when any file changed,
# force-pushes the result to the `bot/lock-refresh` branch and opens (or
# rule 4), it reruns scripts/compile_constraints.py (which also moves the
# pre-commit hooks' ruff and import-linter pins to the new lint lock) and, when
# any file changed, force-pushes the result to the `bot/lock-refresh` branch and opens (or
# updates) ONE PR to `develop`. The bump PR's own CI is the gate: the nightly
# proves the newest releases work, the PR proves the exact pins do.
#
Expand Down Expand Up @@ -114,8 +115,10 @@ jobs:
git config user.name "github-actions[bot]"
git config user.email "41898282+github-actions[bot]@users.noreply.github.com"
git switch -C "$BRANCH"
# Only a moved lock opens a PR; the regenerated docs ride along with it.
git add constraints README.md ../README.md ../ARCHITECTURE.md ../docs ../website/content ../website/public
# Only a moved lock opens a PR; the regenerated docs ride along with it,
# and so do the pre-commit hook pins compile_constraints.py moved to
# the new lint lock (tests/unit/test_noxfile.py holds them equal, #1206).
git add constraints README.md ../README.md ../ARCHITECTURE.md ../docs ../website/content ../website/public ../.pre-commit-config.yaml
git commit -q -m "chore(deps): refresh the PR CI lock" \
-m "Recompiled by lock-refresh.yml after a green nightly: ${RUN_URL}"
# The checkout persisted no credentials, so authenticate the push with
Expand Down
3 changes: 2 additions & 1 deletion .pre-commit-config.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,8 @@
# them: python/pyproject.toml's [tool.ruff.format] exclude covers ruff format,
# and the `exclude:` below covers both ruff hooks.
# tests/unit/test_noxfile.py keeps the ruff and import-linter versions below
# equal to the typecheck-and-lint job's lock.
# equal to the typecheck-and-lint job's lock, and python/scripts/
# compile_constraints.py rewrites both whenever it refreshes that lock.
repos:
- repo: https://github.com/gitleaks/gitleaks
rev: v8.30.1
Expand Down
5 changes: 4 additions & 1 deletion CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -761,8 +761,11 @@ build plan has the rationale behind each rule:
```bash
cd python
python scripts/compile_constraints.py # needs `uv` on PATH (dev-time only; CI still uses plain pip)
git diff constraints/ # review before committing
git diff constraints/ ../.pre-commit-config.yaml # review before committing
```
The script also moves the pre-commit hooks' ruff `rev:` and `import-linter`
pin to the new typecheck-and-lint lock, which `tests/unit/test_noxfile.py`
holds them equal to (#1206); commit that file with the lock.
`python scripts/compile_constraints.py --check` recompiles every combo and
exits 1 without writing if any file would change; because each combo is
compiled on its own, a clean `--check` run also proves every committed file
Expand Down
5 changes: 4 additions & 1 deletion docs/ci.md
Original file line number Diff line number Diff line change
Expand Up @@ -116,7 +116,10 @@ issue once the nightly is green again.

After every green nightly on `develop`, and on manual dispatch, this workflow
reruns `python scripts/compile_constraints.py` on `develop` (#763,
[ADR 0011](adr/0011-nightly-lock-refresh-pr.md)). When any file under
[ADR 0011](adr/0011-nightly-lock-refresh-pr.md)). The script also moves the
ruff-pre-commit `rev:` and the lint-imports hook's `import-linter` pin in
`.pre-commit-config.yaml` to the new typecheck-and-lint lock, so a ruff release
doesn't fail `tests/unit/test_noxfile.py` on the bump PR (#1206). When any file under
`python/constraints/` changed, it regenerates the docs that restate the lock
(`python scripts/generate_docs.py`, which writes the
[framework version pages](framework-support.md), #1068) and the site's llms
Expand Down
68 changes: 67 additions & 1 deletion python/scripts/compile_constraints.py
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,12 @@
python scripts/compile_constraints.py # refresh constraints/*.txt
python scripts/compile_constraints.py --check # exit 1 if refreshing would change a file

The repository's `.pre-commit-config.yaml` restates two pins of the
typecheck-and-lint lock: the ruff-pre-commit `rev:` and the lint-imports hook's
`import-linter==` dependency (`tests/unit/test_noxfile.py` holds them equal).
Every refresh rewrites those two from the lock it just compiled, so a ruff or
import-linter release reaches the hooks in the same bump PR (#1206).

`--check` recompiles every combo and diffs against the committed file. Because
each combo is compiled on its own (never merged), a successful `--check` run
also proves every committed file is still satisfiable for its own combo: an
Expand All @@ -53,6 +59,7 @@
from __future__ import annotations

import argparse
import re
import subprocess
import sys
import tempfile
Expand All @@ -61,6 +68,16 @@

_PYTHON_ROOT = Path(__file__).resolve().parents[1]
_CONSTRAINTS_DIR = _PYTHON_ROOT / "constraints"
_PRE_COMMIT = _PYTHON_ROOT.parent / ".pre-commit-config.yaml"
#: The typecheck-and-lint job's lock: the ruff and import-linter the hooks run.
_LINT_LOCK = _CONSTRAINTS_DIR / "typecheck-and-lint-py3.11.txt"
#: The ruff-pre-commit repo's `rev:` line (its tags are `v<ruff version>`).
_RUFF_REV = re.compile(
r"^(\s*- repo: https://github\.com/astral-sh/ruff-pre-commit\n\s+rev: )v\S+$",
re.MULTILINE,
)
#: The lint-imports hook's pinned dependency.
_IMPORT_LINTER_DEP = re.compile(r"(?<![\w-])(import-linter==)[^\s,\]]+")


@dataclass(frozen=True)
Expand Down Expand Up @@ -290,6 +307,42 @@ def _compile_one(combo: Combo, python_version: str, out_file: Path) -> str:
return header + compiled


class PreCommitSyncError(RuntimeError):
"""The lint lock or `.pre-commit-config.yaml` lacks a pin the sync rewrites."""


def _lock_pin(lock_text: str, package: str) -> str:
for line in lock_text.splitlines():
if line.startswith(f"{package}=="):
return line.split("==", 1)[1].strip()
raise PreCommitSyncError(f"{package} is not pinned in {_LINT_LOCK.name}")


def sync_pre_commit_pins(config_text: str, lint_lock_text: str) -> str:
"""Return ``config_text`` with its ruff and import-linter pins taken from the lock.

Rewrites the ruff-pre-commit ``rev:`` to ``v<ruff pin>`` and the
lint-imports hook's ``import-linter==`` dependency to the import-linter
pin, and leaves every other byte alone (a YAML round trip would drop the
comments). Raises :class:`PreCommitSyncError` when either pin or either
line is missing, so a renamed hook fails the refresh instead of leaving the
hooks behind the lock.
"""
ruff = _lock_pin(lint_lock_text, "ruff")
import_linter = _lock_pin(lint_lock_text, "import-linter")
synced, revs = _RUFF_REV.subn(lambda m: f"{m.group(1)}v{ruff}", config_text)
synced, deps = _IMPORT_LINTER_DEP.subn(lambda m: f"{m.group(1)}{import_linter}", synced)
if revs != 1:
raise PreCommitSyncError(
f"expected one astral-sh/ruff-pre-commit `rev:` in {_PRE_COMMIT.name}, found {revs}"
)
if deps != 1:
raise PreCommitSyncError(
f"expected one import-linter== pin in {_PRE_COMMIT.name}, found {deps}"
)
return synced


def main(argv: list[str]) -> int:
parser = argparse.ArgumentParser(description=__doc__.splitlines()[0])
parser.add_argument(
Expand All @@ -301,6 +354,7 @@ def main(argv: list[str]) -> int:

_CONSTRAINTS_DIR.mkdir(exist_ok=True)
changed: list[str] = []
lint_lock_text = ""
with tempfile.TemporaryDirectory() as tmp:
tmp_dir = Path(tmp)
for combo in _COMBOS:
Expand All @@ -310,6 +364,8 @@ def main(argv: list[str]) -> int:
# committed path — a --check run must not mutate it.
scratch = tmp_dir / combo.file_name(python_version)
content = _compile_one(combo, python_version, scratch)
if target == _LINT_LOCK:
lint_lock_text = content
if args.check:
existing = target.read_text(encoding="utf-8") if target.is_file() else ""
if existing != content:
Expand All @@ -323,9 +379,19 @@ def main(argv: list[str]) -> int:
)
print(f"wrote {target.relative_to(_PYTHON_ROOT)} ({pins} pins)")

# The hooks follow the lint lock just compiled (written, or only diffed under --check).
config = _PRE_COMMIT.read_text(encoding="utf-8")
synced = sync_pre_commit_pins(config, lint_lock_text)
if synced != config:
if args.check:
changed.append(_PRE_COMMIT.name)
else:
_PRE_COMMIT.write_text(synced, encoding="utf-8")
print(f"wrote {_PRE_COMMIT.name} (ruff and import-linter pins from {_LINT_LOCK.name})")

if args.check and changed:
names = ", ".join(changed)
print(f"::error::stale constraints files, run scripts/compile_constraints.py: {names}")
print(f"::error::stale lock files, run scripts/compile_constraints.py: {names}")
return 1
return 0

Expand Down
134 changes: 134 additions & 0 deletions python/tests/unit/test_pre_commit_lock_sync.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,134 @@
"""The lock refresh moves the pre-commit tool pins with the lint lock (#1206).

`tests/unit/test_noxfile.py` holds the ruff-pre-commit `rev:` and the
lint-imports hook's `import-linter==` pin equal to the typecheck-and-lint
job's lock. `lock-refresh.yml` moves that lock, so a ruff or import-linter
release used to turn every leg of the bump PR red until someone bumped
`.pre-commit-config.yaml` by hand (#1143, #1165, #1206). These checks hold the
refresh to rewriting those two pins itself, from the lock it just wrote, and
to committing the result with the lock.
"""

from __future__ import annotations

import re
import sys
from pathlib import Path
from typing import Any

import pytest
import yaml

_PYTHON_ROOT = Path(__file__).resolve().parents[2]
_REPO = _PYTHON_ROOT.parent
_PRE_COMMIT = _REPO / ".pre-commit-config.yaml"
_LOCK_REFRESH = _REPO / ".github" / "workflows" / "lock-refresh.yml"

sys.path.insert(0, str(_PYTHON_ROOT / "scripts"))
from compile_constraints import ( # noqa: E402
_LINT_LOCK,
PreCommitSyncError,
sync_pre_commit_pins,
)


def _require(path: Path) -> Path:
if not path.is_file():
pytest.skip(f"not a repo checkout (no {path.relative_to(_REPO)})")
return path


def _lock(**pins: str) -> str:
"""A lint lock with the committed one's pins, ``pins`` replacing some."""
lines = []
for line in _LINT_LOCK.read_text(encoding="utf-8").splitlines():
name = line.split("==", 1)[0]
lines.append(f"{name}=={pins[name]}" if name in pins else line)
return "\n".join(lines) + "\n"


def _hook_pins(config_text: str) -> tuple[str, str, list[str]]:
hooks: dict[str, dict[str, Any]] = {
hook["id"]: {**hook, "rev": repo.get("rev")}
for repo in yaml.safe_load(config_text)["repos"]
for hook in repo["hooks"]
}
return (
hooks["ruff-check"]["rev"],
hooks["ruff-format"]["rev"],
hooks["lint-imports"]["additional_dependencies"],
)


def test_a_ruff_release_in_the_lock_moves_the_ruff_pre_commit_rev() -> None:
# The #1206 case: the lock takes ruff 0.17.2 while the hook says v0.17.1.
config = _require(_PRE_COMMIT).read_text(encoding="utf-8")
stale = sync_pre_commit_pins(config, _lock(ruff="0.17.1"))
assert _hook_pins(stale)[0] == "v0.17.1"
synced = sync_pre_commit_pins(stale, _lock(ruff="0.17.2"))
check_rev, format_rev, linter = _hook_pins(synced)
assert check_rev == format_rev == "v0.17.2"
assert linter == [f"import-linter=={_pin(_LINT_LOCK.read_text(encoding='utf-8'))}"]


def test_an_import_linter_release_in_the_lock_moves_the_hook_dependency() -> None:
config = _require(_PRE_COMMIT).read_text(encoding="utf-8")
synced = sync_pre_commit_pins(config, _lock(**{"import-linter": "9.9.9"}))
assert _hook_pins(synced)[2] == ["import-linter==9.9.9"]


def test_the_sync_rewrites_only_the_two_pins() -> None:
# Comments, the gitleaks rev and every other line survive byte for byte.
config = _require(_PRE_COMMIT).read_text(encoding="utf-8")
synced = sync_pre_commit_pins(config, _lock(ruff="99.0.0", **{"import-linter": "9.9.9"}))
assert len(config.splitlines()) == len(synced.splitlines())
changed = [
new for old, new in zip(config.splitlines(), synced.splitlines(), strict=True) if old != new
]
assert [new.strip() for new in changed] == [
"rev: v99.0.0",
"additional_dependencies: [import-linter==9.9.9]",
]


def test_the_committed_hooks_already_match_the_committed_lock() -> None:
# A refresh with no ruff or import-linter release leaves the file alone.
config = _require(_PRE_COMMIT).read_text(encoding="utf-8")
assert sync_pre_commit_pins(config, _LINT_LOCK.read_text(encoding="utf-8")) == config


def test_a_lock_without_the_pin_is_an_error_not_a_silent_skip() -> None:
config = _require(_PRE_COMMIT).read_text(encoding="utf-8")
lock = "".join(
line + "\n"
for line in _LINT_LOCK.read_text(encoding="utf-8").splitlines()
if not line.startswith("ruff==")
)
with pytest.raises(PreCommitSyncError, match="ruff"):
sync_pre_commit_pins(config, lock)


def test_a_config_without_the_hook_is_an_error_not_a_silent_skip() -> None:
config = _require(_PRE_COMMIT).read_text(encoding="utf-8")
moved = config.replace("astral-sh/ruff-pre-commit", "example/elsewhere")
with pytest.raises(PreCommitSyncError, match="ruff-pre-commit"):
sync_pre_commit_pins(moved, _LINT_LOCK.read_text(encoding="utf-8"))


def test_the_lock_refresh_commits_the_synced_pre_commit_config() -> None:
# The bump PR carries the rewritten hooks, or test_noxfile.py fails on it.
workflow = yaml.safe_load(_require(_LOCK_REFRESH).read_text(encoding="utf-8"))
runs = [step.get("run", "") for step in workflow["jobs"]["refresh"]["steps"]]
adds = [
line.strip() for run in runs for line in run.splitlines() if re.match(r"\s*git add ", line)
]
assert len(adds) == 1, adds
assert "../.pre-commit-config.yaml" in adds[0].split()


def _pin(lock_text: str) -> str:
return next(
line.split("==", 1)[1].strip()
for line in lock_text.splitlines()
if line.startswith("import-linter==")
)
Loading