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
38 changes: 33 additions & 5 deletions .agents/skills/align-recipe-pyproject/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ description: >
metadata:
author: Google
license: Apache-2.0
version: 1.0.0
version: 1.1.0
---

# Align Recipe pyproject.toml
Expand All @@ -28,7 +28,7 @@ Scope: **`pyproject.toml` only**. Standalone `ruff.toml` / `.ruff.toml` files ar

## What This Skill Checks

Runs `scripts/align_pyproject.py` against a recipe directory. Six rules:
Runs `scripts/align_pyproject.py` against a recipe directory. Eight rules:

| Rule ID | What it checks | Auto-fix |
|---|---|---|
Expand All @@ -38,6 +38,28 @@ Runs `scripts/align_pyproject.py` against a recipe directory. Six rules:
| `description-matches-manifest` | If `[project].description` is set, it must equal `manifest.description`. Field is optional; skipped when absent. | Only with `--description-source={pyproject,manifest,delete}`. Refuses to touch description otherwise. |
| `build-system-present` | `[build-system]` must have both `requires` and `build-backend`. Without it, `uv build` and `pip install .` fail. | **No** — backend choice is editorial. Reported for the human to fix. |
| `default-pypi-index` | `[[tool.uv.index]]` must have an entry with `default = true` pointing at public PyPI (`https://pypi.org/simple[/]`). Required so `uv sync` works on Google corp workstations without corp Airlock auth — see the block comment in the root `pyproject.toml` for the full rationale. | Yes when the block is entirely missing — appends it. **No** when a default entry exists but points elsewhere (custom private index, TestPyPI, mirror) — reported for the human to reconcile, since the divergence may be intentional. |
| `stale-python-version-refs` | Scans **every text file in the recipe** for references to a Python version below the 3.11 floor. Raising `requires-python` is not a self-contained edit: the version is typically repeated in prose (`README.md`, `SKILL.md`) and in executable setup code. | **No** — report-only. Which references are stale and how to reword them is editorial. |
| `runnability-test-in-testpaths` | If `[tool.pytest.ini_options].testpaths` is set, at least one entry must collect `tests/test_runnability.py` (which `.github/policy.yml` requires every Python recipe to have). | **No** — report-only. Broadening `testpaths` changes what CI collects. |

#### Why `stale-python-version-refs` exists

A bootstrap script that picks an interpreter from an allowlist still
containing the old floor will happily build a venv the recipe then refuses to
install into:

```bash
for py in python3.13 python3.12 python3.11 python3.10 python3; do
# ...
# ERROR: Package requires a different Python: 3.10.x not in '>=3.11'
```

The scan requires a Python-ish context (`python 3.10`, `Python :: 3.10`,
`>=3.10`) rather than matching bare digits, so a model name like
`gemini-3.5-flash` is not a false positive. It skips lockfiles, virtualenvs,
caches, and binary files, and ignores `requires-python` in `pyproject.toml`
itself (owned by `python-version-floor`, and still pre-rewrite in memory when
this check runs). Hits are capped at 40 in `details.hits`, but
`details.files` always lists every affected file.

### Edit safety

Expand Down Expand Up @@ -66,9 +88,13 @@ Runs `scripts/align_pyproject.py` against a recipe directory. Six rules:

7. **If `default-pypi-index` returns `report_only`**, the recipe declares a default index that is NOT public PyPI (e.g. a private mirror, TestPyPI). The skill will not overwrite an intentional choice. Show the user the current `url` from `details.current_url` and ask whether it's deliberate. If yes, they can `# noqa`-comment it or update the repo standard; if no, they should change the URL to `https://pypi.org/simple/`. Do not auto-rewrite.

8. **After apply mode succeeds**, remind the user to run `uv sync` in the recipe directory if the pyproject changes touched dependencies (the script emits this in `notes` when relevant).
8. **If `stale-python-version-refs` returns `report_only`**, list the affected files from `details.files` and call out any **executable** ones first (`.sh`, `.py`, `Makefile`, CI YAML) — a stale interpreter allowlist in a bootstrap script is a live bug, not a docs nit, and it produces a confusing `requires a different Python` failure at install time. Prose files (`README.md`, `SKILL.md`) are lower priority but still inconsistent. Offer to update them; do not rewrite without asking, since some references are legitimately historical ("dropped 3.10 support in v2").

