Coverage for src/rhiza_hooks/check_license_metadata.py: 100%
54 statements
« prev ^ index » next coverage.py v7.17.0, created at 2026-10-27 05:51 +0000
« prev ^ index » next coverage.py v7.17.0, created at 2026-10-27 05:51 +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
36from pathlib import Path
37from typing import Any
39from rhiza_hooks._repo import find_repo_root
40from rhiza_hooks._toml import load_toml
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 data = load_toml(repo_root / "pyproject.toml")
52 if data is None:
53 return None
54 project = data.get("project")
55 return project if isinstance(project, dict) else None
58def license_classifiers(project: dict[str, Any]) -> list[str]:
59 """Return the ``License :: …`` trove classifiers declared in ``[project]``.
61 >>> license_classifiers(
62 ... {"classifiers": ["License :: OSI Approved :: MIT License", "Topic :: Utilities"]}
63 ... )
64 ['License :: OSI Approved :: MIT License']
65 >>> license_classifiers({"classifiers": ["Topic :: Utilities"]})
66 []
67 >>> license_classifiers({})
68 []
69 """
70 classifiers = project.get("classifiers")
71 if not isinstance(classifiers, list):
72 return []
73 return [c for c in classifiers if isinstance(c, str) and c.startswith(_CLASSIFIER_PREFIX)]
76def spdx_expression(project: dict[str, Any]) -> str | None:
77 """Return the PEP 639 SPDX ``license`` expression, if that is the form in use.
79 PEP 639 makes ``license`` a string. The older table forms
80 (``{file = …}`` / ``{text = …}``) are *not* SPDX expressions and do not conflict
81 with a classifier, so they read as absent here.
83 >>> spdx_expression({"license": "MIT"})
84 'MIT'
85 >>> spdx_expression({"license": {"file": "LICENSE"}}) is None
86 True
87 >>> spdx_expression({}) is None
88 True
89 """
90 value = project.get("license")
91 return value if isinstance(value, str) else None
94def _conflicting_declaration(expression: str | None, classifiers: list[str]) -> list[str]:
95 """Report the unbuildable combination: a PEP 639 expression *and* a classifier.
97 Either form alone is fine; it is the pair that setuptools>=77 refuses to build.
99 >>> _conflicting_declaration("MIT", [])
100 []
101 >>> _conflicting_declaration(None, ["License :: OSI Approved :: MIT License"])
102 []
103 >>> _conflicting_declaration("MIT", ["License :: OSI Approved :: MIT License"])[0].startswith(
104 ... "pyproject.toml declares both"
105 ... )
106 True
107 """
108 if expression is None or not classifiers:
109 return []
110 listed = ", ".join(repr(c) for c in classifiers)
111 return [
112 f"pyproject.toml declares both the PEP 639 license expression {expression!r} and "
113 f"the classifier(s) {listed}. setuptools>=77 refuses to build a project that has "
114 "both. Delete the classifier(s) and keep the expression."
115 ]
118def _missing_declaration(project: dict[str, Any], expression: str | None, classifiers: list[str]) -> list[str]:
119 """Report a project that declares no licence in any form.
121 ``"license" not in project`` is checked as well as the two accessors, so a table
122 form (``license = {file = "LICENSE"}``) — which is a declaration, just not an
123 SPDX expression — is not reported as absent.
124 """
125 if expression is None and not classifiers and "license" not in project:
126 return ['pyproject.toml declares no license: add a PEP 639 license expression, e.g. license = "MIT".']
127 return []
130def check_license_metadata(repo_root: Path, require_license: bool = False) -> list[str]:
131 """Check the licence metadata in pyproject.toml.
133 Args:
134 repo_root: Root directory of the repository.
135 require_license: Also report a project that declares no licence at all.
137 Returns:
138 List of error messages (empty when the metadata is coherent).
139 """
140 project = _load_project_table(repo_root)
141 if project is None:
142 return []
144 expression = spdx_expression(project)
145 classifiers = license_classifiers(project)
147 conflict = _conflicting_declaration(expression, classifiers)
148 if conflict:
149 return conflict
150 if require_license:
151 return _missing_declaration(project, expression, classifiers)
152 return []
155def main(argv: list[str] | None = None) -> int:
156 """Run the hook and return a process exit code."""
157 parser = argparse.ArgumentParser(description="Check pyproject.toml licence metadata is coherent")
158 parser.add_argument(
159 "filenames",
160 nargs="*",
161 help="Filenames (ignored, checks the repo's pyproject.toml)",
162 )
163 parser.add_argument(
164 "--require-license",
165 action="store_true",
166 help="Also fail when no license is declared at all (off by default: a private package may have none)",
167 )
168 args = parser.parse_args(argv)
170 errors = check_license_metadata(find_repo_root(), require_license=args.require_license)
172 for error in errors:
173 print(f"ERROR: {error}", file=sys.stderr)
175 return 1 if errors else 0
178if __name__ == "__main__":
179 sys.exit(main())