From 3e80074dbf335f52ae3eefe9e94bb3c1aeaa3af2 Mon Sep 17 00:00:00 2001 From: Jean-Pierre Martineau Date: Sun, 13 Sep 2026 13:17:43 -0400 Subject: [PATCH] The standalone builds carry their own licences, and everyone else's The five installers bundled Cascadia's font licence and nothing else -- not MeshTerm's own LICENSE and NOTICE, not the MIT/BSD/PSF notices Apache-2.0 and those licences all want kept with a redistributed copy. packaging/notices.py walks the mesh-term distribution's actual runtime dependency closure through importlib.metadata (markers evaluated for this platform, the dev extra excluded), gathers each package's licence text, and fails the build loudly if one is missing rather than shipping a silent gap -- which is exactly what it found: pyserial and the nine winrt-* packages bleak's Windows backend pulls in all declare a licence but ship no licence file in their wheel at all. Their real text, pulled from each project's own repository, is vendored under packaging/vendored-licenses/ for those two documented, verified gaps; anything else missing still stops the build. The spec generates THIRD-PARTY-NOTICES.txt fresh into the build directory on every run and bundles it at the archive root alongside LICENSE and NOTICE. installers.yml uploads the same three files once, from the linux-x64 leg, so they land beside the downloads on the release page too, without extracting an installer just to read them. Co-Authored-By: Claude Fable 5.1 --- .github/workflows/installers.yml | 31 ++ CHANGELOG.md | 6 + README.md | 5 + packaging/meshterm.spec | 31 ++ packaging/notices.py | 382 ++++++++++++++++++ .../vendored-licenses/pyserial-LICENSE.txt | 53 +++ .../vendored-licenses/pywinrt-LICENSE.txt | 37 ++ tests/test_notices.py | 148 +++++++ 8 files changed, 693 insertions(+) create mode 100644 packaging/notices.py create mode 100644 packaging/vendored-licenses/pyserial-LICENSE.txt create mode 100644 packaging/vendored-licenses/pywinrt-LICENSE.txt create mode 100644 tests/test_notices.py diff --git a/.github/workflows/installers.yml b/.github/workflows/installers.yml index 0e03d3f..7d6a79f 100644 --- a/.github/workflows/installers.yml +++ b/.github/workflows/installers.yml @@ -69,6 +69,37 @@ jobs: working-directory: packaging run: pyinstaller --clean --noconfirm meshterm.spec + # `packaging/meshterm.spec` regenerates `THIRD-PARTY-NOTICES.txt` fresh on every + # leg's own build (see `packaging/notices.py`) and bundles it, along with LICENSE + # and NOTICE, inside that leg's own installer -- so each downloaded binary always + # carries an accurate copy of what it actually contains. This step is a separate + # convenience: the same three files land on the release page itself, next to the + # downloads, so nobody has to extract an installer just to read a license. One + # copy serves every platform there (LICENSE/NOTICE never vary, and the runtime + # dependency closures differ only in a handful of platform-specific packages), so + # it rides on this leg alone rather than being uploaded -- and attached to the + # release -- five identical-in-substance times over. linux-x64 was picked + # arbitrarily among the five; nothing here is Linux-specific. + - name: Upload the licences for the release page + if: matrix.label == 'linux-x64' + shell: bash + run: | + # `build//` is PyInstaller's own workpath layout (see the spec's + # comment on `workpath`), not something worth this step knowing by heart + # twice -- copy it flat, next to LICENSE and NOTICE, so the artifact (and, + # via `find`, the release assets built from it) is three plain files. + cp packaging/build/meshterm/THIRD-PARTY-NOTICES.txt . + + - uses: actions/upload-artifact@v4 + if: matrix.label == 'linux-x64' + with: + name: meshterm-licenses + path: | + LICENSE + NOTICE + THIRD-PARTY-NOTICES.txt + if-no-files-found: error + - name: Check it actually runs shell: bash run: | diff --git a/CHANGELOG.md b/CHANGELOG.md index abaddbe..01a2834 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -19,6 +19,12 @@ allowed to change behaviour, not just add to it. ### Added +- **The standalone installers carry their own licences now.** Each one-file build + bundles `LICENSE`, `NOTICE` and a generated `THIRD-PARTY-NOTICES.txt` — every + MIT/BSD/PSF dependency's own licence text, gathered at build time from what that + build actually installed — at the root of the archive, and the same three files ride + beside the downloads on the GitHub release page. + - **The map credits OpenStreetMap on the map.** The full-screen map is arrived at with `© OpenMapTiles · Data from OpenStreetMap` in its bottom-right corner — the corner a paper map puts it in — and the first pan, zoom, reframe or find keystroke collapses it diff --git a/README.md b/README.md index 53947ce..07f3f55 100644 --- a/README.md +++ b/README.md @@ -536,3 +536,8 @@ is Microsoft's Cascadia Mono PL, redistributed unmodified under the SIL Open Fon 1.1, which travels with it as [CascadiaMono-OFL.txt](meshterm/assets/fonts/CascadiaMono-OFL.txt) and stays its only license. + +Each standalone build carries `LICENSE`, `NOTICE` and a generated `THIRD-PARTY-NOTICES.txt` +(every dependency's own license text) inside the archive, and the same three files sit +beside the downloads on the [releases page](https://github.com/jpmartineau/MeshTerm/releases) +so you don't have to extract one just to read them. diff --git a/packaging/meshterm.spec b/packaging/meshterm.spec index 79770cf..9eda764 100644 --- a/packaging/meshterm.spec +++ b/packaging/meshterm.spec @@ -9,13 +9,44 @@ # Built on the machine it targets. There is no cross-compiling here — the Windows build # comes off a Windows runner, the macOS one off macOS. That is what the workflow is for. +import sys + from PyInstaller.utils.hooks import collect_submodules +# `packaging/notices.py` builds THIRD-PARTY-NOTICES.txt at build time; it sits beside +# this spec rather than under `meshterm/` because — like `entry.py` above — it has no +# business being importable from the installed package, only from the freezer. `SPECPATH` +# is one of the names PyInstaller injects into a running .spec's namespace (the directory +# holding this file, resolved from the path given to `pyinstaller`, not the process's +# cwd), so this finds it however the spec was invoked. +sys.path.insert(0, SPECPATH) +import notices # noqa: E402 (import must follow the sys.path edit above) + +# Generated fresh on every build rather than checked into git: it is a function of +# whichever distributions are actually installed in *this* interpreter, and a stale copy +# would drift the moment a dependency's license text changed upstream without anyone +# noticing. `workpath` is PyInstaller's own scratch directory for exactly this kind of +# build-time artefact — cleaned by `--clean`, never mistaken for a source file. It lands +# at `//THIRD-PARTY-NOTICES.txt` (PyInstaller appends the spec's own +# name, minus its extension, to whatever workpath it was given — see `build()` in +# `PyInstaller/building/build_main.py`), which for this spec means `build/meshterm/`. +third_party_notices = notices.write_third_party_notices( + os.path.join(workpath, "THIRD-PARTY-NOTICES.txt") +) + # The assets have to land at `meshterm/assets`, because `ui/about.py` finds them with # `Path(__file__).parent.parent / "assets"` and that path has to keep resolving inside # the bundle exactly as it does in a checkout. datas = [ ("../meshterm/assets", "meshterm/assets"), + # A one-file build is a *copy* of MeshTerm, and Apache-2.0 §4(a)/(d) want MeshTerm's + # own LICENSE and NOTICE distributed with every copy — not folded into the app's own + # assets, but sitting at the bundle root the way they sit at the repository root. + ("../LICENSE", "."), + ("../NOTICE", "."), + # Every MIT/BSD/PSF dependency's own notice wants to travel with copies the same way; + # see packaging/notices.py for how this is built. + (str(third_party_notices), "."), ] hiddenimports = [ diff --git a/packaging/notices.py b/packaging/notices.py new file mode 100644 index 0000000..9b636ad --- /dev/null +++ b/packaging/notices.py @@ -0,0 +1,382 @@ +# 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. +_VENDORED_LICENSE_GAPS: dict[str, tuple[str, ...]] = { + "pyserial": ("vendored-licenses/pyserial-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()) diff --git a/packaging/vendored-licenses/pyserial-LICENSE.txt b/packaging/vendored-licenses/pyserial-LICENSE.txt new file mode 100644 index 0000000..3c60900 --- /dev/null +++ b/packaging/vendored-licenses/pyserial-LICENSE.txt @@ -0,0 +1,53 @@ +Vendored by packaging/notices.py -- not part of pyserial's own distribution. + +pyserial's wheels on PyPI ship no license file of any kind. Verified directly: the 3.5 +wheel (pyserial-3.5-py2.py3-none-any.whl, the latest release on PyPI, uploaded +2020-07-23 -- there has been no release since) contains only DESCRIPTION.rst, +entry_points.txt, metadata.json, top_level.txt, WHEEL, METADATA and RECORD; nothing +matching LICENSE*, LICENCE*, COPYING*, or NOTICE*. There is no newer version to upgrade +to that fixes this, so the text below is reproduced here instead of going unnoticed. + +Pulled unmodified, 2026-09-13, from the project's own repository: +https://raw.githubusercontent.com/pyserial/pyserial/master/LICENSE.txt +Its copyright range (2001-2020) matches the pinned 3.5 release exactly. +=================================================================================== + +Copyright (c) 2001-2020 Chris Liechti +All Rights Reserved. + +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions are +met: + + * Redistributions of source code must retain the above copyright + notice, this list of conditions and the following disclaimer. + + * Redistributions in binary form must reproduce the above + copyright notice, this list of conditions and the following + disclaimer in the documentation and/or other materials provided + with the distribution. + + * Neither the name of the copyright holder nor the names of its + contributors may be used to endorse or promote products derived + from this software without specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS +"AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT +LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR +A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT +HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, +SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT +LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, +DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY +THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT +(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE +OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + +--------------------------------------------------------------------------- +Note: +Individual files contain the following tag instead of the full license text. + + SPDX-License-Identifier: BSD-3-Clause + +This enables machine processing of license information based on the SPDX +License Identifiers that are here available: https://spdx.org/licenses/ diff --git a/packaging/vendored-licenses/pywinrt-LICENSE.txt b/packaging/vendored-licenses/pywinrt-LICENSE.txt new file mode 100644 index 0000000..daf7609 --- /dev/null +++ b/packaging/vendored-licenses/pywinrt-LICENSE.txt @@ -0,0 +1,37 @@ +Vendored by packaging/notices.py -- not part of these packages' own distributions. + +The nine `winrt-*` packages (winrt-runtime and the eight winrt-windows-* API projection +packages bleak's Windows BLE backend pulls in) are all generated and published together +by the pywinrt project, and their wheels ship no license file of any kind -- verified +directly against winrt-runtime 3.2.1: the wheel contains only INSTALLER, METADATA, +RECORD, top_level.txt and WHEEL. Each one's own METADATA does declare +`License-Expression: MIT`, so the omission is a packaging gap, not an unlicensed +dependency -- the same text below covers all nine, since they come from one upstream +project and one license file. + +Pulled unmodified, 2026-09-13, from the project's own repository: +https://raw.githubusercontent.com/pywinrt/pywinrt/main/LICENSE +=================================================================================== + + MIT License + + Copyright (c) Microsoft Corporation. All rights reserved. + Copyright (c) 2021-2025 David Lechner + + Permission is hereby granted, free of charge, to any person obtaining a copy + of this software and associated documentation files (the "Software"), to deal + in the Software without restriction, including without limitation the rights + to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + copies of the Software, and to permit persons to whom the Software is + furnished to do so, subject to the following conditions: + + The above copyright notice and this permission notice shall be included in all + copies or substantial portions of the Software. + + THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + SOFTWARE diff --git a/tests/test_notices.py b/tests/test_notices.py new file mode 100644 index 0000000..60b35e1 --- /dev/null +++ b/tests/test_notices.py @@ -0,0 +1,148 @@ +# SPDX-License-Identifier: Apache-2.0 +"""Tests for ``packaging/notices.py``, the third-party notices generator. + +Loaded by file path rather than as ``packaging.notices``: ``packaging`` is also the name +of a PyPI library that module itself imports (``packaging.markers``, +``packaging.requirements``), and the repository's ``packaging/`` directory is not a real +package — it has no ``__init__.py``, deliberately, so it can never shadow that library +(a regular package anywhere on ``sys.path`` always wins over a same-named directory with +no ``__init__.py``, which is what keeps ``import packaging.markers`` finding the real one +even with the repo root on the path). Reaching the module by file avoids relying on that +distinction holding forever. + +Skips cleanly when ``mesh-term`` isn't installed in the interpreter running the tests — +the generator walks the *installed* dependency closure, so without an installed +distribution to walk there is nothing here to test. +""" + +from __future__ import annotations + +import importlib.util +import sys +from importlib.metadata import PackageNotFoundError, distribution +from pathlib import Path + +import pytest +from packaging.requirements import Requirement + +if sys.version_info >= (3, 11): + import tomllib +else: # pragma: no cover - exercised only on 3.10 + import tomli as tomllib + +try: + distribution("mesh-term") +except PackageNotFoundError: + pytest.skip("mesh-term is not installed in this interpreter", allow_module_level=True) + +_REPO_ROOT = Path(__file__).resolve().parent.parent +_spec = importlib.util.spec_from_file_location( + "meshterm_packaging_notices", _REPO_ROOT / "packaging" / "notices.py" +) +assert _spec is not None and _spec.loader is not None +notices = importlib.util.module_from_spec(_spec) +# Registered before exec: `Notice` is a dataclass under `from __future__ import +# annotations`, and dataclasses resolves deferred annotations via `sys.modules[cls. +# __module__]` — a module never registered there fails with a confusing `NoneType has +# no attribute '__dict__'` the moment the class body runs, unrelated to anything this +# test is actually checking. +sys.modules[_spec.name] = notices +_spec.loader.exec_module(notices) + +#: The copyleft licenses no *runtime* dependency of MeshTerm may carry. Checking for +#: "GPL" alone also catches LGPL and AGPL (both contain it as a substring); MPL needs +#: its own check. PyInstaller itself is GPL-with-bootloader-exception, and must never +#: appear here precisely because it is a build tool, not part of the runtime closure +#: this module walks — its absence from the generated notices is the thing this test +#: guards, not an oversight to work around. +_COPYLEFT_MARKERS = ("GPL", "MPL") + + +@pytest.fixture(scope="module") +def all_notices() -> list: + """Every notice this interpreter's install of ``mesh-term`` would generate.""" + return notices.collect_notices() + + +def _direct_runtime_dependencies() -> list[Requirement]: + """The ``[project.dependencies]`` MeshTerm itself declares, applicable here. + + Mirrors :func:`notices.dependency_closure`'s own marker evaluation (``extra`` forced + to ``""``, meaning "no extras requested") so a dependency gated to a platform or + Python version this interpreter isn't — the pre-3.11 ``tomli`` backport, one day a + Windows- or macOS-only package — is excluded here exactly as it would be from the + generated notices, rather than flagged as "missing". + """ + from packaging.markers import default_environment + + environment = default_environment() + environment["extra"] = "" + pyproject = tomllib.loads((_REPO_ROOT / "pyproject.toml").read_text(encoding="utf-8")) + requirements = [Requirement(raw) for raw in pyproject["project"]["dependencies"]] + return [req for req in requirements if req.marker is None or req.marker.evaluate(environment)] + + +def test_every_direct_dependency_has_a_notice(all_notices: list) -> None: + """Every applicable ``pyproject.toml`` dependency turns up as a generated notice.""" + from packaging.utils import canonicalize_name + + covered = {canonicalize_name(n.name) for n in all_notices} + missing = [ + req.name + for req in _direct_runtime_dependencies() + if canonicalize_name(req.name) not in covered + ] + assert not missing, f"no notice generated for: {missing}" + + +def test_every_notice_has_license_text(all_notices: list) -> None: + """Nothing in the generated set carries empty or whitespace-only license text.""" + empty = [f"{n.name} {n.version}" for n in all_notices if not n.text.strip()] + assert not empty, f"empty license text for: {empty}" + + +def test_python_entry_present_and_mentions_psf(all_notices: list) -> None: + """Python's own PSF license is the first entry, and it is actually the PSF text.""" + assert all_notices, "no notices generated at all" + python_entry = all_notices[0] + assert python_entry.name == "Python" + assert "PSF" in python_entry.summary or "PSF" in python_entry.text + + +def test_no_copyleft_dependency(all_notices: list) -> None: + """No runtime dependency's declared license is GPL/LGPL/AGPL/MPL. + + MeshTerm's dependency closure is entirely MIT/BSD/ISC/PSF/Apache-2.0. PyInstaller + itself is GPL-with-bootloader-exception but is a *build* tool, never a runtime + dependency of the frozen app, so it must never appear in ``all_notices`` at all — + this both confirms that and guards against a future dependency quietly adding one. + """ + offenders = [ + f"{n.name} {n.version} ({n.summary})" + for n in all_notices + if any(marker in n.summary.upper() for marker in _COPYLEFT_MARKERS) + ] + assert not offenders, f"copyleft license found: {offenders}" + + +def test_output_is_deterministic() -> None: + """Rendering the same closure twice produces byte-identical output.""" + first = notices.render(notices.collect_notices()) + second = notices.render(notices.collect_notices()) + assert first == second + + +def test_rendered_output_is_newline_terminated_utf8() -> None: + """The rendered document ends with a single trailing newline and encodes as UTF-8.""" + rendered = notices.render(notices.collect_notices()) + assert rendered.endswith("\n") + assert not rendered.endswith("\n\n") + rendered.encode("utf-8") # raises on anything that wouldn't round-trip + + +def test_write_third_party_notices_writes_utf8_lf(tmp_path: Path) -> None: + """The file on disk matches ``render()`` exactly, with no newline translation.""" + out = notices.write_third_party_notices(tmp_path / "THIRD-PARTY-NOTICES.txt") + raw = out.read_bytes() + assert b"\r\n" not in raw + assert raw.decode("utf-8") == notices.render(notices.collect_notices())