# 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 _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