Coverage for plugin/scripts/set_python_version.py: 100%
97 statements
« prev ^ index » next coverage.py v7.16.1, created at 2026-09-15 14:46 +0000
« prev ^ index » next coverage.py v7.16.1, created at 2026-09-15 14:46 +0000
1#!/usr/bin/env python3
2"""Set (or retarget) a project's standard Python version — behind `/rhiza:python-version`.
4Edits ``pyproject.toml``'s ``[project]`` table: pins ``requires-python`` to
5``>=X.Y`` and rewrites the ``Programming Language :: Python :: X.Y`` trove
6classifiers to the supported range (dropping any stale Python classifiers,
7including a bare ``... :: 3``, while preserving non-Python classifiers). Stdlib-only,
8so `/init` and `/python-version` can run it without the `rhiza` CLI.
10Usage:
11 uv run --python 3.12 --no-project python \
12 scripts/set_python_version.py [TARGET] --python-version 3.12 [--json]
13"""
15from __future__ import annotations
17import argparse
18import json
19import re
20import sys
21from pathlib import Path
22from typing import Any
24sys.path.insert(0, str(Path(__file__).resolve().parent))
25from _rhiza_toml import rejoin, require_table # noqa: E402
27# Python minor versions we standardise on (oldest → newest).
28KNOWN_PY_VERSIONS = ("3.11", "3.12", "3.13", "3.14")
29_PY_CLASSIFIER = re.compile(r"^Programming Language :: Python :: 3(\.\d+)?$")
32def python_version_classifiers(python_version: str) -> list[str]:
33 """Concrete ``Programming Language :: Python :: X.Y`` classifiers from *python_version* up.
35 Never the bare major-version ``... :: 3`` classifier, which modern tooling
36 discourages.
37 """
38 if python_version not in KNOWN_PY_VERSIONS:
39 raise ValueError(
40 f"unknown python version {python_version!r}; choose from {', '.join(KNOWN_PY_VERSIONS)}"
41 )
42 start = KNOWN_PY_VERSIONS.index(python_version)
43 return [f"Programming Language :: Python :: {v}" for v in KNOWN_PY_VERSIONS[start:]]
46def _classifiers_span(lines: list[str], header: int, end: int) -> tuple[int, int] | None:
47 """Return ``(start, stop)`` line indices of the ``classifiers = [...]`` array, or None."""
48 for i in range(header + 1, end):
49 if re.match(r"^\s*classifiers\s*=", lines[i]):
50 if "]" in lines[i]:
51 return i, i
52 for j in range(i + 1, len(lines)):
53 if lines[j].strip() == "]":
54 return i, j
55 return i, i
56 return None
59def _classifiers_block(classifiers: list[str]) -> list[str]:
60 """Render a ``classifiers = [...]`` array as the lines it occupies."""
61 return ["classifiers = ["] + [f' "{c}",' for c in classifiers] + ["]"]
64def _apply_requires_python(lines: list[str], header: int, end: int, python_version: str) -> bool:
65 """Pin ``requires-python`` in place, inserting it when absent; return whether it moved."""
66 wanted = f'requires-python = ">={python_version}"'
67 pattern = re.compile(r"^\s*requires-python\s*=")
68 for i in range(header + 1, end):
69 if not pattern.match(lines[i]):
70 continue
71 if lines[i] == wanted:
72 return False
73 lines[i] = wanted
74 return True
75 lines.insert(header + 1, wanted)
76 return True
79def _apply_classifiers(lines: list[str], header: int, end: int, wanted: list[str]) -> bool:
80 """Merge *wanted* into ``[project].classifiers``; return whether anything changed.
82 Non-Python classifiers are preserved and keep their order — only the
83 ``Programming Language :: Python :: X.Y`` entries are swapped for the supported range,
84 since those are the ones this script owns.
85 """
86 span = _classifiers_span(lines, header, end)
87 if span is None:
88 lines[header + 1 : header + 1] = _classifiers_block(wanted)
89 return True
91 start, stop = span
92 existing = re.findall(r'"([^"]*)"', "\n".join(lines[start : stop + 1]))
93 kept = [entry for entry in existing if not _PY_CLASSIFIER.match(entry)]
94 # `dict.fromkeys` dedupes while preserving first-seen order.
95 rebuilt = _classifiers_block(list(dict.fromkeys([*kept, *wanted])))
96 if rebuilt == lines[start : stop + 1]:
97 return False
98 lines[start : stop + 1] = rebuilt
99 return True
102def apply_python_metadata(text: str, python_version: str) -> tuple[str, list[str]]:
103 """Pin ``requires-python`` and rewrite Python version classifiers in ``[project]``.
105 ``requires-python`` is corrected in place (inserted if absent); the Python
106 version classifiers are replaced with the supported range while any other
107 classifiers are preserved. Returns ``(new_text, changes)``.
108 """
109 lines = text.splitlines()
110 changes: list[str] = []
112 header, end = require_table(lines, "project", "pyproject.toml")
113 if _apply_requires_python(lines, header, end, python_version):
114 changes.append("requires-python")
116 # Re-bound the table: inserting `requires-python` shifted every index after it.
117 header, end = require_table(lines, "project", "pyproject.toml")
118 if _apply_classifiers(lines, header, end, python_version_classifiers(python_version)):
119 changes.append("classifiers")
121 return rejoin(text, lines), changes
124def set_python_version(target: Path, *, python_version: str) -> dict[str, Any]:
125 """Retarget the repo at *target* to *python_version*; return a summary dict."""
126 modified: list[str] = []
127 notes: list[str] = []
128 pyproject = target / "pyproject.toml"
129 if not pyproject.exists():
130 notes.append("pyproject.toml absent — nothing to retarget")
131 return {"python_version": python_version, "modified": modified, "notes": notes}
132 try:
133 new_text, changes = apply_python_metadata(
134 pyproject.read_text(encoding="utf-8"), python_version
135 )
136 except ValueError as exc:
137 notes.append(f"pyproject.toml: {exc}")
138 return {"python_version": python_version, "modified": modified, "notes": notes}
139 if changes:
140 pyproject.write_text(new_text, encoding="utf-8")
141 modified.append("pyproject.toml")
142 notes.append("pyproject.toml: " + ", ".join(changes))
143 else:
144 notes.append("already up to date")
145 return {"python_version": python_version, "modified": modified, "notes": notes}
148def main(argv: list[str] | None = None) -> int:
149 """Entry point: parse args, retarget, return an exit code."""
150 parser = argparse.ArgumentParser(description="Set or retarget a project's Python version.")
151 parser.add_argument(
152 "target", nargs="?", default=".", help="Repository root (default: current directory)."
153 )
154 parser.add_argument(
155 "--python-version",
156 dest="python_version",
157 required=True,
158 help=f"Standard Python minor version ({', '.join(KNOWN_PY_VERSIONS)}).",
159 )
160 parser.add_argument(
161 "--json", dest="json_output", action="store_true", help="Emit the summary as JSON."
162 )
163 args = parser.parse_args(argv)
165 if args.python_version not in KNOWN_PY_VERSIONS:
166 parser.error(
167 f"unknown --python-version {args.python_version!r}; "
168 f"choose from {', '.join(KNOWN_PY_VERSIONS)}"
169 )
171 summary = set_python_version(Path(args.target).resolve(), python_version=args.python_version)
173 if args.json_output:
174 print(json.dumps(summary, indent=2))
175 else:
176 for path in summary["modified"]:
177 print(f"modified {path}")
178 for note in summary["notes"]:
179 print(f"note {note}", file=sys.stderr)
180 return 0
183if __name__ == "__main__":
184 raise SystemExit(main())