godosa-engine

git clone https://git.godosa.eu/godosa-engine

master

raw ยท 6687 bytes

#!/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