Coverage for src/mafw/devtools/toolchain/tools/ruff.py: 97%

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

5RuffTool — concrete :class:`~mafw.devtools.toolchain.ProjectTool` for ruff. 

6 

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

8``ruff`` linter/formatter 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/astral-sh/ruff-pre-commit`` repository entry 

14 

15Because ruff 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 both the ``ruff-check`` 

18and ``ruff-format`` hatch scripts at the highest supported Python version, 

19and reverts **both** files if either script fails. 

20 

21Unlike simpler project tools (e.g. git-cliff, pip-audit), RuffTool overrides 

22:meth:`~RuffTool.update`, :meth:`~RuffTool.verify`, and 

23:meth:`~RuffTool.post_update` because of the cross-file synchronization 

24requirement. The base class's ``_saved_content`` and ``_revert()`` handle 

25pyproject.toml, while a separate ``_saved_precommit`` attribute tracks the 

26pre-commit config file state. 

27""" 

28 

29from __future__ import annotations 

30 

31import subprocess 

32from pathlib import Path 

33from typing import Final 

34 

35from packaging.version import Version 

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

37 

38from mafw.devtools import DevtoolsError 

39from mafw.devtools.toolchain import Issue, ProjectTool 

40from mafw.devtools.toolchain.precommit_modifier import PreCommitModifier 

41 

42_PRECOMMIT_REPO_URL: Final[str] = 'https://github.com/astral-sh/ruff-pre-commit' 

43"""Repository URL for the ruff pre-commit hook entry.""" 

44 

45 

46class RuffTool(ProjectTool): 

47 """Manage the ruff linter/formatter across pyproject.toml and pre-commit. 

48 

49 Ruff is categorized as a *project* tool. Its version lower bound lives in 

50 ``project.optional-dependencies.dev`` and the pre-commit hook revision is 

51 declared in ``.pre-commit-config.yaml`` under the ``astral-sh/ruff-pre-commit`` 

52 repository. Both must stay in sync. 

53 

54 The :meth:`update` method writes both files (pyproject first, then 

55 pre-commit). On :meth:`post_update` failure, both files are reverted 

56 to their pre-update contents: pyproject.toml via the inherited 

57 ``_revert()`` helper, and ``.pre-commit-config.yaml`` via the local 

58 ``_saved_precommit`` attribute. 

59 

60 :param project_root: Path to the project root directory containing both 

61 ``pyproject.toml`` and ``.pre-commit-config.yaml``. Defaults to the 

62 current working directory. 

63 :type project_root: Path | None 

64 """ 

65 

66 def __init__(self, project_root: Path | None = None) -> None: 

67 super().__init__(project_root) 

68 self._precommit_path: Path = self._project_root / '.pre-commit-config.yaml' 

69 # Saved pre-commit file content for revert on post_update failure. 

70 self._saved_precommit: str | None = None 

71 

72 @property 

73 def package_name(self) -> str: 

74 """PyPI package name. 

75 

76 :return: ``"ruff"`` 

77 :rtype: str 

78 """ 

79 return 'ruff' 

80 

81 @property 

82 def section_path(self) -> str: 

83 """TOML section path where ruff is declared. 

84 

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

86 :rtype: str 

87 """ 

88 return 'project.optional-dependencies.dev' 

89 

90 @property 

91 def env_name(self) -> str: 

92 """Hatch environment where ruff is validated. 

93 

94 :return: ``"dev"`` 

95 :rtype: str 

96 """ 

97 return 'dev' 

98 

99 def update(self) -> bool: 

100 """Update the ruff version in pyproject.toml and .pre-commit-config.yaml. 

101 

102 Performs both updates: 

103 

104 1. Sets the ``>=`` lower bound for ruff in ``project.optional-dependencies.dev`` 

105 to the latest PyPI version (delegated to the parent class). 

106 2. Updates the ``rev`` field of the ``astral-sh/ruff-pre-commit`` repo entry 

107 in ``.pre-commit-config.yaml`` to ``v{latest_version}``. 

108 

109 Before modifying, saves both file contents so they can be reverted 

110 by :meth:`post_update` on failure. The parent class handles saving 

111 pyproject.toml via ``_saved_content``; this method additionally saves 

112 the pre-commit config. 

113 

114 :return: ``True`` if either file was changed, ``False`` if both are 

115 already up to date. 

116 :rtype: bool 

