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

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 

36from pathlib import Path 

37from typing import Any 

38 

39from rhiza_hooks._repo import find_repo_root 

40from rhiza_hooks._toml import load_toml 

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

56 

57 

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

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

60 

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

74 

75 

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

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

78 

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. 

82 

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 

92 

93 

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

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

96 

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

98 

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 ] 

116 

117 

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. 

120 

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

128 

129 

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

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

132 

133 Args: 

134 repo_root: Root directory of the repository. 

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

136 

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

143 

144 expression = spdx_expression(project) 

145 classifiers = license_classifiers(project) 

146 

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

153 

154 

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) 

169 

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

171 

172 for error in errors: 

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

174 

175 return 1 if errors else 0 

176 

177 

178if __name__ == "__main__": 

179 sys.exit(main())