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()