From 39066900773e7857faf2d02e7ed51b71d219d97d Mon Sep 17 00:00:00 2001 From: godosa Date: Tue, 6 Oct 2026 23:39:36 +0200 Subject: godosa-engine: initial public history --- scripts/godosa_licenses.py | 145 +++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 145 insertions(+) create mode 100644 scripts/godosa_licenses.py (limited to 'scripts/godosa_licenses.py') diff --git a/scripts/godosa_licenses.py b/scripts/godosa_licenses.py new file mode 100644 index 0000000..f28f312 --- /dev/null +++ b/scripts/godosa_licenses.py @@ -0,0 +1,145 @@ +#!/usr/bin/env python3 +"""Third-party licence list for a .NET game: generates licenses.md and checks it (Python stdlib only). + +A game keeps a small `scripts/licenses.py` that builds a `Config` and calls `main(argv, config)`: + + python3 scripts/licenses.py check: fail if stale, a licence is unknown, or a copyleft + package ships (exit 1) + python3 scripts/licenses.py --write regenerate licenses.md + +Sources: every csproj in the solution: its obj/project.assets.json (needs `dotnet restore`/build first), the nuspec in +the NuGet cache for licence + copyright. Lifted from an earlier project of the same author (NuGet part). +""" +import json +import os +import re +import sys +import xml.etree.ElementTree as ET +from dataclasses import dataclass, field +from pathlib import Path + +# Licence expressions that must not ship linked into the game's code. LGPL is allowed only as a separate shared +# library, via Config.allow_shipped with the reason. +COPYLEFT = re.compile(r"\b(A?GPL|LGPL|MPL|EPL|CDDL|EUPL|OSL|CC-BY-SA)", re.I) + + +@dataclass +class Config: + solution: str # the .slnx whose projects count + shipped_dirs: tuple[str, ...] # project dir prefixes whose output ships; everything else = dev/test only + header: str # markdown put above the tables + allow_shipped: dict[str, str] = field(default_factory=dict) # package id -> why a copyleft licence may ship + overrides: dict[str, str] = field(default_factory=dict) # package id -> licence (nuspec has no expression) + extra: list[tuple[str, str, str, str, str]] = field(default_factory=list) # (what, version, licence, copyright, where) + planned: list[tuple[str, str, str, str, str]] = field(default_factory=list) # (what, version, licence, ships as, checked in) + md_path: str = "licenses.md" + command: str = "python3 scripts/licenses.py" + + +def nuget_root() -> Path: + return Path(os.environ.get("NUGET_PACKAGES", Path.home() / ".nuget" / "packages")) + + +def nuspec_info(pid: str, ver: str, cache: Path) -> tuple[str, str]: + d = cache / pid.lower() / ver.lower() + specs = list(d.glob("*.nuspec")) if d.is_dir() else [] + if not specs: + return "", "" + root = ET.parse(specs[0]).getroot() + lic = copy = "" + for el in root.iter(): + tag = el.tag.rsplit("}", 1)[-1] + if tag == "license" and el.get("type") == "expression": + lic = (el.text or "").strip() + elif tag == "copyright": + copy = " ".join((el.text or "").split()) + return lic, copy + + +def has_runtime_assets(target: dict) -> bool: + """False for build-only packages (analyzers, MSBuild tasks): nothing lands in the output.""" + for key in ("runtime", "native", "runtimeTargets"): + if any(not f.endswith("/_._") for f in target.get(key, {})): + return True + return False + + +def collect_nuget(repo: Path, cache: Path, cfg: Config) -> tuple[dict, list[str]]: + """{(id, ver): {"users": set, "ship_users": set, "shipped": bool}}, errors.""" + pkgs: dict = {} + errors = [] + for rel in sorted(re.findall(r'Path="([^"]+\.csproj)"', (repo / cfg.solution).read_text())): + csproj = repo / rel + assets = csproj.parent / "obj" / "project.assets.json" + if not assets.exists(): + errors.append(f"{rel}: no obj/project.assets.json (run dotnet restore)") + continue + data = json.loads(assets.read_text()) + runtime = {} + for tgt in data.get("targets", {}).values(): + for key, t in tgt.items(): + if t.get("type") == "package" and has_runtime_assets(t): + runtime[key] = True + shipped = rel.startswith(cfg.shipped_dirs) + for key, lib in data.get("libraries", {}).items(): + if lib.get("type") != "package": + continue + pid, ver = key.split("/", 1) + e = pkgs.setdefault((pid, ver), {"users": set(), "ship_users": set(), "shipped": False}) + e["users"].add(csproj.stem) + if key in runtime and shipped: + e["ship_users"].add(csproj.stem) + e["shipped"] = True + return pkgs, errors + + +def generate(repo: Path, cache: Path, cfg: Config) -> tuple[str, list[str]]: + pkgs, errors = collect_nuget(repo, cache, cfg) + rows = [] + for (pid, ver), e in sorted(pkgs.items(), key=lambda kv: (kv[0][0].lower(), kv[0][1])): + lic, copy = nuspec_info(pid, ver, cache) + lic = cfg.overrides.get(pid, lic) + if not lic: + errors.append(f"NuGet {pid} {ver}: unknown licence (add to the overrides after checking)") + lic = "UNKNOWN" + if e["shipped"] and COPYLEFT.search(lic) and pid not in cfg.allow_shipped: + errors.append(f"NuGet {pid} {ver}: copyleft licence {lic} ships") + rows.append((pid, ver, lic, copy, e)) + + def cell(s: str) -> str: + return s.replace("|", "\\|") + + md = [cfg.header] + md.append("## Shipped (NuGet)\n") + md.append("| Package | Version | Licence | Copyright | Used by | Notes |\n|---|---|---|---|---|---|") + for pid, ver, lic, copy, e in rows: + if e["shipped"]: + md.append(f"| {pid} | {ver} | {lic} | {cell(copy)} | {', '.join(sorted(e['ship_users']))} | {cfg.allow_shipped.get(pid, '')} |") + md.append("\n## Not a package\n") + md.append("| What | Version | Licence | Copyright | Where |\n|---|---|---|---|---|") + for what, ver, lic, copy, where in cfg.extra: + md.append(f"| {what} | {ver} | {lic} | {copy} | {where} |") + md.append("\n## Planned (audited, not yet used)\n") + md.append("| What | Version | Licence | Ships as | Checked in |\n|---|---|---|---|---|") + for what, ver, lic, how, src in cfg.planned: + md.append(f"| {what} | {ver} | {cell(lic)} | {how} | {src} |") + md.append("\n## Test and build-only (NuGet, nothing in the output)\n") + md.append("| Package | Version | Licence | Used by |\n|---|---|---|---|") + for pid, ver, lic, copy, e in rows: + if not e["shipped"]: + md.append(f"| {pid} | {ver} | {lic} | {', '.join(sorted(e['users']))} |") + return "\n".join(md) + "\n", errors + + +def main(argv: list[str], cfg: Config, repo: Path, cache: Path | None = None) -> int: + cache = cache or nuget_root() + md, errors = generate(repo, cache, cfg) + if "--write" in argv: + (repo / cfg.md_path).write_text(md) + else: + f = repo / cfg.md_path + if not f.exists() or f.read_text() != md: + errors.append(f"{cfg.md_path} is stale: run {cfg.command} --write") + for e in errors: + print(e, file=sys.stderr) + return 1 if errors else 0 -- cgit