Installers / windows-x64 (push) Canceled after 0s
Installers / macos-x64 (push) Canceled after 0s
Installers / macos-arm64 (push) Canceled after 0s
Installers / linux-x64 (push) Canceled after 0s
Installers / linux-arm64 (push) Canceled after 0s
GitHub release / build (push) Canceled after 0s
GitHub release / create the release (push) Canceled after 0s
The macOS installers of 0.3.3 never built: the notices step refused pyobjc-core and pyobjc-framework-libdispatch, whose wheels ship no licence text. Their MIT text is vendored now, and a test checks every vendored path on every platform. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
392 lines
18 KiB
Python
392 lines
18 KiB
Python
# SPDX-License-Identifier: Apache-2.0
|
|
"""Builds ``THIRD-PARTY-NOTICES.txt``: every license the frozen build has to carry.
|
|
|
|
A one-file PyInstaller build is a *copy* of MeshTerm and of everything MeshTerm imports
|
|
at runtime — Apache-2.0 §4(a)/(d) want MeshTerm's own LICENSE and NOTICE distributed
|
|
with every such copy, and each MIT/BSD/PSF dependency wants its own notice kept with
|
|
copies too. Before this module existed, the spec (``packaging/meshterm.spec``) bundled
|
|
neither: the five installers carried the bundled font's license and nothing else.
|
|
|
|
This is deliberately a *generator*, not a checked-in file: a checked-in copy invites
|
|
drift the moment a dependency changes hands or its license text, silently, the next time
|
|
someone bumps a version pin. It runs at build time (see the spec) and again in
|
|
:mod:`tests.test_notices`, walking the actual runtime dependency closure of the
|
|
``mesh-term`` distribution installed in *this* interpreter — so a build's notices always
|
|
match what that build actually bundled, on whichever platform built it. A dependency
|
|
only one platform needs (a bleak backend, ``tomli`` below 3.11) shows up only in that
|
|
platform's notices, because it was never in the other platforms' closures to begin with.
|
|
|
|
Two things get no exception:
|
|
|
|
* A dependency with no discoverable license file fails the whole build, loudly, rather
|
|
than shipping quietly without one — see :data:`_VENDORED_LICENSE_GAPS` for the one
|
|
currently-known exception, and why it is a documented fix rather than a silent one.
|
|
* Python's own PSF license is always the first entry, found the same way the ``license``
|
|
builtin finds it (reusing its own candidate-file search, not re-deriving the layout it
|
|
already knows) — venv or not, Windows or POSIX, a future interpreter layout it wasn't
|
|
written against still resolves correctly, or fails loudly instead of guessing wrong.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import builtins
|
|
import platform
|
|
import sys
|
|
from dataclasses import dataclass
|
|
from importlib.metadata import Distribution, PackagePath, distribution
|
|
from pathlib import Path
|
|
|
|
from packaging.markers import default_environment
|
|
from packaging.requirements import Requirement
|
|
from packaging.utils import canonicalize_name
|
|
|
|
#: The distribution this build actually ships — see ``pyproject.toml``. Not a parameter
|
|
#: callers are expected to vary: a notices file for a *different* distribution's closure
|
|
#: would not describe what this build bundles.
|
|
ROOT_DISTRIBUTION = "mesh-term"
|
|
|
|
#: A handful of dependencies whose wheels on PyPI carry a license *classifier* or
|
|
#: *expression* but were built and uploaded without the license text itself — verified
|
|
#: by inspecting each wheel directly (``unzip -l``), not assumed from the metadata
|
|
#: alone. Their real text is vendored here, unmodified, from each project's own
|
|
#: repository — see each vendored file's own header for the exact source and the date
|
|
#: it was pulled. Nothing else gets this treatment: a dependency not listed here still
|
|
#: fails the build the moment its wheel has the same gap, which is the point — this is
|
|
#: a documented fix for known, verified problems, not a general escape hatch for an
|
|
#: unknown future one.
|
|
#:
|
|
#: * ``pyserial`` — no newer release exists to bump to; 3.5 (2020) is still the latest
|
|
#: pyserial on PyPI, and its wheel has never shipped a LICENSE, COPYING, or NOTICE
|
|
#: file of any kind.
|
|
#: * The nine ``winrt-*`` packages — bleak's Windows BLE backend (``bleak.backends.
|
|
#: winrt``) pulls these in only on ``sys_platform == "win32"``, so they only appear
|
|
#: in a Windows build's closure. All nine are generated and published together by the
|
|
#: pywinrt project and share the one upstream license file, which none of their
|
|
#: wheels include.
|
|
#: * ``pyobjc-core`` and ``pyobjc-framework-libdispatch`` — two of the four pyobjc
|
|
#: packages bleak's macOS BLE backend (``bleak.backends.corebluetooth``) pulls in on
|
|
#: ``sys_platform == "darwin"``, so they only appear in a macOS build's closure, which
|
|
#: is where the 0.3.3 release build caught them. Their 12.2.2 wheels ship no license
|
|
#: file; the other two (Cocoa, CoreBluetooth) do, and are read from their dist-info
|
|
#: like everything else. All four come from the one pyobjc repository and its single
|
|
#: MIT text.
|
|
_VENDORED_LICENSE_GAPS: dict[str, tuple[str, ...]] = {
|
|
"pyserial": ("vendored-licenses/pyserial-LICENSE.txt",),
|
|
"pyobjc-core": ("vendored-licenses/pyobjc-LICENSE.txt",),
|
|
"pyobjc-framework-libdispatch": ("vendored-licenses/pyobjc-LICENSE.txt",),
|
|
"winrt-runtime": ("vendored-licenses/pywinrt-LICENSE.txt",),
|
|
"winrt-windows-devices-bluetooth": ("vendored-licenses/pywinrt-LICENSE.txt",),
|
|
"winrt-windows-devices-bluetooth-advertisement": ("vendored-licenses/pywinrt-LICENSE.txt",),
|
|
"winrt-windows-devices-bluetooth-genericattributeprofile": (
|
|
"vendored-licenses/pywinrt-LICENSE.txt",
|
|
),
|
|
"winrt-windows-devices-enumeration": ("vendored-licenses/pywinrt-LICENSE.txt",),
|
|
"winrt-windows-devices-radios": ("vendored-licenses/pywinrt-LICENSE.txt",),
|
|
"winrt-windows-foundation": ("vendored-licenses/pywinrt-LICENSE.txt",),
|
|
"winrt-windows-foundation-collections": ("vendored-licenses/pywinrt-LICENSE.txt",),
|
|
"winrt-windows-storage-streams": ("vendored-licenses/pywinrt-LICENSE.txt",),
|
|
}
|
|
|
|
#: Conventional bare-directory license filenames a pre-PEP-639 wheel might bundle at its
|
|
#: dist-info root without ever declaring them in metadata. Matched case-insensitively by
|
|
#: prefix, so ``LICENSE``, ``LICENSE.txt``, ``LICENSE-MIT`` and ``License.rst`` all hit.
|
|
_CONVENTIONAL_LICENSE_PREFIXES = ("LICENSE", "LICENCE", "COPYING", "NOTICE")
|
|
|
|
|
|
class NoticesError(RuntimeError):
|
|
"""A license notice this build needs could not be found.
|
|
|
|
Raised instead of quietly omitting an entry — a bundle that silently drops a notice
|
|
is the exact failure mode this module exists to prevent, so a missing one stops the
|
|
build rather than shipping.
|
|
"""
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class Notice:
|
|
"""One entry in the generated notices file.
|
|
|
|
Attributes:
|
|
name: The distribution's own name, as PyPI (or, for Python itself, the
|
|
``platform`` module) spells it — not canonicalised, so the rendered file
|
|
reads the way a person searching for the package by its usual name expects.
|
|
version: The installed version.
|
|
summary: The license expression or classifier(s) this distribution declares,
|
|
for the header line. Never the license text itself — that is :attr:`text`.
|
|
text: The verbatim license text (one or more files, concatenated), unmodified.
|
|
"""
|
|
|
|
name: str
|
|
version: str
|
|
summary: str
|
|
text: str
|
|
|
|
|
|
def _python_notice() -> Notice:
|
|
"""Return the PSF license notice for the interpreter running this build.
|
|
|
|
Reuses the candidate-file search the ``license`` builtin itself does
|
|
(``site.setcopyright`` builds it from ``os.path.dirname(os.__file__)`` plus the
|
|
Windows install-root layout, the POSIX lib layout, and a same-directory fallback)
|
|
rather than re-deriving those paths and risking a layout it didn't anticipate. This
|
|
resolves correctly from inside a venv too, without any venv-specific casing here,
|
|
because ``os.__file__`` always points at the *base* interpreter's standard library.
|
|
|
|
Raises:
|
|
NoticesError: No candidate file exists, and the ``license`` builtin's own
|
|
fallback text (a pointer to a URL, not license text) is not good enough to
|
|
ship as a notice.
|
|
"""
|
|
printer = getattr(builtins, "license", None)
|
|
if printer is None:
|
|
# `site` normally installs this at interpreter startup; it is only missing if
|
|
# the interpreter was started with `-S`. Install it ourselves rather than give
|
|
# up — a build environment that happens to do this shouldn't lose the notice.
|
|
import site
|
|
|
|
site.setcopyright()
|
|
printer = builtins.license
|
|
candidates = getattr(printer, "_Printer__filenames", None)
|
|
if not candidates:
|
|
raise NoticesError(
|
|
"could not find Python's own license: the 'license' builtin exposes no "
|
|
"candidate file list (unexpected interpreter internals)"
|
|
)
|
|
for candidate in candidates:
|
|
path = Path(candidate)
|
|
if path.is_file():
|
|
return Notice(
|
|
name="Python",
|
|
version=platform.python_version(),
|
|
summary="PSF License Agreement",
|
|
text=path.read_text(encoding="utf-8"),
|
|
)
|
|
raise NoticesError(
|
|
"could not find Python's own license text; looked in: "
|
|
+ ", ".join(str(c) for c in candidates)
|
|
)
|
|
|
|
|
|
def _license_summary(dist: Distribution) -> str:
|
|
"""The short "which license" line for a distribution's header.
|
|
|
|
Prefers the PEP 639 ``License-Expression`` (the SPDX expression a modern build
|
|
backend writes), then the ``License ::`` classifiers older packages rely on
|
|
instead, then the legacy free-text ``License`` field only if it is short enough to
|
|
be a label rather than a pasted-in license itself. "unspecified" is a last resort,
|
|
not a failure: an unclear *label* is a readability problem, unlike a missing license
|
|
*file*, which is a compliance one and raises instead (see :func:`_license_text`).
|
|
"""
|
|
metadata = dist.metadata
|
|
expression = metadata.get("License-Expression")
|
|
if expression:
|
|
return expression
|
|
classifiers = [c for c in metadata.get_all("Classifier") or [] if c.startswith("License")]
|
|
if classifiers:
|
|
return "; ".join(classifiers)
|
|
legacy = metadata.get("License")
|
|
if legacy and "\n" not in legacy and len(legacy) <= 100:
|
|
return legacy
|
|
return "unspecified"
|
|
|
|
|
|
def _dist_info_files(dist: Distribution) -> list[PackagePath]:
|
|
"""Every file RECORD lists under this distribution's own ``.dist-info`` directory.
|
|
|
|
Built from :attr:`Distribution.files` rather than the private
|
|
``Distribution._path`` some tools reach for, so this stays correct however a future
|
|
``importlib.metadata`` lays a distribution's metadata out on disk — it only ever
|
|
asks the distribution for files it already told us it owns.
|
|
"""
|
|
files = dist.files or []
|
|
return [f for f in files if str(f).split("/", 1)[0].endswith(".dist-info")]
|
|
|
|
|
|
def _license_files(dist: Distribution) -> list[PackagePath]:
|
|
"""The dist-info files that make up this distribution's license text.
|
|
|
|
PEP 639's ``License-File`` metadata entries win when present, matched by basename
|
|
since a build backend is free to nest them (hatchling puts every one under a
|
|
``licenses/`` subdirectory; other tools leave them at the dist-info root). Lacking
|
|
any declaration at all — true of most wheels built before 2024 — falls back to
|
|
whatever the wheel happened to bundle at the dist-info root under a conventional
|
|
name, which is the same thing a person skimming the directory would go looking for.
|
|
"""
|
|
dist_files = _dist_info_files(dist)
|
|
declared = dist.metadata.get_all("License-File") or []
|
|
if declared:
|
|
wanted = [Path(name).name for name in declared]
|
|
by_name = {Path(str(f)).name: f for f in dist_files}
|
|
# Declared order, not RECORD's, so the concatenated text below reads in the
|
|
# order the package's own author declared it.
|
|
return [by_name[name] for name in wanted if name in by_name]
|
|
return sorted(
|
|
(
|
|
f
|
|
for f in dist_files
|
|
# dist-info root only — one path segment below the directory itself. A
|
|
# nested `licenses/` folder with no metadata declaration pointing at it
|
|
# isn't a convention to guess at.
|
|
if str(f).count("/") == 1
|
|
and Path(str(f)).name.upper().startswith(_CONVENTIONAL_LICENSE_PREFIXES)
|
|
),
|
|
key=str,
|
|
)
|
|
|
|
|
|
def _vendored_license_files(canonical_name: str) -> list[Path]:
|
|
"""Local fallback license text for a dependency whose wheel ships none at all."""
|
|
here = Path(__file__).parent
|
|
return [here / rel for rel in _VENDORED_LICENSE_GAPS.get(canonical_name, ())]
|
|
|
|
|
|
def _license_text(dist: Distribution, canonical_name: str) -> str:
|
|
"""The concatenated, verbatim license text for one distribution.
|
|
|
|
Raises:
|
|
NoticesError: Nothing was found in the dist-info and nothing is vendored for
|
|
it either. Nothing downstream is in a position to notice a quietly-missing
|
|
notice, so this stops the build instead of shipping a gap.
|
|
"""
|
|
matches = _license_files(dist)
|
|
if matches:
|
|
return "\n\n".join(
|
|
f"--- {Path(str(f)).name} ---\n{f.locate().read_text(encoding='utf-8')}"
|
|
for f in matches
|
|
)
|
|
vendored = _vendored_license_files(canonical_name)
|
|
if vendored:
|
|
for path in vendored:
|
|
if not path.is_file():
|
|
raise NoticesError(
|
|
f"{dist.metadata['Name']} {dist.version}: vendored license file "
|
|
f"missing at {path} (see _VENDORED_LICENSE_GAPS)"
|
|
)
|
|
return "\n\n".join(
|
|
f"--- {path.name} ---\n{path.read_text(encoding='utf-8')}" for path in vendored
|
|
)
|
|
raise NoticesError(
|
|
f"{dist.metadata['Name']} {dist.version}: no license file found in its "
|
|
"dist-info (checked declared License-File entries and "
|
|
"LICENSE*/LICENCE*/COPYING*/NOTICE* at the dist-info root) and none is "
|
|
"vendored for it either — a bundle would ship this dependency without its "
|
|
"license notice"
|
|
)
|
|
|
|
|
|
def dependency_closure(root: str = ROOT_DISTRIBUTION) -> list[Distribution]:
|
|
"""The installed distributions ``root`` actually pulls in on this interpreter.
|
|
|
|
Walks ``Requires-Dist`` recursively (``importlib.metadata`` exposes it as
|
|
:attr:`Distribution.requires`), evaluating each requirement's environment marker
|
|
against *this* interpreter and platform — with ``extra`` forced to the empty
|
|
string, which is what "no extras requested" means to a marker (a bare
|
|
``pip install mesh-term`` asks for none), so a requirement guarded by
|
|
``extra == "dev"`` reads as false and the ``dev`` extra's own tooling (pytest,
|
|
pytest-asyncio, ruff) never enters the closure. A marker that instead names a
|
|
platform or Python version this interpreter doesn't match is excluded the same
|
|
way — the point of evaluating markers at all rather than reading every
|
|
``Requires-Dist`` line flat.
|
|
|
|
Returns:
|
|
Distributions sorted by their canonical (PEP 503) name, deterministically, and
|
|
never including ``root`` itself — MeshTerm's own license is ``LICENSE`` and
|
|
``NOTICE``, not a third-party notice.
|
|
"""
|
|
environment = default_environment()
|
|
environment["extra"] = ""
|
|
root_key = canonicalize_name(root)
|
|
seen: dict[str, Distribution] = {}
|
|
pending = [root]
|
|
while pending:
|
|
name = pending.pop()
|
|
key = canonicalize_name(name)
|
|
if key in seen:
|
|
continue
|
|
dist = distribution(name)
|
|
seen[key] = dist
|
|
for raw in dist.requires or ():
|
|
requirement = Requirement(raw)
|
|
if requirement.marker is not None and not requirement.marker.evaluate(environment):
|
|
continue
|
|
pending.append(requirement.name)
|
|
seen.pop(root_key, None)
|
|
return sorted(seen.values(), key=lambda d: canonicalize_name(d.metadata["Name"]))
|
|
|
|
|
|
def collect_notices(root: str = ROOT_DISTRIBUTION) -> list[Notice]:
|
|
"""All notices this build's ``THIRD-PARTY-NOTICES.txt`` should carry.
|
|
|
|
Python's own PSF notice always comes first — Python is not a PyPI distribution
|
|
``Requires-Dist`` can name, so it can never turn up in :func:`dependency_closure`.
|
|
Every dependency follows it, sorted by name, for a stable, reviewable diff between
|
|
one build's notices file and the next.
|
|
"""
|
|
notices = [_python_notice()]
|
|
for dist in dependency_closure(root):
|
|
canonical = canonicalize_name(dist.metadata["Name"])
|
|
notices.append(
|
|
Notice(
|
|
name=dist.metadata["Name"],
|
|
version=dist.version,
|
|
summary=_license_summary(dist),
|
|
text=_license_text(dist, canonical),
|
|
)
|
|
)
|
|
return notices
|
|
|
|
|
|
_HEADER = """\
|
|
Third-party notices
|
|
====================
|
|
|
|
This build of MeshTerm bundles the packages listed below as part of its Python
|
|
runtime. Each entry below reproduces that package's own license, verbatim and
|
|
unmodified, as its terms require. MeshTerm's own license and copyright notice are
|
|
in the LICENSE and NOTICE files bundled alongside this one, not repeated here.
|
|
|
|
Generated by packaging/notices.py at build time -- do not edit by hand.
|
|
"""
|
|
|
|
_RULE = "=" * 78
|
|
|
|
|
|
def render(notices: list[Notice]) -> str:
|
|
"""Render ``notices`` as one deterministic, newline-terminated UTF-8 document."""
|
|
blocks = [_HEADER]
|
|
for notice in notices:
|
|
header = f"{notice.name} {notice.version} — {notice.summary}"
|
|
blocks.append(f"{_RULE}\n{header}\n{_RULE}\n\n{notice.text.rstrip()}\n")
|
|
return "\n".join(blocks).rstrip("\n") + "\n"
|
|
|
|
|
|
def write_third_party_notices(path: str | Path, root: str = ROOT_DISTRIBUTION) -> Path:
|
|
r"""Generate and write ``THIRD-PARTY-NOTICES.txt`` to ``path``.
|
|
|
|
Written as raw UTF-8 bytes rather than through text mode, so a Windows build
|
|
doesn't translate the ``\n`` line endings to ``\r\n`` — the file is meant to be
|
|
byte-identical in content across the platforms that each build it.
|
|
|
|
Returns:
|
|
``path``, resolved to a :class:`~pathlib.Path`, for the caller's convenience.
|
|
"""
|
|
path = Path(path)
|
|
path.parent.mkdir(parents=True, exist_ok=True)
|
|
path.write_bytes(render(collect_notices(root)).encode("utf-8"))
|
|
return path
|
|
|
|
|
|
def main(argv: list[str] | None = None) -> int:
|
|
"""CLI entry point: ``python packaging/notices.py OUTPUT_PATH``."""
|
|
args = sys.argv[1:] if argv is None else argv
|
|
if len(args) != 1:
|
|
print("usage: notices.py OUTPUT_PATH", file=sys.stderr)
|
|
return 2
|
|
written = write_third_party_notices(args[0])
|
|
print(f"wrote {written}")
|
|
return 0
|
|
|
|
|
|
if __name__ == "__main__":
|
|
raise SystemExit(main())
|