Coverage for src / mafw / devtools / release / changelog.py: 96%
55 statements
« prev ^ index » next coverage.py v7.14.0, created at 2026-06-28 13:34 +0000
« prev ^ index » next coverage.py v7.14.0, created at 2026-06-28 13:34 +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 CONSOLE.print('Generating changelog...')
45 cmd(['hatch', 'run', 'dev.py3.14:change', '-t', version, '--output', str(CHANGELOG_FILE)], dry_run=dry_run)
48def extract_version_changelog_block(changelog_content: str, version: str) -> str:
49 """
50 Extract the changelog block associated with the target version.
52 :param changelog_content: Complete changelog markdown text.
53 :type changelog_content: str
54 :param version: Target release version without leading ``v``.
55 :type version: str
56 :return: Markdown block for the selected release.
57 :rtype: str
58 :raises DevtoolsError: If no section for the target version is found.
59 """
60 header_match = re.search(rf'^## \[{re.escape(version)}\][^\n]*\n', changelog_content, flags=re.MULTILINE)
61 if header_match is None:
62 raise DevtoolsError(
63 f'Unable to find changelog section for version {version} in {CHANGELOG_FILE}. '
64 'Ensure changelog generation produced a matching release section.'
65 )
67 block_start = header_match.end()
68 next_release = re.search(r'^## \[[^\]]+\]', changelog_content[block_start:], flags=re.MULTILINE)
69 if next_release is None:
70 return changelog_content[block_start:].strip()
71 return changelog_content[block_start : block_start + next_release.start()].strip()
74def _classify_changelog_subsection(subsection_heading: str) -> str | None:
75 """
76 Map a changelog subsection heading to a release-note category key.
78 :param subsection_heading: Raw level-3 heading from changelog.
79 :type subsection_heading: str
80 :return: Category key, or ``None`` if the heading is not mappable.
81 :rtype: str | None
82 """
83 normalized = re.sub(r'[^a-z]+', ' ', subsection_heading.lower()).strip()
84 if 'new' in normalized and 'feature' in normalized:
85 return 'new_features'
86 if 'bug' in normalized and 'fix' in normalized:
87 return 'bug_fixes'
88 if 'refactor' in normalized:
89 return 'refactorings'
90 if 'doc' in normalized:
91 return 'documentation'
92 if 'other' in normalized:
93 return 'other_changes'
94 return None
97def extract_change_sections_from_changelog_block(changelog_block: str) -> dict[str, str]:
98 """
99 Extract release change sections from a changelog block.
101 The parsing logic expects the block to contain level-3 headings (``###``)
102 with standardized labels (e.g. "New Features", "Bug Fixes", ...).
104 :param changelog_block: Changelog markdown block to parse.
105 :type changelog_block: str
106 :return: Mapping from release-note category key to markdown content.
107 :rtype: dict[str, str]
108 """
109 sections: dict[str, str] = {key: '' for key in RELEASE_SECTION_HEADERS}
111 subsection_matches = list(re.finditer(r'^###\s+.*$', changelog_block, flags=re.MULTILINE))
112 for idx, heading_match in enumerate(subsection_matches):
113 heading = heading_match.group(0)
114 category = _classify_changelog_subsection(heading)
115 if category is None or category not in sections: 115 ↛ 116line 115 didn't jump to line 116 because the condition on line 115 was never true
116 continue
118 start = heading_match.end()
119 end = subsection_matches[idx + 1].start() if idx + 1 < len(subsection_matches) else len(changelog_block)
120 body = changelog_block[start:end].strip()
121 if body: 121 ↛ 112line 121 didn't jump to line 112 because the condition on line 121 was always true
122 sections[category] = body
124 return sections
127def extract_change_sections_from_changelog(version: str) -> dict[str, str]:
128 """
129 Extract release change sections from ``CHANGELOG.md``.
131 :param version: Target release version without leading ``v``.
132 :type version: str
133 :return: Mapping from release-note category key to markdown content.
134 :rtype: dict[str, str]
135 :raises DevtoolsError: If changelog file does not exist or the requested section is missing.
136 """
137 if not CHANGELOG_FILE.exists():
138 raise DevtoolsError(f'Unable to find {CHANGELOG_FILE}.')
140 changelog_content = CHANGELOG_FILE.read_text(encoding='utf-8')
141 block = extract_version_changelog_block(changelog_content, version)
142 return extract_change_sections_from_changelog_block(block)