summaryrefslogtreecommitdiffstats
path: root/packaging/third_party_notices.py
diff options
context:
space:
mode:
Diffstat (limited to 'packaging/third_party_notices.py')
-rw-r--r--packaging/third_party_notices.py198
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()