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

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 

27from dataclasses import dataclass 

28from pathlib import Path 

29from typing import Any 

30 

31from rhiza_hooks._toml import load_toml 

32 

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

38 

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) 

42 

43 

44@dataclass(frozen=True) 

45class BumpversionTarget: 

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

47 

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

53 

54 filename: str 

55 search: str | None 

56 regex: bool 

57 

58 

59@dataclass(frozen=True) 

60class BumpversionConfig: 

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

62 

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

67 

68 filename: str 

69 current_version: str | None 

70 targets: list[BumpversionTarget] 

71 

72 

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

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

75 

76 Args: 

77 path: File to parse. 

78 

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 

90 

91 

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. 

94 

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 

107 

108 

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

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

111 

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 ) 

126 

127 

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] 

134 

135 

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

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

138 

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 ] 

153 

154 

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 

167 

168 

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

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

171 

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 

185 

186 

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

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

189 

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

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

192 

193 Args: 

194 repo_root: Root directory of the repository. 

195 

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)