Coverage for src/mafw/devtools/toolchain/pyproject_modifier.py: 100%

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

5Pyproject.toml modifier for toolchain dependency management. 

6 

7This module provides the :class:`PyprojectModifier` helper that reads, 

8modifies, and writes dependency specifiers in ``pyproject.toml`` while 

9preserving all existing comments, formatting, and key ordering via 

10:mod:`tomlkit`. 

11 

12Multiple concrete :class:`~mafw.devtools.toolchain.base.ToolChainTool` 

13implementations delegate their ``update`` logic to this helper so that 

14version-specifier manipulation is centralized and tested once. 

15 

16Supported section paths: 

17 

18- ``project.dependencies`` 

19- ``project.optional-dependencies.<group>`` 

20- ``tool.hatch.envs.<env>.extra-dependencies`` 

21""" 

22 

23from __future__ import annotations 

24 

25import re 

26from pathlib import Path 

27from typing import Final 

28 

29import tomlkit 

30from tomlkit import TOMLDocument 

31 

32from mafw.devtools import DevtoolsError 

33 

34_PEP503_NORMALIZE_RE: Final[re.Pattern[str]] = re.compile(r'[-_.]+') 

35"""Regex used for PEP 503 package-name normalization.""" 

36 

37_LOWER_BOUND_RE: Final[re.Pattern[str]] = re.compile(r'(>=)\s*([A-Za-z0-9.*!+]+)') 

38"""Regex matching a ``>=`` specifier and its version portion. 

39 

40Group 1 captures the ``>=`` operator. 

41Group 2 captures the version string to be replaced. 

42""" 

43 

44 

45def _normalize_name(name: str) -> str: 

46 """Normalize a Python package name per PEP 503. 

47 

48 Converts to lowercase and replaces runs of hyphens, underscores, and 

49 dots with a single hyphen, making name comparison canonical. 

50 

51 :param name: The raw package name (e.g. ``"ruamel.yaml"``). 

52 :type name: str 

53 :return: Normalized form (e.g. ``"ruamel-yaml"``). 

54 :rtype: str 

55 """ 

56 return _PEP503_NORMALIZE_RE.sub('-', name).lower() 

57 

58 

59def _extract_package_name(specifier: str) -> str: 

60 """Extract the distribution name from a PEP 508 requirement string. 

61 

62 Handles extras (e.g. ``"pandas[hdf5]>=2.2.3"``), environment markers 

63 (e.g. ``'; python_version >= "3.14"'``), and bare names. 

64 

65 :param specifier: A PEP 508 requirement string. 

66 :type specifier: str 

67 :return: The distribution name portion. 

68 :rtype: str 

69 """ 

70 # Strip leading/trailing whitespace. 

71 s = specifier.strip() 

72 # The name ends at the first '[', '>', '<', '=', '!', '~', ';', or '@'. 

73 match = re.match(r'^([A-Za-z0-9]([A-Za-z0-9._-]*[A-Za-z0-9])?)', s) 

74 if match: 

75 return match.group(1) 

76 return s 

77 

78 

79class PyprojectModifier: 

80 """Read, modify, and write dependency specifiers in ``pyproject.toml``. 

81 

82 The modifier preserves all TOML comments, formatting, and key ordering 

83 by using :mod:`tomlkit` for parsing and serialization. 

84 

85 Usage:: 

86 

87 modifier = PyprojectModifier(Path('pyproject.toml')) 

88 modifier.read() 

89 specifier = modifier.get_dependency_specifier( 

90 'pytest', 'project.optional-dependencies.test' 

91 ) 

92 modifier.update_lower_bound( 

93 'pytest', '9.1.0', 'project.optional-dependencies.test' 

94 ) 

95 modifier.write() 

96 

97 :param path: Path to the ``pyproject.toml`` file. 

98 :type path: Path 

99 """ 

100 

101 def __init__(self, path: Path) -> None: 

102 self._path: Path = path 

103 self._doc: TOMLDocument | None = None 

104 

105 def read(self) -> None: 

106 """Load ``pyproject.toml`` from disk preserving formatting. 

107 

108 :raises DevtoolsError: If the file does not exist or cannot be parsed. 

109 """ 

110 if not self._path.exists(): 

111 raise DevtoolsError(f'pyproject.toml not found at {self._path}') 

112 try: 

113 content = self._path.read_text(encoding='utf-8') 

114 self._doc = tomlkit.parse(content) 

115 except Exception as exc: 

116 raise DevtoolsError(f'Failed to parse {self._path}: {exc}') from exc 

117 

118 def write(self) -> None: 

119 """Write the (possibly modified) document back to disk. 

120 

121 Preserves all comments, formatting, and key ordering for portions 

122 of the file that were not explicitly modified. 

123 

124 :raises DevtoolsError: If no document has been loaded via :meth:`read`. 

125 """ 

126 if self._doc is None: 

127 raise DevtoolsError('No document loaded. Call read() first.') 

128 self._path.write_text(tomlkit.dumps(self._doc), encoding='utf-8') 

129 

130 def get_dependency_specifier(self, dependency_name: str, section_path: str) -> str: 

131 """Return the raw PEP 508 requirement string for a named dependency. 

