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

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. 

6 

7This module contains the business logic for generating the project changelog 

8and extracting version-specific change sections from it. 

9""" 

10 

11from __future__ import annotations 

12 

13import re 

14from pathlib import Path 

15 

16from mafw.devtools import DevtoolsError 

17from mafw.tools.shell_tools import CONSOLE 

18from mafw.tools.shell_tools import run as cmd 

19 

20CHANGELOG_FILE = Path('CHANGELOG.md') 

21"""Path to the changelog file that is regenerated for each release.""" 

22 

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.""" 

33 

34 

35def generate_changelog(version: str, dry_run: bool) -> None: 

36 """ 

37 Generate the project changelog for the target release. 

38 

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 

45 

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) 

49 

50 

51def extract_version_changelog_block(changelog_content: str, version: str) -> str: 

52 """ 

53 Extract the changelog block associated with the target version. 

54 

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 ) 

69 

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

75 

76 

77def _classify_changelog_subsection(subsection_heading: str) -> str | None: 

78 """ 

79 Map a changelog subsection heading to a release-note category key. 

80 

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 

98 

99 

100def extract_change_sections_from_changelog_block(changelog_block: str) -> dict[str, str]: 

101 """ 

102 Extract release change sections from a changelog block. 

103 

104 The parsing logic expects the block to contain level-3 headings (``###``) 

105 with standardized labels (e.g. "New Features", "Bug Fixes", ...). 

106 

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} 

113 

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 

120 

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 

126 

127 return sections 

128 

129 

130def extract_change_sections_from_changelog(version: str) -> dict[str, str]: 

131 """ 

132 Extract release change sections from ``CHANGELOG.md``. 

133 

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}.') 

142 

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)