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

1#!/usr/bin/env python3 

2"""Set (or retarget) a project's standard Python version — behind `/rhiza:python-version`. 

3 

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. 

9 

10Usage: 

11 uv run --python 3.12 --no-project python \ 

12 scripts/set_python_version.py [TARGET] --python-version 3.12 [--json] 

13""" 

14 

15from __future__ import annotations 

16 

17import argparse 

18import json 

19import re 

20import sys 

21from pathlib import Path 

22from typing import Any 

23 

24sys.path.insert(0, str(Path(__file__).resolve().parent)) 

25from _rhiza_toml import rejoin, require_table # noqa: E402 

26 

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+)?$") 

30 

31 

32def python_version_classifiers(python_version: str) -> list[str]: 

33 """Concrete ``Programming Language :: Python :: X.Y`` classifiers from *python_version* up. 

34 

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:]] 

44 

45 

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 

57 

58 

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] + ["]"] 

62 

63 

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 

77 

78 

79def _apply_classifiers(lines: list[str], header: int, end: int, wanted: list[str]) -> bool: 

80 """Merge *wanted* into ``[project].classifiers``; return whether anything changed. 

81 

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 

90 

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 

100 

101 

102def apply_python_metadata(text: str, python_version: str) -> tuple[str, list[str]]: 

103 """Pin ``requires-python`` and rewrite Python version classifiers in ``[project]``. 

104 

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] = [] 

111 

112 header, end = require_table(lines, "project", "pyproject.toml") 

113 if _apply_requires_python(lines, header, end, python_version): 

114 changes.append("requires-python") 

115 

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

120 

121 return rejoin(text, lines), changes 

122 

123 

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} 

146 

147 

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) 

164 

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 ) 

170 

171 summary = set_python_version(Path(args.target).resolve(), python_version=args.python_version) 

172 

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 

181 

182 

183if __name__ == "__main__": 

184 raise SystemExit(main())