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
« 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`.
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.
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]
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"""
20from __future__ import annotations
22import argparse
23import datetime
24import json
25import re
26import sys
27from pathlib import Path
28from typing import Any
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
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"))
41def render_license(license_id: str, holder: str, year: str) -> str | None:
42 """Return the LICENSE text for an SPDX id (``{year}``/``{holder}`` filled).
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)
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
65def set_license_metadata(text: str, license_id: str) -> tuple[str, bool]:
66 """Force-set (or clear) SPDX ``license``/``license-files`` in ``[project]``.
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
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"
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 }
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")
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")
144 return {
145 "license": license_id,
146 "created": created,
147 "modified": modified,
148 "skipped": skipped,
149 "notes": notes,
150 "needs_force": False,
151 }
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)
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)
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 )
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
205if __name__ == "__main__":
206 raise SystemExit(main())