Coverage for scripts/set_python_version.py: 100%
104 statements
« prev ^ index » next coverage.py v7.15.2, created at 2026-07-31 06:18 +0000
« prev ^ index » next coverage.py v7.15.2, created at 2026-07-31 06:18 +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
24# Python minor versions we standardise on (oldest → newest).
25KNOWN_PY_VERSIONS = ("3.11", "3.12", "3.13", "3.14")
26_PY_CLASSIFIER = re.compile(r"^Programming Language :: Python :: 3(\.\d+)?$")
29def python_version_classifiers(python_version: str) -> list[str]:
30 """Concrete ``Programming Language :: Python :: X.Y`` classifiers from *python_version* up.
32 Never the bare major-version ``... :: 3`` classifier, which modern tooling
33 discourages.
34 """
35 if python_version not in KNOWN_PY_VERSIONS:
36 raise ValueError(
37 f"unknown python version {python_version!r}; choose from {', '.join(KNOWN_PY_VERSIONS)}"
38 )
39 start = KNOWN_PY_VERSIONS.index(python_version)
40 return [f"Programming Language :: Python :: {v}" for v in KNOWN_PY_VERSIONS[start:]]
43def _project_block(lines: list[str]) -> tuple[int, int]:
44 """Return ``(header_idx, end_idx)`` bounding the ``[project]`` table body."""
45 header = next((i for i, line in enumerate(lines) if line.strip() == "[project]"), None)
46 if header is None:
47 raise ValueError("pyproject.toml has no [project] table")
48 end = len(lines)
49 for i in range(header + 1, len(lines)):
50 if lines[i].lstrip().startswith("["):
51 end = i
52 break
53 return header, end
56def _classifiers_span(lines: list[str], header: int, end: int) -> tuple[int, int] | None:
57 """Return ``(start, stop)`` line indices of the ``classifiers = [...]`` array, or None."""
58 for i in range(header + 1, end):
59 if re.match(r"^\s*classifiers\s*=", lines[i]):
60 if "]" in lines[i]:
61 return i, i
62 for j in range(i + 1, len(lines)):
63 if lines[j].strip() == "]":
64 return i, j
65 return i, i
66 return None
69def apply_python_metadata(text: str, python_version: str) -> tuple[str, list[str]]:
70 """Pin ``requires-python`` and rewrite Python version classifiers in ``[project]``.
72 ``requires-python`` is corrected in place (inserted if absent); the Python
73 version classifiers are replaced with the supported range while any other
74 classifiers are preserved. Returns ``(new_text, changes)``.
75 """
76 new_classifiers = python_version_classifiers(python_version)
77 lines = text.splitlines()
78 header, end = _project_block(lines)
79 changes: list[str] = []
81 # requires-python — replace in place, else insert.
82 rp_line = f'requires-python = ">={python_version}"'
83 rp_pat = re.compile(r"^\s*requires-python\s*=")
84 for i in range(header + 1, end):
85 if rp_pat.match(lines[i]):
86 if lines[i] != rp_line:
87 lines[i] = rp_line
88 changes.append("requires-python")
89 break
90 else:
91 lines.insert(header + 1, rp_line)
92 changes.append("requires-python")
94 # classifiers — merge: keep non-Python entries, swap in the new Python range.
95 header, end = _project_block(lines)
96 span = _classifiers_span(lines, header, end)
97 if span is None:
98 block = ["classifiers = ["] + [f' "{c}",' for c in new_classifiers] + ["]"]
99 lines[header + 1 : header + 1] = block
100 changes.append("classifiers")
101 else:
102 start, stop = span
103 existing = re.findall(r'"([^"]*)"', "\n".join(lines[start : stop + 1]))
104 kept = [e for e in existing if not _PY_CLASSIFIER.match(e)]
105 merged: list[str] = []
106 for entry in [*kept, *new_classifiers]:
107 if entry not in merged:
108 merged.append(entry)
109 rebuilt = ["classifiers = ["] + [f' "{c}",' for c in merged] + ["]"]
110 if rebuilt != lines[start : stop + 1]:
111 lines[start : stop + 1] = rebuilt
112 changes.append("classifiers")
114 new_text = "\n".join(lines)
115 if text.endswith("\n"):
116 new_text += "\n"
117 return new_text, changes
120def set_python_version(target: Path, *, python_version: str) -> dict[str, Any]:
121 """Retarget the repo at *target* to *python_version*; return a summary dict."""
122 modified: list[str] = []
123 notes: list[str] = []
124 pyproject = target / "pyproject.toml"
125 if not pyproject.exists():
126 notes.append("pyproject.toml absent — nothing to retarget")
127 return {"python_version": python_version, "modified": modified, "notes": notes}
128 try:
129 new_text, changes = apply_python_metadata(pyproject.read_text(), python_version)
130 except ValueError as exc:
131 notes.append(f"pyproject.toml: {exc}")
132 return {"python_version": python_version, "modified": modified, "notes": notes}
133 if changes:
134 pyproject.write_text(new_text)
135 modified.append("pyproject.toml")
136 notes.append("pyproject.toml: " + ", ".join(changes))
137 else:
138 notes.append("already up to date")
139 return {"python_version": python_version, "modified": modified, "notes": notes}
142def main(argv: list[str] | None = None) -> int:
143 """Entry point: parse args, retarget, return an exit code."""
144 parser = argparse.ArgumentParser(description="Set or retarget a project's Python version.")
145 parser.add_argument(
146 "target", nargs="?", default=".", help="Repository root (default: current directory)."
147 )
148 parser.add_argument(
149 "--python-version",
150 dest="python_version",
151 required=True,
152 help=f"Standard Python minor version ({', '.join(KNOWN_PY_VERSIONS)}).",
153 )
154 parser.add_argument(
155 "--json", dest="json_output", action="store_true", help="Emit the summary as JSON."
156 )
157 args = parser.parse_args(argv)
159 if args.python_version not in KNOWN_PY_VERSIONS:
160 parser.error(
161 f"unknown --python-version {args.python_version!r}; "
162 f"choose from {', '.join(KNOWN_PY_VERSIONS)}"
163 )
165 summary = set_python_version(Path(args.target).resolve(), python_version=args.python_version)
167 if args.json_output:
168 print(json.dumps(summary, indent=2))
169 else:
170 for path in summary["modified"]:
171 print(f"modified {path}")
172 for note in summary["notes"]:
173 print(f"note {note}", file=sys.stderr)
174 return 0
177if __name__ == "__main__":
178 raise SystemExit(main())