9. **If `runnability-test-in-testpaths` returns `report_only`**, the recipe's `testpaths` excludes `tests/test_runnability.py`, so a bare `uv run pytest` never runs it — the recipe looks tested while its import-smoke test silently never executes. Show `details.testpaths` and suggest adding `"tests"`. Do not auto-rewrite.

10. **After apply mode succeeds**, remind the user to run `uv sync` in the recipe directory if the pyproject changes touched dependencies (the script emits this in `notes` when relevant).

9. **Do not commit any changes.** Show the diff or file contents; let the user commit.
11. **Do not commit any changes.** Show the diff or file contents; let the user commit.

---

Expand Down Expand Up @@ -168,7 +194,7 @@ Status-specific guidance for what to put in the **Details** cell:
- **`would_fix`** (dry-run) — describe the current-state problem, then say what apply would do. Include the `from` → `to` or the list of tables to be removed.
- **`fixed`** (apply) — one-liner confirming the change (new value or list of removed tables).
- **`needs_input`** (only `description-matches-manifest`) — the Details cell says something like `"descriptions differ — needs --description-source={pyproject,manifest,delete}"`. Do not put the two long descriptions inside the table. See "Follow-up content" below.
- **`report_only`** (two rules can hit this: `build-system-present` and `default-pypi-index` when a non-PyPI default is declared) — the Details cell names what's missing or non-conforming (e.g. `"[build-system] missing; recipe cannot be built as a package"` or `"default index is TestPyPI, not public PyPI"`). Follow-up content goes below the table (see next section).
- **`report_only`** (four rules can hit this: `build-system-present`, `default-pypi-index` when a non-PyPI default is declared, `stale-python-version-refs`, and `runnability-test-in-testpaths`) — the Details cell names what's missing or non-conforming (e.g. `"[build-system] missing; recipe cannot be built as a package"` or `"default index is TestPyPI, not public PyPI"`). Follow-up content goes below the table (see next section).
- **`error`** — the Details cell shows the message verbatim; if it's very long, truncate with `…` and put the full text below.

### Follow-up content below the table
Expand Down Expand Up @@ -210,6 +236,8 @@ Only for statuses that need extra context. Order: table first, then this content
- **Dry-run with only `report_only`** (and no `would_fix` rows) — after the table, address the specific case:
- `build-system-present`: show the two `[build-system]` template snippets and stop. This is a manual edit; the skill does not auto-fix it.
- `default-pypi-index`: quote `details.current_url`, explain that this is not public PyPI, and ask whether it's intentional. If not, tell the user to change the URL to `https://pypi.org/simple/`. Do not auto-rewrite.
- `stale-python-version-refs`: list `details.files`, executable files first, and offer to update them.
- `runnability-test-in-testpaths`: quote `details.testpaths` and suggest adding `"tests"`.

- **Dry-run with only `error` rows** — do not offer to apply. Errors mean the script bailed before it could compute a fix; the user has to resolve the underlying issue first.

Expand Down
244 changes: 235 additions & 9 deletions .agents/skills/align-recipe-pyproject/scripts/align_pyproject.py
Original file line number Diff line number Diff line change
Expand Up @@ -79,6 +79,8 @@

import argparse
import json
import os
import re
import sys
from dataclasses import asdict, dataclass, field
from pathlib import Path
Expand Down Expand Up @@ -170,9 +172,7 @@ def to_json(self) -> str:
# ---------- no-local-ruff-config: no [tool.ruff*] tables -------------------


def check_no_local_ruff_config(
doc: tomlkit.TOMLDocument, apply: bool
) -> Check:
def check_no_local_ruff_config(doc: tomlkit.TOMLDocument, apply: bool) -> Check:
"""Remove any [tool.ruff*] table from pyproject.toml.

Assumes doc["tool"] (if present) is a table — run() validates that the
Expand Down Expand Up @@ -321,9 +321,7 @@ def _validate_and_apply_python_floor_rewrite(
)


def check_python_version_floor(
doc: tomlkit.TOMLDocument, apply: bool
) -> Check:
def check_python_version_floor(doc: tomlkit.TOMLDocument, apply: bool) -> Check:
"""Ensure [project].requires-python is compatible with MIN_PYTHON.

Interpretation B (aligned with CI in .github/workflows/
Expand Down Expand Up @@ -905,6 +903,224 @@ def check_default_pypi_index(doc: tomlkit.TOMLDocument, apply: bool) -> Check:
)


