python-semantic-release 9.12.2__py3-none-any.whl → 9.13.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.1
2
2
  Name: python-semantic-release
3
- Version: 9.12.2
3
+ Version: 9.13.0
4
4
  Summary: Automatic Semantic Versioning for Python projects
5
5
  Author-email: Rolf Erik Lekang <me@rolflekang.com>
6
6
  License: MIT
@@ -1,4 +1,4 @@
1
- semantic_release/__init__.py,sha256=DhNOjxINWOV6dyjCQmaizz8XX9v6ReMVyZLlzmMRtiw,1229
1
+ semantic_release/__init__.py,sha256=HkSV-d4oTywSXoJB6atMrEkQnimWPdJJGqSSrFeYjQQ,1229
2
2
  semantic_release/__main__.py,sha256=kuotDU7aFKrCBeAJUPWrbIxgJWAmrXUMnztCqWMDMPY,1292
3
3
  semantic_release/const.py,sha256=Z1o2QNh60wSLeF-_1TemMBjU3ZXbV0XghnUFsbTVfOs,831
4
4
  semantic_release/enums.py,sha256=D5B_reQGGKQQT22HO5PUtvn2Bok3fkht6TfJtXkmAUg,1020
@@ -26,26 +26,27 @@ semantic_release/cli/commands/publish.py,sha256=SZQlIewvqyIC14dkIIVVFetE0tPsKbO1
26
26
  semantic_release/cli/commands/version.py,sha256=PKNoP_b8puzcScKkQEbeB3DviJv49cQ-vjq6v25nG9Q,23931
27
27
  semantic_release/commit_parser/__init__.py,sha256=cv5HFBdw7OJd4Laj4Ex8ZZ5Tml8GwXgQcXW6Pasr2Ao,615
28
28
  semantic_release/commit_parser/_base.py,sha256=t-Z9ALgAe7aZpYXz1mk3Fe-uAvipgKdNrq4Okg_WW9c,3026
29
- semantic_release/commit_parser/angular.py,sha256=aq8UxCqwernLX2lEGp3Y9OMYMVt_to9rJLEjcscWF34,5911
30
- semantic_release/commit_parser/emoji.py,sha256=foN7wVDW1Lv7A_cR4mq4X2aas17IEwTgQ8xjUXduN8k,3830
31
- semantic_release/commit_parser/scipy.py,sha256=p7Ox0GJGtJ3jKroDDW55Iu1Ma2089VegESRcagwDwJw,6028
29
+ semantic_release/commit_parser/angular.py,sha256=a1AmgstkclzJnRWqSsF4cuO8awuOJe7VAG__1W7WF7U,7276
30
+ semantic_release/commit_parser/emoji.py,sha256=4OQ5yyHBNC-pA7d6KP_pMjKxutwVaBvO_ydqCT1KtJA,5849
31
+ semantic_release/commit_parser/scipy.py,sha256=u_5OWOeM2V7_jZzYfEicKqx0GJBsAqIOfzRX_4cmOqk,3973
32
32
  semantic_release/commit_parser/tag.py,sha256=4uwIKBqUM2SE6UTGIw-a7B6Jg1OONXmGwXsTyL3yZBA,3490
33
- semantic_release/commit_parser/token.py,sha256=BB4ZCyt753CCaBFF95cQ4sFm6Au96wpO0YyTAWdcOvE,1609
34
- semantic_release/commit_parser/util.py,sha256=vLcVDErZrExM55jMffos0hyMbNVQoJ-PeeVDG1Ej51I,730
33
+ semantic_release/commit_parser/token.py,sha256=-C1ZKG7pdbcGT2nc3-L2APLUUDGTXkbDeNi5mvvUwjk,2621
34
+ semantic_release/commit_parser/util.py,sha256=b9lud_FBjsmimLrvILf_NXvZ2wg8JPDmA364hcfM6is,1710
35
35
  semantic_release/data/templates/angular/md/.release_notes.md.j2,sha256=bJeaCDzbva8ntefPPeAS3YD0GXkZv-j0_fIbVmnAKbQ,52
36
- semantic_release/data/templates/angular/md/CHANGELOG.md.j2,sha256=tFFPsT-EsZHAuSS6xVEAN_Iofu6c-psFoqdyFn_NW2k,838
36
+ semantic_release/data/templates/angular/md/CHANGELOG.md.j2,sha256=zsf5C75jBP2JUdl_7dJvEXgPURvFp6GxPKGRelqLghM,854
37
37
  semantic_release/data/templates/angular/md/.components/changelog_header.md.j2,sha256=qNxTuSr59CV_yyimVU_RYp5azCnK0l6nJ03Zf0u5Ugg,166
38
38
  semantic_release/data/templates/angular/md/.components/changelog_init.md.j2,sha256=d3tS_nCe_ttNQRGl8Jan4H42iJ8hKu03HrfdEdGAh5M,599
39
39
  semantic_release/data/templates/angular/md/.components/changelog_update.md.j2,sha256=JhXF-vYlqd4qNI8rO2Hte3Jbemo17wdeZ7w3G2nwjus,2384
40
- semantic_release/data/templates/angular/md/.components/changes.md.j2,sha256=oC2amekjxsNjgj0BkVzs6ANswxntOi3ItlQd9VtbVm8,339
40
+ semantic_release/data/templates/angular/md/.components/changes.md.j2,sha256=qA190vxbADim4M1b2f-oWF4uWT7Vx8ToSjKrRkPeJfA,1638
41
+ semantic_release/data/templates/angular/md/.components/macros.md.j2,sha256=cUrl4tqjhR6Ur-gl_gHsuRz1DVU7X_-0QYnswahoTm0,1709
41
42
  semantic_release/data/templates/angular/md/.components/unreleased_changes.md.j2,sha256=HRLj6cyRfPZXC0s-0Av6s0Gp3jKxWg9AIEtIXBVqJuY,177
42
43
  semantic_release/data/templates/angular/md/.components/versioned_changes.md.j2,sha256=2Hky2mBrC4jltz3mvaiPDD0KQP0ELe5Ag75HgaLpaIE,257
43
44
  semantic_release/data/templates/angular/rst/CHANGELOG.rst.j2,sha256=OQPAKgqTnYGXFyDTm8hwYsMWXfi_z8I5Fsgtw09sWBQ,840
