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
« 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.
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.
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"""
20from __future__ import annotations
22import re
23from pathlib import Path
24from typing import Final
26from ruamel.yaml import YAML, YAMLError # type: ignore[attr-defined] # ruamel.yaml has no py.typed marker
27from ruamel.yaml.comments import CommentedMap, CommentedSeq
29from mafw.devtools import DevtoolsError
31_MAX_REV_LENGTH: Final[int] = 128
32"""Maximum allowed length for a ``rev`` string value."""
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.
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.
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
56 if repo_line_idx is None:
57 raise DevtoolsError(f'Could not locate repository URL {repo_url!r} in raw content.')
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)
69 raise DevtoolsError(f'Could not locate rev field for {repo_url!r} in raw content.')
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.
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.
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
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.')
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)
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.')
122class PreCommitModifier:
123 """Read, modify, and write ``.pre-commit-config.yaml`` preserving formatting.
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.
132 Typical usage::
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 """
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 = ''
150 def read(self, path: Path) -> None:
151 """Load a ``.pre-commit-config.yaml`` file into memory.
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 )
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
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
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 )
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.
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.
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.')
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 )
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.')
212 # Collect available URLs for error reporting.
213 available_urls: list[str] = []
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)
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
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 )
244 def write(self, path: Path) -> None:
245 """Write the (possibly modified) configuration back to disk.
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.
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.')
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