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
« 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`.
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.
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]
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"""
21from __future__ import annotations
23import argparse
24import datetime
25import json
26import re
27import sys
28from pathlib import Path
29from typing import Any
31sys.path.insert(0, str(Path(__file__).resolve().parent))
32from _rhiza_toml import rejoin, require_table, table_end # noqa: E402
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
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"))
45def render_license(license_id: str, holder: str, year: str) -> str | None:
46 """Return the LICENSE text for an SPDX id (``{year}``/``{holder}`` filled).
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)
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*.
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))]
64def set_license_metadata(text: str, license_id: str) -> tuple[str, bool]:
65 """Force-set (or clear) SPDX ``license``/``license-files`` in ``[project]``.
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
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.
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.
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
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 )
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.
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)
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)``.
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
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"
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 }
187 _apply_manifest_metadata(target, license_id, buckets["modified"], notes)
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)
195 return {
196 "license": license_id,
197 **buckets,
198 "notes": notes,
199 "needs_force": False,
200 }
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)
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)
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 )
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
254if __name__ == "__main__":
255 raise SystemExit(main())