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())