Source code for mafw.devtools.toolchain.tools.pyupgrade

#  Copyright 2026 European Union
#  Author: Bulgheroni Antonio (antonio.bulgheroni@ec.europa.eu)
#  SPDX-License-Identifier: EUPL-1.2
"""
PyUpgradeTool — concrete :class:`~mafw.devtools.toolchain.ProjectTool` for pyupgrade.

This module implements the :class:`PyUpgradeTool` class that manages the
``pyupgrade`` syntax modernizer 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/asottile/pyupgrade`` repository entry

Because pyupgrade 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 pyupgrade via
``pre-commit run pyupgrade --all-files`` at the highest supported Python
version, and reverts **both** files if the command fails.

The tool follows the same cross-file synchronization pattern as
:class:`~mafw.devtools.toolchain.tools.ruff.RuffTool`: 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/asottile/pyupgrade'
"""Repository URL for the pyupgrade pre-commit hook entry."""


[docs] class PyUpgradeTool(ProjectTool): """Manage pyupgrade across pyproject.toml and pre-commit. pyupgrade 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 ``asottile/pyupgrade`` 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: ``"pyupgrade"`` :rtype: str """ return 'pyupgrade' @property def section_path(self) -> str: """TOML section path where pyupgrade is declared. :return: ``"project.optional-dependencies.dev"`` :rtype: str """ return 'project.optional-dependencies.dev' @property def env_name(self) -> str: """Hatch environment where pyupgrade is validated. :return: ``"dev"`` :rtype: str """ return 'dev'
[docs] def update(self) -> bool: """Update the pyupgrade version in pyproject.toml and .pre-commit-config.yaml. Performs both updates: 1. Sets the ``>=`` lower bound for pyupgrade in ``project.optional-dependencies.dev`` to the latest PyPI version (delegated to the parent class). 2. Updates the ``rev`` field of the ``asottile/pyupgrade`` 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. 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 pyupgrade via pre-commit. 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 ``pre-commit run pyupgrade --all-files`` via the dev environment to execute pyupgrade on all source files. If environment recreation fails due to a dependency conflict, both pyproject.toml and .pre-commit-config.yaml are reverted. If pyupgrade fails, both files are also reverted. :raises DevtoolsError: If environment recreation fails or pyupgrade 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. 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 pyupgrade via pre-commit at the determined Python version. pyupgrade_cmd = ['hatch', 'run', f'dev.py{py_version}:pre-commit', 'run', 'pyupgrade', '--all-files'] result = subprocess.run( pyupgrade_cmd, # noqa: S603 capture_output=True, text=True, check=False, cwd=self._project_root, ) # pyupgrade via pre-commit exits with code 1 when it rewrites files # (files were modified by this hook). This is expected and successful # behaviour — only a non-zero exit with no modifications indicates a # real failure. However, pre-commit will always exit 1 when files are # modified, so we accept exit code 1 as success. if result.returncode not in (0, 1): conflict = self._parse_resolution_failure(result.stderr) self._revert_all() msg = ( f'pyupgrade 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 pyupgrade 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 ``asottile/pyupgrade`` 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 pyupgrade 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 pyupgrade 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 pyupgrade version from the pre-commit config rev field. Parses ``.pre-commit-config.yaml``, locates the ``asottile/pyupgrade`` 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()