Source code for mafw.devtools.toolchain.precommit_modifier

#  Copyright 2026 European Union
#  Author: Bulgheroni Antonio (antonio.bulgheroni@ec.europa.eu)
#  SPDX-License-Identifier: EUPL-1.2
"""
Pre-commit configuration modifier for toolchain management.

This module provides the :class:`PreCommitModifier` helper that reads,
modifies, and writes ``.pre-commit-config.yaml`` files while preserving
YAML comments, key ordering, and inline formatting. It is used by concrete
:class:`~mafw.devtools.toolchain.base.ToolChainTool` implementations (e.g.
Ruff) to keep pre-commit hook revisions in sync with pyproject.toml.

The implementation uses a hybrid approach: :mod:`ruamel.yaml` in round-trip
mode (``typ='rt'``) for parsing and validating the YAML structure, combined
with string-level replacement for the actual ``rev`` field update. This
guarantees that all bytes not related to the targeted ``rev`` value remain
unchanged after a write operation.
"""

from __future__ import annotations

import re
from pathlib import Path
from typing import Final

from ruamel.yaml import YAML, YAMLError  # type: ignore[attr-defined]  # ruamel.yaml has no py.typed marker
from ruamel.yaml.comments import CommentedMap, CommentedSeq

from mafw.devtools import DevtoolsError

_MAX_REV_LENGTH: Final[int] = 128
"""Maximum allowed length for a ``rev`` string value."""