44
45
  semantic_release/data/templates/angular/rst/.components/changelog_header.rst.j2,sha256=c9xN1SEYLFwMvPYXYKt-ZbYPn2-Ss0V7zepEtFFj3Os,200
45
46
  semantic_release/data/templates/angular/rst/.components/changelog_init.rst.j2,sha256=dOICVsTMVLewBDC9QMHhX6Ub9rUFlBAzHOGvsFtNzYY,596
46
47
  semantic_release/data/templates/angular/rst/.components/changelog_update.rst.j2,sha256=L44PJyCb1xge2wC2-w9cAPCLoJDFVIsm8BnZeQOzg94,2381
47
- semantic_release/data/templates/angular/rst/.components/changes.rst.j2,sha256=bF9fv3ufFIsAmcCI1GN9eind5Oc3YB1QPw7BFCPtdS8,938
48
- semantic_release/data/templates/angular/rst/.components/macros.rst.j2,sha256=jTrUMX5TBpUFP5oo2UZTPB-88hX7NMr-LAuddIJdmhw,551
48
+ semantic_release/data/templates/angular/rst/.components/changes.rst.j2,sha256=0JDnsFghFqHezOim9IuQhlVig5mHBKf8Aq7_Y9M2UQE,2852
49
+ semantic_release/data/templates/angular/rst/.components/macros.rst.j2,sha256=C_5V80wQu-uCLE_Qp3ACwGS-4jQDGc7NmKaDi3C6m1s,2774
49
50
  semantic_release/data/templates/angular/rst/.components/unreleased_changes.rst.j2,sha256=ARBhc1ZpKwehGKDvOMqukmN59mTJiHzHsS7rOfKYCt8,202
50
51
  semantic_release/data/templates/angular/rst/.components/versioned_changes.rst.j2,sha256=NZfn1W14QochiAJ43oNKmcrCn_vgfbkKtvOTAw1jEc8,530
51
52
  semantic_release/hvcs/__init__.py,sha256=JwoaLOF-12L-OBo_9-tOXXhdiHKeVungA9865to2oZk,494
@@ -62,10 +63,10 @@ semantic_release/version/algorithm.py,sha256=ofx_bIWq6ptJVr-ekI11IzxzDEctDKFiVwa
62
63
  semantic_release/version/declaration.py,sha256=f6Ld7hIhrqvDrRBapJHr-KDimuyo-4IG8009Zu9BIgU,7357
63
64
  semantic_release/version/translator.py,sha256=P1noIsVBn8u6zNOFjG0xKYOWapxqf_PHSMvMeLJ9kXg,3050
64
65
  semantic_release/version/version.py,sha256=6PCtSbLP88U1daoxnCwHc--YguZo4waGNLqJ5JfeczE,14175
65
- python_semantic_release-9.12.2.dist-info/AUTHORS.rst,sha256=XOReVvpymEFUPsS2QPH97jlfJBVrxwS2eu8-jVAe4gk,230
66
- python_semantic_release-9.12.2.dist-info/LICENSE,sha256=NE85nszX252sdQdu0xgS9qwfYES0k8qS6gW3uO4jRGE,1083
67
- python_semantic_release-9.12.2.dist-info/METADATA,sha256=ONiKGfUl4d-OGkLjgRYVvT-TG-6QYTxvGGmOhi-pvFY,3571
68
- python_semantic_release-9.12.2.dist-info/WHEEL,sha256=eOLhNAGa2EW3wWl_TU484h7q1UNgy0JXjjoqKoxAAQc,92
69
- python_semantic_release-9.12.2.dist-info/entry_points.txt,sha256=r2Jql3GTQyugQnvf34l2eXk1O_Qx6llR_xixG1ZWgD0,105
70
- python_semantic_release-9.12.2.dist-info/top_level.txt,sha256=qYA24nyg3eP-ti5UW7Vuj2aXVmM0wqVHx4mREdRZNAA,17
71
- python_semantic_release-9.12.2.dist-info/RECORD,,
66
+ python_semantic_release-9.13.0.dist-info/AUTHORS.rst,sha256=XOReVvpymEFUPsS2QPH97jlfJBVrxwS2eu8-jVAe4gk,230
67
+ python_semantic_release-9.13.0.dist-info/LICENSE,sha256=NE85nszX252sdQdu0xgS9qwfYES0k8qS6gW3uO4jRGE,1083
68
+ python_semantic_release-9.13.0.dist-info/METADATA,sha256=aFjXFtGi0j7sN3G2CfwdCPBGENzFlgkU9a2oDp_2Jeo,3571
69
+ python_semantic_release-9.13.0.dist-info/WHEEL,sha256=bFJAMchF8aTQGUgMZzHJyDDMPTO3ToJ7x23SLJa1SVo,92
70
+ python_semantic_release-9.13.0.dist-info/entry_points.txt,sha256=r2Jql3GTQyugQnvf34l2eXk1O_Qx6llR_xixG1ZWgD0,105
71
+ python_semantic_release-9.13.0.dist-info/top_level.txt,sha256=qYA24nyg3eP-ti5UW7Vuj2aXVmM0wqVHx4mREdRZNAA,17
72
+ python_semantic_release-9.13.0.dist-info/RECORD,,
@@ -1,5 +1,5 @@
1
1
  Wheel-Version: 1.0
2
- Generator: bdist_wheel (0.44.0)
2
+ Generator: bdist_wheel (0.45.0)
3
3
  Root-Is-Purelib: true
4
4
  Tag: py3-none-any
5
5
 
@@ -24,7 +24,7 @@ from semantic_release.version import (
24
24
  tags_and_versions,
25
25
  )
26
26
 
27
- __version__ = "9.12.2"
27
+ __version__ = "9.13.0"
28
28
 
