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
« 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.
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`.
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.
16Supported section paths:
18- ``project.dependencies``
19- ``project.optional-dependencies.<group>``
20- ``tool.hatch.envs.<env>.extra-dependencies``
21"""
23from __future__ import annotations
25import re
26from pathlib import Path
27from typing import Final
29import tomlkit
30from tomlkit import TOMLDocument
32from mafw.devtools import DevtoolsError
34_PEP503_NORMALIZE_RE: Final[re.Pattern[str]] = re.compile(r'[-_.]+')
35"""Regex used for PEP 503 package-name normalization."""
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.
40Group 1 captures the ``>=`` operator.
41Group 2 captures the version string to be replaced.
42"""
45def _normalize_name(name: str) -> str:
46 """Normalize a Python package name per PEP 503.
48 Converts to lowercase and replaces runs of hyphens, underscores, and
49 dots with a single hyphen, making name comparison canonical.
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()
59def _extract_package_name(specifier: str) -> str:
60 """Extract the distribution name from a PEP 508 requirement string.
62 Handles extras (e.g. ``"pandas[hdf5]>=2.2.3"``), environment markers
63 (e.g. ``'; python_version >= "3.14"'``), and bare names.
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
79class PyprojectModifier:
80 """Read, modify, and write dependency specifiers in ``pyproject.toml``.
82 The modifier preserves all TOML comments, formatting, and key ordering
83 by using :mod:`tomlkit` for parsing and serialization.
85 Usage::
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()
97 :param path: Path to the ``pyproject.toml`` file.
98 :type path: Path
99 """
101 def __init__(self, path: Path) -> None:
102 self._path: Path = path
103 self._doc: TOMLDocument | None = None
105 def read(self) -> None:
106 """Load ``pyproject.toml`` from disk preserving formatting.
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
118 def write(self) -> None:
119 """Write the (possibly modified) document back to disk.
121 Preserves all comments, formatting, and key ordering for portions
122 of the file that were not explicitly modified.
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')
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.
133 The dependency is located using PEP 503–normalized name comparison
134 (case-insensitive, hyphens ≡ underscores ≡ dots).
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)
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
155 raise DevtoolsError(f'Dependency {dependency_name!r} not found in section {section_path!r}.')
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.
160 Only the version portion of the *first* ``>=`` specifier is replaced;
161 all surrounding whitespace, additional specifiers, extras, and
162 environment markers are preserved.
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)
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
189 raise DevtoolsError(f'Dependency {dependency_name!r} not found in section {section_path!r}.')
191 # ------------------------------------------------------------------
192 # Private helpers
193 # ------------------------------------------------------------------
195 def _resolve_section(self, section_path: str) -> tomlkit.items.Array:
196 """Navigate the TOML document to the dependency array at *section_path*.
198 Supported path formats:
200 - ``project.dependencies``
201 - ``project.optional-dependencies.<group>``
202 - ``tool.hatch.envs.<env>.extra-dependencies``
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.')
212 parts = self._split_section_path(section_path)
213 current: object = self._doc
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]
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.')
228 return current # type: ignore[return-value]
230 @staticmethod
231 def _split_section_path(section_path: str) -> list[str]:
232 """Split a section path into individual keys for document traversal.
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.
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"]``
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('.')