fix(check_licenses): read PEP 639 license-expression metadata (#28529)

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.
This commit is contained in:
yuneng-jiang 2026-05-22 11:22:38 -07:00 committed by GitHub
parent b0b25ae4b9
commit 985574b6be
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
3 changed files with 288 additions and 7 deletions

View File

@ -31,6 +31,13 @@ DEFAULT_TRANSITIVE_PIN_PACKAGES = (
"wheel", "wheel",
) )
# SPDX license expressions (PEP 639 "License-Expression") join identifiers with
# the uppercase operators OR / AND / WITH. The split is case-sensitive: the
# lowercase "-or-later" inside an identifier such as "GPL-2.0-or-later" is part
# of the identifier, not an operator.
_SPDX_OPERATOR_SPLIT = re.compile(r"\s+(?:OR|AND)\s+")
_SPDX_WITH_SUFFIX = re.compile(r"\s+WITH\s+.*", re.DOTALL)
@dataclass @dataclass
class PackageLicense: class PackageLicense:
@ -109,21 +116,86 @@ class LicenseChecker:
def get_package_license_from_pypi( def get_package_license_from_pypi(
self, package_name: str, version: str self, package_name: str, version: str
) -> Optional[str]: ) -> Optional[str]:
"""Fetch license information for a package from PyPI.""" """Fetch license information for a package from PyPI.
Prefers the PEP 639 SPDX expression (``info.license_expression``),
falls back to the legacy free-text ``info.license`` field, and as a
last resort derives the license from the ``License :: OSI Approved ::
...`` trove classifiers.
"""
try: try:
url = f"https://pypi.org/pypi/{package_name}/{version}/json" url = f"https://pypi.org/pypi/{package_name}/{version}/json"
response = requests.get(url, timeout=10) response = requests.get(url, timeout=10)
response.raise_for_status() response.raise_for_status()
data = response.json() info = response.json().get("info", {}) or {}
return data.get("info", {}).get("license") return (
info.get("license_expression")
or info.get("license")
or self._license_from_classifiers(info.get("classifiers") or [])
)
except Exception as e: except Exception as e:
print( print(
f"Warning: Failed to fetch license for {package_name} {version}: {str(e)}" f"Warning: Failed to fetch license for {package_name} {version}: {str(e)}"
) )
return None return None
def is_license_acceptable(self, license_str: str) -> Tuple[bool, str]: @staticmethod
"""Check if a license is acceptable based on configured lists.""" def _license_from_classifiers(classifiers: List[str]) -> Optional[str]:
"""Derive a license name from the ``License :: OSI Approved :: ...`` trove classifiers."""
prefix = "License :: OSI Approved :: "
for classifier in classifiers:
if classifier.startswith(prefix):
license_name = classifier[len(prefix) :].strip()
if license_name:
return license_name
return None
@staticmethod
def _split_spdx_expression(license_str: str) -> Optional[List[str]]:
"""Split an SPDX license expression into its component identifiers.
Returns ``None`` when the string is not a recognizable SPDX expression
(for example a free-text license blob), so callers fall back to
whole-string matching.
"""
if "OR" not in license_str and "AND" not in license_str:
return None
components: List[str] = []
normalized = license_str.replace("(", " ").replace(")", " ")
for part in _SPDX_OPERATOR_SPLIT.split(normalized):
# Drop any "WITH <exception>" suffix: the exception qualifies the
# preceding license, it is not itself a license to authorize.
identifier = _SPDX_WITH_SUFFIX.sub("", part).strip()
if not identifier:
continue
# SPDX short-form identifiers are single whitespace-free tokens; a
# component with internal whitespace means this is free text.
if any(char.isspace() for char in identifier):
return None
components.append(identifier)
return components if len(components) > 1 else None
def is_license_acceptable(self, license_str: Optional[str]) -> Tuple[bool, str]:
"""Check if a license (or compound SPDX expression) is acceptable."""
if not license_str:
return False, "Unknown license"
components = self._split_spdx_expression(license_str)
if components is None:
return self._is_single_license_acceptable(license_str)
# Compound SPDX expression: conservatively require every component to
# be acceptable on its own (the safe direction for a CI gate).
for component in components:
is_acceptable, reason = self._is_single_license_acceptable(component)
if not is_acceptable:
return False, f"{reason} (in SPDX expression '{license_str}')"
return True, f"All SPDX components authorized: {', '.join(components)}"
def _is_single_license_acceptable(self, license_str: str) -> Tuple[bool, str]:
"""Check if a single license identifier is acceptable based on configured lists."""
if not license_str: if not license_str:
return False, "Unknown license" return False, "Unknown license"

View File

@ -90,7 +90,6 @@ jinja2: >=3.1.4 # BSD 3-Clause License
litellm-proxy-extras: >=0.1.1 # MIT License litellm-proxy-extras: >=0.1.1 # MIT License
litellm-enterprise: >=0.1.1 # LiteLLM Enterprise License litellm-enterprise: >=0.1.1 # LiteLLM Enterprise License
a2a-sdk: >=0.3.22 # Apache 2.0 license a2a-sdk: >=0.3.22 # Apache 2.0 license
pydantic-settings: >=2.14.1 # MIT License manually verified (uses PEP 639 License-Expression: MIT, not the legacy License field, so liccheck reports it as unknown)
anyio: >=4.5.0 # Unknown license anyio: >=4.5.0 # Unknown license
httpx-aiohttp: >=0.1.4 # Unknown license httpx-aiohttp: >=0.1.4 # Unknown license
backoff: >=2.2.1 # Unknown license backoff: >=2.2.1 # Unknown license
@ -156,7 +155,6 @@ pytest: >=9.0.3 # MIT license
pytest-postgresql: >=7.0.2 # LGPLv3+ license pytest-postgresql: >=7.0.2 # LGPLv3+ license
pytest-xdist: >=3.8.0 # MIT License pytest-xdist: >=3.8.0 # MIT License
ruff: >=0.15.3 # MIT License ruff: >=0.15.3 # MIT License
black: >=26.3.1 # MIT License manually verified (uses PEP 639 License-Expression: MIT, not the legacy License field, so liccheck reports it as unknown)
types-requests: >=2.32.4.20260107 # Apache 2.0 license (typeshed) types-requests: >=2.32.4.20260107 # Apache 2.0 license (typeshed)
types-pyyaml: >=6.0.12.20250915 # Apache 2.0 license (typeshed) types-pyyaml: >=6.0.12.20250915 # Apache 2.0 license (typeshed)
fakeredis: >=2.34.1 # BSD license fakeredis: >=2.34.1 # BSD license

View File

@ -0,0 +1,211 @@
"""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