29
29
  __all__ = [
30
30
  "CommitParser",
@@ -8,19 +8,26 @@ from __future__ import annotations
8
8
  import logging
9
9
  import re
10
10
  from functools import reduce
11
+ from itertools import zip_longest
11
12
  from re import compile as regexp
12
13
  from typing import TYPE_CHECKING, Tuple
13
14
 
14
15
  from pydantic.dataclasses import dataclass
15
16
 
16
17
  from semantic_release.commit_parser._base import CommitParser, ParserOptions
17
- from semantic_release.commit_parser.token import ParsedCommit, ParseError, ParseResult
18
+ from semantic_release.commit_parser.token import (
19
+ ParsedCommit,
20
+ ParsedMessageResult,
21
+ ParseError,
22
+ ParseResult,
23
+ )
18
24
  from semantic_release.commit_parser.util import breaking_re, parse_paragraphs
19
25
  from semantic_release.enums import LevelBump
20
26
 
21
27
  if TYPE_CHECKING:
22
28
  from git.objects.commit import Commit
23
29
 
30
+
24
31
  logger = logging.getLogger(__name__)
25
32
 
26
33
 
@@ -65,11 +72,16 @@ class AngularParserOptions(ParserOptions):
65
72
  default_bump_level: LevelBump = LevelBump.NO_RELEASE
66
73
 
67
74
  def __post_init__(self) -> None:
68
- self.tag_to_level = {tag: self.default_bump_level for tag in self.allowed_tags}
69
- for tag in self.patch_tags:
70
- self.tag_to_level[tag] = LevelBump.PATCH
71
- for tag in self.minor_tags:
72
- self.tag_to_level[tag] = LevelBump.MINOR
75
+ self.tag_to_level: dict[str, LevelBump] = dict(
76
+ [
77
+ # we have to do a type ignore as zip_longest provides a type that is not specific enough
78
+ # for our expected output. Due to the empty second array, we know the first is always longest
79
+ # and that means no values in the first entry of the tuples will ever be a LevelBump.
80
+ *zip_longest(self.allowed_tags, (), fillvalue=self.default_bump_level), # type: ignore[list-item]
81
+ *zip_longest(self.patch_tags, (), fillvalue=LevelBump.PATCH), # type: ignore[list-item]
82
+ *zip_longest(self.minor_tags, (), fillvalue=LevelBump.MINOR), # type: ignore[list-item]
83
+ ]
84
+ )
73
85
 
74
86
 
75
87
  class AngularCommitParser(CommitParser[ParseResult, AngularParserOptions]):
@@ -98,6 +110,10 @@ class AngularCommitParser(CommitParser[ParseResult, AngularParserOptions]):
98
110
  ),
99
111
  flags=re.DOTALL,
100
112
  )
113
+ # GitHub & Gitea use (#123), GitLab uses (!123), and BitBucket uses (pull request #123)
114
+ self.mr_selector = regexp(
115
+ r"[\t ]+\((?:pull request )?(?P<mr_number>[#!]\d+)\)[\t ]*$"
116
+ )
101
117
 
102
118
  @staticmethod
103
119
  def get_default_options() -> AngularParserOptions:
@@ -114,27 +130,23 @@ class AngularCommitParser(CommitParser[ParseResult, AngularParserOptions]):
114
130
  accumulator["descriptions"].append(text)
115
131
  return accumulator
116
132
 
117
- # Maybe this can be cached as an optimization, similar to how
118
- # mypy/pytest use their own caching directories, for very large commit
119
- # histories?
120
- # The problem is the cache likely won't be present in CI environments
121
- def parse(self, commit: Commit) -> ParseResult:
122
- """
123
- Attempt to parse the commit message with a regular expression into a
124
- ParseResult
125
- """
126
- message = str(commit.message)
127
- parsed = self.re_parser.match(message)
128
- if not parsed:
129
- return _logged_parse_error(
130
- commit, f"Unable to parse commit message: {message}"
131
- )
133
+ def parse_message(self, message: str) -> ParsedMessageResult | None:
134
+ if not (parsed := self.re_parser.match(message)):
135
+ return None
136
+
132
137
  parsed_break = parsed.group("break")
133
138
  parsed_scope = parsed.group("scope")
134
139
  parsed_subject = parsed.group("subject")
135
140
  parsed_text = parsed.group("text")
136
141
  parsed_type = parsed.group("type")
137
142
 
143
+ linked_merge_request = ""
144
+ if mr_match := self.mr_selector.search(parsed_subject):
145
+ linked_merge_request = mr_match.group("mr_number")
146
+ # TODO: breaking change v10, removes PR number from subject/descriptions
147
+ # expects changelog template to format the line accordingly
148
+ # parsed_subject = self.pr_selector.sub("", parsed_subject).strip()
149
+
138
150
  body_components: dict[str, list[str]] = reduce(
139
151
  self.commit_body_components_separator,
140
152
  [
@@ -157,19 +169,34 @@ class AngularCommitParser(CommitParser[ParseResult, AngularParserOptions]):
157
169
  )
158
170
  )
159
171
 
160
- # TODO: remove in the future
161
- if level_bump == LevelBump.MAJOR:
162
- parsed_type = "breaking"
172
+ return ParsedMessageResult(
173
+ bump=level_bump,
174
+ type=parsed_type,
175
+ category=LONG_TYPE_NAMES.get(parsed_type, parsed_type),
176
+ scope=parsed_scope,
177
+ descriptions=tuple(body_components["descriptions"]),
178
+ breaking_descriptions=tuple(body_components["breaking_descriptions"]),
179
+ linked_merge_request=linked_merge_request,
180
+ )
181
+
182
+ # Maybe this can be cached as an optimization, similar to how
183
+ # mypy/pytest use their own caching directories, for very large commit
184
+ # histories?
185
+ # The problem is the cache likely won't be present in CI environments
186
+ def parse(self, commit: Commit) -> ParseResult:
187
+ """
188
+ Attempt to parse the commit message with a regular expression into a
189
+ ParseResult
190
+ """
191
+ if not (pmsg_result := self.parse_message(str(commit.message))):
192
+ return _logged_parse_error(
193
+ commit, f"Unable to parse commit message: {commit.message!r}"
194
+ )
163
195
 
164
196
  logger.debug(
165
- "commit %s introduces a %s level_bump", commit.hexsha[:8], level_bump
197
+ "commit %s introduces a %s level_bump",
198
+ commit.hexsha[:8],
199
+ pmsg_result.bump,
166
200
  )
167
201
 
168
- return ParsedCommit(
169
- bump=level_bump,
170
- type=LONG_TYPE_NAMES.get(parsed_type, parsed_type),
171
- scope=parsed_scope,
172
- descriptions=body_components["descriptions"],
173
- breaking_descriptions=body_components["breaking_descriptions"],
174
- commit=commit,
175
- )
202
+ return ParsedCommit.from_parsed_message_result(commit, pmsg_result)
@@ -1,13 +1,21 @@
1
1
  """Commit parser which looks for emojis to determine the type of commit"""
2
2
 
3
+ from __future__ import annotations
4
+
3
5
  import logging
