Source code for mafw.devtools.toolchain.tools.precommit
# Copyright 2026 European Union
# Author: Bulgheroni Antonio (antonio.bulgheroni@ec.europa.eu)
# SPDX-License-Identifier: EUPL-1.2
"""
Concrete :class:`~mafw.devtools.toolchain.ProjectTool` implementation for **pre-commit**.
pre-commit is a project tool whose version lower bound is declared in
``project.optional-dependencies.dev``. This module manages the ``>=``
specifier for pre-commit and validates updates by running
``pre-commit run --all-files`` within the recreated ``dev`` hatch environment.
Update logic (inherited from :class:`~mafw.devtools.toolchain.ProjectTool`):
1. Query PyPI for the latest stable pre-commit release.
2. Compare against the current lower bound in pyproject.toml.
3. If a newer version exists, update the ``>=`` specifier.
Post-update validation (overridden here):
4. Force recreation of the ``dev`` hatch environment to ensure fresh
dependency resolution with the new pre-commit version.
5. Run ``pre-commit run --all-files`` via the recreated dev environment.
6. If the command fails, revert pyproject.toml to its previous content.
The revert pattern uses ``_revert()``
which restores the raw pyproject.toml content saved before the update.
"""
from __future__ import annotations
import subprocess
from mafw.devtools import DevtoolsError
from mafw.devtools.toolchain import ProjectTool
from mafw.devtools.toolchain.base import Issue
[docs]
class PreCommitTool(ProjectTool):
"""Manage pre-commit's version lower bound in pyproject.toml.
pre-commit is categorized as a *project* tool because its version is
controlled through the ``dev`` optional-dependencies group in
``pyproject.toml``. After an update, the ``dev`` hatch environment is
recreated to ensure fresh dependency resolution, and then
``pre-commit run --all-files`` is executed to validate compatibility.
If the command fails, pyproject.toml is reverted to its pre-update state.
All standard behaviour (version detection, update, bootstrap) is provided
by :class:`~mafw.devtools.toolchain.ProjectTool`.
The :meth:`post_update` is overridden to run pre-commit hooks, and
:meth:`verify` explicitly returns an empty list since pre-commit is
referenced in a single location.
:param project_root: Path to the project root directory containing
``pyproject.toml``. Defaults to the current working directory.
:type project_root: Path | None
"""
@property
def package_name(self) -> str:
"""PyPI package name.
:return: ``"pre-commit"``
:rtype: str
"""
return 'pre-commit'
@property
def section_path(self) -> str:
"""TOML section path where pre-commit is declared.
:return: ``"project.optional-dependencies.dev"``
:rtype: str
"""
return 'project.optional-dependencies.dev'
@property
def env_name(self) -> str:
"""Hatch environment where pre-commit hooks are run.
:return: ``"dev"``
:rtype: str
"""
return 'dev'
[docs]
def post_update(self) -> None:
"""Recreate the dev environment, then run pre-commit hooks; revert on failure.
Forces the recreation of the ``dev`` hatch environment to ensure
the newly specified pre-commit version is resolved and installed.
Then runs ``pre-commit run --all-files`` via the ``dev`` environment
to validate compatibility. If the command fails (non-zero exit code),
pyproject.toml is reverted to its saved pre-update content and a
:class:`~mafw.devtools.DevtoolsError` is raised.
:raises DevtoolsError: If environment recreation fails or if
``pre-commit run --all-files`` exits with a non-zero code.
"""
# Force fresh dependency resolution with the new version.
# Determine the highest supported Python version dynamically.
py_version = self._get_highest_python_version()
# Validate dependency resolution via temporary clone.
self._recreate_environment(self.env_name, py_version)
# Run pre-commit hooks via the recreated dev environment.
result = subprocess.run(
['hatch', 'run', f'dev.py{py_version}:pre-commit', 'run', '--all-files'], # noqa: S603, S607
capture_output=True,
text=True,
check=False,
cwd=self._project_root,
)
if result.returncode != 0:
# Parse resolution failure for detailed error reporting.
conflict = self._parse_resolution_failure(result.stderr)
# Revert pyproject.toml to its pre-update state.
self._revert()
msg = f'pre-commit run --all-files failed after updating {self.name} — pyproject.toml has been reverted.'
if conflict:
msg += f'\nConflicting package: {conflict}'
msg += f'\n{result.stderr.strip()}'
raise DevtoolsError(msg)
[docs]
def verify(self) -> list[Issue]:
"""Return an empty list — pre-commit is referenced in a single location.
pre-commit has no cross-file consistency to verify (unlike ruff which
appears in both pyproject.toml and ``.pre-commit-config.yaml``).
:return: An empty list of issues.
:rtype: list[toolchain.Issue]
"""
return []