Source code for mafw.devtools.toolchain.base
# Copyright 2026 European Union
# Author: Bulgheroni Antonio (antonio.bulgheroni@ec.europa.eu)
# SPDX-License-Identifier: EUPL-1.2
"""
Abstract base class and data models for the toolchain management system.
This module defines the :class:`ToolChainTool` interface that every managed
development tool must implement, the :class:`~mafw.devtools.toolchain.ProjectTool` intermediate base
class for tools managed via pyproject.toml, the :class:`~mafw.devtools.toolchain.HostTool`
intermediate base class for pipx-managed host tools, and supporting data
models used by the CLI commands to aggregate and report results.
"""
from __future__ import annotations
import abc
import json
import re
import shutil
import subprocess
from dataclasses import dataclass
from pathlib import Path
from typing import TYPE_CHECKING, Final, Literal
from packaging.version import Version
from mafw.devtools import DevtoolsError
from mafw.devtools.toolchain.pypi import fetch_latest_version
if TYPE_CHECKING:
from mafw.devtools.toolchain.pyproject_modifier import PyprojectModifier
LOWER_BOUND_RE: Final[re.Pattern[str]] = re.compile(r'>=\s*([A-Za-z0-9.*!+]+)')
"""Regex to extract the version portion from a ``>=`` specifier.
Shared across all project tools that parse pyproject.toml dependency
specifiers. Defined here to avoid duplication in each tool module.
"""
Category = Literal['host', 'project']
"""Tool category discriminator.
- ``"host"``: tools installed system-wide via pipx (e.g. hatch, uv).
- ``"project"``: tools whose version is managed through pyproject.toml.
"""
[docs]
@dataclass(frozen=True, slots=True)
class Issue:
"""A configuration inconsistency detected during verification.
Each instance captures a single problem found by a tool's
:meth:`ToolChainTool.verify` method.
:param tool_name: Name of the tool that reported the issue.
:param description: Human-readable explanation of the inconsistency.
"""
tool_name: str
description: str
[docs]
@dataclass(slots=True)
class ToolUpdateResult:
"""Result of processing a single tool during the update command.
Aggregates the outcome of calling :meth:`ToolChainTool.update` and the
optional :meth:`ToolChainTool.post_update` hook.
:param tool_name: Name of the tool that was processed.
:param updated: Whether the tool's configuration was changed.
:param error: Error message if the update method raised an exception.
:param hook_error: Error message if the post_update hook failed.
:param revert_failed: Whether the rollback attempt after a hook failure
also failed.
"""
tool_name: str
updated: bool
error: str | None = None
hook_error: str | None = None
revert_failed: bool = False
[docs]
@dataclass(slots=True)
class ToolCheckResult:
"""Result of checking a single tool's version status.
Captures the current and latest versions along with any errors
encountered during detection.
:param tool_name: Name of the tool that was checked.
:param category: The tool's category (``"host"`` or ``"project"``).
:param current_version: Currently installed/configured version, or
``None`` if detection failed.
:param latest_version: Latest available version from the canonical
source, or ``None`` if detection failed.
:param current_error: Error message if current version detection failed.
:param latest_error: Error message if latest version detection failed.
"""
tool_name: str
category: str
current_version: Version | None
latest_version: Version | None
current_error: str | None = None
latest_error: str | None = None
@property
def in_sync(self) -> bool:
"""Tool is in sync when both versions are known and equal.
Returns ``False`` if either version detection encountered an error
or if the two versions differ.
"""
if self.current_error or self.latest_error:
return False
return self.current_version == self.latest_version
[docs]
class ToolChainTool(abc.ABC):
"""Abstract interface that every managed development tool must implement.
Concrete subclasses represent individual tools (e.g. ruff, pytest, hatch)
and provide the logic for bootstrapping, version detection, updating, and
configuration verification.
"""
@property
@abc.abstractmethod
def name(self) -> str:
"""Human-readable tool identifier (lowercase, e.g. ``'ruff'``)."""
@property
@abc.abstractmethod
def category(self) -> Category:
"""Tool category: ``'host'`` (pipx-managed) or ``'project'`` (pyproject-managed)."""
[docs]
@abc.abstractmethod
def bootstrap(self) -> bool:
"""Install the tool if not present.
:return: ``True`` if installation was performed.
:raises Exception: If the tool is already installed or installation fails.
"""
[docs]
@abc.abstractmethod
def detect_current_version(self) -> Version | None:
"""Return the currently installed or configured version.
:return: The current :class:`~packaging.version.Version`, or ``None``
if the tool is not installed.
"""
[docs]
@abc.abstractmethod
def detect_latest_version(self) -> Version:
"""Return the latest available version from PyPI or the canonical source.
:return: The latest :class:`~packaging.version.Version`.
:raises Exception: If the version cannot be determined (e.g. network error).
"""
[docs]
@abc.abstractmethod
def update(self) -> bool:
"""Update the tool to its latest version.
:return: ``True`` if a configuration change was made, ``False`` if already
up to date.
:raises Exception: If the update process fails.
"""
[docs]
@abc.abstractmethod
def verify(self) -> list[Issue]:
"""Check configuration consistency and return any detected issues.
:return: A list of :class:`Issue` objects describing inconsistencies,
or an empty list when the tool's configuration is consistent.
"""
[docs]
def post_update(self) -> None:
"""Hook executed after a successful update.
Override in subclasses to run validation (e.g. test suites, type
checks) after the tool's version has been bumped. The default
implementation is a no-op.
"""
[docs]
class ProjectTool(ToolChainTool, abc.ABC):
"""Intermediate base for tools managed via pyproject.toml.
Concrete subclasses need only define :attr:`package_name` and
:attr:`section_path` as class-level attributes. The base class
provides working default implementations of all abstract methods
from :class:`ToolChainTool`.
Subclasses override only what differs (e.g. custom update logic
for ruff, ``post_update`` hooks for pytest/mypy/sphinx/pre-commit).
:param project_root: Path to the project root directory containing
``pyproject.toml``. Defaults to the current working directory.
:type project_root: Path | None
"""
@property
@abc.abstractmethod
def package_name(self) -> str:
"""PyPI package name (e.g. ``'pytest'``, ``'git-cliff'``)."""
@property
@abc.abstractmethod
def section_path(self) -> str:
"""Dot-separated TOML path to the dependency array.
Examples: ``'project.optional-dependencies.dev'``,
``'tool.hatch.envs.types.extra-dependencies'``.
"""
@property
@abc.abstractmethod
def env_name(self) -> str:
"""Hatch environment base name where this tool lives.
This is the environment that gets recreated (via a temporary
clone) during post-update validation. Examples: ``'dev'``,
``'types'``, ``'hatch-test'``.
"""
def __init__(self, project_root: Path | None = None) -> None:
self._project_root: Path = project_root or Path.cwd()
self._pyproject_path: Path = self._project_root / 'pyproject.toml'
# Saved content for the revert-on-failure pattern used by post_update hooks.
self._saved_content: str | None = None
# Cached highest Python version — computed lazily on first access.
self._py_version: str | None = None
@property
def name(self) -> str:
"""Human-readable tool identifier.
Defaults to :attr:`package_name`. Override in subclasses that need
a different display name.
"""
return self.package_name
@property
def category(self) -> Category:
"""Tool category.
:return: ``"project"`` — this tool is managed via pyproject.toml.
:rtype: Category
"""
return 'project'
[docs]
def bootstrap(self) -> bool:
"""Project tools cannot be bootstrapped independently.
They are installed automatically when syncing the appropriate Hatch
environment.
:raises DevtoolsError: Always, since project tools do not support
standalone bootstrapping.
"""
raise DevtoolsError(
f'{self.name!r} is a project tool managed through pyproject.toml. '
'It is installed automatically when creating the appropriate Hatch environment.'
)
[docs]
def detect_current_version(self) -> Version | None:
"""Parse the ``>=`` lower bound from pyproject.toml.
Reads the dependency specifier for :attr:`package_name` from the
section at :attr:`section_path` and extracts the version from its
``>=`` constraint using :data:`~mafw.devtools.toolchain.base.LOWER_BOUND_RE`.
:return: The currently configured lower-bound version, or ``None``
if the dependency cannot be found or lacks a ``>=`` specifier.
:rtype: Version | None
"""
modifier = self._get_modifier()
modifier.read()
try:
specifier = modifier.get_dependency_specifier(self.package_name, self.section_path)
except DevtoolsError:
return None
match = LOWER_BOUND_RE.search(specifier)
if not match:
return None
return Version(match.group(1))
[docs]
def detect_latest_version(self) -> Version:
"""Query PyPI for the latest stable release.
Delegates to :func:`~mafw.devtools.toolchain.pypi.fetch_latest_version`
using :attr:`package_name`.
:return: The latest non-pre-release version on PyPI.
:rtype: Version
:raises DevtoolsError: If the PyPI query fails.
"""
return fetch_latest_version(self.package_name)
[docs]
def update(self) -> bool:
"""Update the lower bound in pyproject.toml.
Compares the current lower bound with the latest PyPI version and
updates the specifier if necessary. Saves the original file content
before modification so that subclasses with :meth:`post_update` hooks
can revert on failure via :meth:`_revert`.
:return: ``True`` if the version was changed, ``False`` if already
up to date.
:raises DevtoolsError: If the update process fails.
"""
latest = self.detect_latest_version()
current = self.detect_current_version()
# Already up to date: no change needed.
if current is not None and current == latest:
return False
# Save pyproject.toml content before modifying for revert-on-failure.
self._saved_content = self._pyproject_path.read_text(encoding='utf-8')
# Write the updated lower bound.
modifier = self._get_modifier()
modifier.read()
modifier.update_lower_bound(self.package_name, str(latest), self.section_path)
modifier.write()
return True
[docs]
def verify(self) -> list[Issue]:
"""Return an empty list — default for single-location tools.
Most project tools are referenced in a single pyproject.toml section,
so there is no cross-file consistency to check. Tools with multiple
locations (e.g. ruff) override this method.
:return: An empty list of issues.
:rtype: list[Issue]
"""
return []
# ------------------------------------------------------------------
# Protected helpers available to subclasses
# ------------------------------------------------------------------
[docs]
def _get_modifier(self) -> PyprojectModifier:
"""Create a :class:`PyprojectModifier` for this project's pyproject.toml.
:return: A fresh modifier instance (not yet loaded).
:rtype: PyprojectModifier
"""
from mafw.devtools.toolchain.pyproject_modifier import PyprojectModifier as _PM
return _PM(self._pyproject_path)
[docs]
def _revert(self) -> None:
"""Restore pyproject.toml to the content saved before :meth:`update`.
If no content was saved (update was not called or already reverted),
this method is a no-op.
"""
if self._saved_content is not None:
self._pyproject_path.write_text(self._saved_content, encoding='utf-8')
self._saved_content = None
[docs]
def _recreate_environment(self, env_name: str, py_version: str | None = None) -> None:
"""Validate dependency resolution via a temporary clone environment.
Instead of removing and recreating the real environment (which may be
the active environment running ``devtools``), this method creates a
temporary clone environment in pyproject.toml, attempts to create it
via ``hatch env create``, and removes it afterwards. If the clone
creation fails due to a dependency resolution conflict, the
pyproject.toml is reverted (removing the clone declaration) and a
descriptive error is raised.
The temporary environment inherits the ``template`` of the target
environment, so it resolves the same dependency graph. Its name
is ``_toolchain_verify`` to avoid collisions with real environments.
:param env_name: The hatch environment base name to validate
(e.g. ``'dev'``, ``'types'``, ``'hatch-test'``).
:param py_version: Python version for matrix slot targeting
(e.g. ``'3.14'``). When provided, the clone is created for
that specific Python version.
:raises DevtoolsError: If the temporary environment creation fails
(indicating a resolution conflict with the updated dependencies).
"""
import tomlkit
clone_env_name = '_toolchain_verify'
clone_target = f'{clone_env_name}.py{py_version}' if py_version else clone_env_name
# Step 1: Add a temporary clone environment that templates the target.
# This inherits the target's features, dependencies, and scripts
# so that dependency resolution mirrors the real environment.
content = self._pyproject_path.read_text(encoding='utf-8')
doc = tomlkit.parse(content)
# Ensure [tool.hatch.envs] section exists, then add the clone.
tool = doc.setdefault('tool', tomlkit.table())
hatch = tool.setdefault('hatch', tomlkit.table())
envs = hatch.setdefault('envs', tomlkit.table())
# Build the clone env declaration: template = target, single matrix entry.
clone_table = tomlkit.table()
clone_table.add('template', env_name)
if py_version:
# Add a matrix entry so hatch creates the versioned slot.
# In TOML, [[tool.hatch.envs.X.matrix]] is an array of tables.
matrix_entry = tomlkit.table()
matrix_entry.add('python', [py_version])
matrix_array = tomlkit.aot()
matrix_array.append(matrix_entry)
clone_table.add('matrix', matrix_array)
envs[clone_env_name] = clone_table
self._pyproject_path.write_text(tomlkit.dumps(doc), encoding='utf-8')
# Step 2: Remove any leftover clone environment (ignore failures).
subprocess.run(
['hatch', 'env', 'remove', clone_target], # noqa: S603, S607
capture_output=True,
text=True,
check=False,
cwd=self._project_root,
)
# Step 3: Create the clone environment — this forces dependency resolution.
result = subprocess.run(
['hatch', 'env', 'create', clone_target], # noqa: S603, S607
capture_output=True,
text=True,
check=False,
cwd=self._project_root,
)
# Step 4: Clean up — remove the clone environment and its declaration.
subprocess.run(
['hatch', 'env', 'remove', clone_target], # noqa: S603, S607
capture_output=True,
text=True,
check=False,
cwd=self._project_root,
)
# Restore pyproject.toml to remove the clone declaration.
# Use the saved content if available (pre-update state), otherwise
# re-read and remove the section manually.
self._pyproject_path.write_text(content, encoding='utf-8')
# Step 5: Check if creation succeeded.
if result.returncode != 0:
conflict = self._parse_resolution_failure(result.stderr)
msg = f'Dependency resolution failed for environment {env_name!r}'
if py_version:
msg += f' (Python {py_version})'
if conflict:
msg += f' — conflicting package: {conflict}'
else:
msg += f': {result.stderr.strip()}'
self._revert()
raise DevtoolsError(msg)
[docs]
def _get_highest_python_version(self) -> str:
"""Return the highest supported Python version (cached after first call).
On the first invocation, reads ``tool.mafw.supported-python`` from
pyproject.toml and caches the result. Subsequent calls return the
cached value without re-reading the file, avoiding repeated TOML
parsing across multiple tools.
:return: The highest supported Python version as a dotted string
(e.g. ``"3.14"``).
:rtype: str
:raises DevtoolsError: If ``tool.mafw.supported-python`` is missing
or empty in pyproject.toml.
"""
if self._py_version is not None:
return self._py_version
modifier = self._get_modifier()
modifier.read()
# Access the parsed tomlkit document directly to read the version list.
doc = modifier._doc # noqa: SLF001
try:
versions: list[str] = list(doc['tool']['mafw']['supported-python']) # type: ignore[index]
except (KeyError, TypeError) as exc:
raise DevtoolsError(
'Cannot determine highest Python version: '
f'tool.mafw.supported-python not found in pyproject.toml ({exc})'
) from exc
if not versions:
raise DevtoolsError('tool.mafw.supported-python is empty in pyproject.toml')
# Sort by numeric tuple to find the highest version reliably.
self._py_version = sorted(versions, key=lambda v: tuple(int(p) for p in v.split('.')))[-1]
return self._py_version
[docs]
@staticmethod
def _parse_resolution_failure(stderr: str) -> str | None:
"""Extract the conflicting package name from resolver error output.
Parses hatch/uv resolver error messages to identify which package
caused a dependency resolution failure. Returns the package name
if found, or ``None`` if the error cannot be parsed.
Handles common patterns such as:
- ``"Because only <package><=X.Y is available ..."``
- ``"Because <package>>=X.Y depends on ..."``
- ``"package <package> has no version that satisfies ..."``
- ``"Could not find a version that satisfies the requirement <package>"``
- ``"No matching distribution found for <package>"``
Package names may contain letters, digits, hyphens, underscores,
and dots (e.g. ``sphinxcontrib-external-links``, ``ruamel.yaml``).
:param stderr: The standard error output from a failed resolver command.
:return: The conflicting package name, or ``None`` if not parseable.
:rtype: str | None
"""
# Package name pattern: allows letters, digits, hyphens, underscores, and dots
# per PEP 508 / PyPI naming rules.
_pkg = r'[A-Za-z0-9](?:[A-Za-z0-9._-]*[A-Za-z0-9])?'
patterns = [
re.compile(rf'Because (?:only )?({_pkg})'),
re.compile(rf'package\s+({_pkg})\s+has no version'),
re.compile(rf'satisfies the requirement\s+({_pkg})'),
re.compile(rf'No matching distribution found for\s+({_pkg})'),
]
for pattern in patterns:
match = pattern.search(stderr)
if match:
return match.group(1)
return None
[docs]
class HostTool(ToolChainTool, abc.ABC):
"""Intermediate base for tools installed system-wide via pipx.
Concrete subclasses need only define :attr:`pipx_package_name` as a
class-level attribute. This base class provides working default
implementations of all abstract methods from :class:`ToolChainTool`.
Subclasses may override individual methods for tool-specific behaviour
(e.g. :class:`~mafw.devtools.toolchain.tools.uv.UvTool` overrides
:meth:`update` and :meth:`verify` to additionally synchronize
pyproject.toml).
"""
@property
@abc.abstractmethod
def pipx_package_name(self) -> str:
"""The package name as known to pipx (e.g. ``'hatch'``, ``'uv'``)."""
@property
def name(self) -> str:
"""Human-readable tool identifier.
Defaults to :attr:`pipx_package_name`. Override in subclasses that
need a different display name.
"""
return self.pipx_package_name
@property
def category(self) -> Category:
"""Tool category: ``'host'`` (pipx-managed)."""
return 'host'
[docs]
def bootstrap(self) -> bool:
"""Install the tool via ``pipx install``.
:return: ``True`` if installation was performed successfully.
:raises DevtoolsError: If pipx is not available or installation fails.
"""
pipx_path = self._ensure_pipx_available()
result = subprocess.run(
[pipx_path, 'install', self.pipx_package_name],
capture_output=True,
text=True,
check=False,
)
if result.returncode != 0:
raise DevtoolsError(
f'Failed to install {self.name} via pipx (exit code {result.returncode}): {result.stderr.strip()}'
)
return True
[docs]
def detect_current_version(self) -> Version | None:
"""Detect the installed version from pipx metadata.
Runs ``pipx list --json`` and parses the JSON output to extract the
version of this package.
:return: The installed :class:`~packaging.version.Version`, or
``None`` if the package is not installed via pipx.
:raises DevtoolsError: If pipx is not available or the JSON output
cannot be parsed.
"""
return self._get_pipx_version()
[docs]
def detect_latest_version(self) -> Version:
"""Query PyPI for the latest stable release.
:return: The latest available :class:`~packaging.version.Version`.
:raises DevtoolsError: If the PyPI query fails.
"""
return fetch_latest_version(self.pipx_package_name)
[docs]
def update(self) -> bool:
"""Upgrade the tool via ``pipx upgrade``.
Compares the installed version before and after the upgrade to
determine whether a change occurred.
:return: ``True`` if the version changed, ``False`` if the tool was
already at the latest version.
:raises DevtoolsError: If pipx is not available or the upgrade fails.
"""
pipx_path = self._ensure_pipx_available()
# Record version before upgrade for comparison.
version_before = self.detect_current_version()
result = subprocess.run(
[pipx_path, 'upgrade', self.pipx_package_name],
capture_output=True,
text=True,
check=False,
)
if result.returncode != 0:
raise DevtoolsError(
f'Failed to upgrade {self.name} via pipx (exit code {result.returncode}): {result.stderr.strip()}'
)
# Detect version after upgrade to determine if it changed.
version_after = self.detect_current_version()
return version_before != version_after
[docs]
def verify(self) -> list[Issue]:
"""Verify configuration consistency.
Host tools that do not modify repository files have no cross-file
consistency checks. Override in subclasses that need verification
(e.g. uv checks its pyproject.toml specifier).
:return: An empty list (no issues to report).
"""
return []
# ------------------------------------------------------------------
# Protected helpers
# ------------------------------------------------------------------
[docs]
def _ensure_pipx_available(self) -> str:
"""Locate the pipx executable on the system PATH.
:return: Absolute path to the pipx executable.
:raises DevtoolsError: If pipx is not found on the system PATH.
"""
pipx_path = shutil.which('pipx')
if pipx_path is None:
raise DevtoolsError(
'pipx is required but not found on the system PATH. '
'Install pipx first: https://pipx.pypa.io/stable/installation/'
)
return pipx_path
[docs]
def _get_pipx_version(self) -> Version | None:
"""Parse the installed version from ``pipx list --json`` output.
Queries pipx for its list of managed packages and extracts the
version string for :attr:`pipx_package_name`.
:return: The installed :class:`~packaging.version.Version`, or
``None`` if the package is not present in pipx.
:raises DevtoolsError: If the pipx command fails or the JSON output
has an unexpected structure.
"""
pipx_path = self._ensure_pipx_available()
result = subprocess.run(
[pipx_path, 'list', '--json'],
capture_output=True,
text=True,
check=False,
)
if result.returncode != 0:
raise DevtoolsError(
f'Failed to query pipx metadata (exit code {result.returncode}): {result.stderr.strip()}'
)
try:
data = json.loads(result.stdout)
except (json.JSONDecodeError, ValueError) as exc:
raise DevtoolsError(f'Unable to parse pipx list JSON output: {exc}') from exc
# pipx list --json returns a dict with "venvs" key containing package info.
venvs = data.get('venvs', {})
pkg_info = venvs.get(self.pipx_package_name)
if pkg_info is None:
return None
# Extract version from the package metadata.
try:
version_str = pkg_info['metadata']['main_package']['package_version']
except (KeyError, TypeError) as exc:
raise DevtoolsError(f'Unexpected pipx metadata structure for {self.name}: {exc}') from exc
return Version(version_str)