6
+ from itertools import zip_longest
7
+ from re import compile as regexp
4
8
  from typing import Tuple
5
9
 
6
10
  from git.objects.commit import Commit
7
11
  from pydantic.dataclasses import dataclass
8
12
 
9
13
  from semantic_release.commit_parser._base import CommitParser, ParserOptions
10
- from semantic_release.commit_parser.token import ParsedCommit, ParseResult
14
+ from semantic_release.commit_parser.token import (
15
+ ParsedCommit,
16
+ ParsedMessageResult,
17
+ ParseResult,
18
+ )
11
19
  from semantic_release.commit_parser.util import parse_paragraphs
12
20
  from semantic_release.enums import LevelBump
13
21
 
@@ -41,8 +49,26 @@ class EmojiParserOptions(ParserOptions):
41
49
  ":robot:",
42
50
  ":green_apple:",
43
51
  )
52
+ allowed_tags: Tuple[str, ...] = (
53
+ *major_tags,
54
+ *minor_tags,
55
+ *patch_tags,
56
+ )
44
57
  default_bump_level: LevelBump = LevelBump.NO_RELEASE
45
58
 
59
+ def __post_init__(self) -> None:
60
+ self.tag_to_level: dict[str, LevelBump] = dict(
61
+ [
62
+ # we have to do a type ignore as zip_longest provides a type that is not specific enough
63
+ # for our expected output. Due to the empty second array, we know the first is always longest
64
+ # and that means no values in the first entry of the tuples will ever be a LevelBump.
65
+ *zip_longest(self.allowed_tags, (), fillvalue=self.default_bump_level), # type: ignore[list-item]
66
+ *zip_longest(self.patch_tags, (), fillvalue=LevelBump.PATCH), # type: ignore[list-item]
67
+ *zip_longest(self.minor_tags, (), fillvalue=LevelBump.MINOR), # type: ignore[list-item]
68
+ *zip_longest(self.major_tags, (), fillvalue=LevelBump.MAJOR), # type: ignore[list-item]
69
+ ]
70
+ )
71
+
46
72
 
47
73
  class EmojiCommitParser(CommitParser[ParseResult, EmojiParserOptions]):
