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