Coverage for src/rhiza_hooks/_bumpversion_config.py: 100%
76 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"""Read bump-my-version's configuration out of the filenames it auto-discovers.
4This module is responsible solely for *obtaining* a bumpversion configuration —
5locating the file bump-my-version would actually read, and normalising whichever
6of its two formats that file uses into a :class:`BumpversionConfig`. Judging the
7result (is it discoverable at all, does it agree with pyproject, are its targets
8rewritable) lives in :mod:`rhiza_hooks.check_bumpversion_config`.
10The two formats disagree about more than syntax, which is why they need separate
11readers even though they produce the same shape:
13* TOML holds the section at the top level (``.bumpversion.toml``) or nested under
14 ``[tool]`` (``pyproject.toml``), and its targets are ``[[tool.bumpversion.files]]``
15 tables carrying a ``filename`` key.
16* INI holds a flat ``[bumpversion]`` section, and encodes each target's path in a
17 sibling section *name* — ``[bumpversion:file:<path>]`` — rather than in a key.
19Both readers take the same lenient stance as the rest of this package: a file that
20is missing, malformed, or unreadable reads as absent rather than raising. A broken
21``pyproject.toml`` is somebody else's error to report.
22"""
24from __future__ import annotations
26import configparser
27import tomllib
28from dataclasses import dataclass
29from pathlib import Path
30from typing import Any
32# The only filenames bump-my-version auto-discovers, in its own search order. It
33# stops at the first file carrying a bumpversion section. Any other path — however
34# well-formed — is read only when passed explicitly via --config-file.
35TOML_CANDIDATES = (".bumpversion.toml", "pyproject.toml")
36INI_CANDIDATES = (".bumpversion.cfg", "setup.cfg")
38# Every searched filename, TOML before INI, for error messages that need to name
39# where the tool actually looked.
40SEARCHED_FILENAMES = (*TOML_CANDIDATES, *INI_CANDIDATES)
43@dataclass(frozen=True)
44class BumpversionTarget:
45 """One file entry a release would rewrite, normalised across both formats.
47 ``search`` is None when the entry omits it (or gives a non-string) —
48 bump-my-version then defaults to ``{current_version}``. ``regex`` marks an
49 entry whose pattern is a regular expression, which cannot be counted
50 literally; bump-my-version owns that check.
51 """
53 filename: str
54 search: str | None
55 regex: bool
58@dataclass(frozen=True)
59class BumpversionConfig:
60 """The bumpversion configuration bump-my-version would read, and its targets.
62 ``current_version`` is None when the section omits that key: there is then no
63 stale value to bump from, which is different from one that disagrees with the
64 project's. ``targets`` holds the normalised file entries, unusable ones dropped.
65 """
67 filename: str
68 current_version: str | None
69 targets: list[BumpversionTarget]
72def load_toml(path: Path) -> dict[str, Any] | None:
73 """Parse a TOML file, treating unreadable or malformed input as absent.
75 Part of this module's cross-module surface: :mod:`check_bumpversion_config`
76 reads ``[project].version`` and probes the undiscovered ``.rhiza/.cfg.toml``
77 with it, so both go through the same lenient parse as the candidate search.
79 Args:
80 path: File to parse.
82 Returns:
83 The parsed mapping, or None if the file is missing, malformed, or cannot
84 be opened.
85 """
86 if not path.exists():
87 return None
88 try:
89 with path.open("rb") as handle:
90 return tomllib.load(handle)
91 except (tomllib.TOMLDecodeError, OSError, UnicodeDecodeError):
92 # tomllib decodes the stream itself, so invalid UTF-8 surfaces as
93 # UnicodeDecodeError rather than a TOML error — without this the hook
94 # crashed with a traceback on a binary pyproject.toml.
95 return None
98def _load_ini(path: Path) -> configparser.ConfigParser | None:
99 """Parse an INI file, treating unreadable or malformed input as absent.
101 Args:
102 path: File to parse.
104 Returns:
105 The parser, or None if the file is missing, malformed, or unreadable.
106 """
107 if not path.exists():
108 return None
109 parser = configparser.ConfigParser()
110 try:
111 parser.read(path, encoding="utf-8")
112 except (configparser.Error, OSError, UnicodeDecodeError):
113 return None
114 return parser
117def _toml_bumpversion_section(path: Path) -> dict[Any, Any] | None:
118 """Return the bumpversion section of a TOML candidate, or None if it has none.
120 ``.bumpversion.toml`` holds the section at the top level; ``pyproject.toml``
121 nests it under ``[tool]``. Accept whichever this file uses. A section that is
122 present but not a table reads as absent, like a malformed file.
123 """
124 data = load_toml(path)
125 if data is None:
126 return None
127 tool = data.get("tool")
128 section = tool.get("bumpversion") if isinstance(tool, dict) else None
129 if section is None:
130 section = data.get("bumpversion")
131 return section if isinstance(section, dict) else None
134def _toml_target(entry: Any) -> BumpversionTarget | None:
135 """Normalise one ``[[tool.bumpversion.files]]`` entry, or None if it is unusable.
137 An entry that is not a table, or carries no string ``filename``, has nothing
138 checkable about it.
139 """
140 if not isinstance(entry, dict):
141 return None
142 filename = entry.get("filename")
143 if not isinstance(filename, str):
144 return None
145 search = entry.get("search")
146 return BumpversionTarget(
147 filename=filename,
148 search=search if isinstance(search, str) else None,
149 regex=bool(entry.get("regex")),
150 )
153def _toml_targets(section: dict[Any, Any]) -> list[BumpversionTarget]:
154 """Normalise a TOML section's ``[[tool.bumpversion.files]]`` entries, dropping unusable ones."""
155 entries = section.get("files")
156 if not isinstance(entries, list):
157 return []
158 return [target for entry in entries if (target := _toml_target(entry)) is not None]
161def _ini_targets(parser: configparser.ConfigParser) -> list[BumpversionTarget]:
162 """Normalise an INI parser's ``[bumpversion:file:<path>]`` sections.
164 The INI format encodes the target path in the section name rather than in a
165 ``filename`` key, so the two formats need separate readers even though they
166 produce the same shape.
167 """
168 prefix = "bumpversion:file:"
169 return [
170 BumpversionTarget(
171 filename=name[len(prefix) :],
172 search=parser.get(name, "search", fallback=None),
173 regex=parser.getboolean(name, "regex", fallback=False),
174 )
175 for name in parser.sections()
176 if name.startswith(prefix)
177 ]
180def _find_toml_config(repo_root: Path) -> BumpversionConfig | None:
181 """Locate the first TOML candidate carrying a bumpversion section."""
182 for name in TOML_CANDIDATES:
183 section = _toml_bumpversion_section(repo_root / name)
184 if section is not None:
185 declared = section.get("current_version")
186 return BumpversionConfig(
187 filename=name,
188 current_version=declared if isinstance(declared, str) else None,
189 targets=_toml_targets(section),
190 )
191 return None
194def _find_ini_config(repo_root: Path) -> BumpversionConfig | None:
195 """Locate the first INI candidate carrying a ``[bumpversion]`` section.
197 Unlike the TOML reader this keeps the whole parser, because an INI config's
198 targets live in sibling ``[bumpversion:file:<path>]`` sections rather than
199 inside the main one.
200 """
201 for name in INI_CANDIDATES:
202 parser = _load_ini(repo_root / name)
203 if parser is not None and parser.has_section("bumpversion"):
204 return BumpversionConfig(
205 filename=name,
206 current_version=parser.get("bumpversion", "current_version", fallback=None),
207 targets=_ini_targets(parser),
208 )
209 return None
212def find_config(repo_root: Path) -> BumpversionConfig | None:
213 """Locate the bumpversion config bump-my-version would read, with its file entries.
215 TOML candidates are searched before INI candidates, and the first file carrying
216 a section wins — bump-my-version's own search order.
218 Args:
219 repo_root: Root directory of the repository.
221 Returns:
222 The winning :class:`BumpversionConfig`, or None when no searched file
223 carries a bumpversion section.
224 """
225 return _find_toml_config(repo_root) or _find_ini_config(repo_root)