132 

133 The dependency is located using PEP 503–normalized name comparison 

134 (case-insensitive, hyphens ≡ underscores ≡ dots). 

135 

136 :param dependency_name: The package name to look up (e.g. ``"ruff"``). 

137 :type dependency_name: str 

138 :param section_path: Dot-separated path to the dependency array 

139 (e.g. ``"project.optional-dependencies.dev"``). 

140 :type section_path: str 

141 :return: The raw requirement string as stored in the TOML array. 

142 :rtype: str 

143 :raises DevtoolsError: If the section does not exist or the dependency 

144 is not found within it. 

145 """ 

146 deps = self._resolve_section(section_path) 

147 normalized_target = _normalize_name(dependency_name) 

148 

149 for item in deps: 

150 raw = str(item) 

151 pkg_name = _extract_package_name(raw) 

152 if _normalize_name(pkg_name) == normalized_target: 

153 return raw 

154 

155 raise DevtoolsError(f'Dependency {dependency_name!r} not found in section {section_path!r}.') 

156 

157 def update_lower_bound(self, dependency_name: str, new_version: str, section_path: str) -> None: 

158 """Replace the ``>=`` lower-bound version for a named dependency. 

159 

160 Only the version portion of the *first* ``>=`` specifier is replaced; 

161 all surrounding whitespace, additional specifiers, extras, and 

162 environment markers are preserved. 

163 

164 :param dependency_name: The package name whose bound to update. 

165 :type dependency_name: str 

166 :param new_version: The new PEP 440 version string (e.g. ``"9.1.0"``). 

167 :type new_version: str 

168 :param section_path: Dot-separated path to the dependency array. 

169 :type section_path: str 

170 :raises DevtoolsError: If the section does not exist, the dependency 

171 is not found, or the dependency does not contain a ``>=`` specifier. 

172 """ 

173 deps = self._resolve_section(section_path) 

174 normalized_target = _normalize_name(dependency_name) 

175 

176 for idx, item in enumerate(deps): 

177 raw = str(item) 

178 pkg_name = _extract_package_name(raw) 

179 if _normalize_name(pkg_name) == normalized_target: 

180 # Replace the version in the first >= specifier. 

181 new_raw, count = _LOWER_BOUND_RE.subn(rf'\g<1>{new_version}', raw, count=1) 

182 if count == 0: 

183 raise DevtoolsError( 

184 f'Dependency {dependency_name!r} in section {section_path!r} does not contain a >= specifier.' 

185 ) 

186 deps[idx] = new_raw 

187 return 

188 

189 raise DevtoolsError(f'Dependency {dependency_name!r} not found in section {section_path!r}.') 

190 

191 # ------------------------------------------------------------------ 

192 # Private helpers 

193 # ------------------------------------------------------------------ 

194 

195 def _resolve_section(self, section_path: str) -> tomlkit.items.Array: 

196 """Navigate the TOML document to the dependency array at *section_path*. 

197 

198 Supported path formats: 

199 

200 - ``project.dependencies`` 

201 - ``project.optional-dependencies.<group>`` 

202 - ``tool.hatch.envs.<env>.extra-dependencies`` 

203 

204 :param section_path: Dot-separated path to the dependency array. 

205 :type section_path: str 

206 :return: The tomlkit Array object containing the dependency strings. 

207 :raises DevtoolsError: If the section path does not exist in the document. 

208 """ 

209 if self._doc is None: 

210 raise DevtoolsError('No document loaded. Call read() first.') 

211 

212 parts = self._split_section_path(section_path) 

213 current: object = self._doc 

214 

215 traversed: list[str] = [] 

216 for part in parts: 

217 traversed.append(part) 

218 if not isinstance(current, dict) or part not in current: 

219 raise DevtoolsError( 

220 f'Section {section_path!r} not found in pyproject.toml (missing key: {".".join(traversed)!r}).' 

221 ) 

222 current = current[part] 

223 

224 # Validate that we reached an array (dependency list). 

225 if not isinstance(current, (list, tomlkit.items.Array)): 

226 raise DevtoolsError(f'Section {section_path!r} does not point to a dependency array in pyproject.toml.') 

227 

228 return current # type: ignore[return-value] 

229 

230 @staticmethod 

231 def _split_section_path(section_path: str) -> list[str]: 

232 """Split a section path into individual keys for document traversal. 

233 

234 Handles the special case of ``optional-dependencies`` and 

235 ``extra-dependencies`` where the key itself contains a hyphen and 

236 must not be split on it. 

237 

238 Known patterns: 

239 - ``project.dependencies`` → ``["project", "dependencies"]`` 

240 - ``project.optional-dependencies.test`` → ``["project", "optional-dependencies", "test"]`` 

241 - ``tool.hatch.envs.types.extra-dependencies`` → ``["tool", "hatch", "envs", "types", "extra-dependencies"]`` 

242 

243 :param section_path: The dot-separated section path. 

244 :type section_path: str 

245 :return: List of keys to traverse. 

246 :rtype: list[str] 

247 """ 

248 return section_path.split('.')