Coverage for plugin/scripts/set_license.py: 100%

102 statements  

« prev     ^ index     » next       coverage.py v7.16.1, created at 2026-09-15 14:46 +0000

1#!/usr/bin/env python3 

2"""Apply or change a project's license — the engine behind `/rhiza:license`. 

3 

4Sets the SPDX ``license`` / ``license-files`` metadata in ``pyproject.toml`` 

5(Python repos) and the ``license`` key in ``Cargo.toml``'s ``[package]`` table 

6(Rust repos), and writes the ``LICENSE`` file's full text from the bundled 

7templates. Unlike the *greenfield* scaffolder, this **changes** an existing 

8license: it replaces the metadata, and (with ``--force``) overwrites an existing 

9``LICENSE`` file. Stdlib-only, so `/init` and `/license` can run it without the 

10`rhiza` CLI. 

11 

12Usage: 

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

14 scripts/set_license.py [TARGET] --license SPDX --owner OWNER \ 

15 [--license-year YYYY] [--force] [--json] 

16 

17`--license none` clears the metadata and leaves any existing `LICENSE` in place. 

18Exit code 3 means an existing `LICENSE` differs and `--force` was not given. 

19""" 

20 

21from __future__ import annotations 

22 

23import argparse 

24import datetime 

25import json 

26import re 

27import sys 

28from pathlib import Path 

29from typing import Any 

30 

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

32from _rhiza_toml import rejoin, require_table, table_end # noqa: E402 

33 

34DEFAULT_LICENSE = "none" 

35# Full-text license templates (`<SPDX id>.txt`, with `{year}`/`{holder}` fills). 

36_LICENSES_DIR = Path(__file__).resolve().parent / "licenses" 

37_NEEDS_FORCE = 3 

38 

39 

40def bundled_licenses() -> list[str]: 

41 """SPDX ids with a bundled full text, sorted.""" 

42 return sorted(p.stem for p in _LICENSES_DIR.glob("*.txt")) 

43 

44 

45def render_license(license_id: str, holder: str, year: str) -> str | None: 

46 """Return the LICENSE text for an SPDX id (``{year}``/``{holder}`` filled). 

47 

48 Returns ``None`` when no full text is bundled for *license_id*. 

49 """ 

50 path = _LICENSES_DIR / f"{license_id}.txt" 

51 if not path.is_file(): 

52 return None 

53 return path.read_text(encoding="utf-8").replace("{year}", year).replace("{holder}", holder) 

54 

55 

56def _strip_keys(lines: list[str], header: int, end: int, key: re.Pattern[str]) -> list[str]: 

57 """Return *lines* without the lines in ``(header, end)`` whose key matches *key*. 

58 

59 Bounded to the one table so a ``license`` under some other table is left alone. 

60 """ 

61 return [line for i, line in enumerate(lines) if not (header < i < end and key.match(line))] 

62 

63 

64def set_license_metadata(text: str, license_id: str) -> tuple[str, bool]: 

65 """Force-set (or clear) SPDX ``license``/``license-files`` in ``[project]``. 

66 

67 Removes any existing ``license``/``license-files`` lines, then — unless 

68 *license_id* is ``none`` — reinserts them (the PEP 639 expression field, never 

69 a deprecated ``License ::`` trove classifier). Returns ``(new_text, changed)``. 

70 """ 

71 lines = text.splitlines() 

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

73 kept = _strip_keys(lines, header, end, re.compile(r"^\s*license(-files)?\s*=")) 

74 if license_id != DEFAULT_LICENSE: 

75 header, _ = require_table(kept, "project", "pyproject.toml") 

76 kept[header + 1 : header + 1] = [ 

77 f'license = "{license_id}"', 

78 'license-files = ["LICENSE"]', 

79 ] 

80 new_text = rejoin(text, kept) 

81 return new_text, new_text != text 

82 

83 

84def set_cargo_license_metadata(text: str, license_id: str) -> tuple[str, bool]: 

85 """Force-set (or clear) the SPDX ``license`` key in Cargo's ``[package]`` table. 

86 

87 Cargo's manifest has no ``license-files`` array — the SPDX expression goes in 

88 ``license``, and ``license-file`` is the escape hatch for a licence with no SPDX 

89 id. Both are removed first, then ``license`` is reinserted unless *license_id* is 

90 ``none``: leaving a stale ``license-file`` pointing at a replaced LICENSE would 

91 make ``cargo publish`` describe the wrong terms. 

92 

93 Returns ``(new_text, changed)``. 

94 """ 

95 lines = text.splitlines() 

96 header, end = require_table(lines, "package", "Cargo.toml") 

97 kept = _strip_keys(lines, header, end, re.compile(r"^\s*license(-file)?\s*=")) 

98 if license_id != DEFAULT_LICENSE: 

99 # Appended to the end of the table, not under the header, so repeated runs 

100 # leave `name`/`version` where cargo put them instead of walking them down. 

101 header, end = require_table(kept, "package", "Cargo.toml") 

102 kept.insert(table_end(kept, header, end), f'license = "{license_id}"') 

103 new_text = rejoin(text, kept) 

104 return new_text, new_text != text 

105 

106 

107def _overwrite_needs_force(lic_path: Path, body: str | None, *, force: bool) -> bool: 

108 """Would writing *body* replace a different existing LICENSE without permission?""" 

109 return ( 

110 body is not None 

111 and lic_path.exists() 

112 and lic_path.read_text(encoding="utf-8") != body 

113 and not force 

114 ) 

115 

116 

117def _apply_manifest_metadata( 

118 target: Path, license_id: str, modified: list[str], notes: list[str] 

119) -> None: 