48
74
  """
@@ -60,56 +86,77 @@ class EmojiCommitParser(CommitParser[ParseResult, EmojiParserOptions]):
60
86
  # TODO: Deprecate in lieu of get_default_options()
61
87
  parser_options = EmojiParserOptions
62
88
 
89
+ def __init__(self, options: EmojiParserOptions | None = None) -> None:
90
+ super().__init__(options)
91
+ prcedence_order_regex = str.join(
92
+ "|",
93
+ [
94
+ *self.options.major_tags,
95
+ *self.options.minor_tags,
96
+ *self.options.patch_tags,
97
+ ],
98
+ )
99
+ self.emoji_selector = regexp(r"(?P<type>%s)" % prcedence_order_regex)
100
+
101
+ # GitHub & Gitea use (#123), GitLab uses (!123), and BitBucket uses (pull request #123)
102
+ self.mr_selector = regexp(
103
+ r"[\t ]+\((?:pull request )?(?P<mr_number>[#!]\d+)\)[\t ]*$"
104
+ )
105
+
63
106
  @staticmethod
64
107
  def get_default_options() -> EmojiParserOptions:
65
108
  return EmojiParserOptions()
66
109
 
67
- def parse(self, commit: Commit) -> ParseResult:
68
- all_emojis = (
69
- self.options.major_tags + self.options.minor_tags + self.options.patch_tags
110
+ def parse_message(self, message: str) -> ParsedMessageResult:
111
+ subject = message.split("\n", maxsplit=1)[0]
112
+
113
+ linked_merge_request = ""
114
+ if mr_match := self.mr_selector.search(subject):
115
+ linked_merge_request = mr_match.group("mr_number")
116
+ # TODO: breaking change v10, removes PR number from subject/descriptions
117
+ # expects changelog template to format the line accordingly
118
+ # subject = self.mr_selector.sub("", subject).strip()
119
+
120
+ # Search for emoji of the highest importance in the subject
121
+ primary_emoji = (
122
+ match.group("type")
123
+ if (match := self.emoji_selector.search(subject))
124
+ else "Other"
70
125
  )
71
126
 
72
- message = str(commit.message)
73
- subject = message.split("\n")[0]
74
-
75
- # Loop over emojis from most important to least important
76
- # Therefore, we find the highest level emoji first
77
- primary_emoji = "Other"
78
- for emoji in all_emojis:
79
- if emoji in subject:
80
- primary_emoji = emoji
81
- break
82
- logger.debug("Selected %s as the primary emoji", primary_emoji)
83
-
84
- # Find which level this commit was from
85
- level_bump = LevelBump.NO_RELEASE
86
- if primary_emoji in self.options.major_tags:
87
- level_bump = LevelBump.MAJOR
88
- elif primary_emoji in self.options.minor_tags:
89
- level_bump = LevelBump.MINOR
90
- elif primary_emoji in self.options.patch_tags:
91
- level_bump = LevelBump.PATCH
92
- else:
93
- level_bump = self.options.default_bump_level
94
- logger.debug(
95
- "commit %s introduces a level bump of %s due to the default_bump_level",
96
- commit.hexsha[:8],
97
- level_bump,
98
- )
99
-
100
- logger.debug(
101
- "commit %s introduces a %s level_bump", commit.hexsha[:8], level_bump
127
+ level_bump = self.options.tag_to_level.get(
128
+ primary_emoji, self.options.default_bump_level
102
129
  )
103
130
 
104
131
  # All emojis will remain part of the returned description
105
- descriptions = parse_paragraphs(message)
106
- return ParsedCommit(
132
+ descriptions = tuple(parse_paragraphs(message))
133
+ return ParsedMessageResult(
107
134
  bump=level_bump,
108
135
  type=primary_emoji,
109
- scope="",
136
+ category=primary_emoji,
137
+ scope="", # TODO: add scope support
138
+ # TODO: breaking change v10, removes breaking change footers from descriptions
139
+ # descriptions=(
140
+ # descriptions[:1] if level_bump is LevelBump.MAJOR else descriptions
141
+ # )
110
142
  descriptions=descriptions,
111
143
  breaking_descriptions=(
112
- descriptions[1:] if level_bump is LevelBump.MAJOR else []
144
+ descriptions[1:] if level_bump is LevelBump.MAJOR else ()
113
145
  ),
114
- commit=commit,
146
+ linked_merge_request=linked_merge_request,
147
+ )
148
+
149
+ def parse(self, commit: Commit) -> ParseResult:
150
+ """
151
+ Attempt to parse the commit message with a regular expression into a
152
+ ParseResult
153
+ """
154
+ pmsg_result = self.parse_message(str(commit.message))
155
+
156
+ logger.debug(
157
+ "commit %s introduces a %s level_bump",
158
+ commit.hexsha[:8],
159
+ pmsg_result.bump,
115
160
  )
161
+
162
+ return ParsedCommit.from_parsed_message_result(commit, pmsg_result)
@@ -47,13 +47,18 @@ Supported Changelog Sections::
47
47
  from __future__ import annotations
48
48
 
49
49
  import logging
50
- import re
51
50
  from typing import TYPE_CHECKING, Tuple
52
51
 
53
52
  from pydantic.dataclasses import dataclass
54
53
 
55
- from semantic_release.commit_parser._base import CommitParser, ParserOptions
56
- from semantic_release.commit_parser.token import ParsedCommit, ParseError, ParseResult
54
+ from semantic_release.commit_parser.angular import (
55
+ AngularCommitParser,
56
+ AngularParserOptions,
57
+ )
58
+ from semantic_release.commit_parser.token import (
59
+ ParsedMessageResult,
60
+ ParseError,
61
+ )
57
62
  from semantic_release.enums import LevelBump
58
63
 
59
64
  if TYPE_CHECKING:
@@ -86,11 +91,16 @@ tag_to_section = {
86
91
  "TEST": "None",
87
92
  }
88
93
 
89
- _COMMIT_FILTER = "|".join(tag_to_section)
90
-
91
94
 
92
95
  @dataclass
93
- class ScipyParserOptions(ParserOptions):
96
+ class ScipyParserOptions(AngularParserOptions):
97
+ """
98
+ Options dataclass for ScipyCommitParser
99
+
100
+ Scipy-style commit messages follow the same format as Angular-style commit
101
+ just with different tag names.
102
+ """
103
+
94
104
  major_tags: Tuple[str, ...] = ("API",)
95
105
  minor_tags: Tuple[str, ...] = ("DEP", "DEV", "ENH", "REV", "FEAT")
96
106
  patch_tags: Tuple[str, ...] = ("BLD", "BUG", "MAINT")
@@ -105,19 +115,18 @@ class ScipyParserOptions(ParserOptions):
105
115
  "REL",
106
116
  "TEST",
107
117
  )
118
+ # TODO: breaking v10, make consistent with AngularParserOptions
108
119
  default_level_bump: LevelBump = LevelBump.NO_RELEASE
109
120
 
110
121
  def __post_init__(self) -> None:
111
- self.tag_to_level = {tag: LevelBump.NO_RELEASE for tag in self.allowed_tags}
112
- for tag in self.patch_tags:
113
- self.tag_to_level[tag] = LevelBump.PATCH
114
- for tag in self.minor_tags:
115
- self.tag_to_level[tag] = LevelBump.MINOR
122
+ # TODO: breaking v10, remove as the name is now consistent
123
+ self.default_bump_level = self.default_level_bump
124
+ super().__post_init__()
116
125
  for tag in self.major_tags:
117
126
  self.tag_to_level[tag] = LevelBump.MAJOR
118
127
 
119
128
 
120
- class ScipyCommitParser(CommitParser[ParseResult, ScipyParserOptions]):
129
+ class ScipyCommitParser(AngularCommitParser):
121
130
  """Parser for scipy-style commit messages"""
122
131
 
123
132
  # TODO: Deprecate in lieu of get_default_options()
@@ -125,79 +134,19 @@ class ScipyCommitParser(CommitParser[ParseResult, ScipyParserOptions]):
125
134
 
126
135
  def __init__(self, options: ScipyParserOptions | None = None) -> None:
127
136
  super().__init__(options)
128
- self.re_parser = re.compile(
129
- rf"(?P<tag>{_COMMIT_FILTER})?"
130
- r"(?:\((?P<scope>[^\n]+)\))?"
131
- r":? "
132
- r"(?P<subject>[^\n]+):?"
133
- r"(\n\n(?P<text>.*))?",
134
- re.DOTALL,
135
- )
136
137
 
137
138
  @staticmethod
138
139
  def get_default_options() -> ScipyParserOptions:
139
140
  return ScipyParserOptions()
140
141
 
141
- def parse(self, commit: Commit) -> ParseResult:
142
- message = str(commit.message)
143
- parsed = self.re_parser.match(message)
144
-
145
- if not parsed:
146
- return _logged_parse_error(
147
- commit, f"Unable to parse the given commit message: {message}"
142
+ def parse_message(self, message: str) -> ParsedMessageResult | None:
143
+ return (
144
+ None
145
+ if not (pmsg_result := super().parse_message(message))
146
+ else ParsedMessageResult(
147
+ **{
148
+ **pmsg_result._asdict(),
149
+ "category": tag_to_section.get(pmsg_result.type, "None"),
150
+ }
148
151
  )
149
-
150
- if parsed.group("subject"):
151
- subject = parsed.group("subject")
152
- else:
153
- return _logged_parse_error(commit, f"Commit has no subject {message!r}")
154
-
155
- if parsed.group("text"):
156
- blocks = parsed.group("text").split("\n\n")
157
- blocks = [x for x in blocks if x]
158
- blocks.insert(0, subject)
159
- else:
160
- blocks = [subject]
161
-
162
- for tag in self.options.allowed_tags:
163
- if tag == parsed.group("tag"):
164
- section = tag_to_section.get(tag, "None")
165
- level_bump = self.options.tag_to_level.get(
166
- tag, self.options.default_level_bump
167
- )
168
- logger.debug(
169
- "commit %s introduces a %s level_bump",
170
- commit.hexsha[:8],
171
- level_bump,
172
- )
173
- break
174
- else:
175
- # some commits may not have a tag, e.g. if they belong to a PR that
176
- # wasn't squashed (for maintainability) ignore them
177
- section, level_bump = "None", self.options.default_level_bump
178
- logger.debug(
179
- "commit %s introduces a level bump of %s due to the default bump level",
180
- commit.hexsha[:8],
181
- level_bump,
182
- )
183
-
184
- # Look for descriptions of breaking changes
185
- migration_instructions = [
186
- block for block in blocks if block.startswith("BREAKING CHANGE")
187
- ]
188
- if migration_instructions:
189
- level_bump = LevelBump.MAJOR
190
- logger.debug(
191
- "commit %s upgraded to a %s level bump due to included migration instructions",
192
- commit.hexsha[:8],
193
- level_bump,
194
- )
195
-
196
- return ParsedCommit(
197
- bump=level_bump,
198
- type=section,
199
- scope=parsed.group("scope"),
200
- descriptions=blocks,
201
- breaking_descriptions=migration_instructions,
202
- commit=commit,
203
152
  )
@@ -10,6 +10,16 @@ if TYPE_CHECKING:
10
10
  from semantic_release.enums import LevelBump
11
11
 
12
12
 
13
+ class ParsedMessageResult(NamedTuple):
14
+ bump: LevelBump
15
+ type: str
16
+ category: str
17
+ scope: str
18
+ descriptions: tuple[str, ...]
19
+ breaking_descriptions: tuple[str, ...] = ()
20
+ linked_merge_request: str = ""
21
+
22
+
13
23
  class ParsedCommit(NamedTuple):
14
24
  bump: LevelBump
15
25
  type: str
@@ -17,6 +27,7 @@ class ParsedCommit(NamedTuple):
17
27
  descriptions: list[str]
18
28
  breaking_descriptions: list[str]
19
29
  commit: Commit
30
+ linked_merge_request: str = ""
20
31
 
21
32
  @property
22
33
  def message(self) -> str:
@@ -32,6 +43,25 @@ class ParsedCommit(NamedTuple):
32
43
  def short_hash(self) -> str:
33
44
  return self.commit.hexsha[:7]
34
45
 
46
+ @property
47
+ def linked_pull_request(self) -> str:
48
+ return self.linked_merge_request
49
+
50
+ @staticmethod
51
+ def from_parsed_message_result(
52
+ commit: Commit, parsed_message_result: ParsedMessageResult
53
+ ) -> ParsedCommit:
54
+ return ParsedCommit(
55
+ bump=parsed_message_result.bump,
56
+ # TODO: breaking v10, swap back to type rather than category
57
+ type=parsed_message_result.category,
58
+ scope=parsed_message_result.scope,
59
+ descriptions=list(parsed_message_result.descriptions),
60
+ breaking_descriptions=list(parsed_message_result.breaking_descriptions),
61
+ commit=commit,
62
+ linked_merge_request=parsed_message_result.linked_merge_request,
63
+ )
64
+
35
65
 
36
66
  class ParseError(NamedTuple):
37
67
  commit: Commit
@@ -1,8 +1,33 @@
1
1
  from __future__ import annotations
2
2
 
3
- import re
3
+ from functools import reduce
4
+ from re import compile as regexp
5
+ from typing import TYPE_CHECKING
4
6
 
5
- breaking_re = re.compile(r"BREAKING[ -]CHANGE:\s?(.*)")
7
+ if TYPE_CHECKING:
8
+ from re import Pattern
9
+ from typing import TypedDict
10
+
11
+ class RegexReplaceDef(TypedDict):
12
+ pattern: Pattern
13
+ repl: str
14
+
15
+
16
+ breaking_re = regexp(r"BREAKING[ -]CHANGE:\s?(.*)")
17
+ un_word_wrap: RegexReplaceDef = {
18
+ # Match a line ending where the next line is not indented, or a bullet
19
+ "pattern": regexp(r"((?<!-)\n(?![\s*-]))"),
20
+ "repl": r" ", # Replace with a space
21
+ }
22
+ un_word_wrap_hyphen: RegexReplaceDef = {
23
+ "pattern": regexp(r"((?<=\w)-\n(?=\w))"),
24
+ "repl": r"-", # Replace with single hyphen
25
+ }
26
+ trim_line_endings: RegexReplaceDef = {
27
+ # Match line endings with optional whitespace
28
+ "pattern": regexp(r"[\r\t\f\v ]*\r?\n"),
29
+ "repl": "\n", # remove the optional whitespace & remove windows newlines
30
+ }
6
31
 
7
32
 
8
33
  def parse_paragraphs(text: str) -> list[str]:
@@ -16,12 +41,18 @@ def parse_paragraphs(text: str) -> list[str]:
16
41
  :param text: The text string to be divided.
17
42
  :return: A list of condensed paragraphs, as strings.
18
43
  """
