Coverage for src/mafw/devtools/toolchain/tools/ruff.py: 97%
114 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"""
5RuffTool — concrete :class:`~mafw.devtools.toolchain.ProjectTool` for ruff.
7This module implements the :class:`RuffTool` class that manages the
8``ruff`` linter/formatter across two configuration files:
10- ``pyproject.toml`` — lower-bound ``>=`` specifier in
11 ``project.optional-dependencies.dev``
12- ``.pre-commit-config.yaml`` — the ``rev`` field of the
13 ``https://github.com/astral-sh/ruff-pre-commit`` repository entry
15Because ruff is referenced in two places, the :meth:`verify` method checks
16that the two declared versions are consistent. The :meth:`post_update` hook
17recreates the ``dev`` hatch environment, then runs both the ``ruff-check``
18and ``ruff-format`` hatch scripts at the highest supported Python version,
19and reverts **both** files if either script fails.
21Unlike simpler project tools (e.g. git-cliff, pip-audit), RuffTool overrides
22:meth:`~RuffTool.update`, :meth:`~RuffTool.verify`, and
23:meth:`~RuffTool.post_update` because of the cross-file synchronization
24requirement. The base class's ``_saved_content`` and ``_revert()`` handle
25pyproject.toml, while a separate ``_saved_precommit`` attribute tracks the
26pre-commit config file state.
27"""
29from __future__ import annotations
31import subprocess
32from pathlib import Path
33from typing import Final
35from packaging.version import Version
36from ruamel.yaml import YAML, YAMLError # type: ignore[attr-defined]
38from mafw.devtools import DevtoolsError
39from mafw.devtools.toolchain import Issue, ProjectTool
40from mafw.devtools.toolchain.precommit_modifier import PreCommitModifier
42_PRECOMMIT_REPO_URL: Final[str] = 'https://github.com/astral-sh/ruff-pre-commit'
43"""Repository URL for the ruff pre-commit hook entry."""
46class RuffTool(ProjectTool):
47 """Manage the ruff linter/formatter across pyproject.toml and pre-commit.
49 Ruff is categorized as a *project* tool. Its version lower bound lives in
50 ``project.optional-dependencies.dev`` and the pre-commit hook revision is
51 declared in ``.pre-commit-config.yaml`` under the ``astral-sh/ruff-pre-commit``
52 repository. Both must stay in sync.
54 The :meth:`update` method writes both files (pyproject first, then
55 pre-commit). On :meth:`post_update` failure, both files are reverted
56 to their pre-update contents: pyproject.toml via the inherited
57 ``_revert()`` helper, and ``.pre-commit-config.yaml`` via the local
58 ``_saved_precommit`` attribute.
60 :param project_root: Path to the project root directory containing both
61 ``pyproject.toml`` and ``.pre-commit-config.yaml``. Defaults to the
62 current working directory.
63 :type project_root: Path | None
64 """
66 def __init__(self, project_root: Path | None = None) -> None:
67 super().__init__(project_root)
68 self._precommit_path: Path = self._project_root / '.pre-commit-config.yaml'
69 # Saved pre-commit file content for revert on post_update failure.
70 self._saved_precommit: str | None = None
72 @property
73 def package_name(self) -> str:
74 """PyPI package name.
76 :return: ``"ruff"``
77 :rtype: str
78 """
79 return 'ruff'
81 @property
82 def section_path(self) -> str:
83 """TOML section path where ruff is declared.
85 :return: ``"project.optional-dependencies.dev"``
86 :rtype: str
87 """
88 return 'project.optional-dependencies.dev'
90 @property
91 def env_name(self) -> str:
92 """Hatch environment where ruff is validated.
94 :return: ``"dev"``
95 :rtype: str
96 """
97 return 'dev'
99 def update(self) -> bool:
100 """Update the ruff version in pyproject.toml and .pre-commit-config.yaml.
102 Performs both updates:
104 1. Sets the ``>=`` lower bound for ruff in ``project.optional-dependencies.dev``
105 to the latest PyPI version (delegated to the parent class).
106 2. Updates the ``rev`` field of the ``astral-sh/ruff-pre-commit`` repo entry
107 in ``.pre-commit-config.yaml`` to ``v{latest_version}``.
109 Before modifying, saves both file contents so they can be reverted
110 by :meth:`post_update` on failure. The parent class handles saving
111 pyproject.toml via ``_saved_content``; this method additionally saves
112 the pre-commit config.
114 :return: ``True`` if either file was changed, ``False`` if both are
115 already up to date.
116 :rtype: bool
117 :raises DevtoolsError: If either file cannot be read or written.
118 """
119 latest = self.detect_latest_version()
120 current = self.detect_current_version()
122 if current is not None and current == latest:
123 return False
125 # Save both files for potential revert.
126 # The parent's _saved_content is set here for pyproject.toml.
127 self._saved_content = self._pyproject_path.read_text(encoding='utf-8')
128 self._saved_precommit = self._precommit_path.read_text(encoding='utf-8')
130 # 1. Update pyproject.toml lower bound.
131 modifier = self._get_modifier()
132 modifier.read()
133 modifier.update_lower_bound(self.package_name, str(latest), self.section_path)
134 modifier.write()
136 # 2. Update .pre-commit-config.yaml rev.
137 pc_modifier = PreCommitModifier()
138 pc_modifier.read(self._precommit_path)
139 pc_modifier.update_rev(_PRECOMMIT_REPO_URL, f'v{latest}')
140 pc_modifier.write(self._precommit_path)
142 return True
144 def post_update(self) -> None:
145 """Recreate the dev environment and run ruff-check and ruff-format.
147 Performs the following steps after a successful version update:
149 1. Forces recreation of the ``dev`` hatch environment so that the
150 newly specified dependency versions are resolved and installed.
151 2. Determines the highest supported Python version dynamically from
152 ``tool.mafw.supported-python`` in pyproject.toml.
153 3. Runs ``hatch run dev.py<version>:ruff-check``.
154 4. Runs ``hatch run dev.py<version>:ruff-format``.
156 If environment recreation fails due to a dependency conflict, both
157 pyproject.toml and .pre-commit-config.yaml are reverted. If either
158 ruff script fails, both files are also reverted.
160 :raises DevtoolsError: If environment recreation fails or either ruff
161 script exits with a non-zero exit code.
162 """
163 # Step 1: Determine the highest Python version dynamically.
164 py_version = self._get_highest_python_version()
166 # Step 2: Validate dependency resolution via temporary clone.
167 # Note: _recreate_environment reverts only pyproject.toml on failure,
168 # so we need to also revert pre-commit if it raises.
169 try:
170 self._recreate_environment(self.env_name, py_version)
171 except DevtoolsError:
172 # _recreate_environment already reverted pyproject.toml via _revert().
173 # We must also revert the pre-commit config.
174 self._revert_precommit()
175 raise
177 # Step 3: Run ruff-check at the determined Python version.
178 ruff_check_cmd = ['hatch', 'run', f'dev.py{py_version}:ruff-check']
179 result = subprocess.run(
180 ruff_check_cmd, # noqa: S603
181 capture_output=True,
182 text=True,
183 check=False,
184 cwd=self._project_root,
185 )
187 if result.returncode != 0:
188 conflict = self._parse_resolution_failure(result.stderr)
189 self._revert_all()
190 msg = (
191 f'ruff-check failed after updating {self.name} — '
192 f'both pyproject.toml and .pre-commit-config.yaml have been reverted.'
193 )
194 if conflict:
195 msg += f'\nConflicting package: {conflict}'
196 msg += f'\n{result.stderr.strip()}'
197 raise DevtoolsError(msg)
199 # Step 4: Run ruff-format at the determined Python version.
200 ruff_format_cmd = ['hatch', 'run', f'dev.py{py_version}:ruff-format']
201 result = subprocess.run(
202 ruff_format_cmd, # noqa: S603
203 capture_output=True,
204 text=True,
205 check=False,
206 cwd=self._project_root,
207 )
209 if result.returncode != 0:
210 conflict = self._parse_resolution_failure(result.stderr)
211 self._revert_all()
212 msg = (
213 f'ruff-format failed after updating {self.name} — '
214 f'both pyproject.toml and .pre-commit-config.yaml have been reverted.'
215 )
216 if conflict: 216 ↛ 218line 216 didn't jump to line 218 because the condition on line 216 was always true
217 msg += f'\nConflicting package: {conflict}'
218 msg += f'\n{result.stderr.strip()}'
219 raise DevtoolsError(msg)
221 def verify(self) -> list[Issue]:
222 """Compare ruff version in pyproject.toml with the pre-commit rev.
224 Reads the ``>=`` lower bound from ``project.optional-dependencies.dev``
225 and the ``rev`` field (stripped of ``v`` prefix) from the
226 ``astral-sh/ruff-pre-commit`` repo entry. Returns an :class:`Issue` if
227 they do not match.
229 :return: A list containing at most one :class:`.toolchain.Issue` if the two
230 versions differ, or an empty list if they are consistent.
231 :rtype: list[toolchain.Issue]
232 """
233 # Get pyproject version.
234 pyproject_version = self.detect_current_version()
235 if pyproject_version is None:
236 return [
237 Issue(
238 tool_name=self.name,
239 description=(
240 f'Cannot determine ruff version from {self.section_path}: '
241 'dependency not found or missing >= specifier.'
242 ),
243 )
244 ]
246 # Get pre-commit rev.
247 precommit_version = self._get_precommit_version()
248 if precommit_version is None:
249 return [
250 Issue(
251 tool_name=self.name,
252 description=(
253 'Cannot determine ruff version from .pre-commit-config.yaml: '
254 f'repository {_PRECOMMIT_REPO_URL!r} not found or missing rev field.'
255 ),
256 )
257 ]
259 # Compare versions.
260 if pyproject_version != precommit_version:
261 return [
262 Issue(
263 tool_name=self.name,
264 description=(
265 f'Version mismatch: pyproject.toml declares >={pyproject_version} '
266 f'but .pre-commit-config.yaml has rev v{precommit_version}.'
267 ),
268 )
269 ]
271 return []
273 # ------------------------------------------------------------------
274 # Private helpers
275 # ------------------------------------------------------------------
277 def _get_precommit_version(self) -> Version | None:
278 """Read the ruff version from the pre-commit config rev field.
280 Parses ``.pre-commit-config.yaml``, locates the ``astral-sh/ruff-pre-commit``
281 repo entry, reads its ``rev`` field, strips the ``v`` prefix, and
282 returns it as a :class:`Version`.
284 :return: The version from the pre-commit rev field, or ``None`` if
285 the repo or rev cannot be found.
286 :rtype: Version | None
287 """
288 if not self._precommit_path.exists():
289 return None
291 yaml = YAML(typ='rt')
292 yaml.preserve_quotes = True
294 try:
295 data = yaml.load(self._precommit_path.read_text(encoding='utf-8'))
296 except (OSError, YAMLError):
297 return None
299 if not isinstance(data, dict):
300 return None
302 repos = data.get('repos')
303 if not isinstance(repos, list):
304 return None
306 for repo_entry in repos: 306 ↛ 322line 306 didn't jump to line 322 because the loop on line 306 didn't complete
307 if not isinstance(repo_entry, dict):
308 continue
309 if repo_entry.get('repo') == _PRECOMMIT_REPO_URL: 309 ↛ 306line 309 didn't jump to line 306 because the condition on line 309 was always true
310 rev = repo_entry.get('rev')
311 if rev is None:
312 return None
313 rev_str = str(rev)
314 # Strip leading 'v' prefix if present.
315 if rev_str.startswith('v'):
316 rev_str = rev_str[1:]
317 try:
318 return Version(rev_str)
319 except Exception: # noqa: BLE001
320 return None
322 return None
324 def _revert_precommit(self) -> None:
325 """Restore .pre-commit-config.yaml to its saved pre-update state.
327 Silently handles the case where saved content is not available.
328 """
329 if self._saved_precommit is not None:
330 self._precommit_path.write_text(self._saved_precommit, encoding='utf-8')
331 self._saved_precommit = None
333 def _revert_all(self) -> None:
334 """Restore both config files to their saved pre-update state.
336 Uses the parent class ``_revert()`` for pyproject.toml and
337 manually restores ``.pre-commit-config.yaml`` from the saved
338 content. Silently handles the case where saved content is not
339 available.
340 """
341 # Revert pyproject.toml via the inherited ProjectTool helper.
342 self._revert()
343 # Revert .pre-commit-config.yaml manually.
344 self._revert_precommit()