[docs] def _extract_raw_rev(content: str, repo_url: str) -> str: """Extract the literal rev value from raw YAML content for a given repo URL. This avoids relying on ruamel.yaml's parsed representation, which may coerce values (e.g. ``0000000`` becomes integer ``0`` under YAML 1.1 octal rules). Instead, we locate the ``rev:`` line following the repo URL and extract the value as a raw string. :param content: The raw YAML file content. :param repo_url: The repository URL whose rev to extract. :return: The literal rev value as it appears in the file. :raises DevtoolsError: If the rev line cannot be found. """ lines = content.splitlines() repo_line_idx: int | None = None for i, line in enumerate(lines): stripped = line.strip() if stripped == f'- repo: {repo_url}' or stripped == f'repo: {repo_url}': repo_line_idx = i break if repo_line_idx is None: raise DevtoolsError(f'Could not locate repository URL {repo_url!r} in raw content.') # Search for the rev: line after the repo URL. rev_pattern = re.compile(r'^\s*rev:\s*(.+?)\s*(?:#.*)?$') for i in range(repo_line_idx + 1, len(lines)): line = lines[i] if re.match(r'\s*-\s*repo:', line): break match = rev_pattern.match(line) if match: return match.group(1) raise DevtoolsError(f'Could not locate rev field for {repo_url!r} in raw content.')
[docs] def _replace_rev_after_repo(content: str, repo_url: str, old_rev: str, new_rev: str) -> str: """Replace the rev value in raw YAML content after a specific repo URL line. This function finds the line containing the repo URL and then locates the first ``rev:`` line that follows it (before the next ``- repo:`` line or end of repos). It replaces only the old_rev value on that line. :param content: The raw YAML file content. :param repo_url: The repository URL to locate. :param old_rev: The current rev value to replace. :param new_rev: The new rev value to set. :return: The modified content with only the rev value changed. """ lines = content.splitlines(keepends=True) # Find the line index containing the repo URL. repo_line_idx: int | None = None for i, line in enumerate(lines): # Match lines like: "- repo: <url>" or " - repo: <url>" stripped = line.strip() if stripped == f'- repo: {repo_url}' or stripped == f'repo: {repo_url}': repo_line_idx = i break if repo_line_idx is None: # Should not happen since we validated via ruamel.yaml first, but # raise a clear error just in case. raise DevtoolsError(f'Could not locate repository URL {repo_url!r} in raw content.') # Find the rev: line after the repo URL, before the next repo entry. # Build a regex that matches `rev: <old_rev>` possibly with trailing comment. rev_pattern = re.compile(r'^(\s*rev:\s*)' + re.escape(old_rev) + r'(\s*(?:#.*)?)$') for i in range(repo_line_idx + 1, len(lines)): line = lines[i] # Stop if we hit the next repo entry. if re.match(r'\s*-\s*repo:', line): break match = rev_pattern.match(line) if match: # Replace only the rev value, preserving prefix whitespace and # any trailing inline comment. lines[i] = match.group(1) + new_rev + match.group(2) # Ensure we preserve the original line ending. if not lines[i].endswith('\n') and line.endswith('\n'): lines[i] += '\n' return ''.join(lines) # Fallback: should not reach here since ruamel.yaml validated the rev exists. raise DevtoolsError(f'Could not locate rev field for {repo_url!r} in raw content.')
[docs] class PreCommitModifier: """Read, modify, and write ``.pre-commit-config.yaml`` preserving formatting. This class uses a hybrid approach: :mod:`ruamel.yaml` in round-trip mode for parsing and locating repository entries, and string-level replacement for the actual ``rev`` field update. This guarantees that all bytes not related to the updated ``rev`` value remain byte-for-byte identical — including sequence indentation, comments, and inline formatting that ruamel.yaml might otherwise normalize. Typical usage:: modifier = PreCommitModifier() modifier.read(Path('.pre-commit-config.yaml')) modifier.update_rev( repo_url='https://github.com/astral-sh/ruff-pre-commit', new_rev='v0.15.12', ) modifier.write(Path('.pre-commit-config.yaml')) """ def __init__(self) -> None: self._yaml: YAML = YAML(typ='rt') self._yaml.preserve_quotes = True self._data: CommentedMap | None = None # Raw file content for string-level replacement. self._raw_content: str = ''
[docs] def read(self, path: Path) -> None: """Load a ``.pre-commit-config.yaml`` file into memory. :param path: Path to the ``.pre-commit-config.yaml`` file. :type path: Path :raises DevtoolsError: If the file does not exist, cannot be read, or contains invalid YAML. """ if not path.exists(): raise DevtoolsError( f'Pre-commit configuration file not found: {path}. Ensure the file exists in the repository root.' ) try: self._raw_content = path.read_text(encoding='utf-8') except OSError as exc: raise DevtoolsError(f'Unable to read pre-commit configuration file {path}: {exc}') from exc try: self._data = self._yaml.load(self._raw_content) except YAMLError as exc: raise DevtoolsError(f'Failed to parse {path} as valid YAML: {exc}') from exc # Validate that the parsed content is a mapping (expected structure). if not isinstance(self._data, CommentedMap): raise DevtoolsError( f'Invalid pre-commit configuration in {path}: expected a YAML mapping at the top level.' )
[docs] def update_rev(self, repo_url: str, new_rev: str) -> None: """Update the ``rev`` field for a repository entry matching the given URL. Locates the repository entry whose ``repo`` field matches *repo_url* and replaces its ``rev`` value with *new_rev*. The replacement is performed at the string level to guarantee byte-for-byte preservation of all other content. :param repo_url: The repository URL to search for in the ``repos`` list. :type repo_url: str :param new_rev: The new revision string to set (e.g. ``"v0.15.12"``). Must be non-empty and at most 128 characters. :type new_rev: str :raises DevtoolsError: If no data has been loaded, if the URL is not found, if the matched entry has no ``rev`` field, or if *new_rev* is empty or exceeds 128 characters. """ if self._data is None: raise DevtoolsError('No pre-commit configuration loaded. Call read() first.') # Validate the new_rev argument. if not new_rev: raise DevtoolsError('The rev value must be a non-empty string.') if len(new_rev) > _MAX_REV_LENGTH: raise DevtoolsError( f'The rev value must be at most {_MAX_REV_LENGTH} characters, got {len(new_rev)} characters.' ) # Retrieve the repos list from the configuration. repos: CommentedSeq | list[object] | None = self._data.get('repos') if not isinstance(repos, (CommentedSeq, list)) or not repos: raise DevtoolsError('Pre-commit configuration does not contain a valid "repos" list.') # Collect available URLs for error reporting. available_urls: list[str] = [] for repo_entry in repos: if not isinstance(repo_entry, (CommentedMap, dict)): continue entry_url = repo_entry.get('repo', '') if isinstance(entry_url, str): available_urls.append(entry_url) if entry_url == repo_url: # Found the matching repository entry. if 'rev' not in repo_entry: raise DevtoolsError(f'Repository entry for {repo_url!r} does not contain a "rev" field to update.') # Extract old_rev from raw content rather than from the parsed # YAML object. ruamel.yaml may coerce values (e.g. "0000000" # becomes int 0 under YAML 1.1 rules), so we read the literal # text to guarantee the string-level replacement succeeds. old_rev = _extract_raw_rev(self._raw_content, repo_url) # Perform string-level replacement in the raw content. self._raw_content = _replace_rev_after_repo(self._raw_content, repo_url, old_rev, new_rev) # Also update the parsed data for consistency. repo_entry['rev'] = new_rev return # If we get here, the URL was not found. urls_display = ', '.join(repr(u) for u in available_urls) raise DevtoolsError( f'Repository URL {repo_url!r} not found in pre-commit configuration. ' f'Available repository URLs: [{urls_display}]' )
[docs] def write(self, path: Path) -> None: """Write the (possibly modified) configuration back to disk. The output preserves all YAML comments, key ordering, and inline formatting from the original file. Only explicitly modified fields (via :meth:`update_rev`) will differ from the original content. :param path: Path where the configuration should be written. :type path: Path :raises DevtoolsError: If no data has been loaded or if writing fails. """ if self._data is None: raise DevtoolsError('No pre-commit configuration loaded. Call read() first.') try: path.write_text(self._raw_content, encoding='utf-8') except OSError as exc: raise DevtoolsError(f'Failed to write pre-commit configuration to {path}: {exc}') from exc