Coverage for src/rhiza_hooks/check_bumpversion_config.py: 100%

85 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 bump-my-version can actually discover this project's version config. 

3 

4bump-my-version reads its configuration from a fixed set of filenames. When it 

5finds none it does **not** fail — it falls back to ``git describe`` and reports 

6the last reachable tag as the current version. Release tooling then computes 

7bump candidates from that number instead of the project's own, which can offer a 

8version that has already been published. 

9 

10That failure is silent by construction, so this hook makes it loud: if the 

11project declares a version, a bumpversion section must live somewhere the tool 

12will look. 

13 

14The specific trap this was written for: rhiza syncs a fully-formed 

15``[tool.bumpversion]`` block into ``.rhiza/.cfg.toml``, which is *not* one of the 

16searched filenames, so it never takes effect. 

17 

18The same hook also checks the config's *targets* — the ``[[tool.bumpversion.files]]`` 

19entries a release rewrites — because they fail equally late and just as quietly: 

20 

21* An entry pointing at a template-owned file loses its pattern at the next sync, 

22 which restores that file. The release after that aborts, long after the commit 

23 that caused it. 

24* A pattern that no longer occurs in its file (or occurs twice) breaks the bump 

25 itself. bump-my-version reports that loudly, but only while cutting a version. 

26 

27Locating and parsing the configuration lives in 

28:mod:`rhiza_hooks._bumpversion_config`, which owns the two on-disk formats and 

29normalises them into a :class:`~rhiza_hooks._bumpversion_config.BumpversionConfig`. 

30This module is the CLI/orchestration layer: it judges that result and reports. 

31That module is private, so the package's public surface is unchanged by the 

32split; within it a leading underscore marks a helper with no caller outside its 

33own file, which is why this module imports only unprefixed names from it. 

34 

35Exit codes: 

36 0 - Validation passed 

37 1 - Validation failed 

38""" 

39 

40from __future__ import annotations 

41 

42import argparse 

43import sys 

44from pathlib import Path 

45 

46from rhiza_hooks._bumpversion_config import ( 

47 SEARCHED_FILENAMES, 

48 BumpversionTarget, 

49 find_config, 

50 load_toml, 

51) 

52from rhiza_hooks._managed import managed_paths 

53from rhiza_hooks._repo import find_repo_root 

54 

55# Looks authoritative, is never auto-discovered. Named explicitly so the error can 

56# point at the actual cause rather than just reporting an absence. 

57_UNDISCOVERED = Path(".rhiza") / ".cfg.toml" 

58 

59 

60def read_project_version(repo_root: Path) -> str | None: 

61 """Read ``[project].version`` from pyproject.toml. 

62 

63 Args: 

64 repo_root: Root directory of the repository. 

65 

66 Returns: 

67 The declared version, or None when there is no pyproject.toml, no 

68 ``[project]`` table, or no static ``version`` key. A project using 

69 ``dynamic = ["version"]`` therefore reads as None and is not checked — 

70 its version does not live in a file bump-my-version would rewrite. 

71 """ 

72 data = load_toml(repo_root / "pyproject.toml") 

73 if data is None: 

74 return None 

75 project = data.get("project") 

76 if not isinstance(project, dict): 

77 return None 

78 version = project.get("version") 

79 return version if isinstance(version, str) else None 

80 

81 

82def find_discoverable_config(repo_root: Path) -> tuple[str, str | None] | None: 

83 """Locate the first bumpversion section bump-my-version would actually read. 

84 

85 Args: 

86 repo_root: Root directory of the repository. 

87 

88 Returns: 

89 A ``(filename, current_version)`` pair for the winning config, where 

90 ``current_version`` is None when the section omits that key. Returns None 

91 when no searched file carries a bumpversion section. 

92 """ 

93 config = find_config(repo_root) 

94 if config is None: 

95 return None 

96 return config.filename, config.current_version 

97 

98 

99def _resolved_needle(target: BumpversionTarget, project_version: str) -> str | None: 

100 """Resolve a target's search pattern to the literal text a bump would look for. 

101 

102 Returns None when the pattern cannot be resolved here and the check must be 

103 skipped: a ``regex`` entry (not countable literally — bump-my-version owns that 

104 check), or one whose pattern still holds a placeholder such as ``{new_version}`` 

105 after ``{current_version}`` is substituted. 

106 """ 

107 if target.regex: 

108 return None 

109 needle = (target.search or "{current_version}").replace("{current_version}", project_version) 

110 return None if "{" in needle else needle 

111 

112 

113def _occurrence_errors(path: Path, filename: str, needle: str) -> list[str]: 

114 """Report a pattern that appears in ``path`` zero times, or more than once. 

115 

116 An unreadable or binary file yields no error: that is somebody else's problem to 

117 report, the same lenient stance :func:`~rhiza_hooks._bumpversion_config.load_toml` 

118 takes. 

119 """ 

120 try: 

121 occurrences = path.read_text(encoding="utf-8").count(needle) 

122 except (OSError, UnicodeDecodeError): 

123 return [] 

124 

125 if occurrences == 0: 

126 return [f"Bumpversion pattern {needle!r} does not occur in {filename}, so the next release will abort."] 

127 if occurrences > 1: 

128 return [ 

129 f"Bumpversion pattern {needle!r} occurs {occurrences} times in {filename}; " 

130 "it is ambiguous which line a bump rewrites." 

131 ] 

132 return [] 

133 

134 

135def _check_target(repo_root: Path, project_version: str, target: BumpversionTarget, managed: set[str]) -> list[str]: 

136 """Check one bumpversion file entry: who owns it, and whether its pattern is there. 

