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

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 

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

27 

28 

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

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

31 

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

41 

42 

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 

54 

55 

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 

67 

68 

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

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

71 

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

80 

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

93 

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

113 

114 new_text = "\n".join(lines) 

115 if text.endswith("\n"): 

116 new_text += "\n" 

117 return new_text, changes 

118 

119 

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} 

140 

141 

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) 

158 

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 ) 

164 

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

166 

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 

175 

176 

177if __name__ == "__main__": 

178 raise SystemExit(main())