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 []