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

1#!/usr/bin/env python3 

2"""Read bump-my-version's configuration out of the filenames it auto-discovers. 

3 

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

9 

10The two formats disagree about more than syntax, which is why they need separate 

11readers even though they produce the same shape: 

12 

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. 

18 

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

23 

24from __future__ import annotations 

25 

26import configparser 

27import tomllib 

28from dataclasses import dataclass 

29from pathlib import Path 

30from typing import Any 

31 

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

37 

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) 

41 

42 

43@dataclass(frozen=True) 

44class BumpversionTarget: 

45 """One file entry a release would rewrite, normalised across both formats. 

46 

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

52 

53 filename: str 

54 search: str | None 

55 regex: bool 

56 

57 

58@dataclass(frozen=True) 

59class BumpversionConfig: 

60 """The bumpversion configuration bump-my-version would read, and its targets. 

61 

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

66 

67 filename: str 

68 current_version: str | None 

69 targets: list[BumpversionTarget] 

70 

71 

72def load_toml(path: Path) -> dict[str, Any] | None: 

73 """Parse a TOML file, treating unreadable or malformed input as absent. 

74 

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. 

78 

79 Args: 

80 path: File to parse. 

81 

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 

96 

97 

98def _load_ini(path: Path) -> configparser.ConfigParser | None: 

99 """Parse an INI file, treating unreadable or malformed input as absent. 

100 

101 Args: 

102 path: File to parse. 

103 

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 

115 

116 

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. 

119 

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 

132 

133 

134def _toml_target(entry: Any) -> BumpversionTarget | None: 

135 """Normalise one ``[[tool.bumpversion.files]]`` entry, or None if it is unusable. 

136 

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 ) 

151 

152 

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] 

159 

160 

161def _ini_targets(parser: configparser.ConfigParser) -> list[BumpversionTarget]: 

162 """Normalise an INI parser's ``[bumpversion:file:<path>]`` sections. 

163 

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 ] 

178 

179 

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 

192 

193 

194def _find_ini_config(repo_root: Path) -> BumpversionConfig | None: 

195 """Locate the first INI candidate carrying a ``[bumpversion]`` section. 

196 

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 

210 

211 

212def find_config(repo_root: Path) -> BumpversionConfig | None: 

213 """Locate the bumpversion config bump-my-version would read, with its file entries. 

214 

215 TOML candidates are searched before INI candidates, and the first file carrying 

216 a section wins — bump-my-version's own search order. 

217 

218 Args: 

219 repo_root: Root directory of the repository. 

220 

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)