# Copyright 2026 European Union
# Author: Bulgheroni Antonio (antonio.bulgheroni@ec.europa.eu)
# SPDX-License-Identifier: EUPL-1.2
"""
Pyproject.toml modifier for toolchain dependency management.
This module provides the :class:`PyprojectModifier` helper that reads,
modifies, and writes dependency specifiers in ``pyproject.toml`` while
preserving all existing comments, formatting, and key ordering via
:mod:`tomlkit`.
Multiple concrete :class:`~mafw.devtools.toolchain.base.ToolChainTool`
implementations delegate their ``update`` logic to this helper so that
version-specifier manipulation is centralized and tested once.
Supported section paths:
- ``project.dependencies``
- ``project.optional-dependencies.<group>``
- ``tool.hatch.envs.<env>.extra-dependencies``
"""
from __future__ import annotations
import re
from pathlib import Path
from typing import Final
import tomlkit
from tomlkit import TOMLDocument
from mafw.devtools import DevtoolsError
_PEP503_NORMALIZE_RE: Final[re.Pattern[str]] = re.compile(r'[-_.]+')
"""Regex used for PEP 503 package-name normalization."""
_LOWER_BOUND_RE: Final[re.Pattern[str]] = re.compile(r'(>=)\s*([A-Za-z0-9.*!+]+)')
"""Regex matching a ``>=`` specifier and its version portion.
Group 1 captures the ``>=`` operator.
Group 2 captures the version string to be replaced.
"""
[docs]
def _normalize_name(name: str) -> str:
"""Normalize a Python package name per PEP 503.
Converts to lowercase and replaces runs of hyphens, underscores, and
dots with a single hyphen, making name comparison canonical.
:param name: The raw package name (e.g. ``"ruamel.yaml"``).
:type name: str
:return: Normalized form (e.g. ``"ruamel-yaml"``).
:rtype: str
"""
return _PEP503_NORMALIZE_RE.sub('-', name).lower()
[docs]
class PyprojectModifier:
"""Read, modify, and write dependency specifiers in ``pyproject.toml``.
The modifier preserves all TOML comments, formatting, and key ordering
by using :mod:`tomlkit` for parsing and serialization.
Usage::
modifier = PyprojectModifier(Path('pyproject.toml'))
modifier.read()
specifier = modifier.get_dependency_specifier(
'pytest', 'project.optional-dependencies.test'
)
modifier.update_lower_bound(
'pytest', '9.1.0', 'project.optional-dependencies.test'
)
modifier.write()
:param path: Path to the ``pyproject.toml`` file.
:type path: Path
"""
def __init__(self, path: Path) -> None:
self._path: Path = path
self._doc: TOMLDocument | None = None
[docs]
def read(self) -> None:
"""Load ``pyproject.toml`` from disk preserving formatting.
:raises DevtoolsError: If the file does not exist or cannot be parsed.
"""
if not self._path.exists():
raise DevtoolsError(f'pyproject.toml not found at {self._path}')
try:
content = self._path.read_text(encoding='utf-8')
self._doc = tomlkit.parse(content)
except Exception as exc:
raise DevtoolsError(f'Failed to parse {self._path}: {exc}') from exc
[docs]
def write(self) -> None:
"""Write the (possibly modified) document back to disk.
Preserves all comments, formatting, and key ordering for portions
of the file that were not explicitly modified.
:raises DevtoolsError: If no document has been loaded via :meth:`read`.
"""
if self._doc is None:
raise DevtoolsError('No document loaded. Call read() first.')
self._path.write_text(tomlkit.dumps(self._doc), encoding='utf-8')
[docs]
def get_dependency_specifier(self, dependency_name: str, section_path: str) -> str:
"""Return the raw PEP 508 requirement string for a named dependency.
The dependency is located using PEP 503–normalized name comparison
(case-insensitive, hyphens ≡ underscores ≡ dots).
:param dependency_name: The package name to look up (e.g. ``"ruff"``).
:type dependency_name: str
:param section_path: Dot-separated path to the dependency array
(e.g. ``"project.optional-dependencies.dev"``).
:type section_path: str
:return: The raw requirement string as stored in the TOML array.
:rtype: str
:raises DevtoolsError: If the section does not exist or the dependency
is not found within it.
"""
deps = self._resolve_section(section_path)
normalized_target = _normalize_name(dependency_name)
for item in deps:
raw = str(item)
pkg_name = _extract_package_name(raw)
if _normalize_name(pkg_name) == normalized_target:
return raw
raise DevtoolsError(f'Dependency {dependency_name!r} not found in section {section_path!r}.')
[docs]
def update_lower_bound(self, dependency_name: str, new_version: str, section_path: str) -> None:
"""Replace the ``>=`` lower-bound version for a named dependency.
Only the version portion of the *first* ``>=`` specifier is replaced;
all surrounding whitespace, additional specifiers, extras, and
environment markers are preserved.
:param dependency_name: The package name whose bound to update.
:type dependency_name: str
:param new_version: The new PEP 440 version string (e.g. ``"9.1.0"``).
:type new_version: str
:param section_path: Dot-separated path to the dependency array.
:type section_path: str
:raises DevtoolsError: If the section does not exist, the dependency
is not found, or the dependency does not contain a ``>=`` specifier.
"""
deps = self._resolve_section(section_path)
normalized_target = _normalize_name(dependency_name)
for idx, item in enumerate(deps):
raw = str(item)
pkg_name = _extract_package_name(raw)
if _normalize_name(pkg_name) == normalized_target:
# Replace the version in the first >= specifier.
new_raw, count = _LOWER_BOUND_RE.subn(rf'\g<1>{new_version}', raw, count=1)
if count == 0:
raise DevtoolsError(
f'Dependency {dependency_name!r} in section {section_path!r} does not contain a >= specifier.'
)
deps[idx] = new_raw
return
raise DevtoolsError(f'Dependency {dependency_name!r} not found in section {section_path!r}.')
# ------------------------------------------------------------------
# Private helpers
# ------------------------------------------------------------------
[docs]
def _resolve_section(self, section_path: str) -> tomlkit.items.Array:
"""Navigate the TOML document to the dependency array at *section_path*.
Supported path formats:
- ``project.dependencies``
- ``project.optional-dependencies.<group>``
- ``tool.hatch.envs.<env>.extra-dependencies``
:param section_path: Dot-separated path to the dependency array.
:type section_path: str
:return: The tomlkit Array object containing the dependency strings.
:raises DevtoolsError: If the section path does not exist in the document.
"""
if self._doc is None:
raise DevtoolsError('No document loaded. Call read() first.')
parts = self._split_section_path(section_path)
current: object = self._doc
traversed: list[str] = []
for part in parts:
traversed.append(part)
if not isinstance(current, dict) or part not in current:
raise DevtoolsError(
f'Section {section_path!r} not found in pyproject.toml (missing key: {".".join(traversed)!r}).'
)
current = current[part]
# Validate that we reached an array (dependency list).
if not isinstance(current, (list, tomlkit.items.Array)):
raise DevtoolsError(f'Section {section_path!r} does not point to a dependency array in pyproject.toml.')
return current # type: ignore[return-value]
[docs]
@staticmethod
def _split_section_path(section_path: str) -> list[str]:
"""Split a section path into individual keys for document traversal.
Handles the special case of ``optional-dependencies`` and
``extra-dependencies`` where the key itself contains a hyphen and
must not be split on it.
Known patterns:
- ``project.dependencies`` → ``["project", "dependencies"]``
- ``project.optional-dependencies.test`` → ``["project", "optional-dependencies", "test"]``
- ``tool.hatch.envs.types.extra-dependencies`` → ``["tool", "hatch", "envs", "types", "extra-dependencies"]``
:param section_path: The dot-separated section path.
:type section_path: str
:return: List of keys to traverse.
:rtype: list[str]
"""
return section_path.split('.')