120 """Write the licence key into whichever manifests *target* has, recording each. 

121 

122 Both manifests are attempted rather than dispatched on a declared language: a repo can 

123 legitimately carry a pyproject.toml *and* a Cargo.toml (a pyo3/maturin extension), and 

124 the licence must not disagree between them. 

125 """ 

126 for name, setter in ( 

127 ("pyproject.toml", set_license_metadata), 

128 ("Cargo.toml", set_cargo_license_metadata), 

129 ): 

130 manifest = target / name 

131 if not manifest.exists(): 

132 continue 

133 try: 

134 new_text, changed = setter(manifest.read_text(encoding="utf-8"), license_id) 

135 except ValueError as exc: 

136 notes.append(f"{name}: {exc}") 

137 else: 

138 if changed: 

139 manifest.write_text(new_text, encoding="utf-8") 

140 modified.append(name) 

141 

142 

143def _write_license_file( 

144 lic_path: Path, license_id: str, body: str | None 

145) -> tuple[str | None, str | None]: 

146 """Write the LICENSE file if it needs writing; return ``(bucket, note)``. 

147 

148 *bucket* is which summary list should record ``LICENSE`` — ``created``, ``modified``, 

149 ``skipped``, or None when nothing was touched — and *note* is the explanation for the 

150 two cases where no file can be written. 

151 """ 

152 if license_id == DEFAULT_LICENSE: 

153 return None, "license set to none — cleared metadata; any existing LICENSE left in place" 

154 if body is None: 

155 return None, ( 

156 f"license {license_id}: no bundled text; add a LICENSE file manually " 

157 f"(bundled: {', '.join(bundled_licenses())})" 

158 ) 

159 if lic_path.exists() and lic_path.read_text(encoding="utf-8") == body: 

160 return "skipped", None 

161 existed = lic_path.exists() 

162 lic_path.write_text(body, encoding="utf-8") 

163 return ("modified" if existed else "created"), None 

164 

165 

166def set_license( 

167 target: Path, *, license_id: str, holder: str, year: str, force: bool 

168) -> dict[str, Any]: 

169 """Apply *license_id* to the repo at *target*; return a summary dict.""" 

170 buckets: dict[str, list[str]] = {"created": [], "modified": [], "skipped": []} 

171 notes: list[str] = [] 

172 lic_path = target / "LICENSE" 

173 

174 # Resolve the LICENSE-file text first, and refuse *before* touching anything 

175 # when an overwrite needs confirmation — so metadata and file never diverge. 

176 body = None if license_id == DEFAULT_LICENSE else render_license(license_id, holder, year) 

177 if _overwrite_needs_force(lic_path, body, force=force): 

178 return { 

179 "license": license_id, 

180 "created": [], 

181 "modified": [], 

182 "skipped": ["LICENSE"], 

183 "notes": ["LICENSE exists and differs — pass --force to overwrite"], 

184 "needs_force": True, 

185 } 

186 

187 _apply_manifest_metadata(target, license_id, buckets["modified"], notes) 

188 

189 bucket, note = _write_license_file(lic_path, license_id, body) 

190 if bucket is not None: 

191 buckets[bucket].append("LICENSE") 

192 if note is not None: 

193 notes.append(note) 

194 

195 return { 

196 "license": license_id, 

197 **buckets, 

198 "notes": notes, 

199 "needs_force": False, 

200 } 

201 

202 

203def main(argv: list[str] | None = None) -> int: 

204 """Entry point: parse args, apply the license, return an exit code.""" 

205 parser = argparse.ArgumentParser(description="Apply or change a project's license.") 

206 parser.add_argument( 

207 "target", nargs="?", default=".", help="Repository root (default: current directory)." 

208 ) 

209 parser.add_argument( 

210 "--license", 

211 dest="license_id", 

212 required=True, 

213 help="SPDX license id to apply (e.g. MIT), or 'none' to clear.", 

214 ) 

215 parser.add_argument("--owner", default="your-org", help="Copyright holder (default: your-org).") 

216 parser.add_argument( 

217 "--license-year", dest="license_year", help="Copyright year (default: current year)." 

218 ) 

219 parser.add_argument("--force", action="store_true", help="Overwrite an existing LICENSE file.") 

220 parser.add_argument( 

221 "--json", dest="json_output", action="store_true", help="Emit the summary as JSON." 

222 ) 

223 args = parser.parse_args(argv) 

224 

225 if args.license_id != DEFAULT_LICENSE and render_license(args.license_id, "", "") is None: 

226 parser.error( 

227 f"no bundled text for --license {args.license_id!r}; " 

228 f"choose from {', '.join(bundled_licenses())} (or 'none')" 

229 ) 

230 year = args.license_year or str(datetime.date.today().year) 

231 

232 summary = set_license( 

233 Path(args.target).resolve(), 

234 license_id=args.license_id, 

235 holder=args.owner, 

236 year=year, 

237 force=args.force, 

238 ) 

239 

240 if args.json_output: 

241 print(json.dumps(summary, indent=2)) 

242 else: 

243 for path in summary["created"]: 

244 print(f"created {path}") 

245 for path in summary["modified"]: 

246 print(f"modified {path}") 

247 for path in summary["skipped"]: 

248 print(f"skipped {path}", file=sys.stderr) 

249 for note in summary["notes"]: 

250 print(f"note {note}", file=sys.stderr) 

251 return _NEEDS_FORCE if summary["needs_force"] else 0 

252 

253 

254if __name__ == "__main__": 

255 raise SystemExit(main())