Source code for mafw.devtools
# Copyright 2026 European Union
# Author: Bulgheroni Antonio (antonio.bulgheroni@ec.europa.eu)
# SPDX-License-Identifier: EUPL-1.2
"""
MAFw development tools package.
This package contains the business logic modules used by the ``release-mgt``
and ``multiversion-doc`` CLI scripts. It requires optional development
dependencies (``requests``, ``packaging``) that are not part of the core
MAFw runtime.
Use :func:`ensure_devtools_available` as a guard before importing subpackages.
"""
from __future__ import annotations
[docs]
class DevtoolsError(Exception):
"""Base exception for MAFw development tool errors.
This exception replaces direct usage of :exc:`click.ClickException` in
business-logic modules so that the library layer remains independent of the
CLI framework.
"""
_devtools_checked: bool = False
"""Module-level flag to skip repeated import checks after the first successful call."""
[docs]
def _read_devtools_deps() -> list[str]:
"""Read the ``devtools`` optional-dependency names from MAFw package metadata.
This function requires ``packaging`` to be importable (the caller ensures
this). It uses :class:`~packaging.requirements.Requirement` to parse the
dependency specifiers reliably.
Falls back to reading ``pyproject.toml`` from the source tree if the
installed metadata does not yet include the ``devtools`` extra (e.g. stale
editable install).
:return: List of top-level package names required by the ``devtools`` extra.
:rtype: list[str]
"""
import importlib.metadata
from packaging.requirements import Requirement
try:
requires = importlib.metadata.metadata('mafw').get_all('Requires-Dist') or []
names: list[str] = []
for entry in requires:
req = Requirement(entry)
# Only match entries whose marker explicitly mentions extra == "devtools".
if req.marker and 'extra == "devtools"' in str(req.marker):
names.append(req.name.replace('-', '_').lower())
if names:
return names
except importlib.metadata.PackageNotFoundError:
pass
# Fallback: read pyproject.toml from the source tree.
import tomllib
from pathlib import Path
pyproject = Path(__file__).resolve().parents[2] / 'pyproject.toml'
if pyproject.exists():
with pyproject.open('rb') as f:
data = tomllib.load(f)
deps = data.get('project', {}).get('optional-dependencies', {}).get('devtools', [])
return [Requirement(dep).name.replace('-', '_').lower() for dep in deps]
return []
[docs]
def ensure_devtools_available() -> None:
"""Verify that optional development dependencies are importable.
The list of required packages is read from MAFw's own ``[devtools]``
optional-dependency group in ``pyproject.toml``, keeping it as the single
source of truth. The check is performed only once; subsequent calls return
immediately.
:raises ImportError: If ``packaging`` or any other package from the
``devtools`` extra cannot be imported.
"""
global _devtools_checked # noqa: PLW0603
if _devtools_checked:
return
# packaging is required to parse the dependency list itself.
try:
import packaging # noqa: F401
except ImportError as exc:
raise ImportError(
'Unable to import "packaging", which is required by MAFw development tools. '
'Install MAFw with the optional [devtools] feature, or invoke the helper via Hatch '
'(e.g. "hatch run dev.py3.14:release --help").'
) from exc
# Read remaining deps from metadata/pyproject.toml and verify each.
import importlib
missing: list[str] = []
for name in _read_devtools_deps():
try:
importlib.import_module(name)
except ImportError:
missing.append(name)
if missing:
raise ImportError(
f'Unable to import optional development dependencies: {", ".join(missing)}. '
'Install MAFw with the optional [devtools] feature, or invoke the helper via Hatch '
'(e.g. "hatch run dev.py3.14:release --help").'
)
_devtools_checked = True