Coverage for src/mafw/devtools/toolchain/precommit_modifier.py: 97%

102 statements  

« prev     ^ index     » next       coverage.py v7.15.0, created at 2026-07-26 09:13 +0000

1# Copyright 2026 European Union 

2# Author: Bulgheroni Antonio (antonio.bulgheroni@ec.europa.eu) 

3# SPDX-License-Identifier: EUPL-1.2 

4""" 

5Pre-commit configuration modifier for toolchain management. 

6 

7This module provides the :class:`PreCommitModifier` helper that reads, 

8modifies, and writes ``.pre-commit-config.yaml`` files while preserving 

9YAML comments, key ordering, and inline formatting. It is used by concrete 

10:class:`~mafw.devtools.toolchain.base.ToolChainTool` implementations (e.g. 

11Ruff) to keep pre-commit hook revisions in sync with pyproject.toml. 

12 

13The implementation uses a hybrid approach: :mod:`ruamel.yaml` in round-trip 

14mode (``typ='rt'``) for parsing and validating the YAML structure, combined 

15with string-level replacement for the actual ``rev`` field update. This 

16guarantees that all bytes not related to the targeted ``rev`` value remain 

17unchanged after a write operation. 

18""" 

19 

20from __future__ import annotations 

21 

22import re 

23from pathlib import Path 

24from typing import Final 

25 

26from ruamel.yaml import YAML, YAMLError # type: ignore[attr-defined] # ruamel.yaml has no py.typed marker 

27from ruamel.yaml.comments import CommentedMap, CommentedSeq 

28 

29from mafw.devtools import DevtoolsError 

30 

31_MAX_REV_LENGTH: Final[int] = 128 

32"""Maximum allowed length for a ``rev`` string value.""" 

33 

34 

35def _extract_raw_rev(content: str, repo_url: str) -> str: 

36 """Extract the literal rev value from raw YAML content for a given repo URL. 

37 

38 This avoids relying on ruamel.yaml's parsed representation, which may 

39 coerce values (e.g. ``0000000`` becomes integer ``0`` under YAML 1.1 

40 octal rules). Instead, we locate the ``rev:`` line following the repo 

41 URL and extract the value as a raw string. 

42 

43 :param content: The raw YAML file content. 

44 :param repo_url: The repository URL whose rev to extract. 

45 :return: The literal rev value as it appears in the file. 

46 :raises DevtoolsError: If the rev line cannot be found. 

47 """ 

48 lines = content.splitlines() 

49 repo_line_idx: int | None = None 

50 for i, line in enumerate(lines): 

51 stripped = line.strip() 

52 if stripped == f'- repo: {repo_url}' or stripped == f'repo: {repo_url}': 

53 repo_line_idx = i 

54 break 

55 

56 if repo_line_idx is None: 

57 raise DevtoolsError(f'Could not locate repository URL {repo_url!r} in raw content.') 

58 

59 # Search for the rev: line after the repo URL. 

60 rev_pattern = re.compile(r'^\s*rev:\s*(.+?)\s*(?:#.*)?$') 

61 for i in range(repo_line_idx + 1, len(lines)): 61 ↛ 69line 61 didn't jump to line 69 because the loop on line 61 didn't complete

62 line = lines[i] 

63 if re.match(r'\s*-\s*repo:', line): 

64 break 

65 match = rev_pattern.match(line) 

66 if match: 

67 return match.group(1) 

68 

69 raise DevtoolsError(f'Could not locate rev field for {repo_url!r} in raw content.') 

70 

71 

72def _replace_rev_after_repo(content: str, repo_url: str, old_rev: str, new_rev: str) -> str: 

73 """Replace the rev value in raw YAML content after a specific repo URL line. 

74 

75 This function finds the line containing the repo URL and then locates the 

76 first ``rev:`` line that follows it (before the next ``- repo:`` line or 

77 end of repos). It replaces only the old_rev value on that line. 

78 

79 :param content: The raw YAML file content. 

80 :param repo_url: The repository URL to locate. 

81 :param old_rev: The current rev value to replace. 

82 :param new_rev: The new rev value to set. 

83 :return: The modified content with only the rev value changed. 

84 """ 

85 lines = content.splitlines(keepends=True) 

