aboutsummaryrefslogtreecommitdiffziptar.gz
path: root/scripts/godosa_licenses.py
diff options
context:
space:
mode:
Diffstat (limited to 'scripts/godosa_licenses.py')
-rw-r--r--scripts/godosa_licenses.py145
1 files changed, 145 insertions, 0 deletions
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