Source code for mafw.devtools.toolchain.tools.ruff
# Copyright 2026 European Union
# Author: Bulgheroni Antonio (antonio.bulgheroni@ec.europa.eu)
# SPDX-License-Identifier: EUPL-1.2
"""
RuffTool — concrete :class:`~mafw.devtools.toolchain.ProjectTool` for ruff.
This module implements the :class:`RuffTool` class that manages the
``ruff`` linter/formatter across two configuration files:
- ``pyproject.toml`` — lower-bound ``>=`` specifier in
``project.optional-dependencies.dev``
- ``.pre-commit-config.yaml`` — the ``rev`` field of the
``https://github.com/astral-sh/ruff-pre-commit`` repository entry
Because ruff is referenced in two places, the :meth:`verify` method checks
that the two declared versions are consistent. The :meth:`post_update` hook
recreates the ``dev`` hatch environment, then runs both the ``ruff-check``
and ``ruff-format`` hatch scripts at the highest supported Python version,
and reverts **both** files if either script fails.
Unlike simpler project tools (e.g. git-cliff, pip-audit), RuffTool overrides
:meth:`~RuffTool.update`, :meth:`~RuffTool.verify`, and
:meth:`~RuffTool.post_update` because of the cross-file synchronization
requirement. The base class's ``_saved_content`` and ``_revert()`` handle
pyproject.toml, while a separate ``_saved_precommit`` attribute tracks the
pre-commit config file state.
"""
from __future__ import annotations
import subprocess
from pathlib import Path
from typing import Final
from packaging.version import Version
from ruamel.yaml import YAML, YAMLError # type: ignore[attr-defined]
from mafw.devtools import DevtoolsError
from mafw.devtools.toolchain import Issue, ProjectTool
from mafw.devtools.toolchain.precommit_modifier import PreCommitModifier
_PRECOMMIT_REPO_URL: Final[str] = 'https://github.com/astral-sh/ruff-pre-commit'
"""Repository URL for the ruff pre-commit hook entry."""
[docs]
class RuffTool(ProjectTool):
"""Manage the ruff linter/formatter across pyproject.toml and pre-commit.
Ruff is categorized as a *project* tool. Its version lower bound lives in
``project.optional-dependencies.dev`` and the pre-commit hook revision is
declared in ``.pre-commit-config.yaml`` under the ``astral-sh/ruff-pre-commit``
repository. Both must stay in sync.
The :meth:`update` method writes both files (pyproject first, then
pre-commit). On :meth:`post_update` failure, both files are reverted
to their pre-update contents: pyproject.toml via the inherited
``_revert()`` helper, and ``.pre-commit-config.yaml`` via the local
``_saved_precommit`` attribute.
:param project_root: Path to the project root directory containing both
``pyproject.toml`` and ``.pre-commit-config.yaml``. Defaults to the
current working directory.
:type project_root: Path | None
"""
def __init__(self, project_root: Path | None = None) -> None:
super().__init__(project_root)
self._precommit_path: Path = self._project_root / '.pre-commit-config.yaml'
# Saved pre-commit file content for revert on post_update failure.
self._saved_precommit: str | None = None
@property
def package_name(self) -> str:
"""PyPI package name.
:return: ``"ruff"``
:rtype: str
"""
return 'ruff'
@property
def section_path(self) -> str:
"""TOML section path where ruff is declared.
:return: ``"project.optional-dependencies.dev"``
:rtype: str
"""
return 'project.optional-dependencies.dev'
@property
def env_name(self) -> str:
"""Hatch environment where ruff is validated.
:return: ``"dev"``
:rtype: str
"""
return 'dev'
[docs]
def update(self) -> bool:
"""Update the ruff version in pyproject.toml and .pre-commit-config.yaml.
Performs both updates:
1. Sets the ``>=`` lower bound for ruff in ``project.optional-dependencies.dev``
to the latest PyPI version (delegated to the parent class).
2. Updates the ``rev`` field of the ``astral-sh/ruff-pre-commit`` repo entry
in ``.pre-commit-config.yaml`` to ``v{latest_version}``.
Before modifying, saves both file contents so they can be reverted
by :meth:`post_update` on failure. The parent class handles saving
pyproject.toml via ``_saved_content``; this method additionally saves
the pre-commit config.
:return: ``True`` if either file was changed, ``False`` if both are
already up to date.
:rtype: bool
:raises DevtoolsError: If either file cannot be read or written.
"""
latest = self.detect_latest_version()
current = self.detect_current_version()
if current is not None and current == latest:
return False
# Save both files for potential revert.
# The parent's _saved_content is set here for pyproject.toml.
self._saved_content = self._pyproject_path.read_text(encoding='utf-8')
self._saved_precommit = self._precommit_path.read_text(encoding='utf-8')
# 1. Update pyproject.toml lower bound.
modifier = self._get_modifier()
modifier.read()
modifier.update_lower_bound(self.package_name, str(latest), self.section_path)
modifier.write()
# 2. Update .pre-commit-config.yaml rev.
pc_modifier = PreCommitModifier()
pc_modifier.read(self._precommit_path)
pc_modifier.update_rev(_PRECOMMIT_REPO_URL, f'v{latest}')
pc_modifier.write(self._precommit_path)
return True
[docs]
def post_update(self) -> None:
"""Recreate the dev environment and run ruff-check and ruff-format.
Performs the following steps after a successful version update:
1. Forces recreation of the ``dev`` hatch environment so that the
newly specified dependency versions are resolved and installed.
2. Determines the highest supported Python version dynamically from
``tool.mafw.supported-python`` in pyproject.toml.
3. Runs ``hatch run dev.py<version>:ruff-check``.
4. Runs ``hatch run dev.py<version>:ruff-format``.
If environment recreation fails due to a dependency conflict, both
pyproject.toml and .pre-commit-config.yaml are reverted. If either
ruff script fails, both files are also reverted.
:raises DevtoolsError: If environment recreation fails or either ruff
script exits with a non-zero exit code.
"""
# Step 1: Determine the highest Python version dynamically.
py_version = self._get_highest_python_version()
# Step 2: Validate dependency resolution via temporary clone.
# Note: _recreate_environment reverts only pyproject.toml on failure,
# so we need to also revert pre-commit if it raises.
try:
self._recreate_environment(self.env_name, py_version)
except DevtoolsError:
# _recreate_environment already reverted pyproject.toml via _revert().
# We must also revert the pre-commit config.
self._revert_precommit()
raise
# Step 3: Run ruff-check at the determined Python version.
ruff_check_cmd = ['hatch', 'run', f'dev.py{py_version}:ruff-check']
result = subprocess.run(
ruff_check_cmd, # noqa: S603
capture_output=True,
text=True,
check=False,
cwd=self._project_root,
)
if result.returncode != 0:
conflict = self._parse_resolution_failure(result.stderr)
self._revert_all()
msg = (
f'ruff-check failed after updating {self.name} — '
f'both pyproject.toml and .pre-commit-config.yaml have been reverted.'
)
if conflict:
msg += f'\nConflicting package: {conflict}'
msg += f'\n{result.stderr.strip()}'
raise DevtoolsError(msg)
# Step 4: Run ruff-format at the determined Python version.
ruff_format_cmd = ['hatch', 'run', f'dev.py{py_version}:ruff-format']
result = subprocess.run(
ruff_format_cmd, # noqa: S603
capture_output=True,
text=True,
check=False,
cwd=self._project_root,
)
if result.returncode != 0:
conflict = self._parse_resolution_failure(result.stderr)
self._revert_all()
msg = (
f'ruff-format failed after updating {self.name} — '
f'both pyproject.toml and .pre-commit-config.yaml have been reverted.'
)
if conflict:
msg += f'\nConflicting package: {conflict}'
msg += f'\n{result.stderr.strip()}'
raise DevtoolsError(msg)
[docs]
def verify(self) -> list[Issue]:
"""Compare ruff version in pyproject.toml with the pre-commit rev.
Reads the ``>=`` lower bound from ``project.optional-dependencies.dev``
and the ``rev`` field (stripped of ``v`` prefix) from the
``astral-sh/ruff-pre-commit`` repo entry. Returns an :class:`Issue` if
they do not match.
:return: A list containing at most one :class:`.toolchain.Issue` if the two
versions differ, or an empty list if they are consistent.
:rtype: list[toolchain.Issue]
"""
# Get pyproject version.
pyproject_version = self.detect_current_version()
if pyproject_version is None:
return [
Issue(
tool_name=self.name,
description=(
f'Cannot determine ruff version from {self.section_path}: '
'dependency not found or missing >= specifier.'
),
)
]
# Get pre-commit rev.
precommit_version = self._get_precommit_version()
if precommit_version is None:
return [
Issue(
tool_name=self.name,
description=(
'Cannot determine ruff version from .pre-commit-config.yaml: '
f'repository {_PRECOMMIT_REPO_URL!r} not found or missing rev field.'
),
)
]
# Compare versions.
if pyproject_version != precommit_version:
return [
Issue(
tool_name=self.name,
description=(
f'Version mismatch: pyproject.toml declares >={pyproject_version} '
f'but .pre-commit-config.yaml has rev v{precommit_version}.'
),
)
]
return []
# ------------------------------------------------------------------
# Private helpers
# ------------------------------------------------------------------
[docs]
def _get_precommit_version(self) -> Version | None:
"""Read the ruff version from the pre-commit config rev field.
Parses ``.pre-commit-config.yaml``, locates the ``astral-sh/ruff-pre-commit``
repo entry, reads its ``rev`` field, strips the ``v`` prefix, and
returns it as a :class:`Version`.
:return: The version from the pre-commit rev field, or ``None`` if
the repo or rev cannot be found.
:rtype: Version | None
"""
if not self._precommit_path.exists():
return None
yaml = YAML(typ='rt')
yaml.preserve_quotes = True
try:
data = yaml.load(self._precommit_path.read_text(encoding='utf-8'))
except (OSError, YAMLError):
return None
if not isinstance(data, dict):
return None
repos = data.get('repos')
if not isinstance(repos, list):
return None
for repo_entry in repos:
if not isinstance(repo_entry, dict):
continue
if repo_entry.get('repo') == _PRECOMMIT_REPO_URL:
rev = repo_entry.get('rev')
if rev is None:
return None
rev_str = str(rev)
# Strip leading 'v' prefix if present.
if rev_str.startswith('v'):
rev_str = rev_str[1:]
try:
return Version(rev_str)
except Exception: # noqa: BLE001
return None
return None
[docs]
def _revert_precommit(self) -> None:
"""Restore .pre-commit-config.yaml to its saved pre-update state.
Silently handles the case where saved content is not available.
"""
if self._saved_precommit is not None:
self._precommit_path.write_text(self._saved_precommit, encoding='utf-8')
self._saved_precommit = None
[docs]
def _revert_all(self) -> None:
"""Restore both config files to their saved pre-update state.
Uses the parent class ``_revert()`` for pyproject.toml and
manually restores ``.pre-commit-config.yaml`` from the saved
content. Silently handles the case where saved content is not
available.
"""
# Revert pyproject.toml via the inherited ProjectTool helper.
self._revert()
# Revert .pre-commit-config.yaml manually.
self._revert_precommit()