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

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 CONSOLE.print('Generating changelog...') 

45 cmd(['hatch', 'run', 'dev.py3.14:change', '-t', version, '--output', str(CHANGELOG_FILE)], dry_run=dry_run) 

46 

47 

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

49 """ 

50 Extract the changelog block associated with the target version. 

51 

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 ) 

66 

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

72 

73 

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

75 """ 

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

77 

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 

95 

96 

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

98 """ 

99 Extract release change sections from a changelog block. 

100 

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

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

103 

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} 

110 

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 

117 

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 

123 

124 return sections 

125 

126 

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

128 """ 

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

130 

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

139 

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)