117 :raises DevtoolsError: If either file cannot be read or written. 

118 """ 

119 latest = self.detect_latest_version() 

120 current = self.detect_current_version() 

121 

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

123 return False 

124 

125 # Save both files for potential revert. 

126 # The parent's _saved_content is set here for pyproject.toml. 

127 self._saved_content = self._pyproject_path.read_text(encoding='utf-8') 

128 self._saved_precommit = self._precommit_path.read_text(encoding='utf-8') 

129 

130 # 1. Update pyproject.toml lower bound. 

131 modifier = self._get_modifier() 

132 modifier.read() 

133 modifier.update_lower_bound(self.package_name, str(latest), self.section_path) 

134 modifier.write() 

135 

136 # 2. Update .pre-commit-config.yaml rev. 

137 pc_modifier = PreCommitModifier() 

138 pc_modifier.read(self._precommit_path) 

139 pc_modifier.update_rev(_PRECOMMIT_REPO_URL, f'v{latest}') 

140 pc_modifier.write(self._precommit_path) 

141 

142 return True 

143 

144 def post_update(self) -> None: 

145 """Recreate the dev environment and run ruff-check and ruff-format. 

146 

147 Performs the following steps after a successful version update: 

148 

149 1. Forces recreation of the ``dev`` hatch environment so that the 

150 newly specified dependency versions are resolved and installed. 

151 2. Determines the highest supported Python version dynamically from 

152 ``tool.mafw.supported-python`` in pyproject.toml. 

153 3. Runs ``hatch run dev.py<version>:ruff-check``. 

154 4. Runs ``hatch run dev.py<version>:ruff-format``. 

155 

156 If environment recreation fails due to a dependency conflict, both 

157 pyproject.toml and .pre-commit-config.yaml are reverted. If either 

158 ruff script fails, both files are also reverted. 

159 

160 :raises DevtoolsError: If environment recreation fails or either ruff 

161 script exits with a non-zero exit code. 

162 """ 

163 # Step 1: Determine the highest Python version dynamically. 

164 py_version = self._get_highest_python_version() 

165 

166 # Step 2: Validate dependency resolution via temporary clone. 

167 # Note: _recreate_environment reverts only pyproject.toml on failure, 

168 # so we need to also revert pre-commit if it raises. 

169 try: 

170 self._recreate_environment(self.env_name, py_version) 

171 except DevtoolsError: 

172 # _recreate_environment already reverted pyproject.toml via _revert(). 

173 # We must also revert the pre-commit config. 

174 self._revert_precommit() 

175 raise 

176 

177 # Step 3: Run ruff-check at the determined Python version. 

178 ruff_check_cmd = ['hatch', 'run', f'dev.py{py_version}:ruff-check'] 

179 result = subprocess.run( 

180 ruff_check_cmd, # noqa: S603 

181 capture_output=True, 

182 text=True, 

183 check=False, 

184 cwd=self._project_root, 

185 ) 

186 

187 if result.returncode != 0: 

188 conflict = self._parse_resolution_failure(result.stderr) 

189 self._revert_all() 

190 msg = ( 

191 f'ruff-check failed after updating {self.name}' 

192 f'both pyproject.toml and .pre-commit-config.yaml have been reverted.' 

193 ) 

194 if conflict: 

195 msg += f'\nConflicting package: {conflict}' 

196 msg += f'\n{result.stderr.strip()}' 

197 raise DevtoolsError(msg) 

198 

199 # Step 4: Run ruff-format at the determined Python version. 

200 ruff_format_cmd = ['hatch', 'run', f'dev.py{py_version}:ruff-format'] 

201 result = subprocess.run( 

202 ruff_format_cmd, # noqa: S603 

203 capture_output=True, 

204 text=True, 

205 check=False, 

206 cwd=self._project_root, 

207 ) 

208 

209 if result.returncode != 0: 

210 conflict = self._parse_resolution_failure(result.stderr) 

211 self._revert_all() 

212 msg = ( 

213 f'ruff-format failed after updating {self.name}' 

214 f'both pyproject.toml and .pre-commit-config.yaml have been reverted.' 

215 ) 

216 if conflict: 216 ↛ 218line 216 didn't jump to line 218 because the condition on line 216 was always true

217 msg += f'\nConflicting package: {conflict}' 

218 msg += f'\n{result.stderr.strip()}' 

219 raise DevtoolsError(msg) 

220 

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

222 """Compare ruff version in pyproject.toml with the pre-commit rev. 

