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

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. 

6 

7This module implements the :class:`PyUpgradeTool` class that manages the 

8``pyupgrade`` syntax modernizer across two configuration files: 

9 

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 

14 

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. 

20 

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""" 

26 

27from __future__ import annotations 

28 

29import subprocess 

30from pathlib import Path 

31from typing import Final 

32 

33from packaging.version import Version 

34from ruamel.yaml import YAML, YAMLError # type: ignore[attr-defined] 

35 

36from mafw.devtools import DevtoolsError 

37from mafw.devtools.toolchain import Issue, ProjectTool 

38from mafw.devtools.toolchain.precommit_modifier import PreCommitModifier 

39 

40_PRECOMMIT_REPO_URL: Final[str] = 'https://github.com/asottile/pyupgrade' 

41"""Repository URL for the pyupgrade pre-commit hook entry.""" 

42 

43 

44class PyUpgradeTool(ProjectTool): 

45 """Manage pyupgrade across pyproject.toml and pre-commit. 

46 

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. 

51 

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. 

57 

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 """ 

63 

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 

69 

70 @property 

71 def package_name(self) -> str: 

72 """PyPI package name. 

73 

74 :return: ``"pyupgrade"`` 

75 :rtype: str 

76 """ 

77 return 'pyupgrade' 

78 

79 @property 

80 def section_path(self) -> str: 

81 """TOML section path where pyupgrade is declared. 

82 

83 :return: ``"project.optional-dependencies.dev"`` 

84 :rtype: str 

85 """ 

86 return 'project.optional-dependencies.dev' 

87 

88 @property 

89 def env_name(self) -> str: 

90 """Hatch environment where pyupgrade is validated. 

91 

92 :return: ``"dev"`` 

93 :rtype: str 

94 """ 

95 return 'dev' 

96 

97 def update(self) -> bool: 

98 """Update the pyupgrade version in pyproject.toml and .pre-commit-config.yaml. 

99 

100 Performs both updates: 

101 

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}``. 

107 

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. 

112 

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

120 

121 if current is not None and current == latest: 

122 return False 

123 

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

127 

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

133 

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) 

139 

140 return True 

141 

142 def post_update(self) -> None: 

143 """Recreate the dev environment and run pyupgrade via pre-commit. 

144 

145 Performs the following steps after a successful version update: 

146 

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. 

153 

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. 

157 

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

163 

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 

172 

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 ) 

182 

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) 

199 

200 def verify(self) -> list[Issue]: 

201 """Compare pyupgrade version in pyproject.toml with the pre-commit rev. 

202 

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. 

207 

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 ] 

224 

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 ] 

237 

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 ] 

249 

250 return [] 

251 

252 # ------------------------------------------------------------------ 

253 # Private helpers 

254 # ------------------------------------------------------------------ 

255 

256 def _get_precommit_version(self) -> Version | None: 

257 """Read the pyupgrade version from the pre-commit config rev field. 

258 

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`. 

262 

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 

269 

270 yaml = YAML(typ='rt') 

271 yaml.preserve_quotes = True 

272 

273 try: 

274 data = yaml.load(self._precommit_path.read_text(encoding='utf-8')) 

275 except (OSError, YAMLError): 

276 return None 

277 

278 if not isinstance(data, dict): 

279 return None 

280 

281 repos = data.get('repos') 

282 if not isinstance(repos, list): 

283 return None 

284 

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 

300 

301 return None 

302 

303 def _revert_precommit(self) -> None: 

304 """Restore .pre-commit-config.yaml to its saved pre-update state. 

305 

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 

311 

312 def _revert_all(self) -> None: 

313 """Restore both config files to their saved pre-update state. 

314 

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