Coverage for scripts/set_license.py: 100%

94 statements  

« prev     ^ index     » next       coverage.py v7.15.2, created at 2026-07-31 06:18 +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 writes the ``LICENSE`` file's full text from the bundled 

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

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

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

9`rhiza` CLI. 

10 

11Usage: 

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

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

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

15 

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

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

18""" 

19 

20from __future__ import annotations 

21 

22import argparse 

23import datetime 

24import json 

25import re 

26import sys 

27from pathlib import Path 

28from typing import Any 

29 

30DEFAULT_LICENSE = "none" 

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

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

33_NEEDS_FORCE = 3 

34 

35 

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

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

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

39 

40 

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

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

43 

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

45 """ 

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

47 if not path.is_file(): 

48 return None 

49 return path.read_text().replace("{year}", year).replace("{holder}", holder) 

50 

51 

52def _project_block(lines: list[str]) -> tuple[int, int]: 

53 """Return ``(header_idx, end_idx)`` bounding the ``[project]`` table body.""" 

54 header = next((i for i, line in enumerate(lines) if line.strip() == "[project]"), None) 

55 if header is None: 

56 raise ValueError("pyproject.toml has no [project] table") 

57 end = len(lines) 

58 for i in range(header + 1, len(lines)): 

59 if lines[i].lstrip().startswith("["): 

60 end = i 

61 break 

62 return header, end 

63 

64 

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

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

67 

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

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

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

71 """ 

72 lines = text.splitlines() 

73 header, end = _project_block(lines) 

74 lic = re.compile(r"^\s*license\s*=") 

75 lic_files = re.compile(r"^\s*license-files\s*=") 

76 kept = [ 

77 line 

78 for i, line in enumerate(lines) 

79 if not (header < i < end and (lic.match(line) or lic_files.match(line))) 

80 ] 

81 if license_id != DEFAULT_LICENSE: 

82 header, _ = _project_block(kept) 

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

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

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

86 ] 

87 new_text = "\n".join(kept) 

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

89 new_text += "\n" 

90 return new_text, new_text != text 

91 

92 

93def set_license( 

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

95) -> dict[str, Any]: 

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

97 created: list[str] = [] 

98 modified: list[str] = [] 

99 skipped: list[str] = [] 

100 notes: list[str] = [] 

101 lic_path = target / "LICENSE" 

102 

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

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

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

106 if body is not None and lic_path.exists() and lic_path.read_text() != body and not force: 

107 notes.append("LICENSE exists and differs — pass --force to overwrite") 

108 return { 

109 "license": license_id, 

110 "created": [], 

111 "modified": [], 

112 "skipped": ["LICENSE"], 

113 "notes": notes, 

114 "needs_force": True, 

115 } 

116 

117 # 1. pyproject.toml metadata (Python repos only). 

118 pyproject = target / "pyproject.toml" 

119 if pyproject.exists(): 

120 try: 

121 new_text, changed = set_license_metadata(pyproject.read_text(), license_id) 

122 except ValueError as exc: 

123 notes.append(f"pyproject.toml: {exc}") 

124 else: 

125 if changed: 

126 pyproject.write_text(new_text) 

127 modified.append("pyproject.toml") 

128 

129 # 2. The LICENSE file itself. 

130 if license_id == DEFAULT_LICENSE: 

131 notes.append("license set to none — cleared metadata; any existing LICENSE left in place") 

132 elif body is None: 

133 notes.append( 

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

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

136 ) 

137 elif lic_path.exists() and lic_path.read_text() == body: 

138 skipped.append("LICENSE") 

139 else: 

140 existed = lic_path.exists() 

141 lic_path.write_text(body) 

142 (modified if existed else created).append("LICENSE") 

143 

144 return { 

145 "license": license_id, 

146 "created": created, 

147 "modified": modified, 

148 "skipped": skipped, 

149 "notes": notes, 

150 "needs_force": False, 

151 } 

152 

153 

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

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

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

157 parser.add_argument( 

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

159 ) 

160 parser.add_argument( 

161 "--license", 

162 dest="license_id", 

163 required=True, 

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

165 ) 

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

167 parser.add_argument( 

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

169 ) 

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

171 parser.add_argument( 

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

173 ) 

174 args = parser.parse_args(argv) 

175 

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

177 parser.error( 

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

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

180 ) 

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

182 

183 summary = set_license( 

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

185 license_id=args.license_id, 

186 holder=args.owner, 

187 year=year, 

188 force=args.force, 

189 ) 

190 

191 if args.json_output: 

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

193 else: 

194 for path in summary["created"]: 

195 print(f"created {path}") 

196 for path in summary["modified"]: 

197 print(f"modified {path}") 

198 for path in summary["skipped"]: 

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

200 for note in summary["notes"]: 

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

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

203 

204 

205if __name__ == "__main__": 

206 raise SystemExit(main())