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 <noreply@anthropic.com>
This commit is contained in:
Jean-Pierre MartineauandClaude Fable 5.1 committed 2026-09-13 13:18:35 -04:00
1 parent 6d7be118b3
commit 3e80074dbf
8 files changed
+693

No files matched your search

+31
View File
@@ -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/<specname>/` 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: |
+6
View File
@@ -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
+5
View File
@@ -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.
+31
View File
@@ -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 `<workpath>/<specname>/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 = [
+382
View File
@@ -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())
@@ -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 <cliechti@gmx.net>
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/
@@ -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 <david@pybricks.com>
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
+148
View File
@@ -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())