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

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. 

6 

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. 

11 

12Use :func:`ensure_devtools_available` as a guard before importing subpackages. 

13""" 

14 

15from __future__ import annotations 

16 

17 

18class DevtoolsError(Exception): 

19 """Base exception for MAFw development tool errors. 

20 

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 """ 

25 

26 

27_devtools_checked: bool = False 

28"""Module-level flag to skip repeated import checks after the first successful call.""" 

29 

30 

31def _read_devtools_deps() -> list[str]: 

32 """Read the ``devtools`` optional-dependency names from MAFw package metadata. 

33 

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. 

37 

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). 

41 

42 :return: List of top-level package names required by the ``devtools`` extra. 

43 :rtype: list[str] 

44 """ 

45 import importlib.metadata 

46 

47 from packaging.requirements import Requirement 

48 

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 

61 

62 # Fallback: read pyproject.toml from the source tree. 

63 import tomllib 

64 from pathlib import Path 

65 

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] 

72 

73 return [] 

74 

75 

76def ensure_devtools_available() -> None: 

77 """Verify that optional development dependencies are importable. 

78 

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. 

83 

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 

90 

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 

100 

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 

107 

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) 

118 

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 ) 

125 

126 _devtools_checked = True