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
« 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.
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.
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.
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.
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:
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.
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.
35Exit codes:
36 0 - Validation passed
37 1 - Validation failed
38"""
40from __future__ import annotations
42import argparse
43import sys
44from pathlib import Path
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
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"
60def read_project_version(repo_root: Path) -> str | None:
61 """Read ``[project].version`` from pyproject.toml.
63 Args:
64 repo_root: Root directory of the repository.
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
82def find_discoverable_config(repo_root: Path) -> tuple[str, str | None] | None:
83 """Locate the first bumpversion section bump-my-version would actually read.
85 Args:
86 repo_root: Root directory of the repository.
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
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.
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
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.
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 []
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 []
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.
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.
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 ]
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 []
161 needle = _resolved_needle(target, project_version)
162 if needle is None:
163 return []
164 return _occurrence_errors(path, target.filename, needle)
167def has_undiscovered_config(repo_root: Path) -> bool:
168 """Report whether a bumpversion section sits in a file that is never searched.
170 Args:
171 repo_root: Root directory of the repository.
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)
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.
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
205def _version_mismatch(filename: str, declared: str | None, project_version: str) -> list[str]:
206 """Report a config whose ``current_version`` disagrees with pyproject's.
208 A config that declares no ``current_version`` at all is not a mismatch: there is
209 no stale value to bump from.
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 ]
227def check_bumpversion_config(repo_root: Path) -> list[str]:
228 """Check the bumpversion config: discoverable, agreeing with pyproject, and rewritable.
230 Args:
231 repo_root: Root directory of the repository.
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 []
241 config = find_config(repo_root)
242 if config is None:
243 return [_no_config_message(repo_root, project_version)]
245 errors = _version_mismatch(config.filename, config.current_version, project_version)
247 managed = managed_paths(repo_root)
248 for target in config.targets:
249 errors.extend(_check_target(repo_root, project_version, target, managed))
251 return errors
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
264 errors = check_bumpversion_config(find_repo_root())
266 for error in errors:
267 print(f"ERROR: {error}", file=sys.stderr)
269 return 1 if errors else 0
272if __name__ == "__main__": # pragma: no mutate
273 sys.exit(main())