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