Coverage for src/rhiza_hooks/_bumpversion_config.py: 100%
68 statements
« prev ^ index » next coverage.py v7.17.0, created at 2026-10-27 05:51 +0000
« prev ^ index » next coverage.py v7.17.0, created at 2026-10-27 05:51 +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
27from dataclasses import dataclass
28from pathlib import Path
29from typing import Any
31from rhiza_hooks._toml import load_toml
33# The only filenames bump-my-version auto-discovers, in its own search order. It
34# stops at the first file carrying a bumpversion section. Any other path — however
35# well-formed — is read only when passed explicitly via --config-file.
36TOML_CANDIDATES = (".bumpversion.toml", "pyproject.toml")
37INI_CANDIDATES = (".bumpversion.cfg", "setup.cfg")
39# Every searched filename, TOML before INI, for error messages that need to name
40# where the tool actually looked.
41SEARCHED_FILENAMES = (*TOML_CANDIDATES, *INI_CANDIDATES)
44@dataclass(frozen=True)
45class BumpversionTarget:
46 """One file entry a release would rewrite, normalised across both formats.
48 ``search`` is None when the entry omits it (or gives a non-string) —
49 bump-my-version then defaults to ``{current_version}``. ``regex`` marks an
50 entry whose pattern is a regular expression, which cannot be counted
51 literally; bump-my-version owns that check.
52 """
54 filename: str
55 search: str | None
56 regex: bool
59@dataclass(frozen=True)
60class BumpversionConfig:
61 """The bumpversion configuration bump-my-version would read, and its targets.
63 ``current_version`` is None when the section omits that key: there is then no
64 stale value to bump from, which is different from one that disagrees with the
65 project's. ``targets`` holds the normalised file entries, unusable ones dropped.
66 """
68 filename: str
69 current_version: str | None
70 targets: list[BumpversionTarget]
73def _load_ini(path: Path) -> configparser.ConfigParser | None:
74 """Parse an INI file, treating unreadable or malformed input as absent.
76 Args:
77 path: File to parse.
79 Returns:
80 The parser, or None if the file is missing, malformed, or unreadable.
81 """
82 if not path.exists():
83 return None
84 parser = configparser.ConfigParser()
85 try:
86 parser.read(path, encoding="utf-8")
87 except (configparser.Error, OSError, UnicodeDecodeError):
88 return None
89 return parser
92def _toml_bumpversion_section(path: Path) -> dict[Any, Any] | None:
93 """Return the bumpversion section of a TOML candidate, or None if it has none.
95 ``.bumpversion.toml`` holds the section at the top level; ``pyproject.toml``
96 nests it under ``[tool]``. Accept whichever this file uses. A section that is
97 present but not a table reads as absent, like a malformed file.
98 """
99 data = load_toml(path)
100 if data is None:
101 return None
102 tool = data.get("tool")
103 section = tool.get("bumpversion") if isinstance(tool, dict) else None
104 if section is None:
105 section = data.get("bumpversion")
106 return section if isinstance(section, dict) else None
109def _toml_target(entry: Any) -> BumpversionTarget | None:
110 """Normalise one ``[[tool.bumpversion.files]]`` entry, or None if it is unusable.
112 An entry that is not a table, or carries no string ``filename``, has nothing
113 checkable about it.
114 """
115 if not isinstance(entry, dict):
116 return None
117 filename = entry.get("filename")
118 if not isinstance(filename, str):
119 return None
120 search = entry.get("search")
121 return BumpversionTarget(
122 filename=filename,
123 search=search if isinstance(search, str) else None,
124 regex=bool(entry.get("regex")),
125 )
128def _toml_targets(section: dict[Any, Any]) -> list[BumpversionTarget]:
129 """Normalise a TOML section's ``[[tool.bumpversion.files]]`` entries, dropping unusable ones."""
130 entries = section.get("files")
131 if not isinstance(entries, list):
132 return []
133 return [target for entry in entries if (target := _toml_target(entry)) is not None]
136def _ini_targets(parser: configparser.ConfigParser) -> list[BumpversionTarget]:
137 """Normalise an INI parser's ``[bumpversion:file:<path>]`` sections.
139 The INI format encodes the target path in the section name rather than in a
140 ``filename`` key, so the two formats need separate readers even though they
141 produce the same shape.
142 """
143 prefix = "bumpversion:file:"
144 return [
145 BumpversionTarget(
146 filename=name[len(prefix) :],
147 search=parser.get(name, "search", fallback=None),
148 regex=parser.getboolean(name, "regex", fallback=False),
149 )
150 for name in parser.sections()
151 if name.startswith(prefix)
152 ]
155def _find_toml_config(repo_root: Path) -> BumpversionConfig | None:
156 """Locate the first TOML candidate carrying a bumpversion section."""
157 for name in TOML_CANDIDATES:
158 section = _toml_bumpversion_section(repo_root / name)
159 if section is not None:
160 declared = section.get("current_version")
161 return BumpversionConfig(
162 filename=name,
163 current_version=declared if isinstance(declared, str) else None,
164 targets=_toml_targets(section),
165 )
166 return None
169def _find_ini_config(repo_root: Path) -> BumpversionConfig | None:
170 """Locate the first INI candidate carrying a ``[bumpversion]`` section.
172 Unlike the TOML reader this keeps the whole parser, because an INI config's
173 targets live in sibling ``[bumpversion:file:<path>]`` sections rather than
174 inside the main one.
175 """
176 for name in INI_CANDIDATES:
177 parser = _load_ini(repo_root / name)
178 if parser is not None and parser.has_section("bumpversion"):
179 return BumpversionConfig(
180 filename=name,
181 current_version=parser.get("bumpversion", "current_version", fallback=None),
182 targets=_ini_targets(parser),
183 )
184 return None
187def find_config(repo_root: Path) -> BumpversionConfig | None:
188 """Locate the bumpversion config bump-my-version would read, with its file entries.
190 TOML candidates are searched before INI candidates, and the first file carrying
191 a section wins — bump-my-version's own search order.
193 Args:
194 repo_root: Root directory of the repository.
196 Returns:
197 The winning :class:`BumpversionConfig`, or None when no searched file
198 carries a bumpversion section.
199 """
200 return _find_toml_config(repo_root) or _find_ini_config(repo_root)