Coverage for src/rhiza_hooks/check_license_metadata.py: 100%
59 statements
« prev ^ index » next coverage.py v7.16.1, created at 2026-09-15 06:14 +0000
« prev ^ index » next coverage.py v7.16.1, created at 2026-09-15 06:14 +0000
1#!/usr/bin/env python3
2"""Check that pyproject.toml does not declare its licence twice over.
4Declaring both a PEP 639 ``license`` expression and a legacy
5``License :: OSI Approved :: …`` trove classifier is not merely redundant — it makes
6the project **unbuildable**. ``setuptools>=77`` refuses outright:
8 License classifiers have been superseded by license expressions … Please remove
10and ``uv_build`` warns. The two are therefore mutually exclusive in practice, and the
11failure shows up at build or publish time rather than at the commit that caused it.
13This is a live problem in this ecosystem: rhiza's own synced test
14``test_license_classifier_present`` still asserts the trove classifier through
15template v1.2.1, which makes it unsatisfiable for exactly the PEP 639 layout
16``/rhiza:license`` produces (filed upstream as jebel-quant/rhiza#1440). A hook cannot
17fix the upstream test, but it can stop the broken combination reaching a build.
19Only the *combination* is an error. Either form alone is fine, and so is the
20pre-PEP-639 table form (``license = {file = "LICENSE"}``) alongside a classifier —
21that combination is valid legacy metadata, and rejecting it would fail projects that
22have not migrated and do not need to.
24Validating the SPDX expression's syntax is deliberately out of scope: that needs a
25license-expression library, and the value here is the rule that breaks builds.
27Exit codes:
28 0 - licence metadata is coherent
29 1 - both forms are declared (or, with --require-license, neither is)
30"""
32from __future__ import annotations
34import argparse
35import sys
36import tomllib
37from pathlib import Path
38from typing import Any
40from rhiza_hooks._repo import find_repo_root
42_CLASSIFIER_PREFIX = "License :: "
45def _load_project_table(repo_root: Path) -> dict[str, Any] | None:
46 """Read ``[project]`` from pyproject.toml, treating unusable input as absent.
48 A missing, malformed or unreadable pyproject.toml is somebody else's error to
49 report — the same lenient stance the other hooks in this package take.
50 """
51 path = repo_root / "pyproject.toml"
52 if not path.exists():
53 return None
54 try:
55 with path.open("rb") as handle:
56 data = tomllib.load(handle)
57 except (tomllib.TOMLDecodeError, OSError, UnicodeDecodeError):
58 # tomllib decodes the stream itself, so invalid UTF-8 surfaces as
59 # UnicodeDecodeError rather than a TOML error.
60 return None
61 project = data.get("project")
62 return project if isinstance(project, dict) else None
65def license_classifiers(project: dict[str, Any]) -> list[str]:
66 """Return the ``License :: …`` trove classifiers declared in ``[project]``.
68 >>> license_classifiers(
69 ... {"classifiers": ["License :: OSI Approved :: MIT License", "Topic :: Utilities"]}
70 ... )
71 ['License :: OSI Approved :: MIT License']
72 >>> license_classifiers({"classifiers": ["Topic :: Utilities"]})
73 []
74 >>> license_classifiers({})
75 []
76 """
77 classifiers = project.get("classifiers")
78 if not isinstance(classifiers, list):
79 return []
80 return [c for c in classifiers if isinstance(c, str) and c.startswith(_CLASSIFIER_PREFIX)]
83def spdx_expression(project: dict[str, Any]) -> str | None:
84 """Return the PEP 639 SPDX ``license`` expression, if that is the form in use.
86 PEP 639 makes ``license`` a string. The older table forms
87 (``{file = …}`` / ``{text = …}``) are *not* SPDX expressions and do not conflict
88 with a classifier, so they read as absent here.
90 >>> spdx_expression({"license": "MIT"})
91 'MIT'
92 >>> spdx_expression({"license": {"file": "LICENSE"}}) is None
93 True
94 >>> spdx_expression({}) is None
95 True
96 """
97 value = project.get("license")
98 return value if isinstance(value, str) else None
101def _conflicting_declaration(expression: str | None, classifiers: list[str]) -> list[str]:
102 """Report the unbuildable combination: a PEP 639 expression *and* a classifier.
104 Either form alone is fine; it is the pair that setuptools>=77 refuses to build.
106 >>> _conflicting_declaration("MIT", [])
107 []
108 >>> _conflicting_declaration(None, ["License :: OSI Approved :: MIT License"])
109 []
110 >>> _conflicting_declaration("MIT", ["License :: OSI Approved :: MIT License"])[0].startswith(
111 ... "pyproject.toml declares both"
112 ... )
113 True
114 """
115 if expression is None or not classifiers:
116 return []
117 listed = ", ".join(repr(c) for c in classifiers)
118 return [
119 f"pyproject.toml declares both the PEP 639 license expression {expression!r} and "
120 f"the classifier(s) {listed}. setuptools>=77 refuses to build a project that has "
121 "both. Delete the classifier(s) and keep the expression."
122 ]
125def _missing_declaration(project: dict[str, Any], expression: str | None, classifiers: list[str]) -> list[str]:
126 """Report a project that declares no licence in any form.
128 ``"license" not in project`` is checked as well as the two accessors, so a table
129 form (``license = {file = "LICENSE"}``) — which is a declaration, just not an
130 SPDX expression — is not reported as absent.
131 """
132 if expression is None and not classifiers and "license" not in project:
133 return ['pyproject.toml declares no license: add a PEP 639 license expression, e.g. license = "MIT".']
134 return []
137def check_license_metadata(repo_root: Path, require_license: bool = False) -> list[str]:
138 """Check the licence metadata in pyproject.toml.
140 Args:
141 repo_root: Root directory of the repository.
142 require_license: Also report a project that declares no licence at all.
144 Returns:
145 List of error messages (empty when the metadata is coherent).
146 """
147 project = _load_project_table(repo_root)
148 if project is None:
149 return []
151 expression = spdx_expression(project)
152 classifiers = license_classifiers(project)
154 conflict = _conflicting_declaration(expression, classifiers)
155 if conflict:
156 return conflict
157 if require_license:
158 return _missing_declaration(project, expression, classifiers)
159 return []
162def main(argv: list[str] | None = None) -> int:
163 """Run the hook and return a process exit code."""
164 parser = argparse.ArgumentParser(description="Check pyproject.toml licence metadata is coherent")
165 parser.add_argument(
166 "filenames",
167 nargs="*",
168 help="Filenames (ignored, checks the repo's pyproject.toml)",
169 )
170 parser.add_argument(
171 "--require-license",
172 action="store_true",
173 help="Also fail when no license is declared at all (off by default: a private package may have none)",
174 )
175 args = parser.parse_args(argv)
177 errors = check_license_metadata(find_repo_root(), require_license=args.require_license)
179 for error in errors:
180 print(f"ERROR: {error}", file=sys.stderr)
182 return 1 if errors else 0
185if __name__ == "__main__": # pragma: no mutate
186 sys.exit(main())