Source code for mafw.devtools.toolchain.pyproject_modifier

#  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] def _extract_package_name(specifier: str) -> str: """Extract the distribution name from a PEP 508 requirement string. Handles extras (e.g. ``"pandas[hdf5]>=2.2.3"``), environment markers (e.g. ``'; python_version >= "3.14"'``), and bare names. :param specifier: A PEP 508 requirement string. :type specifier: str :return: The distribution name portion. :rtype: str """ # Strip leading/trailing whitespace. s = specifier.strip() # The name ends at the first '[', '>', '<', '=', '!', '~', ';', or '@'. match = re.match(r'^([A-Za-z0-9]([A-Za-z0-9._-]*[A-Za-z0-9])?)', s) if match: return match.group(1) return s
[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('.')