Skip to content
Merged
Show file tree
Hide file tree
Changes from 14 commits
Commits
Show all changes
17 commits
Select commit Hold shift + click to select a range
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
3 changes: 3 additions & 0 deletions .github/workflows/python-tests.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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 }}
Expand Down
13 changes: 11 additions & 2 deletions .github/workflows/uv-lock-checks.yml
Original file line number Diff line number Diff line change
@@ -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).

name: 'uv lock checks'

Expand Down Expand Up @@ -54,6 +56,8 @@ jobs:
uvlock-pyprojecttoml:
- 'pyproject.toml'
- 'uv.lock'
- 'pins.json'
- 'scripts/check_pins.py'

- name: setup uv
if: ${{ steps.changed-files.outputs.uvlock-pyprojecttoml_any_changed == 'true' || inputs.always_run == true }}
Expand All @@ -66,3 +70,8 @@ jobs:
if: ${{ steps.changed-files.outputs.uvlock-pyprojecttoml_any_changed == 'true' || inputs.always_run == true }}
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
10 changes: 10 additions & 0 deletions docs/src/content/docs/start-here/manual.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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 <PACKAGE_SPECIFIER>==<VERSION> "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.
:::
</TabItem>
<TabItem label="All other cases">
Do not use a torch backend.
Expand Down
1 change: 1 addition & 0 deletions pins.json
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
"python": "3.12",
"torchIndexUrl": {
"win32": {
"cpu": "https://download.pytorch.org/whl/cpu",
"cuda": "https://download.pytorch.org/whl/cu128"
},
"linux": {
Expand Down
6 changes: 6 additions & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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=<x>` 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
Expand Down
266 changes: 266 additions & 0 deletions scripts/check_pins.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,266 @@
"""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-<backend>``
``[[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``.
Otherwise the launcher creates an interpreter that the package metadata it
is about to install rejects, and the install fails at the last step.

Run from anywhere: python scripts/check_pins.py

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 ("3.12", "3.12.7"), and a
# single requires-python clause ("<3.13"). Both are deliberately narrow: components
# are bounded so a nonsense pin like "3.12.99999999" - which satisfies any specifier
# but which `uv venv --python` cannot resolve to a real interpreter - is rejected
# rather than passed through. 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.
_VERSION_RE = re.compile(r"[0-9]{1,4}(?:\.[0-9]{1,4}){1,2}\Z")
_CLAUSE_RE = re.compile(r"(==|!=|>=|<=|>|<)\s*([0-9]{1,4}(?:\.[0-9]{1,4}){0,2})\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 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 version the launcher can hand to "
"`uv venv --python`, like '3.12' or '3.12.7'"
)
return errors

version = _parse_version(pinned)
requires_python = ", ".join(clauses)

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."""

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 and requires-python 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())
Loading
Loading