The dependency license checker only read the legacy free-text `info.license` field from PyPI. Packages that adopt PEP 639 publish their license as an SPDX expression in `info.license_expression` and leave the legacy field null, so the checker reported "Unknown license" and failed CI for every newly-bumped PEP 639 dependency. `get_package_license_from_pypi` now resolves the license in order: `license_expression`, then legacy `license`, then the `License :: OSI Approved :: ...` trove classifiers. `is_license_acceptable` splits compound SPDX expressions on the uppercase OR/AND operators (case-sensitive, so the lowercase `-or-later` inside an identifier is not mistaken for an operator) and strips `WITH <exception>` suffixes, requiring every component to be acceptable. Free-text license blobs are detected and fall back to the original whole-string matching. The `black` and `pydantic-settings` entries in liccheck.ini that existed solely to work around this now resolve correctly on their own and have been removed.
212 lines
7.2 KiB
Python
212 lines
7.2 KiB
Python
"""Tests for the dependency license checker at tests/code_coverage_tests/check_licenses.py.
|
|
|
|
Focus: PEP 639 license metadata. Packages that adopt PEP 639 publish their
|
|
license as an SPDX expression in ``info.license_expression`` and often leave the
|
|
legacy ``info.license`` field null, so the checker must read the new field (and
|
|
fall back to trove classifiers) instead of reporting "Unknown license".
|
|
|
|
PyPI HTTP responses are mocked — these tests never hit the network.
|
|
"""
|
|
|
|
import os
|
|
import sys
|
|
from pathlib import Path
|
|
|
|
_CODE_COVERAGE_DIR = os.path.join(
|
|
os.path.dirname(os.path.abspath(__file__)), "..", "code_coverage_tests"
|
|
)
|
|
sys.path.insert(0, _CODE_COVERAGE_DIR)
|
|
|
|
import check_licenses # noqa: E402
|
|
|
|
_LICCHECK_INI = Path(_CODE_COVERAGE_DIR) / "liccheck.ini"
|
|
|
|
|
|
class _FakeResponse:
|
|
def __init__(self, payload):
|
|
self._payload = payload
|
|
|
|
def raise_for_status(self):
|
|
return None
|
|
|
|
def json(self):
|
|
return self._payload
|
|
|
|
|
|
def _make_checker():
|
|
return check_licenses.LicenseChecker(config_file=_LICCHECK_INI)
|
|
|
|
|
|
def _patch_pypi(monkeypatch, info):
|
|
"""Make PyPI return a JSON response with the given ``info`` block."""
|
|
|
|
def _fake_get(url, timeout=None):
|
|
return _FakeResponse({"info": info})
|
|
|
|
monkeypatch.setattr(check_licenses.requests, "get", _fake_get)
|
|
|
|
|
|
# --------------------------------------------------------------------------
|
|
# get_package_license_from_pypi: license metadata resolution
|
|
# --------------------------------------------------------------------------
|
|
|
|
|
|
def test_get_license_prefers_license_expression(monkeypatch):
|
|
"""(a) PEP 639 packages publish the SPDX expression in license_expression."""
|
|
_patch_pypi(
|
|
monkeypatch,
|
|
{"license_expression": "MIT", "license": None, "classifiers": []},
|
|
)
|
|
checker = _make_checker()
|
|
assert checker.get_package_license_from_pypi("black", "26.3.1") == "MIT"
|
|
|
|
|
|
def test_license_expression_wins_when_both_present(monkeypatch):
|
|
"""license_expression takes precedence over the legacy license field."""
|
|
_patch_pypi(
|
|
monkeypatch,
|
|
{"license_expression": "Apache-2.0", "license": "stale free text"},
|
|
)
|
|
checker = _make_checker()
|
|
assert checker.get_package_license_from_pypi("pkg", "1.0.0") == "Apache-2.0"
|
|
|
|
|
|
def test_get_license_falls_back_to_legacy_license(monkeypatch):
|
|
"""(b) Pre-PEP-639 packages only set the legacy free-text license field."""
|
|
_patch_pypi(
|
|
monkeypatch,
|
|
{"license_expression": None, "license": "MIT License", "classifiers": []},
|
|
)
|
|
checker = _make_checker()
|
|
assert checker.get_package_license_from_pypi("pkg", "1.0.0") == "MIT License"
|
|
|
|
|
|
def test_get_license_falls_back_to_classifiers(monkeypatch):
|
|
"""(c) Some packages express the license only through trove classifiers."""
|
|
_patch_pypi(
|
|
monkeypatch,
|
|
{
|
|
"license_expression": None,
|
|
"license": None,
|
|
"classifiers": [
|
|
"Programming Language :: Python :: 3",
|
|
"License :: OSI Approved :: Apache Software License",
|
|
],
|
|
},
|
|
)
|
|
checker = _make_checker()
|
|
assert (
|
|
checker.get_package_license_from_pypi("pkg", "1.0.0")
|
|
== "Apache Software License"
|
|
)
|
|
|
|
|
|
def test_get_license_returns_none_when_unset(monkeypatch):
|
|
"""(d) With no license metadata at all the license stays unknown."""
|
|
_patch_pypi(
|
|
monkeypatch,
|
|
{"license_expression": None, "license": None, "classifiers": []},
|
|
)
|
|
checker = _make_checker()
|
|
assert checker.get_package_license_from_pypi("pkg", "1.0.0") is None
|
|
|
|
|
|
def test_get_license_returns_none_on_request_failure(monkeypatch):
|
|
"""Network/HTTP failures are swallowed and reported as unknown."""
|
|
|
|
def _boom(url, timeout=None):
|
|
raise RuntimeError("network down")
|
|
|
|
monkeypatch.setattr(check_licenses.requests, "get", _boom)
|
|
checker = _make_checker()
|
|
assert checker.get_package_license_from_pypi("pkg", "1.0.0") is None
|
|
|
|
|
|
# --------------------------------------------------------------------------
|
|
# is_license_acceptable: SPDX identifiers and compound expressions
|
|
# --------------------------------------------------------------------------
|
|
|
|
|
|
def test_spdx_identifiers_are_authorized():
|
|
"""Plain SPDX identifiers match the legacy-spelled authorized list as-is."""
|
|
checker = _make_checker()
|
|
for identifier in ("MIT", "Apache-2.0", "BSD-3-Clause"):
|
|
is_ok, reason = checker.is_license_acceptable(identifier)
|
|
assert is_ok is True, f"{identifier}: {reason}"
|
|
|
|
|
|
def test_spdx_compound_or_expression_is_authorized():
|
|
checker = _make_checker()
|
|
is_ok, reason = checker.is_license_acceptable("MIT OR Apache-2.0")
|
|
assert is_ok is True, reason
|
|
|
|
|
|
def test_spdx_with_exception_in_compound_is_authorized():
|
|
"""The 'WITH <exception>' suffix is stripped; the base license is checked."""
|
|
checker = _make_checker()
|
|
is_ok, reason = checker.is_license_acceptable(
|
|
"Apache-2.0 WITH LLVM-exception OR MIT"
|
|
)
|
|
assert is_ok is True, reason
|
|
|
|
|
|
def test_spdx_gpl3_is_rejected():
|
|
"""GPL-3.0 spellings must fail — they match no authorized license."""
|
|
checker = _make_checker()
|
|
for expr in ("GPL-3.0-only", "GPL-3.0-or-later"):
|
|
is_ok, reason = checker.is_license_acceptable(expr)
|
|
assert is_ok is False, f"{expr} unexpectedly accepted: {reason}"
|
|
|
|
|
|
def test_spdx_compound_with_copyleft_component_is_rejected():
|
|
"""A permissive-OR-copyleft expression is conservatively rejected."""
|
|
checker = _make_checker()
|
|
is_ok, _ = checker.is_license_acceptable("MIT OR GPL-3.0-only")
|
|
assert is_ok is False
|
|
|
|
|
|
def test_or_later_identifier_is_not_split_as_operator():
|
|
"""The lowercase '-or-later' inside an identifier is not the SPDX OR operator."""
|
|
assert (
|
|
check_licenses.LicenseChecker._split_spdx_expression("GPL-2.0-or-later") is None
|
|
)
|
|
|
|
|
|
def test_free_text_license_is_not_treated_as_spdx():
|
|
"""Free-text license blobs fall back to whole-string substring matching."""
|
|
free_text = "MIT License AND additional redistribution permissions"
|
|
assert check_licenses.LicenseChecker._split_spdx_expression(free_text) is None
|
|
checker = _make_checker()
|
|
assert checker.is_license_acceptable(free_text)[0] is True
|
|
|
|
|
|
def test_unknown_license_is_reported():
|
|
checker = _make_checker()
|
|
is_ok, reason = checker.is_license_acceptable(None)
|
|
assert is_ok is False
|
|
assert reason == "Unknown license"
|
|
|
|
|
|
# --------------------------------------------------------------------------
|
|
# check_package: end-to-end resolution + acceptability
|
|
# --------------------------------------------------------------------------
|
|
|
|
|
|
def test_check_package_accepts_pep639_package(monkeypatch):
|
|
"""A PEP 639 package whose license lives only in license_expression passes."""
|
|
_patch_pypi(
|
|
monkeypatch,
|
|
{"license_expression": "MIT", "license": None, "classifiers": []},
|
|
)
|
|
checker = _make_checker()
|
|
assert checker.check_package("some-pep639-pkg", "1.0.0") is True
|
|
|
|
|
|
def test_check_package_rejects_package_without_license(monkeypatch):
|
|
_patch_pypi(
|
|
monkeypatch,
|
|
{"license_expression": None, "license": None, "classifiers": []},
|
|
)
|
|
checker = _make_checker()
|
|
assert checker.check_package("mystery-pkg", "1.0.0") is False
|