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

1#!/usr/bin/env python3 

2"""Check that pyproject.toml does not declare its licence twice over. 

3 

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: 

7 

8 License classifiers have been superseded by license expressions … Please remove 

9 

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. 

12 

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. 

18 

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. 

23 

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. 

26 

27Exit codes: 

28 0 - licence metadata is coherent 

29 1 - both forms are declared (or, with --require-license, neither is) 

30""" 

31 

32from __future__ import annotations 

33 

34import argparse 

35import sys 

36import tomllib 

37from pathlib import Path 

38from typing import Any 

39 

40from rhiza_hooks._repo import find_repo_root 

41 

42_CLASSIFIER_PREFIX = "License :: " 

43 

44 

45def _load_project_table(repo_root: Path) -> dict[str, Any] | None: 

46 """Read ``[project]`` from pyproject.toml, treating unusable input as absent. 

47 

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 

63 

64 

65def license_classifiers(project: dict[str, Any]) -> list[str]: 

66 """Return the ``License :: …`` trove classifiers declared in ``[project]``. 

67 

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)] 

81 

82 

83def spdx_expression(project: dict[str, Any]) -> str | None: 

84 """Return the PEP 639 SPDX ``license`` expression, if that is the form in use. 

85 

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. 

89 

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 

99 

100 

101def _conflicting_declaration(expression: str | None, classifiers: list[str]) -> list[str]: 

102 """Report the unbuildable combination: a PEP 639 expression *and* a classifier. 

103 

104 Either form alone is fine; it is the pair that setuptools>=77 refuses to build. 

105 

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 ] 

123 

124 

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. 

127 

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 [] 

135 

136 

137def check_license_metadata(repo_root: Path, require_license: bool = False) -> list[str]: 

138 """Check the licence metadata in pyproject.toml. 

139 

140 Args: 

141 repo_root: Root directory of the repository. 

142 require_license: Also report a project that declares no licence at all. 

143 

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 [] 

150 

151 expression = spdx_expression(project) 

152 classifiers = license_classifiers(project) 

153 

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 [] 

160 

161 

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) 

176 

177 errors = check_license_metadata(find_repo_root(), require_license=args.require_license) 

178 

179 for error in errors: 

180 print(f"ERROR: {error}", file=sys.stderr) 

181 

182 return 1 if errors else 0 

183 

184 

185if __name__ == "__main__": # pragma: no mutate 

186 sys.exit(main())