Coverage for src/mafw/devtools/toolchain/base.py: 98%
223 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"""
5Abstract base class and data models for the toolchain management system.
7This module defines the :class:`ToolChainTool` interface that every managed
8development tool must implement, the :class:`~mafw.devtools.toolchain.ProjectTool` intermediate base
9class for tools managed via pyproject.toml, the :class:`~mafw.devtools.toolchain.HostTool`
10intermediate base class for pipx-managed host tools, and supporting data
11models used by the CLI commands to aggregate and report results.
12"""
14from __future__ import annotations
16import abc
17import json
18import re
19import shutil
20import subprocess
21from dataclasses import dataclass
22from pathlib import Path
23from typing import TYPE_CHECKING, Final, Literal
25from packaging.version import Version
27from mafw.devtools import DevtoolsError
28from mafw.devtools.toolchain.pypi import fetch_latest_version
30if TYPE_CHECKING:
31 from mafw.devtools.toolchain.pyproject_modifier import PyprojectModifier
33LOWER_BOUND_RE: Final[re.Pattern[str]] = re.compile(r'>=\s*([A-Za-z0-9.*!+]+)')
34"""Regex to extract the version portion from a ``>=`` specifier.
36Shared across all project tools that parse pyproject.toml dependency
37specifiers. Defined here to avoid duplication in each tool module.
38"""
40Category = Literal['host', 'project']
41"""Tool category discriminator.
43- ``"host"``: tools installed system-wide via pipx (e.g. hatch, uv).
44- ``"project"``: tools whose version is managed through pyproject.toml.
45"""
48@dataclass(frozen=True, slots=True)
49class Issue:
50 """A configuration inconsistency detected during verification.
52 Each instance captures a single problem found by a tool's
53 :meth:`ToolChainTool.verify` method.
55 :param tool_name: Name of the tool that reported the issue.
56 :param description: Human-readable explanation of the inconsistency.
57 """
59 tool_name: str
60 description: str
63@dataclass(slots=True)
64class ToolUpdateResult:
65 """Result of processing a single tool during the update command.
67 Aggregates the outcome of calling :meth:`ToolChainTool.update` and the
68 optional :meth:`ToolChainTool.post_update` hook.
70 :param tool_name: Name of the tool that was processed.
71 :param updated: Whether the tool's configuration was changed.
72 :param error: Error message if the update method raised an exception.
73 :param hook_error: Error message if the post_update hook failed.
74 :param revert_failed: Whether the rollback attempt after a hook failure
75 also failed.
76 """
78 tool_name: str
79 updated: bool
80 error: str | None = None
81 hook_error: str | None = None
82 revert_failed: bool = False
85@dataclass(slots=True)
86class ToolCheckResult:
87 """Result of checking a single tool's version status.
89 Captures the current and latest versions along with any errors
90 encountered during detection.
92 :param tool_name: Name of the tool that was checked.
93 :param category: The tool's category (``"host"`` or ``"project"``).
94 :param current_version: Currently installed/configured version, or
95 ``None`` if detection failed.
96 :param latest_version: Latest available version from the canonical
97 source, or ``None`` if detection failed.
98 :param current_error: Error message if current version detection failed.
99 :param latest_error: Error message if latest version detection failed.
100 """
102 tool_name: str
103 category: str
104 current_version: Version | None
105 latest_version: Version | None
106 current_error: str | None = None
107 latest_error: str | None = None
109 @property
110 def in_sync(self) -> bool:
111 """Tool is in sync when both versions are known and equal.
113 Returns ``False`` if either version detection encountered an error
114 or if the two versions differ.
115 """
116 if self.current_error or self.latest_error:
117 return False
118 return self.current_version == self.latest_version
121class ToolChainTool(abc.ABC):
122 """Abstract interface that every managed development tool must implement.
124 Concrete subclasses represent individual tools (e.g. ruff, pytest, hatch)
125 and provide the logic for bootstrapping, version detection, updating, and
126 configuration verification.
127 """
129 @property
130 @abc.abstractmethod
131 def name(self) -> str:
132 """Human-readable tool identifier (lowercase, e.g. ``'ruff'``)."""
134 @property
135 @abc.abstractmethod
136 def category(self) -> Category:
137 """Tool category: ``'host'`` (pipx-managed) or ``'project'`` (pyproject-managed)."""
139 @abc.abstractmethod
140 def bootstrap(self) -> bool:
141 """Install the tool if not present.
143 :return: ``True`` if installation was performed.
144 :raises Exception: If the tool is already installed or installation fails.
145 """
147 @abc.abstractmethod
148 def detect_current_version(self) -> Version | None:
149 """Return the currently installed or configured version.
151 :return: The current :class:`~packaging.version.Version`, or ``None``
152 if the tool is not installed.
153 """
155 @abc.abstractmethod
156 def detect_latest_version(self) -> Version:
157 """Return the latest available version from PyPI or the canonical source.
159 :return: The latest :class:`~packaging.version.Version`.
160 :raises Exception: If the version cannot be determined (e.g. network error).
161 """
163 @abc.abstractmethod
164 def update(self) -> bool:
165 """Update the tool to its latest version.
167 :return: ``True`` if a configuration change was made, ``False`` if already
168 up to date.
169 :raises Exception: If the update process fails.
170 """
172 @abc.abstractmethod
173 def verify(self) -> list[Issue]:
174 """Check configuration consistency and return any detected issues.
176 :return: A list of :class:`Issue` objects describing inconsistencies,
177 or an empty list when the tool's configuration is consistent.
178 """
180 def post_update(self) -> None:
181 """Hook executed after a successful update.
183 Override in subclasses to run validation (e.g. test suites, type
184 checks) after the tool's version has been bumped. The default
185 implementation is a no-op.
186 """
189class ProjectTool(ToolChainTool, abc.ABC):
190 """Intermediate base for tools managed via pyproject.toml.
192 Concrete subclasses need only define :attr:`package_name` and
193 :attr:`section_path` as class-level attributes. The base class
194 provides working default implementations of all abstract methods
195 from :class:`ToolChainTool`.
197 Subclasses override only what differs (e.g. custom update logic
198 for ruff, ``post_update`` hooks for pytest/mypy/sphinx/pre-commit).
200 :param project_root: Path to the project root directory containing
201 ``pyproject.toml``. Defaults to the current working directory.
202 :type project_root: Path | None
203 """
205 @property
206 @abc.abstractmethod
207 def package_name(self) -> str:
208 """PyPI package name (e.g. ``'pytest'``, ``'git-cliff'``)."""
210 @property
211 @abc.abstractmethod
212 def section_path(self) -> str:
213 """Dot-separated TOML path to the dependency array.
215 Examples: ``'project.optional-dependencies.dev'``,
216 ``'tool.hatch.envs.types.extra-dependencies'``.
217 """
219 @property
220 @abc.abstractmethod
221 def env_name(self) -> str:
222 """Hatch environment base name where this tool lives.
224 This is the environment that gets recreated (via a temporary
225 clone) during post-update validation. Examples: ``'dev'``,
226 ``'types'``, ``'hatch-test'``.
227 """
229 def __init__(self, project_root: Path | None = None) -> None:
230 self._project_root: Path = project_root or Path.cwd()
231 self._pyproject_path: Path = self._project_root / 'pyproject.toml'
232 # Saved content for the revert-on-failure pattern used by post_update hooks.
233 self._saved_content: str | None = None
234 # Cached highest Python version — computed lazily on first access.
235 self._py_version: str | None = None
237 @property
238 def name(self) -> str:
239 """Human-readable tool identifier.
241 Defaults to :attr:`package_name`. Override in subclasses that need
242 a different display name.
243 """
244 return self.package_name
246 @property
247 def category(self) -> Category:
248 """Tool category.
250 :return: ``"project"`` — this tool is managed via pyproject.toml.
251 :rtype: Category
252 """
253 return 'project'
255 def bootstrap(self) -> bool:
256 """Project tools cannot be bootstrapped independently.
258 They are installed automatically when syncing the appropriate Hatch
259 environment.
261 :raises DevtoolsError: Always, since project tools do not support
262 standalone bootstrapping.
263 """
264 raise DevtoolsError(
265 f'{self.name!r} is a project tool managed through pyproject.toml. '
266 'It is installed automatically when creating the appropriate Hatch environment.'
267 )
269 def detect_current_version(self) -> Version | None:
270 """Parse the ``>=`` lower bound from pyproject.toml.
272 Reads the dependency specifier for :attr:`package_name` from the
273 section at :attr:`section_path` and extracts the version from its
274 ``>=`` constraint using :data:`~mafw.devtools.toolchain.base.LOWER_BOUND_RE`.
276 :return: The currently configured lower-bound version, or ``None``
277 if the dependency cannot be found or lacks a ``>=`` specifier.
278 :rtype: Version | None
279 """
280 modifier = self._get_modifier()
281 modifier.read()
283 try:
284 specifier = modifier.get_dependency_specifier(self.package_name, self.section_path)
285 except DevtoolsError:
286 return None
288 match = LOWER_BOUND_RE.search(specifier)
289 if not match:
290 return None
292 return Version(match.group(1))
294 def detect_latest_version(self) -> Version:
295 """Query PyPI for the latest stable release.
297 Delegates to :func:`~mafw.devtools.toolchain.pypi.fetch_latest_version`
298 using :attr:`package_name`.
300 :return: The latest non-pre-release version on PyPI.
301 :rtype: Version
302 :raises DevtoolsError: If the PyPI query fails.
303 """
304 return fetch_latest_version(self.package_name)
306 def update(self) -> bool:
307 """Update the lower bound in pyproject.toml.
309 Compares the current lower bound with the latest PyPI version and
310 updates the specifier if necessary. Saves the original file content
311 before modification so that subclasses with :meth:`post_update` hooks
312 can revert on failure via :meth:`_revert`.
314 :return: ``True`` if the version was changed, ``False`` if already
315 up to date.
316 :raises DevtoolsError: If the update process fails.
317 """
318 latest = self.detect_latest_version()
319 current = self.detect_current_version()
321 # Already up to date: no change needed.
322 if current is not None and current == latest:
323 return False
325 # Save pyproject.toml content before modifying for revert-on-failure.
326 self._saved_content = self._pyproject_path.read_text(encoding='utf-8')
328 # Write the updated lower bound.
329 modifier = self._get_modifier()
330 modifier.read()
331 modifier.update_lower_bound(self.package_name, str(latest), self.section_path)
332 modifier.write()
333 return True
335 def verify(self) -> list[Issue]:
336 """Return an empty list — default for single-location tools.
338 Most project tools are referenced in a single pyproject.toml section,
339 so there is no cross-file consistency to check. Tools with multiple
340 locations (e.g. ruff) override this method.
342 :return: An empty list of issues.
343 :rtype: list[Issue]
344 """
345 return []
347 # ------------------------------------------------------------------
348 # Protected helpers available to subclasses
349 # ------------------------------------------------------------------
351 def _get_modifier(self) -> PyprojectModifier:
352 """Create a :class:`PyprojectModifier` for this project's pyproject.toml.
354 :return: A fresh modifier instance (not yet loaded).
355 :rtype: PyprojectModifier
356 """
357 from mafw.devtools.toolchain.pyproject_modifier import PyprojectModifier as _PM
359 return _PM(self._pyproject_path)
361 def _revert(self) -> None:
362 """Restore pyproject.toml to the content saved before :meth:`update`.
364 If no content was saved (update was not called or already reverted),
365 this method is a no-op.
366 """
367 if self._saved_content is not None:
368 self._pyproject_path.write_text(self._saved_content, encoding='utf-8')
369 self._saved_content = None
371 def _recreate_environment(self, env_name: str, py_version: str | None = None) -> None:
372 """Validate dependency resolution via a temporary clone environment.
374 Instead of removing and recreating the real environment (which may be
375 the active environment running ``devtools``), this method creates a
376 temporary clone environment in pyproject.toml, attempts to create it
377 via ``hatch env create``, and removes it afterwards. If the clone
378 creation fails due to a dependency resolution conflict, the
379 pyproject.toml is reverted (removing the clone declaration) and a
380 descriptive error is raised.
382 The temporary environment inherits the ``template`` of the target
383 environment, so it resolves the same dependency graph. Its name
384 is ``_toolchain_verify`` to avoid collisions with real environments.
386 :param env_name: The hatch environment base name to validate
387 (e.g. ``'dev'``, ``'types'``, ``'hatch-test'``).
388 :param py_version: Python version for matrix slot targeting
389 (e.g. ``'3.14'``). When provided, the clone is created for
390 that specific Python version.
391 :raises DevtoolsError: If the temporary environment creation fails
392 (indicating a resolution conflict with the updated dependencies).
393 """
394 import tomlkit
396 clone_env_name = '_toolchain_verify'
397 clone_target = f'{clone_env_name}.py{py_version}' if py_version else clone_env_name
399 # Step 1: Add a temporary clone environment that templates the target.
400 # This inherits the target's features, dependencies, and scripts
401 # so that dependency resolution mirrors the real environment.
402 content = self._pyproject_path.read_text(encoding='utf-8')
403 doc = tomlkit.parse(content)
405 # Ensure [tool.hatch.envs] section exists, then add the clone.
406 tool = doc.setdefault('tool', tomlkit.table())
407 hatch = tool.setdefault('hatch', tomlkit.table())
408 envs = hatch.setdefault('envs', tomlkit.table())
410 # Build the clone env declaration: template = target, single matrix entry.
411 clone_table = tomlkit.table()
412 clone_table.add('template', env_name)
413 if py_version:
414 # Add a matrix entry so hatch creates the versioned slot.
415 # In TOML, [[tool.hatch.envs.X.matrix]] is an array of tables.
416 matrix_entry = tomlkit.table()
417 matrix_entry.add('python', [py_version])
418 matrix_array = tomlkit.aot()
419 matrix_array.append(matrix_entry)
420 clone_table.add('matrix', matrix_array)
422 envs[clone_env_name] = clone_table
423 self._pyproject_path.write_text(tomlkit.dumps(doc), encoding='utf-8')
425 # Step 2: Remove any leftover clone environment (ignore failures).
426 subprocess.run(
427 ['hatch', 'env', 'remove', clone_target], # noqa: S603, S607
428 capture_output=True,
429 text=True,
430 check=False,
431 cwd=self._project_root,
432 )
434 # Step 3: Create the clone environment — this forces dependency resolution.
435 result = subprocess.run(
436 ['hatch', 'env', 'create', clone_target], # noqa: S603, S607
437 capture_output=True,
438 text=True,
439 check=False,
440 cwd=self._project_root,
441 )
443 # Step 4: Clean up — remove the clone environment and its declaration.
444 subprocess.run(
445 ['hatch', 'env', 'remove', clone_target], # noqa: S603, S607
446 capture_output=True,
447 text=True,
448 check=False,
449 cwd=self._project_root,
450 )
451 # Restore pyproject.toml to remove the clone declaration.
452 # Use the saved content if available (pre-update state), otherwise
453 # re-read and remove the section manually.
454 self._pyproject_path.write_text(content, encoding='utf-8')
456 # Step 5: Check if creation succeeded.
457 if result.returncode != 0:
458 conflict = self._parse_resolution_failure(result.stderr)
459 msg = f'Dependency resolution failed for environment {env_name!r}'
460 if py_version: 460 ↛ 461line 460 didn't jump to line 461 because the condition on line 460 was never true
461 msg += f' (Python {py_version})'
462 if conflict: 462 ↛ 463line 462 didn't jump to line 463 because the condition on line 462 was never true
463 msg += f' — conflicting package: {conflict}'
464 else:
465 msg += f': {result.stderr.strip()}'
466 self._revert()
467 raise DevtoolsError(msg)
469 def _get_highest_python_version(self) -> str:
470 """Return the highest supported Python version (cached after first call).
472 On the first invocation, reads ``tool.mafw.supported-python`` from
473 pyproject.toml and caches the result. Subsequent calls return the
474 cached value without re-reading the file, avoiding repeated TOML
475 parsing across multiple tools.
477 :return: The highest supported Python version as a dotted string
478 (e.g. ``"3.14"``).
479 :rtype: str
480 :raises DevtoolsError: If ``tool.mafw.supported-python`` is missing
481 or empty in pyproject.toml.
482 """
483 if self._py_version is not None:
484 return self._py_version
486 modifier = self._get_modifier()
487 modifier.read()
488 # Access the parsed tomlkit document directly to read the version list.
489 doc = modifier._doc # noqa: SLF001
490 try:
491 versions: list[str] = list(doc['tool']['mafw']['supported-python']) # type: ignore[index]
492 except (KeyError, TypeError) as exc:
493 raise DevtoolsError(
494 'Cannot determine highest Python version: '
495 f'tool.mafw.supported-python not found in pyproject.toml ({exc})'
496 ) from exc
497 if not versions:
498 raise DevtoolsError('tool.mafw.supported-python is empty in pyproject.toml')
499 # Sort by numeric tuple to find the highest version reliably.
500 self._py_version = sorted(versions, key=lambda v: tuple(int(p) for p in v.split('.')))[-1]
501 return self._py_version
503 @staticmethod
504 def _parse_resolution_failure(stderr: str) -> str | None:
505 """Extract the conflicting package name from resolver error output.
507 Parses hatch/uv resolver error messages to identify which package
508 caused a dependency resolution failure. Returns the package name
509 if found, or ``None`` if the error cannot be parsed.
511 Handles common patterns such as:
513 - ``"Because only <package><=X.Y is available ..."``
514 - ``"Because <package>>=X.Y depends on ..."``
515 - ``"package <package> has no version that satisfies ..."``
516 - ``"Could not find a version that satisfies the requirement <package>"``
517 - ``"No matching distribution found for <package>"``
519 Package names may contain letters, digits, hyphens, underscores,
520 and dots (e.g. ``sphinxcontrib-external-links``, ``ruamel.yaml``).
522 :param stderr: The standard error output from a failed resolver command.
523 :return: The conflicting package name, or ``None`` if not parseable.
524 :rtype: str | None
525 """
526 # Package name pattern: allows letters, digits, hyphens, underscores, and dots
527 # per PEP 508 / PyPI naming rules.
528 _pkg = r'[A-Za-z0-9](?:[A-Za-z0-9._-]*[A-Za-z0-9])?'
529 patterns = [
530 re.compile(rf'Because (?:only )?({_pkg})'),
531 re.compile(rf'package\s+({_pkg})\s+has no version'),
532 re.compile(rf'satisfies the requirement\s+({_pkg})'),
533 re.compile(rf'No matching distribution found for\s+({_pkg})'),
534 ]
535 for pattern in patterns:
536 match = pattern.search(stderr)
537 if match:
538 return match.group(1)
539 return None
542class HostTool(ToolChainTool, abc.ABC):
543 """Intermediate base for tools installed system-wide via pipx.
545 Concrete subclasses need only define :attr:`pipx_package_name` as a
546 class-level attribute. This base class provides working default
547 implementations of all abstract methods from :class:`ToolChainTool`.
549 Subclasses may override individual methods for tool-specific behaviour
550 (e.g. :class:`~mafw.devtools.toolchain.tools.uv.UvTool` overrides
551 :meth:`update` and :meth:`verify` to additionally synchronize
552 pyproject.toml).
553 """
555 @property
556 @abc.abstractmethod
557 def pipx_package_name(self) -> str:
558 """The package name as known to pipx (e.g. ``'hatch'``, ``'uv'``)."""
560 @property
561 def name(self) -> str:
562 """Human-readable tool identifier.
564 Defaults to :attr:`pipx_package_name`. Override in subclasses that
565 need a different display name.
566 """
567 return self.pipx_package_name
569 @property
570 def category(self) -> Category:
571 """Tool category: ``'host'`` (pipx-managed)."""
572 return 'host'
574 def bootstrap(self) -> bool:
575 """Install the tool via ``pipx install``.
577 :return: ``True`` if installation was performed successfully.
578 :raises DevtoolsError: If pipx is not available or installation fails.
579 """
580 pipx_path = self._ensure_pipx_available()
581 result = subprocess.run(
582 [pipx_path, 'install', self.pipx_package_name],
583 capture_output=True,
584 text=True,
585 check=False,
586 )
587 if result.returncode != 0:
588 raise DevtoolsError(
589 f'Failed to install {self.name} via pipx (exit code {result.returncode}): {result.stderr.strip()}'
590 )
591 return True
593 def detect_current_version(self) -> Version | None:
594 """Detect the installed version from pipx metadata.
596 Runs ``pipx list --json`` and parses the JSON output to extract the
597 version of this package.
599 :return: The installed :class:`~packaging.version.Version`, or
600 ``None`` if the package is not installed via pipx.
601 :raises DevtoolsError: If pipx is not available or the JSON output
602 cannot be parsed.
603 """
604 return self._get_pipx_version()
606 def detect_latest_version(self) -> Version:
607 """Query PyPI for the latest stable release.
609 :return: The latest available :class:`~packaging.version.Version`.
610 :raises DevtoolsError: If the PyPI query fails.
611 """
612 return fetch_latest_version(self.pipx_package_name)
614 def update(self) -> bool:
615 """Upgrade the tool via ``pipx upgrade``.
617 Compares the installed version before and after the upgrade to
618 determine whether a change occurred.
620 :return: ``True`` if the version changed, ``False`` if the tool was
621 already at the latest version.
622 :raises DevtoolsError: If pipx is not available or the upgrade fails.
623 """
624 pipx_path = self._ensure_pipx_available()
626 # Record version before upgrade for comparison.
627 version_before = self.detect_current_version()
629 result = subprocess.run(
630 [pipx_path, 'upgrade', self.pipx_package_name],
631 capture_output=True,
632 text=True,
633 check=False,
634 )
635 if result.returncode != 0:
636 raise DevtoolsError(
637 f'Failed to upgrade {self.name} via pipx (exit code {result.returncode}): {result.stderr.strip()}'
638 )
640 # Detect version after upgrade to determine if it changed.
641 version_after = self.detect_current_version()
642 return version_before != version_after
644 def verify(self) -> list[Issue]:
645 """Verify configuration consistency.
647 Host tools that do not modify repository files have no cross-file
648 consistency checks. Override in subclasses that need verification
649 (e.g. uv checks its pyproject.toml specifier).
651 :return: An empty list (no issues to report).
652 """
653 return []
655 # ------------------------------------------------------------------
656 # Protected helpers
657 # ------------------------------------------------------------------
659 def _ensure_pipx_available(self) -> str:
660 """Locate the pipx executable on the system PATH.
662 :return: Absolute path to the pipx executable.
663 :raises DevtoolsError: If pipx is not found on the system PATH.
664 """
665 pipx_path = shutil.which('pipx')
666 if pipx_path is None:
667 raise DevtoolsError(
668 'pipx is required but not found on the system PATH. '
669 'Install pipx first: https://pipx.pypa.io/stable/installation/'
670 )
671 return pipx_path
673 def _get_pipx_version(self) -> Version | None:
674 """Parse the installed version from ``pipx list --json`` output.
676 Queries pipx for its list of managed packages and extracts the
677 version string for :attr:`pipx_package_name`.
679 :return: The installed :class:`~packaging.version.Version`, or
680 ``None`` if the package is not present in pipx.
681 :raises DevtoolsError: If the pipx command fails or the JSON output
682 has an unexpected structure.
683 """
684 pipx_path = self._ensure_pipx_available()
685 result = subprocess.run(
686 [pipx_path, 'list', '--json'],
687 capture_output=True,
688 text=True,
689 check=False,
690 )
691 if result.returncode != 0:
692 raise DevtoolsError(
693 f'Failed to query pipx metadata (exit code {result.returncode}): {result.stderr.strip()}'
694 )
696 try:
697 data = json.loads(result.stdout)
698 except (json.JSONDecodeError, ValueError) as exc:
699 raise DevtoolsError(f'Unable to parse pipx list JSON output: {exc}') from exc
701 # pipx list --json returns a dict with "venvs" key containing package info.
702 venvs = data.get('venvs', {})
703 pkg_info = venvs.get(self.pipx_package_name)
704 if pkg_info is None:
705 return None
707 # Extract version from the package metadata.
708 try:
709 version_str = pkg_info['metadata']['main_package']['package_version']
710 except (KeyError, TypeError) as exc:
711 raise DevtoolsError(f'Unexpected pipx metadata structure for {self.name}: {exc}') from exc
713 return Version(version_str)