# ---------- stale-python-version-refs (report-only) ------------------------
#
# Raising [project].requires-python is not a self-contained edit. A recipe
# typically repeats its supported Python version in prose (README, SKILL.md)
# and — far more importantly — in EXECUTABLE setup code. A bootstrap script
# that picks an interpreter from an allowlist still containing the old floor
# will happily build a venv the recipe then refuses to install into:
#
# for py in python3.13 python3.12 python3.11 python3.10 python3; do
# ...
# ERROR: Package requires a different Python: 3.10.x not in '>=3.11'
#
# This check is report-only: which of the hits matter, and how to reword
# them, is the maintainer's call. Its job is simply that the bump never lands
# silently.

# Directories that are never the recipe's own source.
_SCAN_SKIP_DIRS = frozenset(
{
".git",
".ruff_cache",
".mypy_cache",
".pytest_cache",
"__pycache__",
".venv",
"venv",
"env",
"build",
"dist",
"node_modules",
}
)

# Files whose Python-version mentions are generated, not authored.
_SCAN_SKIP_NAMES = frozenset({"uv.lock", "poetry.lock", "Pipfile.lock"})

# Two contexts that make "3.x" a Python-version reference rather than a
# coincidence. Without the context requirement, a model name like
# "gemini-3.5-flash" would match and drown the report in false positives.
_PY_REF_PATTERNS = (
# python3.10 / python 3.10 / Python 3.10 / python@3.10 / python_3.10, and
# the trove classifier "Programming Language :: Python :: 3.10" (hence
# ':' in the separator class and a width of 4).
re.compile(r"python[\s@._:-]{0,4}3\.(\d+)", re.IGNORECASE),
# >=3.10 / ==3.9 / ~=3.10 — version-specifier syntax
re.compile(r"[<>=~!]=\s*3\.(\d+)"),
)

# Cap the reported hits so a pathological recipe can't produce a wall of JSON.
_MAX_REPORTED_REFS = 40


def _iter_scannable_files(recipe_dir: Path) -> list[Path]:
"""Every text-ish file in the recipe that could carry a version claim."""
found: list[Path] = []
for root, dirs, files in os.walk(recipe_dir):
dirs[:] = [
d
for d in dirs
if d not in _SCAN_SKIP_DIRS and not d.endswith(".egg-info")
]
for name in sorted(files):
if name in _SCAN_SKIP_NAMES:
continue
found.append(Path(root) / name)
return sorted(found)


# `requires-python` is owned by the python-version-floor check, which rewrites
# it in-memory and persists only at the end of run(). Scanning it here would
# report the pre-rewrite value as a stale reference in apply mode even though
# the file on disk ends up correct — a pure false positive either way, since
# the floor check already reports that key authoritatively.
_REQUIRES_PYTHON_LINE_RE = re.compile(r"^\s*requires-python\s*=")


def _sub_floor_refs_in_text(
text: str, skip_requires_python: bool = False
) -> list[tuple[int, str]]:
"""Return (line_number, line) for lines claiming a sub-floor Python."""
hits: list[tuple[int, str]] = []
for lineno, line in enumerate(text.splitlines(), start=1):
if skip_requires_python and _REQUIRES_PYTHON_LINE_RE.match(line):
continue
for pattern in _PY_REF_PATTERNS:
if any(
int(minor) < MIN_PYTHON[1]
for minor in pattern.findall(line)
if minor.isdigit()
):
hits.append((lineno, line.strip()))
break
return hits


def check_stale_python_version_refs(recipe_dir: Path) -> Check:
"""Report references to a Python version below the enforced floor."""
offenders: dict[str, list[dict[str, Any]]] = {}
total = 0
for path in _iter_scannable_files(recipe_dir):
try:
text = path.read_text(encoding="utf-8")
except (OSError, UnicodeDecodeError):
continue # binary or unreadable — not a prose/version claim
rel = str(path.relative_to(recipe_dir))
hits = _sub_floor_refs_in_text(
text, skip_requires_python=(rel == "pyproject.toml")
)
if not hits:
continue
offenders[rel] = [
{"line": lineno, "text": line} for lineno, line in hits
]
total += len(hits)

if not offenders:
return Check(
"stale-python-version-refs",
OK,
f"No references to a Python version below {MIN_PYTHON_STR}.",
)

# Trim the payload without losing the file list.
trimmed = dict(offenders)
if total > _MAX_REPORTED_REFS:
budget = _MAX_REPORTED_REFS
trimmed = {}
for rel, hits in offenders.items():
if budget <= 0:
break
trimmed[rel] = hits[:budget]
budget -= len(trimmed[rel])

