Coverage for src/mafw/devtools/toolchain/tools/pyupgrade.py: 22%
104 statements
« prev ^ index » next coverage.py v7.15.0, created at 2026-07-26 09:13 +0000
« prev ^ index » next coverage.py v7.15.0, created at 2026-07-26 09:13 +0000
1# Copyright 2026 European Union
2# Author: Bulgheroni Antonio (antonio.bulgheroni@ec.europa.eu)
3# SPDX-License-Identifier: EUPL-1.2
4"""
5PyUpgradeTool — concrete :class:`~mafw.devtools.toolchain.ProjectTool` for pyupgrade.
7This module implements the :class:`PyUpgradeTool` class that manages the
8``pyupgrade`` syntax modernizer across two configuration files:
10- ``pyproject.toml`` — lower-bound ``>=`` specifier in
11 ``project.optional-dependencies.dev``
12- ``.pre-commit-config.yaml`` — the ``rev`` field of the
13 ``https://github.com/asottile/pyupgrade`` repository entry
15Because pyupgrade is referenced in two places, the :meth:`verify` method checks
16that the two declared versions are consistent. The :meth:`post_update` hook
17recreates the ``dev`` hatch environment, then runs pyupgrade via
18``pre-commit run pyupgrade --all-files`` at the highest supported Python
19version, and reverts **both** files if the command fails.
21The tool follows the same cross-file synchronization pattern as
22:class:`~mafw.devtools.toolchain.tools.ruff.RuffTool`: the base class's
23``_saved_content`` and ``_revert()`` handle pyproject.toml, while a separate
24``_saved_precommit`` attribute tracks the pre-commit config file state.
25"""
27from __future__ import annotations
29import subprocess
30from pathlib import Path
31from typing import Final
33from packaging.version import Version
34from ruamel.yaml import YAML, YAMLError # type: ignore[attr-defined]
36from mafw.devtools import DevtoolsError
37from mafw.devtools.toolchain import Issue, ProjectTool
38from mafw.devtools.toolchain.precommit_modifier import PreCommitModifier
40_PRECOMMIT_REPO_URL: Final[str] = 'https://github.com/asottile/pyupgrade'
41"""Repository URL for the pyupgrade pre-commit hook entry."""
44class PyUpgradeTool(ProjectTool):
45 """Manage pyupgrade across pyproject.toml and pre-commit.
47 pyupgrade is categorized as a *project* tool. Its version lower bound lives
48 in ``project.optional-dependencies.dev`` and the pre-commit hook revision is
49 declared in ``.pre-commit-config.yaml`` under the ``asottile/pyupgrade``
50 repository. Both must stay in sync.
52 The :meth:`update` method writes both files (pyproject first, then
53 pre-commit). On :meth:`post_update` failure, both files are reverted
54 to their pre-update contents: pyproject.toml via the inherited
55 ``_revert()`` helper, and ``.pre-commit-config.yaml`` via the local
56 ``_saved_precommit`` attribute.
58 :param project_root: Path to the project root directory containing both
59 ``pyproject.toml`` and ``.pre-commit-config.yaml``. Defaults to the
60 current working directory.
61 :type project_root: Path | None
62 """
64 def __init__(self, project_root: Path | None = None) -> None:
65 super().__init__(project_root)
66 self._precommit_path: Path = self._project_root / '.pre-commit-config.yaml'
67 # Saved pre-commit file content for revert on post_update failure.
68 self._saved_precommit: str | None = None
70 @property
71 def package_name(self) -> str:
72 """PyPI package name.
74 :return: ``"pyupgrade"``
75 :rtype: str
76 """
77 return 'pyupgrade'
79 @property
80 def section_path(self) -> str:
81 """TOML section path where pyupgrade is declared.
83 :return: ``"project.optional-dependencies.dev"``
84 :rtype: str
85 """
86 return 'project.optional-dependencies.dev'
88 @property
89 def env_name(self) -> str:
90 """Hatch environment where pyupgrade is validated.
92 :return: ``"dev"``
93 :rtype: str
94 """
95 return 'dev'
97 def update(self) -> bool:
98 """Update the pyupgrade version in pyproject.toml and .pre-commit-config.yaml.
100 Performs both updates:
102 1. Sets the ``>=`` lower bound for pyupgrade in
103 ``project.optional-dependencies.dev`` to the latest PyPI version
104 (delegated to the parent class).
105 2. Updates the ``rev`` field of the ``asottile/pyupgrade`` repo entry
106 in ``.pre-commit-config.yaml`` to ``v{latest_version}``.
108 Before modifying, saves both file contents so they can be reverted
109 by :meth:`post_update` on failure. The parent class handles saving
110 pyproject.toml via ``_saved_content``; this method additionally saves
111 the pre-commit config.
113 :return: ``True`` if either file was changed, ``False`` if both are
114 already up to date.
115 :rtype: bool
116 :raises DevtoolsError: If either file cannot be read or written.
117 """
118 latest = self.detect_latest_version()
119 current = self.detect_current_version()
121 if current is not None and current == latest:
122 return False
124 # Save both files for potential revert.
125 self._saved_content = self._pyproject_path.read_text(encoding='utf-8')
126 self._saved_precommit = self._precommit_path.read_text(encoding='utf-8')
128 # 1. Update pyproject.toml lower bound.
129 modifier = self._get_modifier()
130 modifier.read()
131 modifier.update_lower_bound(self.package_name, str(latest), self.section_path)
132 modifier.write()
134 # 2. Update .pre-commit-config.yaml rev.
135 pc_modifier = PreCommitModifier()
136 pc_modifier.read(self._precommit_path)
137 pc_modifier.update_rev(_PRECOMMIT_REPO_URL, f'v{latest}')
138 pc_modifier.write(self._precommit_path)
140 return True
142 def post_update(self) -> None:
143 """Recreate the dev environment and run pyupgrade via pre-commit.
145 Performs the following steps after a successful version update:
147 1. Forces recreation of the ``dev`` hatch environment so that the
148 newly specified dependency versions are resolved and installed.
149 2. Determines the highest supported Python version dynamically from
150 ``tool.mafw.supported-python`` in pyproject.toml.
151 3. Runs ``pre-commit run pyupgrade --all-files`` via the dev
152 environment to execute pyupgrade on all source files.
154 If environment recreation fails due to a dependency conflict, both
155 pyproject.toml and .pre-commit-config.yaml are reverted. If
156 pyupgrade fails, both files are also reverted.
158 :raises DevtoolsError: If environment recreation fails or pyupgrade
159 exits with a non-zero exit code.
160 """
161 # Step 1: Determine the highest Python version dynamically.
162 py_version = self._get_highest_python_version()
164 # Step 2: Validate dependency resolution via temporary clone.
165 try:
166 self._recreate_environment(self.env_name, py_version)
167 except DevtoolsError:
168 # _recreate_environment already reverted pyproject.toml via _revert().
169 # We must also revert the pre-commit config.
170 self._revert_precommit()
171 raise
173 # Step 3: Run pyupgrade via pre-commit at the determined Python version.
174 pyupgrade_cmd = ['hatch', 'run', f'dev.py{py_version}:pre-commit', 'run', 'pyupgrade', '--all-files']
175 result = subprocess.run(
176 pyupgrade_cmd, # noqa: S603
177 capture_output=True,
178 text=True,
179 check=False,
180 cwd=self._project_root,
181 )
183 # pyupgrade via pre-commit exits with code 1 when it rewrites files
184 # (files were modified by this hook). This is expected and successful
185 # behaviour — only a non-zero exit with no modifications indicates a
186 # real failure. However, pre-commit will always exit 1 when files are
187 # modified, so we accept exit code 1 as success.
188 if result.returncode not in (0, 1):
189 conflict = self._parse_resolution_failure(result.stderr)
190 self._revert_all()
191 msg = (
192 f'pyupgrade failed after updating {self.name} — '
193 f'both pyproject.toml and .pre-commit-config.yaml have been reverted.'
194 )
195 if conflict:
196 msg += f'\nConflicting package: {conflict}'
197 msg += f'\n{result.stderr.strip()}'
198 raise DevtoolsError(msg)
200 def verify(self) -> list[Issue]:
201 """Compare pyupgrade version in pyproject.toml with the pre-commit rev.
203 Reads the ``>=`` lower bound from ``project.optional-dependencies.dev``
204 and the ``rev`` field (stripped of ``v`` prefix) from the
205 ``asottile/pyupgrade`` repo entry. Returns an :class:`Issue` if
206 they do not match.
208 :return: A list containing at most one :class:`.toolchain.Issue` if the two
209 versions differ, or an empty list if they are consistent.
210 :rtype: list[toolchain.Issue]
211 """
212 # Get pyproject version.
213 pyproject_version = self.detect_current_version()
214 if pyproject_version is None:
215 return [
216 Issue(
217 tool_name=self.name,
218 description=(
219 f'Cannot determine pyupgrade version from {self.section_path}: '
220 'dependency not found or missing >= specifier.'
221 ),
222 )
223 ]
225 # Get pre-commit rev.
226 precommit_version = self._get_precommit_version()
227 if precommit_version is None:
228 return [
229 Issue(
230 tool_name=self.name,
231 description=(
232 'Cannot determine pyupgrade version from .pre-commit-config.yaml: '
233 f'repository {_PRECOMMIT_REPO_URL!r} not found or missing rev field.'
234 ),
235 )
236 ]
238 # Compare versions.
239 if pyproject_version != precommit_version:
240 return [
241 Issue(
242 tool_name=self.name,
243 description=(
244 f'Version mismatch: pyproject.toml declares >={pyproject_version} '
245 f'but .pre-commit-config.yaml has rev v{precommit_version}.'
246 ),
247 )
248 ]
250 return []
252 # ------------------------------------------------------------------
253 # Private helpers
254 # ------------------------------------------------------------------
256 def _get_precommit_version(self) -> Version | None:
257 """Read the pyupgrade version from the pre-commit config rev field.
259 Parses ``.pre-commit-config.yaml``, locates the ``asottile/pyupgrade``
260 repo entry, reads its ``rev`` field, strips the ``v`` prefix, and
261 returns it as a :class:`Version`.
263 :return: The version from the pre-commit rev field, or ``None`` if
264 the repo or rev cannot be found.
265 :rtype: Version | None
266 """
267 if not self._precommit_path.exists():
268 return None
270 yaml = YAML(typ='rt')
271 yaml.preserve_quotes = True
273 try:
274 data = yaml.load(self._precommit_path.read_text(encoding='utf-8'))
275 except (OSError, YAMLError):
276 return None
278 if not isinstance(data, dict):
279 return None
281 repos = data.get('repos')
282 if not isinstance(repos, list):
283 return None
285 for repo_entry in repos:
286 if not isinstance(repo_entry, dict):
287 continue
288 if repo_entry.get('repo') == _PRECOMMIT_REPO_URL:
289 rev = repo_entry.get('rev')
290 if rev is None:
291 return None
292 rev_str = str(rev)
293 # Strip leading 'v' prefix if present.
294 if rev_str.startswith('v'):
295 rev_str = rev_str[1:]
296 try:
297 return Version(rev_str)
298 except Exception: # noqa: BLE001
299 return None
301 return None
303 def _revert_precommit(self) -> None:
304 """Restore .pre-commit-config.yaml to its saved pre-update state.
306 Silently handles the case where saved content is not available.
307 """
308 if self._saved_precommit is not None:
309 self._precommit_path.write_text(self._saved_precommit, encoding='utf-8')
310 self._saved_precommit = None
312 def _revert_all(self) -> None:
313 """Restore both config files to their saved pre-update state.
315 Uses the parent class ``_revert()`` for pyproject.toml and
316 manually restores ``.pre-commit-config.yaml`` from the saved
317 content. Silently handles the case where saved content is not
318 available.
319 """
320 # Revert pyproject.toml via the inherited ProjectTool helper.
321 self._revert()
322 # Revert .pre-commit-config.yaml manually.
323 self._revert_precommit()