223 

224 Reads the ``>=`` lower bound from ``project.optional-dependencies.dev`` 

225 and the ``rev`` field (stripped of ``v`` prefix) from the 

226 ``astral-sh/ruff-pre-commit`` repo entry. Returns an :class:`Issue` if 

227 they do not match. 

228 

229 :return: A list containing at most one :class:`.toolchain.Issue` if the two 

230 versions differ, or an empty list if they are consistent. 

231 :rtype: list[toolchain.Issue] 

232 """ 

233 # Get pyproject version. 

234 pyproject_version = self.detect_current_version() 

235 if pyproject_version is None: 

236 return [ 

237 Issue( 

238 tool_name=self.name, 

239 description=( 

240 f'Cannot determine ruff version from {self.section_path}: ' 

241 'dependency not found or missing >= specifier.' 

242 ), 

243 ) 

244 ] 

245 

246 # Get pre-commit rev. 

247 precommit_version = self._get_precommit_version() 

248 if precommit_version is None: 

249 return [ 

250 Issue( 

251 tool_name=self.name, 

252 description=( 

253 'Cannot determine ruff version from .pre-commit-config.yaml: ' 

254 f'repository {_PRECOMMIT_REPO_URL!r} not found or missing rev field.' 

255 ), 

256 ) 

257 ] 

258 

259 # Compare versions. 

260 if pyproject_version != precommit_version: 

261 return [ 

262 Issue( 

263 tool_name=self.name, 

264 description=( 

265 f'Version mismatch: pyproject.toml declares >={pyproject_version} ' 

266 f'but .pre-commit-config.yaml has rev v{precommit_version}.' 

267 ), 

268 ) 

269 ] 

270 

271 return [] 

272 

273 # ------------------------------------------------------------------ 

274 # Private helpers 

275 # ------------------------------------------------------------------ 

276 

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

278 """Read the ruff version from the pre-commit config rev field. 

279 

280 Parses ``.pre-commit-config.yaml``, locates the ``astral-sh/ruff-pre-commit`` 

281 repo entry, reads its ``rev`` field, strips the ``v`` prefix, and 

282 returns it as a :class:`Version`. 

283 

284 :return: The version from the pre-commit rev field, or ``None`` if 

285 the repo or rev cannot be found. 

286 :rtype: Version | None 

287 """ 

288 if not self._precommit_path.exists(): 

289 return None 

290 

291 yaml = YAML(typ='rt') 

292 yaml.preserve_quotes = True 

293 

294 try: 

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

296 except (OSError, YAMLError): 

297 return None 

298 

299 if not isinstance(data, dict): 

300 return None 

301 

302 repos = data.get('repos') 

303 if not isinstance(repos, list): 

304 return None 

305 

306 for repo_entry in repos: 306 ↛ 322line 306 didn't jump to line 322 because the loop on line 306 didn't complete

307 if not isinstance(repo_entry, dict): 

308 continue 

309 if repo_entry.get('repo') == _PRECOMMIT_REPO_URL: 309 ↛ 306line 309 didn't jump to line 306 because the condition on line 309 was always true

310 rev = repo_entry.get('rev') 

311 if rev is None: 

312 return None 

313 rev_str = str(rev) 

314 # Strip leading 'v' prefix if present. 

315 if rev_str.startswith('v'): 

316 rev_str = rev_str[1:] 

317 try: 

318 return Version(rev_str) 

319 except Exception: # noqa: BLE001 

320 return None 

321 

322 return None 

323 

324 def _revert_precommit(self) -> None: 

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

326 

327 Silently handles the case where saved content is not available. 

328 """ 

329 if self._saved_precommit is not None: 

330 self._precommit_path.write_text(self._saved_precommit, encoding='utf-8') 

331 self._saved_precommit = None 

332 

333 def _revert_all(self) -> None: 

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

335 

336 Uses the parent class ``_revert()`` for pyproject.toml and 

337 manually restores ``.pre-commit-config.yaml`` from the saved 

338 content. Silently handles the case where saved content is not 

339 available. 

340 """ 

341 # Revert pyproject.toml via the inherited ProjectTool helper. 

342 self._revert() 

343 # Revert .pre-commit-config.yaml manually. 

344 self._revert_precommit()