diff options
Diffstat (limited to 'packaging/third_party_notices.py')
| -rw-r--r-- | packaging/third_party_notices.py | 198 |
1 files changed, 198 insertions, 0 deletions
diff --git a/packaging/third_party_notices.py b/packaging/third_party_notices.py new file mode 100644 index 0000000..85a35a9 --- /dev/null +++ b/packaging/third_party_notices.py @@ -0,0 +1,198 @@ +#!/usr/bin/env python3 +"""Write THIRD-PARTY-NOTICES.txt for the Python packages a MeshBay build ships. + +Run with the interpreter of the environment being shipped — the deb/rpm venv +(build-common.sh) or the PyInstaller build venv (build-node-runtime.ps1) — so +the list is the set actually installed there, read from each package's own +metadata, rather than a hand-kept list that drifts with every upgrade. + + python third_party_notices.py -o OUT ROOT... [--extra NAME...] [--with-python] + +ROOTS are walked through their runtime requirements (extras skipped). --extra +names packages that ship without being imported, PyInstaller's bootloader being +the case. Native libraries a wheel grafts into a `<name>.libs/` directory are +listed under the package that carries them: PyAV's FFmpeg build includes +libx264 and libx265, both GPL, and that is not visible in its own BSD licence. +""" + +import argparse +import re +import sys +from importlib import metadata +from pathlib import Path + +OWN = re.compile(r"^meshbay-") +LICENSE_NAME = re.compile(r"(LICEN[CS]E|COPYING|NOTICE|AUTHORS)", re.IGNORECASE) +RULE = "=" * 78 + + +def _norm(name: str) -> str: + return re.sub(r"[-_.]+", "-", name).lower() + + +def _marker_applies(marker: str, extras: set[str]) -> bool: + try: + from packaging.markers import Marker + except ImportError: + # Better a notice too many than one missing. + return "extra" not in marker or any(f'"{e}"' in marker for e in extras) + return any(Marker(marker).evaluate({"extra": e}) for e in extras | {""}) + + +def _parse(req: str) -> tuple[str, set[str], str]: + spec, _, marker = req.partition(";") + m = re.match(r"\s*([A-Za-z0-9._-]+)\s*(?:\[([^\]]*)\])?", spec) + extras = {_norm(e) for e in (m.group(2) or "").split(",") if e.strip()} + return m.group(1), extras, marker.strip() + + +def _closure(roots: list[str]) -> dict[str, metadata.Distribution]: + seen: dict[str, metadata.Distribution] = {} + done: set[tuple[str, str]] = set() + todo = [_parse(r)[:2] for r in roots] + while todo: + name, extras = todo.pop() + name = _norm(name) + try: + dist = seen.get(name) or metadata.distribution(name) + except metadata.PackageNotFoundError: + continue # a requirement whose marker excludes this platform + seen[name] = dist + for extra in extras | {""}: + if (name, extra) in done: + continue + done.add((name, extra)) + for req in dist.requires or []: + dep, dep_extras, marker = _parse(req) + if marker and not _marker_applies(marker, {extra} - {""}): + continue + if not marker and extra: + continue # already taken with the base requirements + todo.append((dep, dep_extras)) + return seen + + +def _license_label(dist: metadata.Distribution) -> str: + md = dist.metadata + expr = md.get("License-Expression") + if expr: + return expr + classifiers = [ + c.split("::")[-1].strip() + for c in md.get_all("Classifier") or [] + if c.startswith("License ::") + ] + if classifiers: + return "; ".join(classifiers) + return (md.get("License") or "see licence text below").splitlines()[0] + + +def _license_texts(dist: metadata.Distribution) -> list[tuple[str, str]]: + texts = [] + for f in dist.files or []: + parts = f.parts + if not parts or not parts[0].endswith(".dist-info"): + continue + if not LICENSE_NAME.search(f.name) or f.suffix in (".py", ".pyc"): + continue + try: + texts.append(("/".join(parts[1:]), f.read_text(encoding="utf-8"))) + except (OSError, UnicodeDecodeError): + continue + return texts + + +def _native_libs(dist: metadata.Distribution) -> list[str]: + return sorted( + f.name for f in dist.files or [] if len(f.parts) > 1 and f.parts[0].endswith(".libs") + ) + + +def _homepage(dist: metadata.Distribution) -> str: + md = dist.metadata + if md.get("Home-page"): + return md["Home-page"] + for url in md.get_all("Project-URL") or []: + label, _, link = url.partition(",") + if label.strip().lower() in ("homepage", "source", "repository", "source code"): + return link.strip() + return "" + + +def render(roots: list[str], extra: list[str], with_python: bool) -> str: + dists = _closure(roots + extra) + own = sorted(n for n in dists if OWN.match(n)) + third = sorted(n for n in dists if not OWN.match(n)) + + out = [ + "MeshBay — third-party notices", + RULE, + "", + "MeshBay itself: meshbay-common is LGPL-3.0-or-later, every other MeshBay", + "component is AGPL-3.0-or-later. Source: https://git.meshbay.org/", + "", + "This build also carries the packages below, each under its own licence.", + "Each is distributed unmodified, as published on https://pypi.org/; the", + "corresponding source of every one is that release's source distribution", + "there, or the project home page given with it.", + "", + ] + if with_python: + out += [f"Python {sys.version.split()[0]} — PSF-2.0 — https://www.python.org/", ""] + for name in own: + out.append( + f" {dists[name].metadata['Name']} {dists[name].version}" + f" — {_license_label(dists[name])}" + ) + out.append("") + for name in third: + d = dists[name] + out.append(f" {d.metadata['Name']} {d.version} — {_license_label(d)}") + out.append("") + + for name in third: + d = dists[name] + out += [RULE, f"{d.metadata['Name']} {d.version}", f"Licence: {_license_label(d)}"] + if home := _homepage(d): + out.append(f"Home: {home}") + if libs := _native_libs(d): + out.append("Native libraries bundled in this package's wheel (each under its") + out.append("own licence, built and published by the project above):") + out += [f" {lib}" for lib in libs] + out.append(RULE) + texts = _license_texts(d) + if not texts: + out.append("(no licence file shipped in this package's metadata)") + for path, text in texts: + out += ["", f"--- {path} ---", "", text.rstrip(), ""] + out.append("") + + base_license = Path(sys.base_prefix) / "LICENSE.txt" + if with_python and base_license.is_file(): + out += [ + RULE, + f"Python {sys.version.split()[0]}", + RULE, + "", + base_license.read_text(encoding="utf-8", errors="replace").rstrip(), + "", + ] + return "\n".join(out) + "\n" + + +def main() -> None: + ap = argparse.ArgumentParser(description=__doc__.splitlines()[0]) + ap.add_argument("-o", "--output", type=Path, required=True) + ap.add_argument("roots", nargs="+") + ap.add_argument("--extra", nargs="*", default=[]) + ap.add_argument( + "--with-python", + action="store_true", + help="the interpreter itself ships too (a frozen build, not a system-python venv)", + ) + args = ap.parse_args() + args.output.write_text(render(args.roots, args.extra, args.with_python), encoding="utf-8") + + +if __name__ == "__main__": + main() |