Files
MeshTerm/tests/test_notices.py
T
Jean-Pierre MartineauandClaude Fable 5.1 3e80074dbf 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>
2026-09-13 13:18:35 -04:00

149 lines
6.6 KiB
Python

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