Coverage for src/mafw/devtools/release/changelog.py: 96%
57 statements
« prev ^ index » next coverage.py v7.15.0, created at 2026-09-01 19:49 +0000
« prev ^ index » next coverage.py v7.15.0, created at 2026-09-01 19:49 +0000
1# Copyright 2026 European Union
2# Author: Bulgheroni Antonio (antonio.bulgheroni@ec.europa.eu)
3# SPDX-License-Identifier: EUPL-1.2
4"""
5Changelog generation and parsing utilities for MAFw releases.
7This module contains the business logic for generating the project changelog
8and extracting version-specific change sections from it.
9"""
11from __future__ import annotations
13import re
14from pathlib import Path
16from mafw.devtools import DevtoolsError
17from mafw.tools.shell_tools import CONSOLE
18from mafw.tools.shell_tools import run as cmd
20CHANGELOG_FILE = Path('CHANGELOG.md')
21"""Path to the changelog file that is regenerated for each release."""
23RELEASE_SECTION_HEADERS = {
24 'new_features': '## 🚀 New Features',
25 'bug_fixes': '## 🐛 Bug Fixes',
26 'refactorings': '## ♻️ Refactorings',
27 'removed': '## 🗑️ Removals',
28 'deprecated': '## ⚠️ Deprecated',
29 'security': '## 🔒 Security',
30 'other_changes': '## ️*️⃣ Other Changes',
31}
32"""Release note section headers used in the markdown template."""
35def generate_changelog(version: str, dry_run: bool) -> None:
36 """
37 Generate the project changelog for the target release.
39 :param version: Target release version.
40 :type version: str
41 :param dry_run: Whether command execution is disabled.
42 :type dry_run: bool
43 """
44 from mafw.devtools.documentation.requirements import _load_default_python_version
46 py_version = _load_default_python_version()
47 CONSOLE.print('Generating changelog...')
48 cmd(['hatch', 'run', f'dev.py{py_version}:change', '-t', version, '--output', str(CHANGELOG_FILE)], dry_run=dry_run)
51def extract_version_changelog_block(changelog_content: str, version: str) -> str:
52 """
53 Extract the changelog block associated with the target version.
55 :param changelog_content: Complete changelog markdown text.
56 :type changelog_content: str
57 :param version: Target release version without leading ``v``.
58 :type version: str
59 :return: Markdown block for the selected release.
60 :rtype: str
61 :raises DevtoolsError: If no section for the target version is found.
62 """
63 header_match = re.search(rf'^## \[{re.escape(version)}\][^\n]*\n', changelog_content, flags=re.MULTILINE)
64 if header_match is None:
65 raise DevtoolsError(
66 f'Unable to find changelog section for version {version} in {CHANGELOG_FILE}. '
67 'Ensure changelog generation produced a matching release section.'
68 )
70 block_start = header_match.end()
71 next_release = re.search(r'^## \[[^\]]+\]', changelog_content[block_start:], flags=re.MULTILINE)
72 if next_release is None:
73 return changelog_content[block_start:].strip()
74 return changelog_content[block_start : block_start + next_release.start()].strip()
77def _classify_changelog_subsection(subsection_heading: str) -> str | None:
78 """
79 Map a changelog subsection heading to a release-note category key.
81 :param subsection_heading: Raw level-3 heading from changelog.
82 :type subsection_heading: str
83 :return: Category key, or ``None`` if the heading is not mappable.
84 :rtype: str | None
85 """
86 normalized = re.sub(r'[^a-z]+', ' ', subsection_heading.lower()).strip()
87 if 'new' in normalized and 'feature' in normalized:
88 return 'new_features'
89 if 'bug' in normalized and 'fix' in normalized:
90 return 'bug_fixes'
91 if 'refactor' in normalized:
92 return 'refactorings'
93 if 'doc' in normalized:
94 return 'documentation'
95 if 'other' in normalized:
96 return 'other_changes'
97 return None
100def extract_change_sections_from_changelog_block(changelog_block: str) -> dict[str, str]:
101 """
102 Extract release change sections from a changelog block.
104 The parsing logic expects the block to contain level-3 headings (``###``)
105 with standardized labels (e.g. "New Features", "Bug Fixes", ...).
107 :param changelog_block: Changelog markdown block to parse.
108 :type changelog_block: str
109 :return: Mapping from release-note category key to markdown content.
110 :rtype: dict[str, str]
111 """
112 sections: dict[str, str] = {key: '' for key in RELEASE_SECTION_HEADERS}
114 subsection_matches = list(re.finditer(r'^###\s+.*$', changelog_block, flags=re.MULTILINE))
115 for idx, heading_match in enumerate(subsection_matches):
116 heading = heading_match.group(0)
117 category = _classify_changelog_subsection(heading)
118 if category is None or category not in sections: 118 ↛ 119line 118 didn't jump to line 119 because the condition on line 118 was never true
119 continue
121 start = heading_match.end()
122 end = subsection_matches[idx + 1].start() if idx + 1 < len(subsection_matches) else len(changelog_block)
123 body = changelog_block[start:end].strip()
124 if body: 124 ↛ 115line 124 didn't jump to line 115 because the condition on line 124 was always true
125 sections[category] = body
127 return sections
130def extract_change_sections_from_changelog(version: str) -> dict[str, str]:
131 """
132 Extract release change sections from ``CHANGELOG.md``.
134 :param version: Target release version without leading ``v``.
135 :type version: str
136 :return: Mapping from release-note category key to markdown content.
137 :rtype: dict[str, str]
138 :raises DevtoolsError: If changelog file does not exist or the requested section is missing.
139 """
140 if not CHANGELOG_FILE.exists():
141 raise DevtoolsError(f'Unable to find {CHANGELOG_FILE}.')
143 changelog_content = CHANGELOG_FILE.read_text(encoding='utf-8')
144 block = extract_version_changelog_block(changelog_content, version)
145 return extract_change_sections_from_changelog_block(block)