raw ยท 6687 bytes
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 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 |