Source code for mafw.devtools.release.changelog

#  Copyright 2026 European Union
#  Author: Bulgheroni Antonio (antonio.bulgheroni@ec.europa.eu)
#  SPDX-License-Identifier: EUPL-1.2
"""
Changelog generation and parsing utilities for MAFw releases.

This module contains the business logic for generating the project changelog
and extracting version-specific change sections from it.
"""

from __future__ import annotations

import re
from pathlib import Path

from mafw.devtools import DevtoolsError
from mafw.tools.shell_tools import CONSOLE
from mafw.tools.shell_tools import run as cmd

CHANGELOG_FILE = Path('CHANGELOG.md')
"""Path to the changelog file that is regenerated for each release."""

RELEASE_SECTION_HEADERS = {
    'new_features': '## 🚀 New Features',
    'bug_fixes': '## 🐛 Bug Fixes',
    'refactorings': '## ♻️ Refactorings',
    'removed': '## 🗑️ Removals',
    'deprecated': '## ⚠️ Deprecated',
    'security': '## 🔒 Security',
    'other_changes': '## ️*️⃣    Other Changes',
}
"""Release note section headers used in the markdown template."""


def generate_changelog(version: str, dry_run: bool) -> None:
    """
    Generate the project changelog for the target release.

    :param version: Target release version.
    :type version: str
    :param dry_run: Whether command execution is disabled.
    :type dry_run: bool
    """
    CONSOLE.print('Generating changelog...')
    cmd(['hatch', 'run', 'dev.py3.14:change', '-t', version, '--output', str(CHANGELOG_FILE)], dry_run=dry_run)


def extract_version_changelog_block(changelog_content: str, version: str) -> str:
    """
    Extract the changelog block associated with the target version.

    :param changelog_content: Complete changelog markdown text.
    :type changelog_content: str
    :param version: Target release version without leading ``v``.
    :type version: str
    :return: Markdown block for the selected release.
    :rtype: str
    :raises DevtoolsError: If no section for the target version is found.
    """
    header_match = re.search(rf'^## \[{re.escape(version)}\][^\n]*\n', changelog_content, flags=re.MULTILINE)
    if header_match is None:
        raise DevtoolsError(
            f'Unable to find changelog section for version {version} in {CHANGELOG_FILE}. '
            'Ensure changelog generation produced a matching release section.'
        )

    block_start = header_match.end()
    next_release = re.search(r'^## \[[^\]]+\]', changelog_content[block_start:], flags=re.MULTILINE)
    if next_release is None:
        return changelog_content[block_start:].strip()
    return changelog_content[block_start : block_start + next_release.start()].strip()


[docs] def _classify_changelog_subsection(subsection_heading: str) -> str | None: """ Map a changelog subsection heading to a release-note category key. :param subsection_heading: Raw level-3 heading from changelog. :type subsection_heading: str :return: Category key, or ``None`` if the heading is not mappable. :rtype: str | None """ normalized = re.sub(r'[^a-z]+', ' ', subsection_heading.lower()).strip() if 'new' in normalized and 'feature' in normalized: return 'new_features' if 'bug' in normalized and 'fix' in normalized: return 'bug_fixes' if 'refactor' in normalized: return 'refactorings' if 'doc' in normalized: return 'documentation' if 'other' in normalized: return 'other_changes' return None
def extract_change_sections_from_changelog_block(changelog_block: str) -> dict[str, str]: """ Extract release change sections from a changelog block. The parsing logic expects the block to contain level-3 headings (``###``) with standardized labels (e.g. "New Features", "Bug Fixes", ...). :param changelog_block: Changelog markdown block to parse. :type changelog_block: str :return: Mapping from release-note category key to markdown content. :rtype: dict[str, str] """ sections: dict[str, str] = {key: '' for key in RELEASE_SECTION_HEADERS} subsection_matches = list(re.finditer(r'^###\s+.*$', changelog_block, flags=re.MULTILINE)) for idx, heading_match in enumerate(subsection_matches): heading = heading_match.group(0) category = _classify_changelog_subsection(heading) if category is None or category not in sections: continue start = heading_match.end() end = subsection_matches[idx + 1].start() if idx + 1 < len(subsection_matches) else len(changelog_block) body = changelog_block[start:end].strip() if body: sections[category] = body return sections def extract_change_sections_from_changelog(version: str) -> dict[str, str]: """ Extract release change sections from ``CHANGELOG.md``. :param version: Target release version without leading ``v``. :type version: str :return: Mapping from release-note category key to markdown content. :rtype: dict[str, str] :raises DevtoolsError: If changelog file does not exist or the requested section is missing. """ if not CHANGELOG_FILE.exists(): raise DevtoolsError(f'Unable to find {CHANGELOG_FILE}.') changelog_content = CHANGELOG_FILE.read_text(encoding='utf-8') block = extract_version_changelog_block(changelog_content, version) return extract_change_sections_from_changelog_block(block)