137 

138 Args: 

139 repo_root: Root directory of the repository. 

140 project_version: The version declared in pyproject.toml. 

141 target: A normalised entry from 

142 :func:`~rhiza_hooks._bumpversion_config.find_config`. 

143 managed: Repo-relative paths the template owns. 

144 

145 Returns: 

146 List of error messages for this entry (empty when it is sound, or when 

147 there is nothing that can be checked). 

148 """ 

149 if target.filename in managed: 

150 return [ 

151 f"Bumpversion targets {target.filename}, which is owned by the rhiza template. " 

152 "The next sync restores it and wipes the pattern, so the release after " 

153 "that aborts. Point the entry at a file this project owns." 

154 ] 

155 

156 path = repo_root / target.filename 

157 if not path.exists(): 

158 # An entry may legitimately precede its file on a work-in-progress branch. 

159 return [] 

160 

161 needle = _resolved_needle(target, project_version) 

162 if needle is None: 

163 return [] 

164 return _occurrence_errors(path, target.filename, needle) 

165 

166 

167def has_undiscovered_config(repo_root: Path) -> bool: 

168 """Report whether a bumpversion section sits in a file that is never searched. 

169 

170 Args: 

171 repo_root: Root directory of the repository. 

172 

173 Returns: 

174 True if ``.rhiza/.cfg.toml`` carries a ``[tool.bumpversion]`` section. 

175 """ 

176 data = load_toml(repo_root / _UNDISCOVERED) 

177 if data is None: 

178 return False 

179 tool = data.get("tool") 

180 return isinstance(tool, dict) and isinstance(tool.get("bumpversion"), dict) 

181 

182 

183def _no_config_message(repo_root: Path, project_version: str) -> str: 

184 """Explain that no discoverable config exists, and why that is silent rather than loud. 

185 

186 Names ``.rhiza/.cfg.toml`` when the section is sitting there, so the error points 

187 at the actual cause instead of just reporting an absence. 

188 """ 

189 searched = ", ".join(SEARCHED_FILENAMES) 

190 message = ( 

191 f"pyproject.toml declares version {project_version!r}, but no bumpversion " 

192 f"config was found in any file bump-my-version searches ({searched}). " 

193 "It will silently fall back to `git describe` and report the last " 

194 "reachable tag as the current version." 

195 ) 

196 if has_undiscovered_config(repo_root): 

197 message += ( 

198 f" A [tool.bumpversion] section exists in {_UNDISCOVERED.as_posix()}, " 

199 "but that path is never auto-discovered — move it, or add a " 

200 "[tool.bumpversion] table to pyproject.toml." 

201 ) 

202 return message 

203 

204 

205def _version_mismatch(filename: str, declared: str | None, project_version: str) -> list[str]: 

206 """Report a config whose ``current_version`` disagrees with pyproject's. 

207 

208 A config that declares no ``current_version`` at all is not a mismatch: there is 

209 no stale value to bump from. 

210 

211 >>> _version_mismatch(".bumpversion.toml", "1.2.0", "1.2.0") 

212 [] 

213 >>> _version_mismatch(".bumpversion.toml", None, "1.2.0") 

214 [] 

215 >>> _version_mismatch(".bumpversion.toml", "1.1.0", "1.2.0")[0].startswith("Version mismatch:") 

216 True 

217 """ 

218 if declared is None or declared == project_version: 

219 return [] 

220 return [ 

221 f"Version mismatch: {filename} declares current_version = {declared!r}, " 

222 f"but pyproject.toml [project].version is {project_version!r}. " 

223 "Bumping from the stale value will not match the version in the file." 

224 ] 

225 

226 

227def check_bumpversion_config(repo_root: Path) -> list[str]: 

228 """Check the bumpversion config: discoverable, agreeing with pyproject, and rewritable. 

229 

230 Args: 

231 repo_root: Root directory of the repository. 

232 

233 Returns: 

234 List of error messages (empty when the configuration is sound). 

235 """ 

236 project_version = read_project_version(repo_root) 

237 if project_version is None: 

238 # No statically declared version: nothing for bump-my-version to own. 

239 return [] 

240 

241 config = find_config(repo_root) 

242 if config is None: 

243 return [_no_config_message(repo_root, project_version)] 

244 

245 errors = _version_mismatch(config.filename, config.current_version, project_version) 

246 

247 managed = managed_paths(repo_root) 

248 for target in config.targets: 

249 errors.extend(_check_target(repo_root, project_version, target, managed)) 

250 

251 return errors 

252 

253 

254def main(argv: list[str] | None = None) -> int: 

255 """Run the hook and return a process exit code.""" 

256 parser = argparse.ArgumentParser(description="Check bump-my-version configuration is discoverable") 

257 parser.add_argument( 

258 "filenames", 

259 nargs="*", 

260 help="Filenames (ignored, checks repo root)", 

261 ) 

262 parser.parse_args(argv) # validate/consume pre-commit's filename args; result unused 

263 

264 errors = check_bumpversion_config(find_repo_root()) 

265 

266 for error in errors: 

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

268 

269 return 1 if errors else 0 

270 

271 

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

273 sys.exit(main())