44
+ adjusted_text = reduce(
45
+ lambda txt, adj: adj["pattern"].sub(adj["repl"], txt),
46
+ [trim_line_endings, un_word_wrap_hyphen],
47
+ text,
48
+ )
49
+
19
50
  return list(
20
51
  filter(
21
52
  None,
22
53
  [
23
- paragraph.replace("\n", " ").strip()
24
- for paragraph in text.replace("\r", "").split("\n\n")
54
+ un_word_wrap["pattern"].sub(un_word_wrap["repl"], paragraph).strip()
55
+ for paragraph in adjusted_text.split("\n\n")
25
56
  ],
26
57
  )
27
58
  )
@@ -1,16 +1,44 @@
1
- {#
2
- #}{% for type_, commits in commit_objects
3
- %}{{
4
- "\n### %s\n" | format(type_ | title)
1
+ {% from 'macros.md.j2' import format_commit_summary_line
2
+ %}{#
3
+ EXAMPLE:
5
4
 
6
- }}{% for commit in commits
7
- %}{{
8
- "\n* %s ([`%s`](%s))\n" | format(
9
- commit.message.rstrip(),
10
- commit.short_hash,
11
- commit.hexsha | commit_hash_url,
12
- )
5
+ ### Features
13
6
 
7
+ - Add new feature ([#10](https://domain.com/namespace/repo/pull/10),
8
+ [`abcdef0`](https://domain.com/namespace/repo/commit/HASH))
9
+
10
+ - Add new feature ([`abcdef0`](https://domain.com/namespace/repo/commit/HASH))
11
+
12
+ ### Fixes
13
+
14
+ - Fix bug ([#11](https://domain.com/namespace/repo/pull/11),
15
+ [`abcdef1`](https://domain.com/namespace/repo/commit/HASH))
16
+
17
+ #}{% set max_line_width = 100
18
+ %}{% set hanging_indent = 2
19
+ %}{#
20
+ #}{% for type_, commits in commit_objects if type_ != "unknown"
21
+ %}{# PREPROCESS COMMITS (order by description & format description line)
22
+ #}{% set commit_descriptions = []
23
+ %}{% for commit in commits
24
+ %}{# # Update the first line with reference links and if commit description
25
+ # has more than one line, add the rest of the lines
26
+ # NOTE: This is specifically to make sure to not hide contents
27
+ # of squash commits (until parse support is added)
28
+ #}{% set description = "- %s" | format(format_commit_summary_line(commit))
29
+ %}{% if commit.descriptions | length > 1
30
+ %}{% set description = "%s\n\n%s" | format(
31
+ description, commit.descriptions[1:] | join("\n\n")
32
+ )
33
+ %}{% endif
34
+ %}{% set description = description | autofit_text_width(max_line_width, hanging_indent)
35
+ %}{{ commit_descriptions.append(description) | default("", true)
14
36
  }}{% endfor
15
- %}{% endfor
37
+ %}{#
38
+ # # PRINT SECTION (header & commits)
39
+ #}{{ "\n"
40
+ }}{{ "### %s\n" | format(type_ | title)
41
+ }}{{ "\n"
42
+ }}{{ "%s\n" | format(commit_descriptions | unique | join("\n\n"))
43
+ }}{% endfor
16
44
  %}
@@ -0,0 +1,48 @@
1
+ {#
2
+ MACRO: commit message links or PR/MR links of commit
3
+ #}{% macro commit_msg_links(commit)
4
+ %}{% if commit.error is undefined
5
+ %}{% set commit_hash_link = "[`%s`](%s)" | format(
6
+ commit.short_hash, commit.hexsha | commit_hash_url
7
+ )
8
+ %}{#
9
+ #}{% set summary_line = commit.descriptions[0] | safe
10
+ %}{% set summary_line = [
11
+ summary_line.split(" ", maxsplit=1)[0] | capitalize,
12
+ summary_line.split(" ", maxsplit=1)[1]
13
+ ] | join(" ")
14
+ %}{#
15
+ #}{% if commit.linked_merge_request != ""
16
+ %}{# # Add PR references with a link to the PR
17
+ #}{% set pr_num = commit.linked_merge_request
18
+ %}{% set pr_link = "[%s](%s)" | format(pr_num, pr_num | pull_request_url)
19
+ %}{#
20
+ # TODO: breaking change v10, remove summary line replacers as PSR will do it for us
21
+ #}{% set summary_line = summary_line | replace("(pull request", "(") | replace("(" ~ pr_num ~ ")", "") | trim
22
+ %}{% set summary_line = "%s (%s, %s)" | format(
23
+ summary_line,
24
+ pr_link,
25
+ commit_hash_link,
26
+ )
27
+ %}{#
28
+ # DEFAULT: No PR identifier found, so just append commit hash as url to the commit summary_line
29
+ #}{% else
30
+ %}{% set summary_line = "%s (%s)" | format(summary_line, commit_hash_link)
31
+ %}{% endif
32
+ %}{#
33
+ # Return the modified summary_line
34
+ #}{{ summary_line
35
+ }}{% endif
36
+ %}{% endmacro
37
+ %}
38
+
39
+ {#
40
+ MACRO: format commit summary line
41
+ #}{% macro format_commit_summary_line(commit)
42
+ %}{% if commit.error is undefined
43
+ %}{{ commit_msg_links(commit)
44
+ }}{% else
45
+ %}{{ (commit.commit.message | string).split("\n", maxsplit=1)[0]
46
+ }}{% endif
47
+ %}{% endmacro
48
+ %}
@@ -8,6 +8,7 @@
8
8
 
9
9
  #}{% set insertion_flag = ctx.changelog_insertion_flag
10
10
  %}{% set unreleased_commits = ctx.history.unreleased | dictsort
11
+ %}{% set releases = ctx.history.released.values() | list
11
12
  %}{#
12
13
  #}{% if ctx.changelog_mode == "init"
13
14
  %}{% include ".components/changelog_init.md.j2"
@@ -15,8 +16,8 @@
15
16
  #}{% elif ctx.changelog_mode == "update"
16
17
  %}{% set prev_changelog_file = ctx.prev_changelog_file
17
18
  %}{% set new_releases = []
18
- %}{% if ctx.history.released.values() | length > 0
19
- %}{% set new_releases = [ctx.history.released.values() | first]
19
+ %}{% if releases | length > 0
20
+ %}{% set new_releases = [releases | first]
20
21
  %}{% endif
21
22
  %}{% include ".components/changelog_update.md.j2"
22
23
  %}{#
@@ -1,36 +1,76 @@
1
- {% from 'macros.rst.j2' import generate_heading_underline, format_link_reference
1
+ {% from 'macros.rst.j2' import extract_pr_link_reference, format_link_reference
2
+ %}{% from 'macros.rst.j2' import format_commit_summary_line, generate_heading_underline
2
3
  %}{#
3
4
 
4
5
  Features
5
6
  --------
6
7
 
7
- * summary (`8a7b8ec`_)
8
+ * Add new feature (`#10`_, `8a7b8ec`_)
9
+
10
+ * Add another feature (`abcdef0`_)
8
11
 
9
12
  Fixes
10
13
  -----
11
14
 
12
- * summary (`8a7b8ec`_)
15
+ * Fix bug (`#11`_, `8a7b8ec`_)
13
16
 
14
- .. _8a7B8ec: https://github.com/owner/repo/commit/8a7b8ec
17
+ .. _10: https://domain.com/namespace/repo/pull/10
18
+ .. _8a7B8ec: https://domain.com/owner/repo/commit/8a7b8ec
19
+ .. _abcdef0: https://domain.com/owner/repo/commit/abcdef0
20
+ .. _11: https://domain.com/namespace/repo/pull/11
15
21
 
16
- #}{% set post_paragraph_links = []
22
+ #}{% set max_line_width = 100
23
+ %}{% set hanging_indent = 2
24
+ %}{#
25
+ #}{% set post_paragraph_links = []
26
+ %}{#
27
+ #}{% for type_, commits in commit_objects if type_ != "unknown"
28
+ %}{# PREPARE SECTION HEADER
29
+ #}{% set section_header = "%s" | format(type_ | title)
30
+ %}{# PREPROCESS COMMITS
31
+ #}{% set commit_descriptions = []
32
+ %}{#
33
+ #}{% for commit in commits
34
+ %}{# # Extract PR/MR reference if it exists and store it for later
35
+ #}{% set pr_link_reference = extract_pr_link_reference(commit) | default("", true)
36
+ %}{% if pr_link_reference != ""
37
+ %}{{ post_paragraph_links.append(pr_link_reference) | default("", true)
38
+ }}{% endif
39
+ %}{#
40
+ # # Always generate a commit hash reference link and store it for later
41
+ #}{% set commit_hash_link_reference = format_link_reference(
42
+ commit.hexsha | commit_hash_url,
43
+ commit.short_hash
44
+ )
45
+ %}{{ post_paragraph_links.append(commit_hash_link_reference) | default("", true)
46
+ }}{#
47
+ # Generate the commit summary line and format it for RST
48
+ # Update the first line with reference links and if commit description
49
+ # has more than one line, add the rest of the lines
50
+ # NOTE: This is specifically to make sure to not hide contents
51
+ # of squash commits (until parse support is added)
52
+ #}{% set description = "* %s" | format(format_commit_summary_line(commit))
53
+ %}{% if commit.descriptions | length > 1
54
+ %}{% set description = "%s\n\n%s" | format(
55
+ description, commit.descriptions[1:] | join("\n\n") | trim
56
+ )
57
+ %}{% endif
58
+ %}{% set description = description | convert_md_to_rst
59
+ %}{% set description = description | autofit_text_width(max_line_width, hanging_indent)
60
+ %}{{ commit_descriptions.append(description) | default("", true)
61
+ }}{% endfor
17
62
  %}{#
18
- #}{% for type_, commits in commit_objects
19
- %}{% set section_header = "%s" | format(type_ | title)
20
- %}{{ "\n"
63
+ # # PRINT SECTION (Header & Commits)
64
+ #}{{ "\n"
21
65
  }}{{ section_header ~ "\n"
22
66
  }}{{ generate_heading_underline(section_header, '-') ~ "\n"
23
- }}{#
24
- #}{% for commit in commits
25
- %}{% set commit_link_reference = format_link_reference(commit.hexsha | commit_hash_url, commit.short_hash)
26
- %}{{ post_paragraph_links.append(commit_link_reference) | default("", true)
27
67
  }}{{
28
- "\n* %s (`%s`_)\n" | format(
29
- commit.message.rstrip() | convert_md_to_rst,
30
- commit.short_hash,
31
- )
68
+ "\n%s\n" | format(commit_descriptions | unique | join("\n\n"))
32
69
 
33
- }}{% endfor
34
- %}{% endfor
70
+ }}{% endfor
71
+ %}{#
72
+ #}{% if post_paragraph_links | length > 0
73
+ %}{# # Print out any PR/MR or Issue URL references that were found in the commit messages
74
+ #}{{ "\n%s\n" | format(post_paragraph_links | unique | sort | join("\n"))
75
+ }}{% endif
35
76
  %}
36
- {{ post_paragraph_links | join("\n") ~ "\n" }}
@@ -1,9 +1,78 @@
1
- {# MACRO: format a post-paragraph link reference in RST #}
2
- {% macro format_link_reference(link, label)
1
+ {#
2
+ MACRO: format a post-paragraph link reference in RST
3
+ #}{% macro format_link_reference(link, label)
3
4
  %}{{ ".. _%s: %s" | format(label, link)
4
5
  }}{% endmacro
5
6
  %}
6
7
 
8
+
9
+ {#
10
+ MACRO: format commit summary line
11
+ #}{% macro format_commit_summary_line(commit)
12
+ %}{% if commit.error is undefined
13
+ %}{{ commit_msg_links(commit)
14
+ }}{% else
15
+ %}{{ (commit.commit.message | string).split("\n", maxsplit=1)[0]
16
+ }}{% endif
17
+ %}{% endmacro
18
+ %}
19
+
20
+
21
+ {#
22
+ MACRO: Create & return an non-inline RST link from a commit message
23
+ - Returns empty string if no PR/MR identifier is found
24
+ #}{% macro extract_pr_link_reference(commit)
25
+ %}{% if commit.error is undefined
26
+ %}{% set summary_line = commit.descriptions[0]
27
+ %}{#
28
+ #}{% if commit.linked_merge_request != ""
29
+ %}{# # Create a PR/MR reference url
30
+ #}{{ format_link_reference(
31
+ commit.linked_merge_request | pull_request_url,
32
+ commit.linked_merge_request,
33
+ )
34
+ }}{% endif
35
+ %}{% endif
36
+ %}{% endmacro
37
+ %}
38
+
39
+
40
+ {#
41
+ MACRO: formats a commit message for a non-inline RST link for a commit hash and/or PR/MR
42
+ #}{% macro commit_msg_links(commit, hvcs_type)
43
+ %}{% if commit.error is undefined
44
+ %}{% set commit_hash_link = "`%s`_" | format(commit.short_hash)
45
+ %}{#
46
+ #}{% set summary_line = commit.descriptions[0] | safe
47
+ %}{% set summary_line = [
48
+ summary_line.split(" ", maxsplit=1)[0] | capitalize,
49
+ summary_line.split(" ", maxsplit=1)[1]
50
+ ] | join(" ")
51
+ %}{#
52
+ #}{% if commit.linked_merge_request != ""
53
+ %}{# # Add PR references with a link to the PR
54
+ #}{% set pr_link = "`%s`_" | format(commit.linked_merge_request)
55
+ %}{#
56
+ # TODO: breaking change v10, remove summary line replacers as PSR will do it for us
57
+ #}{% set summary_line = summary_line | replace("(pull request ", "(") | replace("(" ~ commit.linked_merge_request ~ ")", "") | trim
58
+ %}{% set summary_line = "%s (%s, %s)" | format(
59
+ summary_line,
60
+ pr_link,
61
+ commit_hash_link,
62
+ )
63
+ %}{#
64
+ # DEFAULT: No PR identifier found, so just append a commit hash as url to the commit summary_line
65
+ #}{% else
66
+ %}{% set summary_line = "%s (%s)" | format(summary_line, commit_hash_link)
67
+ %}{% endif
68
+ %}{#
69
+ # Return the modified summary_line
70
+ #}{{ summary_line
71
+ }}{% endif
72
+ %}{% endmacro
73
+ %}
74
+
75
+
7
76
  {# MACRO: generate a heading underline that matches the exact length of the header #}
8
77
  {% macro generate_heading_underline(header, underline_char)
9
78
  %}{% set header_underline = []