86 # Find the line index containing the repo URL. 

87 repo_line_idx: int | None = None 

88 for i, line in enumerate(lines): 

89 # Match lines like: "- repo: <url>" or " - repo: <url>" 

90 stripped = line.strip() 

91 if stripped == f'- repo: {repo_url}' or stripped == f'repo: {repo_url}': 

92 repo_line_idx = i 

93 break 

94 

95 if repo_line_idx is None: 

96 # Should not happen since we validated via ruamel.yaml first, but 

97 # raise a clear error just in case. 

98 raise DevtoolsError(f'Could not locate repository URL {repo_url!r} in raw content.') 

99 

100 # Find the rev: line after the repo URL, before the next repo entry. 

101 # Build a regex that matches `rev: <old_rev>` possibly with trailing comment. 

102 rev_pattern = re.compile(r'^(\s*rev:\s*)' + re.escape(old_rev) + r'(\s*(?:#.*)?)$') 

103 for i in range(repo_line_idx + 1, len(lines)): 

104 line = lines[i] 

105 # Stop if we hit the next repo entry. 

106 if re.match(r'\s*-\s*repo:', line): 

107 break 

108 match = rev_pattern.match(line) 

109 if match: 

110 # Replace only the rev value, preserving prefix whitespace and 

111 # any trailing inline comment. 

112 lines[i] = match.group(1) + new_rev + match.group(2) 

113 # Ensure we preserve the original line ending. 

114 if not lines[i].endswith('\n') and line.endswith('\n'): 114 ↛ 115line 114 didn't jump to line 115 because the condition on line 114 was never true

115 lines[i] += '\n' 

116 return ''.join(lines) 

117 

118 # Fallback: should not reach here since ruamel.yaml validated the rev exists. 

119 raise DevtoolsError(f'Could not locate rev field for {repo_url!r} in raw content.') 

120 

121 

122class PreCommitModifier: 

123 """Read, modify, and write ``.pre-commit-config.yaml`` preserving formatting. 

124 

125 This class uses a hybrid approach: :mod:`ruamel.yaml` in round-trip mode 

126 for parsing and locating repository entries, and string-level replacement 

127 for the actual ``rev`` field update. This guarantees that all bytes not 

128 related to the updated ``rev`` value remain byte-for-byte identical — 

129 including sequence indentation, comments, and inline formatting that 

130 ruamel.yaml might otherwise normalize. 

131 

132 Typical usage:: 

133 

134 modifier = PreCommitModifier() 

135 modifier.read(Path('.pre-commit-config.yaml')) 

136 modifier.update_rev( 

137 repo_url='https://github.com/astral-sh/ruff-pre-commit', 

138 new_rev='v0.15.12', 

139 ) 

140 modifier.write(Path('.pre-commit-config.yaml')) 

141 """ 

142 

143 def __init__(self) -> None: 

144 self._yaml: YAML = YAML(typ='rt') 

145 self._yaml.preserve_quotes = True 

146 self._data: CommentedMap | None = None 

147 # Raw file content for string-level replacement. 

148 self._raw_content: str = '' 

149 

150 def read(self, path: Path) -> None: 

151 """Load a ``.pre-commit-config.yaml`` file into memory. 

152 

153 :param path: Path to the ``.pre-commit-config.yaml`` file. 

154 :type path: Path 

155 :raises DevtoolsError: If the file does not exist, cannot be read, 

156 or contains invalid YAML. 

157 """ 

158 if not path.exists(): 

159 raise DevtoolsError( 

160 f'Pre-commit configuration file not found: {path}. Ensure the file exists in the repository root.' 

161 ) 

162 

163 try: 

164 self._raw_content = path.read_text(encoding='utf-8') 

165 except OSError as exc: 

166 raise DevtoolsError(f'Unable to read pre-commit configuration file {path}: {exc}') from exc 

167 

168 try: 

169 self._data = self._yaml.load(self._raw_content) 

170 except YAMLError as exc: 

171 raise DevtoolsError(f'Failed to parse {path} as valid YAML: {exc}') from exc 

172 

173 # Validate that the parsed content is a mapping (expected structure). 

174 if not isinstance(self._data, CommentedMap): 