return Check(
"stale-python-version-refs",
REPORT_ONLY,
f"{total} reference(s) to a Python version below {MIN_PYTHON_STR} "
f"across {len(offenders)} file(s): {', '.join(sorted(offenders))}. "
f"[project].requires-python is enforced at >={MIN_PYTHON_STR}, so "
f"these are now inconsistent. Executable ones matter most — an "
f"interpreter-picking loop in a bootstrap script that still accepts "
f"the old floor will build a venv the recipe then refuses to install "
f"into. This skill does not auto-fix: which references are stale and "
f"how to reword them is editorial.",
{
"floor": MIN_PYTHON_STR,
"total": total,
"files": sorted(offenders),
"hits": trimmed,
"truncated": total > _MAX_REPORTED_REFS,
},
)


# ---------- runnability-test-in-testpaths (report-only) --------------------


def _testpaths_cover_runnability(entries: list[str]) -> bool:
"""Whether any testpaths entry collects tests/test_runnability.py."""
for raw in entries:
entry = str(raw).strip().rstrip("/")
if entry in {"", ".", "tests", "tests/test_runnability.py"}:
return True
return False


def check_runnability_test_in_testpaths(doc: tomlkit.TOMLDocument) -> Check:
"""Report a `testpaths` setting that excludes the runnability test.

`tests/test_runnability.py` is a required file for Python recipes
(.github/policy.yml), but a narrower `testpaths` — e.g.
`["tests/unit", "tests/integration"]` — means a bare `uv run pytest`
never collects it. The recipe then looks tested while its one
import-smoke test silently never runs.
"""
ini = (
doc.get("tool", {}).get("pytest", {}).get("ini_options", {})
if doc.get("tool") is not None
else {}
)
entries = ini.get("testpaths") if hasattr(ini, "get") else None
if entries is None:
return Check(
"runnability-test-in-testpaths",
OK,
"No [tool.pytest.ini_options].testpaths — pytest collects the "
"whole recipe, including tests/test_runnability.py.",
)
if isinstance(entries, str):
entries = [entries]
if not isinstance(entries, (list, tuple)):
return Check(
"runnability-test-in-testpaths",
OK,
"testpaths is not a list; nothing to check.",
)

listed = [str(e) for e in entries]
if _testpaths_cover_runnability(listed):
return Check(
"runnability-test-in-testpaths",
OK,
f"testpaths {listed} collects tests/test_runnability.py.",
)

return Check(
"runnability-test-in-testpaths",
REPORT_ONLY,
f"[tool.pytest.ini_options].testpaths is {listed}, none of which "
f"collects tests/test_runnability.py — the required runnability "
f'test would never run under a bare `pytest`. Add "tests" to '
f"testpaths, or narrow it deliberately and run the file by path. "
f"Not auto-fixed: broadening testpaths changes what CI collects, "
f"which is the maintainer's call.",
{"testpaths": listed, "expected": "tests/test_runnability.py"},
)


# ---------- Orchestration -------------------------------------------------


Expand Down Expand Up @@ -1081,9 +1297,7 @@ def run(
report.add(
_run_check(
"project-name-matches-folder",
lambda: check_project_name_matches_folder(
recipe_dir, doc, apply
),
lambda: check_project_name_matches_folder(recipe_dir, doc, apply),
)
)
report.add(
Expand All @@ -1107,6 +1321,18 @@ def run(
lambda: check_default_pypi_index(doc, apply),
)
)
report.add(
_run_check(
"stale-python-version-refs",
lambda: check_stale_python_version_refs(recipe_dir),
)
)
report.add(
_run_check(
"runnability-test-in-testpaths",
lambda: check_runnability_test_in_testpaths(doc),
)
)

# Persist edits — only in apply mode, and only if at least one auto-fix
# actually changed something.
Expand Down
24 changes: 24 additions & 0 deletions .agents/skills/align-recipe-pyproject/tests/conftest.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
# Copyright 2026 Google LLC
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# https://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
"""Make this skill's scripts/ directory importable from its tests.

Keeping the path shim inside the skill (rather than in the repo-root pytest
config) preserves the skill as a self-contained, portable bundle.
"""

import sys
from pathlib import Path

SCRIPTS_DIR = Path(__file__).parent.parent / "scripts"
sys.path.insert(0, str(SCRIPTS_DIR))
Loading
Loading