diff --git a/.github/workflows/python-tests.yml b/.github/workflows/python-tests.yml index 3273d6ba812..eca325b1a36 100644 --- a/.github/workflows/python-tests.yml +++ b/.github/workflows/python-tests.yml @@ -83,6 +83,9 @@ jobs: - 'invokeai/**' - '!invokeai/frontend/web/**' - 'tests/**' + # tests/ covers a few of these (e.g. test_check_pins.py, test_docs_json_export.py) + - 'scripts/**' + - 'pins.json' - name: setup uv if: ${{ steps.changed-files.outputs.python_any_changed == 'true' || inputs.always_run == true }} diff --git a/.github/workflows/uv-lock-checks.yml b/.github/workflows/uv-lock-checks.yml index 1d4246ef319..25eb7924ee6 100644 --- a/.github/workflows/uv-lock-checks.yml +++ b/.github/workflows/uv-lock-checks.yml @@ -1,6 +1,8 @@ -# Check the `uv` lockfile for consistency with `pyproject.toml`. +# Check the `uv` lockfile and `pins.json` for consistency with `pyproject.toml`. # -# If this check fails, you should run `uv lock` to update the lockfile. +# If the lockfile check fails, you should run `uv lock` to update the lockfile. +# If the pins check fails, update the torch index URLs in `pins.json` to match +# the `[[tool.uv.index]]` entries in `pyproject.toml` (see scripts/check_pins.py). # # Also checks that the lockfile keeps working for linux/aarch64, which no test job covers. # This reads the lockfile only - no ARM hardware or extra resolution needed. @@ -57,6 +59,8 @@ jobs: uvlock-pyprojecttoml: - 'pyproject.toml' - 'uv.lock' + - 'pins.json' + - 'scripts/check_pins.py' aarch64check: - 'scripts/check_aarch64_lock.py' @@ -72,6 +76,11 @@ jobs: run: uv lock --locked # this will exit with 1 if the lockfile is not consistent with pyproject.toml shell: bash + - name: check pins.json + if: ${{ steps.changed-files.outputs.uvlock-pyprojecttoml_any_changed == 'true' || inputs.always_run == true }} + run: python3 scripts/check_pins.py # pins.json is consumed by the launcher; keep its torch index URLs in sync with pyproject.toml + shell: bash + - name: check aarch64 support in lockfile # Also runs when the check itself changes, so edits to it are exercised. if: ${{ steps.changed-files.outputs.uvlock-pyprojecttoml_any_changed == 'true' || steps.changed-files.outputs.aarch64check_any_changed == 'true' || inputs.always_run == true }} diff --git a/docs/src/content/docs/start-here/manual.mdx b/docs/src/content/docs/start-here/manual.mdx index 4a0c6533852..d2616d5e059 100644 --- a/docs/src/content/docs/start-here/manual.mdx +++ b/docs/src/content/docs/start-here/manual.mdx @@ -131,6 +131,16 @@ The following commands vary depending on the version of Invoke being installed a ```sh --torch-backend=rocm7.1 ``` + + :::caution[ROCm and torch 2.12] + The `rocm7.1` index currently defaults to torch 2.12.x, which has been reported to + break generation on ROCm ([#9410](https://github.com/invoke-ai/InvokeAI/issues/9410)). + Until that issue is closed, also constrain torch when installing: + ```sh + uv pip install == "torch<2.12" --python 3.12 --python-preference only-managed --torch-backend=rocm7.1 --force-reinstall + ``` + This workaround applies to the reported ROCm case only. + ::: Do not use a torch backend. diff --git a/pins.json b/pins.json index 699c21b563f..c4ebc951311 100644 --- a/pins.json +++ b/pins.json @@ -2,6 +2,7 @@ "python": "3.12", "torchIndexUrl": { "win32": { + "cpu": "https://download.pytorch.org/whl/cpu", "cuda": "https://download.pytorch.org/whl/cu128" }, "linux": { diff --git a/pyproject.toml b/pyproject.toml index 20087cc0955..9c042cbd0f1 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -51,6 +51,12 @@ dependencies = [ # Loosely pinned, will respect requirement of `diffusers[torch]`. Split by platform: linux/win allow # >=2.10 so the rocm extra can use torch 2.10.0+rocm7.1, while macOS stays on 2.7.x — newer macOS torch # wheels exercise MPS on CI runners (no usable Metal GPU) and fail with MPS OOM. + # Deliberately NOT capped below 2.12 here. torch 2.12.x+rocm7.1 is reported to break generation (#9410), but + # this range is what every backend-agnostic install resolves against — manual installs use + # `--torch-backend=` with no extra (see docs/start-here/manual), so a blanket cap would also + # reject torch>=2.12 on Windows/Linux CUDA, CPU and ARM64, where 2.12 has no known problem. + # The ROCm path is constrained where it can actually be targeted: the `rocm` extra below pins an + # exact version, and the manual docs carry the ROCm-specific caveat. "torch>=2.7.0,<3.0; sys_platform != 'darwin'", "torch>=2.7.0,<2.8.0; sys_platform == 'darwin'", "torchsde", # diffusers needs this for SDE solvers, but it is not an explicit dep of diffusers diff --git a/scripts/check_pins.py b/scripts/check_pins.py new file mode 100644 index 00000000000..79ff56793bb --- /dev/null +++ b/scripts/check_pins.py @@ -0,0 +1,342 @@ +"""Check that pins.json is consistent with pyproject.toml. + +``pins.json`` is not used anywhere in this repo — it is fetched (at the release +tag) by the Invoke Launcher (https://github.com/invoke-ai/launcher), which uses +its ``torchIndexUrl`` entries to pick the torch wheel index for legacy +(pre-6.14.0) installs. Because nothing in-repo consumes it, it can silently +drift from the ``[[tool.uv.index]]`` URLs in pyproject.toml — which is exactly +what happened when ROCm moved from 6.3 to 7.1 (issue #9328). + +The launcher's schema makes every backend key optional and its install path only +passes ``--index`` when the selected entry exists, so a *missing* entry is just +as damaging as a stale one: the install silently falls back to the default PyPI +index and resolves wheels for the wrong backend. This script therefore checks: + + 1. pins.json carries exactly the platform/backend entries in REQUIRED_BACKENDS + — no more, no less. + 2. Each of those URLs matches the corresponding ``torch-`` + ``[[tool.uv.index]]`` URL in pyproject.toml. + 3. pins.json's ``python`` — the version the launcher builds the venv with, + before it installs anything — satisfies ``project.requires-python`` and is + one of the versions pyproject.toml's classifiers claim support for. + Otherwise the launcher creates an interpreter that the package metadata it + is about to install rejects (or that ``uv`` cannot resolve at all), and the + install fails at the last step. + +The repo root is derived from this file's own location, so the working directory +does not matter — only the path you hand to python does:: + + python3 scripts/check_pins.py # from the repo root + python3 /path/to/InvokeAI/scripts/check_pins.py # from anywhere else + +Kept dependency-free (stdlib only) so CI can run it with a bare ``python3``, +which is why the ``requires-python`` handling below is hand-rolled rather than +using ``packaging``. +""" + +import json +import re +import sys +import tomllib +from pathlib import Path + +REPO_ROOT = Path(__file__).resolve().parent.parent + +# Every platform/backend combination pins.json must carry, and no others. +# +# The launcher offers the same four GPU choices on every OS and maps them to a +# torch backend (amd -> rocm, either nvidia option -> cuda, no GPU -> cpu), then +# looks up `torchIndexUrl[sys.platform][backend]`. Any combination it can look up +# and that Invoke actually supports needs an entry here, because a missing one +# degrades silently rather than failing loudly. +# +# - darwin is deliberately empty: macOS uses MPS, for which PyTorch publishes no +# separate index, so the launcher installs the default PyPI wheels. +# - win32 has no rocm entry: PyTorch publishes no ROCm wheels for Windows, and +# the `rocm` extra in pyproject.toml is marked `sys_platform == 'linux'`. +REQUIRED_BACKENDS: dict[str, set[str]] = { + "win32": {"cpu", "cuda"}, + "linux": {"cpu", "cuda", "rocm"}, + "darwin": set(), +} + +# The version the launcher is told to build the venv with, which must be exactly +# major.minor ("3.12"). A patch-level pin is rejected on purpose: `uv venv --python +# 3.12` resolves to the newest 3.12.x available, whereas `--python 3.12.7` demands +# one exact build, which freezes users on an unpatched interpreter and stops working +# outright once that build leaves uv's index. The launcher's own reinstall check +# compares only `major()`/`minor()` of this field against the existing venv, so a +# patch component is inert there in any case. Forbidding the third component also +# makes a bogus patch such as "3.12.9999" - which satisfies every specifier but which +# `uv python find` cannot resolve - unrepresentable rather than merely unlikely. A +# bogus major.minor is caught instead by requires-python and by the classifier list. +# Leading zeros are rejected too, so the pin has exactly one spelling: "03.12" would +# otherwise compare equal to "3.12" here while reaching the launcher verbatim. +_VERSION_RE = re.compile(r"(?:0|[1-9][0-9]?)\.(?:0|[1-9][0-9]?)\Z") + +# A single requires-python clause ("<3.13", ">=3.11.4"). Anything else legal in PEP +# 440 but not matched here (epochs, pre-releases, `~=`, `.*` wildcards) is reported as +# unevaluatable rather than guessed at, so a specifier this script cannot reason about +# fails loudly. +_CLAUSE_RE = re.compile(r"(==|!=|>=|<=|>|<)\s*([0-9]{1,4}(?:\.[0-9]{1,4}){0,2})\Z") + +# "Programming Language :: Python :: 3.12" - a *whole* classifier naming exactly one +# major.minor version. The required dot excludes "... :: 3" and "... :: 3 :: Only"; +# the anchor excludes "... :: 3.12 :: Only" and "... :: 3.1.4", neither of which +# declares support for the version it appears to name. Digits are bounded like the two +# patterns above so _parse_version's int() cannot hit CPython's str->int digit limit +# and raise out of check_python, which promises never to raise. +_CLASSIFIER_RE = re.compile(r"Programming Language :: Python :: ([0-9]{1,4}\.[0-9]{1,4})\Z") + +_SUPPORTED_OPERATORS = "==, !=, >=, <=, > and <" + + +def _parse_version(text: str) -> tuple[int, ...]: + return tuple(int(part) for part in text.split(".")) + + +def _satisfies(version: tuple[int, ...], operator: str, bound: tuple[int, ...]) -> bool: + """Compare two dotted versions, zero-padding the shorter one (3.12 == 3.12.0).""" + + width = max(len(version), len(bound)) + version += (0,) * (width - len(version)) + bound += (0,) * (width - len(bound)) + + match operator: + case "==": + return version == bound + case "!=": + return version != bound + case ">=": + return version >= bound + case "<=": + return version <= bound + case ">": + return version > bound + case "<": + return version < bound + case _: + # Unreachable via _CLAUSE_RE. Raising rather than falling through keeps a + # newly-added operator from silently inheriting some other operator's answer. + raise ValueError(f"unsupported operator {operator!r}") + + +def _requires_python_clauses(pyproject: dict) -> tuple[list[str], list[str]]: + """Split project.requires-python into clauses. Returns (clauses, errors).""" + + project = pyproject.get("project") + requires_python = project.get("requires-python") if isinstance(project, dict) else None + + if requires_python is None: + return [], [ + "pyproject.toml has no project.requires-python, so there is nothing to validate " + "pins.json's python pin against" + ] + if not isinstance(requires_python, str): + return [], [f"pyproject.toml project.requires-python is {requires_python!r}, not a string"] + + # Empty clauses come from a trailing or doubled comma, which PEP 440 tolerates. + clauses = [clause for clause in (part.strip() for part in requires_python.split(",")) if clause] + if not clauses: + return [], [ + f"pyproject.toml project.requires-python is {requires_python!r}, which constrains " + "nothing, so pins.json's python pin cannot be validated" + ] + return clauses, [] + + +def _classifier_versions(pyproject: dict) -> list[str]: + """The major.minor versions project.classifiers claims support for, in declared order. + + Tolerates every malformed shape - a missing [project] table, a non-list value, non-string + entries - by returning nothing, so the caller reports it rather than raising. + """ + + project = pyproject.get("project") + classifiers = project.get("classifiers") if isinstance(project, dict) else None + if not isinstance(classifiers, list): + return [] + matches = (_CLASSIFIER_RE.match(entry) for entry in classifiers if isinstance(entry, str)) + # dict.fromkeys de-duplicates without reordering; a repeated classifier is legal but would + # otherwise be listed twice in the error message. + return list(dict.fromkeys(match.group(1) for match in matches if match is not None)) + + +def _is_dynamic(pyproject: dict, field: str) -> bool: + """Whether project.dynamic defers `field` to the build backend (PEP 621).""" + + project = pyproject.get("project") + dynamic = project.get("dynamic") if isinstance(project, dict) else None + return isinstance(dynamic, list) and field in dynamic + + +def check_python(pins: dict, pyproject: dict) -> list[str]: + """Check pins.json's `python` against pyproject.toml's `requires-python`. + + Never raises: every malformed shape is reported as an error string, because this + runs before the torchIndexUrl checks and a raise here would hide them. + """ + + clauses, errors = _requires_python_clauses(pyproject) + pinned = pins.get("python") + + if pinned is None: + errors.append( + "pins.json is missing the 'python' field; the launcher uses it to decide which python " + "version to build the venv with, and it must satisfy pyproject.toml's requires-python" + ) + return errors + if not isinstance(pinned, str) or _VERSION_RE.match(pinned) is None: + errors.append( + f"pins.json python is {pinned!r}; expected a major.minor version the launcher can hand " + "to `uv venv --python`, like '3.12' (a patch component is deliberately not accepted - " + "uv already picks the newest patch for a major.minor version)" + ) + return errors + + version = _parse_version(pinned) + requires_python = ", ".join(clauses) + + # requires-python says which versions the metadata *allows*; the classifiers say which ones + # this project actually ships for. Without the second check a pin like '3.99' - inside an + # open-ended requires-python, but not an interpreter uv can find - would still pass. + supported = _classifier_versions(pyproject) + if not supported and _is_dynamic(pyproject, "classifiers"): + errors.append( + "pyproject.toml lists 'classifiers' in project.dynamic, so pins.json's python pin " + "cannot be checked against the versions this project ships for; teach " + "scripts/check_pins.py where the classifiers come from" + ) + elif not supported: + errors.append( + "pyproject.toml declares no 'Programming Language :: Python :: X.Y' classifier, so " + "pins.json's python pin cannot be checked against the versions this project ships for" + ) + elif version not in {_parse_version(entry) for entry in supported}: + errors.append( + f"pins.json python is '{pinned}' but pyproject.toml's classifiers declare support only " + f"for {', '.join(supported)}; add the classifier if that version is really supported, " + "otherwise the launcher builds the venv on an interpreter this project does not ship for" + ) + + for clause in clauses: + match = _CLAUSE_RE.match(clause) + if match is None: + errors.append( + f"pyproject.toml requires-python clause '{clause}' is not one this script can " + f"evaluate (it understands {_SUPPORTED_OPERATORS} against dotted numeric versions); " + "teach scripts/check_pins.py this form rather than assuming the pin is still valid" + ) + continue + operator, bound = match.groups() + if not _satisfies(version, operator, _parse_version(bound)): + errors.append( + f"pins.json python is '{pinned}' but pyproject.toml requires-python is " + f"'{requires_python}' (fails '{clause}'); the launcher would create a python {pinned} " + "venv and then install package metadata that rejects that interpreter" + ) + + return errors + + +def _uv_indexes(pyproject: dict) -> dict[str, str]: + """Map [[tool.uv.index]] name -> url, tolerating a malformed or absent table.""" + + tool = pyproject.get("tool") + uv = tool.get("uv") if isinstance(tool, dict) else None + entries = uv.get("index") if isinstance(uv, dict) else None + if not isinstance(entries, list): + return {} + return { + entry["name"]: entry["url"] + for entry in entries + if isinstance(entry, dict) and isinstance(entry.get("name"), str) and isinstance(entry.get("url"), str) + } + + +def check_pins(pins: dict, pyproject: dict) -> list[str]: + """Return a list of human-readable problems; empty means pins.json is fine.""" + + if not isinstance(pins, dict): + # Everything below indexes into it; report the shape rather than raising. + return [f"pins.json is {pins!r}, not an object with 'python' and 'torchIndexUrl' keys"] + + indexes = _uv_indexes(pyproject) + + errors: list[str] = check_python(pins, pyproject) + + torch_index_url = pins.get("torchIndexUrl", {}) + if not isinstance(torch_index_url, dict): + errors.append(f"pins.json torchIndexUrl is {torch_index_url!r}, not an object mapping platform to backend URLs") + torch_index_url = {} + + for platform in sorted(set(REQUIRED_BACKENDS) - set(torch_index_url)): + errors.append(f"pins.json is missing the torchIndexUrl.{platform} section") + for platform in sorted(set(torch_index_url) - set(REQUIRED_BACKENDS)): + errors.append( + f"pins.json torchIndexUrl.{platform} is not a platform the launcher installs on; " + f"expected only {sorted(REQUIRED_BACKENDS)}" + ) + + for platform, required in sorted(REQUIRED_BACKENDS.items()): + if platform not in torch_index_url: + continue # already reported above as a missing section + + backends = torch_index_url[platform] + if not isinstance(backends, dict): + errors.append( + f"pins.json torchIndexUrl.{platform} is {backends!r}, not an object mapping backend " + "to index URL; the launcher would fail to parse pins.json" + ) + continue + + for backend in sorted(required - set(backends)): + errors.append( + f"pins.json torchIndexUrl.{platform}.{backend} is missing; the launcher would omit " + f"--index for {backend} installs on {platform} and resolve torch from the default index" + ) + for backend in sorted(set(backends) - required): + errors.append( + f"pins.json torchIndexUrl.{platform}.{backend} is not supported on {platform}; " + f"expected only {sorted(required)}" + ) + + for backend in sorted(required & set(backends)): + pinned_url = backends[backend] + index_name = f"torch-{backend}" + expected_url = indexes.get(index_name) + if expected_url is None: + errors.append( + f"pins.json torchIndexUrl.{platform}.{backend}: no [[tool.uv.index]] named '{index_name}' in pyproject.toml" + ) + elif pinned_url != expected_url: + errors.append( + f"pins.json torchIndexUrl.{platform}.{backend} is '{pinned_url}' but pyproject.toml index '{index_name}' is '{expected_url}'" + ) + + return errors + + +def main(repo_root: Path = REPO_ROOT) -> int: + pins = json.loads((repo_root / "pins.json").read_text()) + pyproject = tomllib.loads((repo_root / "pyproject.toml").read_text()) + + errors = check_pins(pins, pyproject) + + if errors: + print("pins.json is out of sync with pyproject.toml:", file=sys.stderr) + for error in errors: + print(f" - {error}", file=sys.stderr) + print( + "\nUpdate pins.json to match the [[tool.uv.index]] URLs, requires-python and python " + "classifiers in pyproject.toml (or vice versa).", + file=sys.stderr, + ) + return 1 + + print("pins.json is consistent with pyproject.toml") + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/tests/test_check_pins.py b/tests/test_check_pins.py new file mode 100644 index 00000000000..5ad82be81c3 --- /dev/null +++ b/tests/test_check_pins.py @@ -0,0 +1,472 @@ +from __future__ import annotations + +import importlib.util +import json +import re +import shutil +import subprocess +import sys +import tomllib +from pathlib import Path + +import pytest + +REPO_ROOT = Path(__file__).resolve().parent.parent + + +def _load_module(module_path: Path, module_name: str): + spec = importlib.util.spec_from_file_location(module_name, module_path) + assert spec is not None + assert spec.loader is not None + module = importlib.util.module_from_spec(spec) + spec.loader.exec_module(module) + return module + + +check_pins = _load_module(REPO_ROOT / "scripts" / "check_pins.py", "check_pins") + + +@pytest.fixture +def repo_copy(tmp_path: Path) -> Path: + """A throwaway directory holding copies of the real pins.json and pyproject.toml.""" + for name in ("pins.json", "pyproject.toml"): + shutil.copyfile(REPO_ROOT / name, tmp_path / name) + return tmp_path + + +def _read_pins(repo: Path) -> dict: + return json.loads((repo / "pins.json").read_text()) + + +def _write_pins(repo: Path, pins: dict) -> None: + (repo / "pins.json").write_text(json.dumps(pins, indent=2)) + + +def _set_requires_python(repo: Path, specifier: str) -> None: + path = repo / "pyproject.toml" + updated, count = re.subn(r"(?m)^requires-python = .*$", f'requires-python = "{specifier}"', path.read_text()) + assert count == 1, "expected exactly one top-level requires-python line in pyproject.toml" + path.write_text(updated) + + +# Every (platform, backend) the checker must insist on, derived from the checker's own matrix so +# the two cannot drift apart. +REQUIRED_ENTRIES = [ + (platform, backend) + for platform, backends in sorted(check_pins.REQUIRED_BACKENDS.items()) + for backend in sorted(backends) +] + + +def test_required_matrix_covers_cpu_cuda_and_rocm(): + """Guard against the matrix being narrowed to the point where it checks nothing.""" + assert ("linux", "rocm") in REQUIRED_ENTRIES + assert ("linux", "cuda") in REQUIRED_ENTRIES + assert ("linux", "cpu") in REQUIRED_ENTRIES + assert ("win32", "cuda") in REQUIRED_ENTRIES + assert ("win32", "cpu") in REQUIRED_ENTRIES + # macOS uses MPS, which has no dedicated torch index. + assert check_pins.REQUIRED_BACKENDS["darwin"] == set() + + +def test_repo_pins_are_consistent(repo_copy: Path): + """The checked-in pins.json and pyproject.toml agree.""" + assert check_pins.main(repo_copy) == 0 + + +@pytest.mark.parametrize(("platform", "backend"), REQUIRED_ENTRIES) +def test_missing_entry_fails(repo_copy: Path, platform: str, backend: str): + """Removing any required entry must fail, not silently pass.""" + pins = _read_pins(repo_copy) + del pins["torchIndexUrl"][platform][backend] + _write_pins(repo_copy, pins) + + assert check_pins.main(repo_copy) == 1 + + +@pytest.mark.parametrize("platform", sorted(check_pins.REQUIRED_BACKENDS)) +def test_missing_platform_section_fails(repo_copy: Path, platform: str): + """Removing a whole platform section must fail, even for platforms with no required backends.""" + pins = _read_pins(repo_copy) + del pins["torchIndexUrl"][platform] + _write_pins(repo_copy, pins) + + assert check_pins.main(repo_copy) == 1 + + +@pytest.mark.parametrize(("platform", "backend"), REQUIRED_ENTRIES) +def test_stale_url_fails(repo_copy: Path, platform: str, backend: str): + """The original drift case: an entry that exists but points at the wrong index.""" + pins = _read_pins(repo_copy) + pins["torchIndexUrl"][platform][backend] = "https://download.pytorch.org/whl/stale" + _write_pins(repo_copy, pins) + + assert check_pins.main(repo_copy) == 1 + + +def test_rocm_url_regression_is_reported(repo_copy: Path, capsys: pytest.CaptureFixture[str]): + """Reverting the ROCm index to 6.3 (issue #9328) is caught and named.""" + pins = _read_pins(repo_copy) + pins["torchIndexUrl"]["linux"]["rocm"] = "https://download.pytorch.org/whl/rocm6.3" + _write_pins(repo_copy, pins) + + assert check_pins.main(repo_copy) == 1 + assert "torchIndexUrl.linux.rocm" in capsys.readouterr().err + + +def test_unsupported_backend_fails(repo_copy: Path): + """ROCm on Windows has no wheels; pinning an index for it must be rejected.""" + pins = _read_pins(repo_copy) + pins["torchIndexUrl"]["win32"]["rocm"] = "https://download.pytorch.org/whl/rocm7.1" + _write_pins(repo_copy, pins) + + assert check_pins.main(repo_copy) == 1 + + +def test_unknown_platform_fails(repo_copy: Path): + pins = _read_pins(repo_copy) + pins["torchIndexUrl"]["freebsd"] = {"cpu": "https://download.pytorch.org/whl/cpu"} + _write_pins(repo_copy, pins) + + assert check_pins.main(repo_copy) == 1 + + +def test_missing_pyproject_index_fails(repo_copy: Path): + """Dropping a [[tool.uv.index]] entry that pins.json references must fail.""" + pyproject = (repo_copy / "pyproject.toml").read_text() + pyproject = pyproject.replace('name = "torch-rocm"', 'name = "torch-rocm-renamed"') + (repo_copy / "pyproject.toml").write_text(pyproject) + + assert check_pins.main(repo_copy) == 1 + + +def test_missing_python_fails(repo_copy: Path, capsys: pytest.CaptureFixture[str]): + """The launcher builds the venv from this field; it cannot be absent.""" + pins = _read_pins(repo_copy) + del pins["python"] + _write_pins(repo_copy, pins) + + assert check_pins.main(repo_copy) == 1 + assert "'python'" in capsys.readouterr().err + + +def test_requires_python_moving_off_the_pin_fails(repo_copy: Path, capsys: pytest.CaptureFixture[str]): + """Bumping requires-python without bumping pins.json: the launcher would build a python + 3.12 venv, then install metadata demanding 3.13, and the install fails at the last step.""" + _set_requires_python(repo_copy, ">=3.13,<3.14") + + assert check_pins.main(repo_copy) == 1 + # Not just "requires-python" - that word also appears in the advice footer printed on + # every failure, so it would be satisfied by any unrelated error. + assert "pins.json python is '3.12'" in capsys.readouterr().err + + +@pytest.mark.parametrize("pinned", ["3.10", "3.13", "3.9", "4.0"]) +def test_python_outside_requires_python_fails(repo_copy: Path, pinned: str): + """The other direction: pins.json moved to a version the metadata rejects.""" + pins = _read_pins(repo_copy) + pins["python"] = pinned + _write_pins(repo_copy, pins) + + assert check_pins.main(repo_copy) == 1 + + +@pytest.mark.parametrize( + "pinned", + [ + "3.12.x", + "", + "python3.12", + "3.12\n", + "3", # `uv venv --python 3` is not a version the launcher should be pinning + "3.12.0.0.0.0", # satisfies every specifier; uv finds no such interpreter + "3.12.99999999", # ditto + "3.12.9999", # ditto, and short enough to look plausible + "3.12.0", # patch pins are rejected outright, even valid-looking ones + "3.12.7", # ditto: uv already resolves "3.12" to the newest patch + "03.12", # normalizes to 3.12 here but reaches the launcher verbatim + 3.12, + ["3.12"], + {"major": 3, "minor": 12}, + ], +) +def test_non_version_python_fails(repo_copy: Path, pinned: object): + pins = _read_pins(repo_copy) + pins["python"] = pinned + _write_pins(repo_copy, pins) + + assert check_pins.main(repo_copy) == 1 + + +@pytest.mark.parametrize("pinned", ["3.12.7", "3.12.9999"]) +def test_patch_pin_is_rejected_for_being_a_patch_pin(repo_copy: Path, capsys: pytest.CaptureFixture[str], pinned: str): + """Assert the reason, not just the exit code. The classifier check rejects these too, so + without this a loosened version pattern would leave the suite green while telling the user + to add a classifier for '3.12.7' - advice that makes no sense.""" + pins = _read_pins(repo_copy) + pins["python"] = pinned + _write_pins(repo_copy, pins) + + assert check_pins.main(repo_copy) == 1 + stderr = capsys.readouterr().err + assert "a patch component is deliberately not accepted" in stderr + # Not the bare word "classifiers" - that appears in the advice footer printed on every failure. + assert "classifiers declare support only for" not in stderr + + +def _drop_python_classifiers(repo: Path) -> None: + path = repo / "pyproject.toml" + updated, count = re.subn(r"(?m)^\s*'Programming Language :: Python :: [0-9]+\.[0-9]+',$\n", "", path.read_text()) + assert count, "expected at least one 'Programming Language :: Python :: X.Y' classifier" + path.write_text(updated) + + +def test_python_not_in_classifiers_fails(repo_copy: Path, capsys: pytest.CaptureFixture[str]): + """requires-python allows 3.11, but the project only ships classifiers for 3.12. The pin + has to name a version this project actually claims to support.""" + pins = _read_pins(repo_copy) + pins["python"] = "3.11" + _write_pins(repo_copy, pins) + + assert check_pins.main(repo_copy) == 1 + assert "classifiers declare support only for" in capsys.readouterr().err + + +def test_unreal_version_inside_an_open_ended_requires_python_fails(repo_copy: Path): + """The hole the classifier check exists to close: with no upper bound in requires-python, + a version that no interpreter has would otherwise satisfy every clause.""" + _set_requires_python(repo_copy, ">=3.11") + pins = _read_pins(repo_copy) + pins["python"] = "3.99" + _write_pins(repo_copy, pins) + + assert check_pins.main(repo_copy) == 1 + + +def test_missing_python_classifiers_fails(repo_copy: Path, capsys: pytest.CaptureFixture[str]): + """Deleting the classifiers must not silently downgrade the pin check to requires-python only.""" + _drop_python_classifiers(repo_copy) + + assert check_pins.main(repo_copy) == 1 + assert "no 'Programming Language :: Python :: X.Y' classifier" in capsys.readouterr().err + + +@pytest.mark.parametrize( + "classifier", + [ + "Programming Language :: Python :: 3 :: Only", # no major.minor at all + "Programming Language :: Python :: 3", # ditto + "Programming Language :: Python :: 3.12 :: Only", # not a real classifier; declares nothing + "Programming Language :: Python :: 3.1.4", # ditto + "Programming Language :: Python :: 3.12 ", # trailing space + "Programming Language :: Python :: Implementation :: CPython", + ], +) +def test_classifier_is_not_read_as_a_version(repo_copy: Path, classifier: str): + """Only a whole classifier naming exactly one major.minor version counts. Anything that + merely contains one must not be read as a declaration of support for it.""" + _drop_python_classifiers(repo_copy) + pyproject = tomllib.loads((repo_copy / "pyproject.toml").read_text()) + pyproject["project"]["classifiers"].append(classifier) + + assert check_pins._classifier_versions(pyproject) == [] + + +def test_duplicate_classifiers_are_reported_once(repo_copy: Path): + pyproject = tomllib.loads((repo_copy / "pyproject.toml").read_text()) + pyproject["project"]["classifiers"].append("Programming Language :: Python :: 3.12") + + assert check_pins._classifier_versions(pyproject) == ["3.12"] + + +@pytest.mark.parametrize("literal", ["3", "true", '"3.12"', "{}", "[3.12]", "[[]]", "[]"]) +def test_malformed_classifiers_do_not_raise(repo_copy: Path, literal: str): + """A non-list, or a list of non-strings, must be reported rather than blowing up - the + same tolerance requires-python already gets.""" + path = repo_copy / "pyproject.toml" + path.write_text(re.sub(r"(?ms)^classifiers = \[.*?^\]$", f"classifiers = {literal}", path.read_text())) + assert "classifiers = [\n" not in (repo_copy / "pyproject.toml").read_text() + + assert check_pins.main(repo_copy) == 1 + + +def test_missing_project_table_does_not_raise(repo_copy: Path): + """No [project] table at all: both the requires-python and the classifier lookups have to + survive it, because a raise here would skip the torch index checks entirely.""" + path = repo_copy / "pyproject.toml" + pyproject = tomllib.loads(path.read_text()) + del pyproject["project"] + assert check_pins._classifier_versions(pyproject) == [] + assert check_pins._requires_python_clauses(pyproject)[0] == [] + + errors = check_pins.check_python(_read_pins(repo_copy), pyproject) + assert errors # reported, not raised + + +def test_absurdly_long_classifier_version_does_not_mask_url_drift(repo_copy: Path, capsys: pytest.CaptureFixture[str]): + """int() refuses to parse a string of more than 4300 digits. An unbounded classifier + pattern would let one reach _parse_version and raise straight out of check_python, + taking the torchIndexUrl checks with it.""" + path = repo_copy / "pyproject.toml" + absurd = "Programming Language :: Python :: " + "9" * 4400 + ".0" + path.write_text(path.read_text().replace("classifiers = [\n", f"classifiers = [\n '{absurd}',\n", 1)) + pins = _read_pins(repo_copy) + pins["torchIndexUrl"]["linux"]["rocm"] = "https://download.pytorch.org/whl/rocm6.3" + _write_pins(repo_copy, pins) + + assert check_pins.main(repo_copy) == 1 + assert "torchIndexUrl.linux.rocm" in capsys.readouterr().err + + +def test_dynamic_classifiers_are_reported_as_such(repo_copy: Path, capsys: pytest.CaptureFixture[str]): + """PEP 621 lets the build backend supply classifiers. The checker can't read them, and must + say so rather than claiming none are declared.""" + _drop_python_classifiers(repo_copy) + path = repo_copy / "pyproject.toml" + path.write_text(re.sub(r'(?m)^dynamic = \["version"\]$', 'dynamic = ["version", "classifiers"]', path.read_text())) + + assert check_pins.main(repo_copy) == 1 + assert "project.dynamic" in capsys.readouterr().err + + +@pytest.mark.parametrize("value", [None, "3.12", ["3.12"], 5]) +def test_non_object_pins_does_not_raise(repo_copy: Path, value: object): + """The top level of pins.json is indexed into everywhere; a wrong shape must be reported.""" + (repo_copy / "pins.json").write_text(json.dumps(value)) + + assert check_pins.main(repo_copy) == 1 + + +def test_unevaluatable_requires_python_fails(repo_copy: Path): + """A specifier form the checker cannot reason about must fail loudly rather than pass.""" + _set_requires_python(repo_copy, "~=3.12") + + assert check_pins.main(repo_copy) == 1 + + +def test_missing_requires_python_fails(repo_copy: Path, capsys: pytest.CaptureFixture[str]): + """requires-python is optional in PEP 621, so its absence must be reported, not raise.""" + path = repo_copy / "pyproject.toml" + path.write_text(re.sub(r"(?m)^requires-python = .*$\n", "", path.read_text())) + + assert check_pins.main(repo_copy) == 1 + assert "no project.requires-python" in capsys.readouterr().err + + +@pytest.mark.parametrize("literal", ["3.12", "3", "true", '["<3.13"]', '""']) +def test_non_string_requires_python_fails(repo_copy: Path, literal: str): + """An unquoted version is an easy TOML typo; it must not crash the checker.""" + path = repo_copy / "pyproject.toml" + path.write_text(re.sub(r"(?m)^requires-python = .*$", f"requires-python = {literal}", path.read_text())) + + assert check_pins.main(repo_copy) == 1 + + +def test_trailing_comma_in_requires_python_is_accepted(repo_copy: Path): + """A trailing comma is legal and must not be reported as an unevaluatable clause.""" + _set_requires_python(repo_copy, ">=3.11, <3.13,") + + assert check_pins.main(repo_copy) == 0 + + +def test_python_check_failure_does_not_mask_url_drift(repo_copy: Path, capsys: pytest.CaptureFixture[str]): + """The python check runs first; a problem there must not stop the torch index checks - + otherwise a bad requires-python would hide the very drift this script exists to catch.""" + path = repo_copy / "pyproject.toml" + path.write_text(re.sub(r"(?m)^requires-python = .*$\n", "", path.read_text())) + pins = _read_pins(repo_copy) + pins["torchIndexUrl"]["linux"]["rocm"] = "https://download.pytorch.org/whl/rocm6.3" + _write_pins(repo_copy, pins) + + assert check_pins.main(repo_copy) == 1 + stderr = capsys.readouterr().err + assert "no project.requires-python" in stderr + assert "torchIndexUrl.linux.rocm" in stderr + + +@pytest.mark.parametrize("platform", sorted(check_pins.REQUIRED_BACKENDS)) +def test_null_platform_section_fails(repo_copy: Path, platform: str): + """A null section is not the same as an absent one: `.get()` returns None for both, + so a present-but-null platform used to pass silently.""" + pins = _read_pins(repo_copy) + pins["torchIndexUrl"][platform] = None + _write_pins(repo_copy, pins) + + assert check_pins.main(repo_copy) == 1 + + +@pytest.mark.parametrize("value", [None, "https://download.pytorch.org/whl/cpu", ["cpu"], 5]) +def test_non_object_platform_section_fails(repo_copy: Path, value: object): + pins = _read_pins(repo_copy) + pins["torchIndexUrl"]["linux"] = value + _write_pins(repo_copy, pins) + + assert check_pins.main(repo_copy) == 1 + + +@pytest.mark.parametrize("value", [None, "https://download.pytorch.org/whl/cpu", [], 5]) +def test_non_object_torch_index_url_fails(repo_copy: Path, value: object): + pins = _read_pins(repo_copy) + pins["torchIndexUrl"] = value + _write_pins(repo_copy, pins) + + assert check_pins.main(repo_copy) == 1 + + +@pytest.mark.parametrize( + ("version", "operator", "bound", "expected"), + [ + # Zero-padding: "3.12" and "3.12.0" are the same version. + ("3.12", ">=", "3.12.0", True), + ("3.12", "<", "3.12.0", False), + ("3.12", "==", "3.12.0", True), + ("3.12.0", ">=", "3.12", True), + # The repo's own bounds. + ("3.12", ">=", "3.11", True), + ("3.12", "<", "3.13", True), + ("3.13", "<", "3.13", False), + ("3.10", ">=", "3.11", False), + # Component-wise, not lexicographic - "3.9" is not above "3.10". + ("3.9", "<", "3.10", True), + ("3.9", ">", "3.10", False), + ("3.12.7", ">", "3.12", True), + # Each operator needs at least one row where its answer differs from every other + # operator's, or a deleted branch can inherit a neighbour's semantics unnoticed. + ("3.12", "!=", "3.13", True), + ("3.12", "!=", "3.11", True), + ("3.12", "!=", "3.12", False), + ("3.12", "<=", "3.12", True), + ("3.11", "<=", "3.12", True), + ("3.13", "<=", "3.12", False), + ("3.11", "==", "3.12", False), + ("3.12", ">=", "3.13", False), + ], +) +def test_version_comparison(version: str, operator: str, bound: str, expected: bool): + assert ( + check_pins._satisfies(check_pins._parse_version(version), operator, check_pins._parse_version(bound)) + is expected + ) + + +@pytest.mark.parametrize("operator", ["~=", "===", "", "foo"]) +def test_unknown_operator_raises(operator: str): + """An operator with no branch must not quietly fall through to some other comparison.""" + with pytest.raises(ValueError): + check_pins._satisfies((3, 12), operator, (3, 13)) + + +def test_script_runs_from_any_working_directory(tmp_path: Path): + """The repo root comes from the script's own path, so cwd is irrelevant - which is what the + module docstring promises, and what CI relies on.""" + script = REPO_ROOT / "scripts" / "check_pins.py" + result = subprocess.run([sys.executable, str(script)], cwd=tmp_path, capture_output=True, text=True) + + assert result.returncode == 0, result.stderr + assert "consistent" in result.stdout + + +def test_every_supported_operator_is_covered(): + """Guard against an operator being added to the clause pattern but never exercised.""" + covered = {operator for _, operator, _, _ in test_version_comparison.pytestmark[0].args[1]} + assert covered == {"==", "!=", ">=", "<=", ">", "<"}