175 raise DevtoolsError( 

176 f'Invalid pre-commit configuration in {path}: expected a YAML mapping at the top level.' 

177 ) 

178 

179 def update_rev(self, repo_url: str, new_rev: str) -> None: 

180 """Update the ``rev`` field for a repository entry matching the given URL. 

181 

182 Locates the repository entry whose ``repo`` field matches *repo_url* 

183 and replaces its ``rev`` value with *new_rev*. The replacement is 

184 performed at the string level to guarantee byte-for-byte preservation 

185 of all other content. 

186 

187 :param repo_url: The repository URL to search for in the ``repos`` list. 

188 :type repo_url: str 

189 :param new_rev: The new revision string to set (e.g. ``"v0.15.12"``). 

190 Must be non-empty and at most 128 characters. 

191 :type new_rev: str 

192 :raises DevtoolsError: If no data has been loaded, if the URL is not 

193 found, if the matched entry has no ``rev`` field, or if *new_rev* 

194 is empty or exceeds 128 characters. 

195 """ 

196 if self._data is None: 

197 raise DevtoolsError('No pre-commit configuration loaded. Call read() first.') 

198 

199 # Validate the new_rev argument. 

200 if not new_rev: 

201 raise DevtoolsError('The rev value must be a non-empty string.') 

202 if len(new_rev) > _MAX_REV_LENGTH: 

203 raise DevtoolsError( 

204 f'The rev value must be at most {_MAX_REV_LENGTH} characters, got {len(new_rev)} characters.' 

205 ) 

206 

207 # Retrieve the repos list from the configuration. 

208 repos: CommentedSeq | list[object] | None = self._data.get('repos') 

209 if not isinstance(repos, (CommentedSeq, list)) or not repos: 

210 raise DevtoolsError('Pre-commit configuration does not contain a valid "repos" list.') 

211 

212 # Collect available URLs for error reporting. 

213 available_urls: list[str] = [] 

214 

215 for repo_entry in repos: 

216 if not isinstance(repo_entry, (CommentedMap, dict)): 

217 continue 

218 entry_url = repo_entry.get('repo', '') 

219 if isinstance(entry_url, str): 219 ↛ 222line 219 didn't jump to line 222 because the condition on line 219 was always true

220 available_urls.append(entry_url) 

221 

222 if entry_url == repo_url: 

223 # Found the matching repository entry. 

224 if 'rev' not in repo_entry: 

225 raise DevtoolsError(f'Repository entry for {repo_url!r} does not contain a "rev" field to update.') 

226 # Extract old_rev from raw content rather than from the parsed 

227 # YAML object. ruamel.yaml may coerce values (e.g. "0000000" 

228 # becomes int 0 under YAML 1.1 rules), so we read the literal 

229 # text to guarantee the string-level replacement succeeds. 

230 old_rev = _extract_raw_rev(self._raw_content, repo_url) 

231 # Perform string-level replacement in the raw content. 

232 self._raw_content = _replace_rev_after_repo(self._raw_content, repo_url, old_rev, new_rev) 

233 # Also update the parsed data for consistency. 

234 repo_entry['rev'] = new_rev 

235 return 

236 

237 # If we get here, the URL was not found. 

238 urls_display = ', '.join(repr(u) for u in available_urls) 

239 raise DevtoolsError( 

240 f'Repository URL {repo_url!r} not found in pre-commit configuration. ' 

241 f'Available repository URLs: [{urls_display}]' 

242 ) 

243 

244 def write(self, path: Path) -> None: 

245 """Write the (possibly modified) configuration back to disk. 

246 

247 The output preserves all YAML comments, key ordering, and inline 

248 formatting from the original file. Only explicitly modified fields 

249 (via :meth:`update_rev`) will differ from the original content. 

250 

251 :param path: Path where the configuration should be written. 

252 :type path: Path 

253 :raises DevtoolsError: If no data has been loaded or if writing fails. 

254 """ 

255 if self._data is None: 

256 raise DevtoolsError('No pre-commit configuration loaded. Call read() first.') 

257 

258 try: 

259 path.write_text(self._raw_content, encoding='utf-8') 

260 except OSError as exc: 

261 raise DevtoolsError(f'Failed to write pre-commit configuration to {path}: {exc}') from exc