#!/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 `.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()