Coverage for src/mafw/devtools/__init__.py: 100%
45 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"""
5MAFw development tools package.
7This package contains the business logic modules used by the ``release-mgt``
8and ``multiversion-doc`` CLI scripts. It requires optional development
9dependencies (``requests``, ``packaging``) that are not part of the core
10MAFw runtime.
12Use :func:`ensure_devtools_available` as a guard before importing subpackages.
13"""
15from __future__ import annotations
18class DevtoolsError(Exception):
19 """Base exception for MAFw development tool errors.
21 This exception replaces direct usage of :exc:`click.ClickException` in
22 business-logic modules so that the library layer remains independent of the
23 CLI framework.
24 """
27_devtools_checked: bool = False
28"""Module-level flag to skip repeated import checks after the first successful call."""
31def _read_devtools_deps() -> list[str]:
32 """Read the ``devtools`` optional-dependency names from MAFw package metadata.
34 This function requires ``packaging`` to be importable (the caller ensures
35 this). It uses :class:`~packaging.requirements.Requirement` to parse the
36 dependency specifiers reliably.
38 Falls back to reading ``pyproject.toml`` from the source tree if the
39 installed metadata does not yet include the ``devtools`` extra (e.g. stale
40 editable install).
42 :return: List of top-level package names required by the ``devtools`` extra.
43 :rtype: list[str]
44 """
45 import importlib.metadata
47 from packaging.requirements import Requirement
49 try:
50 requires = importlib.metadata.metadata('mafw').get_all('Requires-Dist') or []
51 names: list[str] = []
52 for entry in requires:
53 req = Requirement(entry)
54 # Only match entries whose marker explicitly mentions extra == "devtools".
55 if req.marker and 'extra == "devtools"' in str(req.marker):
56 names.append(req.name.replace('-', '_').lower())
57 if names:
58 return names
59 except importlib.metadata.PackageNotFoundError:
60 pass
62 # Fallback: read pyproject.toml from the source tree.
63 import tomllib
64 from pathlib import Path
66 pyproject = Path(__file__).resolve().parents[2] / 'pyproject.toml'
67 if pyproject.exists():
68 with pyproject.open('rb') as f:
69 data = tomllib.load(f)
70 deps = data.get('project', {}).get('optional-dependencies', {}).get('devtools', [])
71 return [Requirement(dep).name.replace('-', '_').lower() for dep in deps]
73 return []
76def ensure_devtools_available() -> None:
77 """Verify that optional development dependencies are importable.
79 The list of required packages is read from MAFw's own ``[devtools]``
80 optional-dependency group in ``pyproject.toml``, keeping it as the single
81 source of truth. The check is performed only once; subsequent calls return
82 immediately.
84 :raises ImportError: If ``packaging`` or any other package from the
85 ``devtools`` extra cannot be imported.
86 """
87 global _devtools_checked # noqa: PLW0603
88 if _devtools_checked:
89 return
91 # packaging is required to parse the dependency list itself.
92 try:
93 import packaging # noqa: F401
94 except ImportError as exc:
95 raise ImportError(
96 'Unable to import "packaging", which is required by MAFw development tools. '
97 'Install MAFw with the optional [devtools] feature, or invoke the helper via Hatch '
98 '(e.g. "hatch run dev.py3.14:release --help").'
99 ) from exc
101 # Read remaining deps from metadata/pyproject.toml and verify each.
102 # We use importlib.metadata to check if a *distribution* is installed
103 # rather than attempting to import by name. This handles namespace packages
104 # (e.g. ruamel.yaml) and packages whose distribution name differs from
105 # their importable top-level module.
106 import importlib.metadata as _meta
108 missing: list[str] = []
109 for name in _read_devtools_deps():
110 # Convert the underscore-normalized name back to a form suitable for
111 # distribution lookup (PEP 503: hyphens, underscores, and dots are
112 # interchangeable, and importlib.metadata handles normalization).
113 dist_name = name.replace('_', '-')
114 try:
115 _meta.distribution(dist_name)
116 except _meta.PackageNotFoundError:
117 missing.append(dist_name)
119 if missing:
120 raise ImportError(
121 f'Optional development dependencies not installed: {", ".join(missing)}. '
122 'Install MAFw with the optional [devtools] feature, or invoke the helper via Hatch '
123 '(e.g. "hatch run dev.py3.14:release --help").'
124 )
126 _devtools_checked = True