python-semantic-release 9.16.0__py3-none-any.whl → 9.17.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.
Files changed (24) hide show
  1. {python_semantic_release-9.16.0.dist-info → python_semantic_release-9.17.0.dist-info}/METADATA +1 -1
  2. {python_semantic_release-9.16.0.dist-info → python_semantic_release-9.17.0.dist-info}/RECORD +24 -24
  3. semantic_release/__init__.py +1 -1
  4. semantic_release/changelog/context.py +3 -0
  5. semantic_release/changelog/release_history.py +66 -56
  6. semantic_release/changelog/template.py +0 -1
  7. semantic_release/cli/changelog_writer.py +6 -1
  8. semantic_release/cli/commands/version.py +1 -1
  9. semantic_release/cli/config.py +6 -1
  10. semantic_release/cli/masking_filter.py +1 -1
  11. semantic_release/commit_parser/_base.py +1 -1
  12. semantic_release/commit_parser/angular.py +214 -15
  13. semantic_release/commit_parser/emoji.py +211 -23
  14. semantic_release/commit_parser/scipy.py +7 -7
  15. semantic_release/commit_parser/tag.py +3 -1
  16. semantic_release/commit_parser/util.py +49 -5
  17. semantic_release/helpers.py +87 -4
  18. semantic_release/hvcs/_base.py +1 -1
  19. semantic_release/version/algorithm.py +18 -4
  20. {python_semantic_release-9.16.0.dist-info → python_semantic_release-9.17.0.dist-info}/AUTHORS.rst +0 -0
  21. {python_semantic_release-9.16.0.dist-info → python_semantic_release-9.17.0.dist-info}/LICENSE +0 -0
  22. {python_semantic_release-9.16.0.dist-info → python_semantic_release-9.17.0.dist-info}/WHEEL +0 -0
  23. {python_semantic_release-9.16.0.dist-info → python_semantic_release-9.17.0.dist-info}/entry_points.txt +0 -0
  24. {python_semantic_release-9.16.0.dist-info → python_semantic_release-9.17.0.dist-info}/top_level.txt +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.1
2
2
  Name: python-semantic-release
3
- Version: 9.16.0
3
+ Version: 9.17.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,38 +1,38 @@
1
- semantic_release/__init__.py,sha256=qoHIydYblv9yYDLj0kA33b0YRNJntB6JxPkiePPKdFU,1229
1
+ semantic_release/__init__.py,sha256=HGRoymlde9dCDLCL2q3_ispJjvMuGYmdsAyWsLPpHfM,1229
2
2
  semantic_release/__main__.py,sha256=KOIBOvLruqfi5ArXcWK3ucIZ7NB55kfCbycJaxx6aQg,1485
3
3
  semantic_release/const.py,sha256=Z1o2QNh60wSLeF-_1TemMBjU3ZXbV0XghnUFsbTVfOs,831
4
4
  semantic_release/enums.py,sha256=vrEw1UNRcNrFjPqOFnuUzfeoqKj0ChixVVlyk5fqbng,1744
5
5
  semantic_release/errors.py,sha256=PY9rmviSFBZkqawW6VXbUfmF9C_RNOIObcmeGxLefMo,2904
6
6
  semantic_release/gitproject.py,sha256=G4XrucN-ZwT1Kj4RMrABcr1vWb0bjKgurEeJjcL-61c,9422
7
7
  semantic_release/globals.py,sha256=imI9WKGa6MS2pTRAZiWZ2qIJup2eWnBz3OZmIj2YIHM,158
8
- semantic_release/helpers.py,sha256=RaAw8OHhH5i0BvOlM_mYgL_CMHmCGW_2pMPxhmjz1v8,6895
8
+ semantic_release/helpers.py,sha256=T7gEYx9npipoV1-lNc0n-_vwVbdVqeREpQ4mv66auTE,9529
9
9
  semantic_release/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
10
10
  semantic_release/changelog/__init__.py,sha256=Bg6Xe5Vt32rWoMscW-hd4sUwiZqzWmsg4CD1EhMesMY,262
11
- semantic_release/changelog/context.py,sha256=jyVluJq8Vu6TyyzQQrsBIQRKm7kEnh1GZt8ObwibR5k,5374
12
- semantic_release/changelog/release_history.py,sha256=nbd-WYVfQSWN4SKPPWEGU6QgNZLBrNxKKxfhPGKqnKc,8499
13
- semantic_release/changelog/template.py,sha256=O4EKXVJtN1z6FowcRUiZdZmi9u_TsTiXcHmYJnGyt94,5721
11
+ semantic_release/changelog/context.py,sha256=TcBG-fVmYuxl7uD-AtsHVVE5Md5KwCRGJQxbE3dJdTc,5459
12
+ semantic_release/changelog/release_history.py,sha256=d_n35sJHFJHgni3-xKXfkCoMiSZLy3ChLU5e9Tn5g4c,9267
13
+ semantic_release/changelog/template.py,sha256=R3V5m-7kv9ES23e_g37fe17tk-ESgvwV0C9rp5uoeOE,5691
14
14
  semantic_release/cli/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
15
- semantic_release/cli/changelog_writer.py,sha256=RupwYqApOeAMidIvjCttnyyGNRxtLftmxDBmEu5azX0,9049
15
+ semantic_release/cli/changelog_writer.py,sha256=S91en98KrmXMA0AUwqsWa8zGY1i2-LRVFBcK0jWqfPw,9168
16
16
  semantic_release/cli/cli_context.py,sha256=Nop71LdVCJOeSUHgTXunMyK3xAu_QKQC2cRp1QBVkX0,4134
17
- semantic_release/cli/config.py,sha256=TnvHDaqMbNp2hWeRyi-o3UbZ1_mH766RL76N_ca2Puc,32073
17
+ semantic_release/cli/config.py,sha256=aYquSXNdjhF45BvqcdjaQSu1DcRJeugifOvpMyknwrU,32337
18
18
  semantic_release/cli/const.py,sha256=h7XE2D0D__TAZSrUUtVszwvzpkHTMOiQCf97XQNbEvA,163
19
19
  semantic_release/cli/github_actions_output.py,sha256=6oNwjnQBg9XF5QgGc4TgbwX_-W0aj65VwGSL4ALvqVg,2296
20
- semantic_release/cli/masking_filter.py,sha256=DxqjiJyABlzwwwZ1r8JGQpb6QrF00StJFm0-2-s5Fv0,3071
20
+ semantic_release/cli/masking_filter.py,sha256=ric34rnXfN5RiAVVaKnhiMJOxTnEl26kI06jQqZPZoQ,3072
21
21
  semantic_release/cli/util.py,sha256=FyXaBkeL7nXKjy3X9rQLEwvn7p46xPekp2V8Z-5MVrk,3755
22
22
  semantic_release/cli/commands/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
23
23
  semantic_release/cli/commands/changelog.py,sha256=w_sXuFJHIqcueBQdeNeWn6fqbLVPPl_c-dEhv3Pb_BA,3870
24
24
  semantic_release/cli/commands/generate_config.py,sha256=2xZOu3NpyhBp0pWr7d8ugKl_kjqQgpSsSMHq5wHTfrE,1699
25
25
  semantic_release/cli/commands/main.py,sha256=237rn_Od4LOWfjUjiUKI_jSV820MfcCtRpwPjxjLbyU,4312
26
26
  semantic_release/cli/commands/publish.py,sha256=y_LalPti_kZeQJzl2CR2pTZUK8DCMvNSTe4NaMC5TJA,2875
27
- semantic_release/cli/commands/version.py,sha256=ztNoAUVFjXE2bjrrKXB1EUIOpwRPpnfR_zYdtnp_uQQ,24813
27
+ semantic_release/cli/commands/version.py,sha256=y8m8ZyJe9vGhNsl5El2a4QwNM-upTRHfJS2T9zJoFWc,24824
28
28
  semantic_release/commit_parser/__init__.py,sha256=cv5HFBdw7OJd4Laj4Ex8ZZ5Tml8GwXgQcXW6Pasr2Ao,615
29
- semantic_release/commit_parser/_base.py,sha256=LAscBtS3_28jebRCeR-eGo3UtAsuxCWBzgb7FF4n4Vo,3046
30
- semantic_release/commit_parser/angular.py,sha256=zDYYOK1itsYJ0Ar7-cf29MnfrEpbQTeQCAcExWPH1fM,10486
31
- semantic_release/commit_parser/emoji.py,sha256=6HtvKvJZwPAvY2fNOXU_gpKf1rGrMiDd0rYamyrdQZ8,10002
32
- semantic_release/commit_parser/scipy.py,sha256=Fm_6WUaliLmqD397uVXwpOSDZ7LpMFu59oz-inKeHko,4526
33
- semantic_release/commit_parser/tag.py,sha256=4uwIKBqUM2SE6UTGIw-a7B6Jg1OONXmGwXsTyL3yZBA,3490
29
+ semantic_release/commit_parser/_base.py,sha256=oDifeTmFDpS238cp_DDrGzfidaKeAD5olCB5IM4Q6z8,3058
30
+ semantic_release/commit_parser/angular.py,sha256=tJqc9A-XJs85FH1EiDgm-k3_ryhPp8ln0EbrkRZkoWo,18367
31
+ semantic_release/commit_parser/emoji.py,sha256=zXlQUwTawMq9G7Uf5nriNQJv_tXFDev9UG3CHT3_Jvs,17283
32
+ semantic_release/commit_parser/scipy.py,sha256=0rYZglJ7uib-1Deu4J30wHh7AZS8KfO0eND82bPtDQ8,4526
33
+ semantic_release/commit_parser/tag.py,sha256=oGB3lgyp2Eu3Tg3jjxqNzN86N6bokSaFu6f4Ir6IS_k,3546
34
34
  semantic_release/commit_parser/token.py,sha256=RXdoCmKMTKMmUJGEUvfCiAOCPRrV0WubXojwa6FbvFg,7363
35
- semantic_release/commit_parser/util.py,sha256=KmJ-M-CJbc7q37GG7o595SmeBwrRrrnjP4q1C05jGYI,2463
35
+ semantic_release/commit_parser/util.py,sha256=9diralZ8QbMOMpVEp9MtrMVdkGsD-fS1F4cmy6eyysU,3674
36
36
  semantic_release/data/templates/angular/md/.release_notes.md.j2,sha256=BzpatS7WYBcpHii8qiDemyI1ygXM6Q1nbxdcdcps__U,2107
37
37
  semantic_release/data/templates/angular/md/CHANGELOG.md.j2,sha256=FZmrQ-qOIoSoJmAa_NFaRelfmqUpypU2xlDeScdGOf4,729
38
38
  semantic_release/data/templates/angular/md/.components/changelog_header.md.j2,sha256=qNxTuSr59CV_yyimVU_RYp5azCnK0l6nJ03Zf0u5Ugg,166
@@ -53,7 +53,7 @@ semantic_release/data/templates/angular/rst/.components/macros.rst.j2,sha256=luN
53
53
  semantic_release/data/templates/angular/rst/.components/unreleased_changes.rst.j2,sha256=ARBhc1ZpKwehGKDvOMqukmN59mTJiHzHsS7rOfKYCt8,202
54
54
  semantic_release/data/templates/angular/rst/.components/versioned_changes.rst.j2,sha256=NZfn1W14QochiAJ43oNKmcrCn_vgfbkKtvOTAw1jEc8,530
55
55
  semantic_release/hvcs/__init__.py,sha256=JwoaLOF-12L-OBo_9-tOXXhdiHKeVungA9865to2oZk,494
56
- semantic_release/hvcs/_base.py,sha256=OSUpUkxxmhafRqz8y60DdKK8PmudzJ7rEzXgIzo3aXc,2627
56
+ semantic_release/hvcs/_base.py,sha256=ycHg0WljEVTqjFXoGRrHMvbyqWqPs4HziB090IcPZSE,2664
57
57
  semantic_release/hvcs/bitbucket.py,sha256=Hzk27OOcpedFKVX4z3cY8DIUNNxe6D57J0d3sWNPIOY,9687
58
58
  semantic_release/hvcs/gitea.py,sha256=YTpAnJ4bBdMjIhgAvVMF8vPZ6O07IgWuXeqTbEU-GNE,12715
59
59
  semantic_release/hvcs/github.py,sha256=PLqbXDpHvXqE-uY8dmv7MuyjjnkMv0Va0vXo3UQUIUA,19937
@@ -62,14 +62,14 @@ semantic_release/hvcs/remote_hvcs_base.py,sha256=cV8qYHtP47bmfIZqV4K2EiMHskFEoIo
62
62
  semantic_release/hvcs/token_auth.py,sha256=ZjT56-NIPB4OKIt1qwHCu1TavXnrWFIBl9ARlg56hgU,663
63
63
  semantic_release/hvcs/util.py,sha256=guxisysY_IW5tv7aaV-iVPEVJzgbOs375kiRRpSquTI,2879
64
64
  semantic_release/version/__init__.py,sha256=CLhtGQry9dLIij5XyRa9ZevxU_1p8tjMTSQ-K_GMpWM,270
65
- semantic_release/version/algorithm.py,sha256=0cj5LqT8DpBr2zo7Va0-VP8t6a3xAIhFiTLb11xdGpo,15246
65
+ semantic_release/version/algorithm.py,sha256=-ppIqvvcAvc4nMtsClNNEzpEHWzgv2oKFydMW6ium6M,15941
66
66
  semantic_release/version/declaration.py,sha256=f6Ld7hIhrqvDrRBapJHr-KDimuyo-4IG8009Zu9BIgU,7357
67
67
  semantic_release/version/translator.py,sha256=P1noIsVBn8u6zNOFjG0xKYOWapxqf_PHSMvMeLJ9kXg,3050
68
68
  semantic_release/version/version.py,sha256=6PCtSbLP88U1daoxnCwHc--YguZo4waGNLqJ5JfeczE,14175
69
- python_semantic_release-9.16.0.dist-info/AUTHORS.rst,sha256=XOReVvpymEFUPsS2QPH97jlfJBVrxwS2eu8-jVAe4gk,230
70
- python_semantic_release-9.16.0.dist-info/LICENSE,sha256=NE85nszX252sdQdu0xgS9qwfYES0k8qS6gW3uO4jRGE,1083
71
- python_semantic_release-9.16.0.dist-info/METADATA,sha256=DvS5Bk7xkZzATIIy7f5thMV7otvrLR_RN_aR_gJv91M,3812
72
- python_semantic_release-9.16.0.dist-info/WHEEL,sha256=P9jw-gEje8ByB7_hXoICnHtVCrEwMQh-630tKvQWehc,91
73
- python_semantic_release-9.16.0.dist-info/entry_points.txt,sha256=r2Jql3GTQyugQnvf34l2eXk1O_Qx6llR_xixG1ZWgD0,105
74
- python_semantic_release-9.16.0.dist-info/top_level.txt,sha256=qYA24nyg3eP-ti5UW7Vuj2aXVmM0wqVHx4mREdRZNAA,17
75
- python_semantic_release-9.16.0.dist-info/RECORD,,
69
+ python_semantic_release-9.17.0.dist-info/AUTHORS.rst,sha256=XOReVvpymEFUPsS2QPH97jlfJBVrxwS2eu8-jVAe4gk,230
70
+ python_semantic_release-9.17.0.dist-info/LICENSE,sha256=NE85nszX252sdQdu0xgS9qwfYES0k8qS6gW3uO4jRGE,1083
71
+ python_semantic_release-9.17.0.dist-info/METADATA,sha256=1xRK6C1aw6HjxmCa963whjgtiQPjhbOcSW0vhaghQ_M,3812
72
+ python_semantic_release-9.17.0.dist-info/WHEEL,sha256=P9jw-gEje8ByB7_hXoICnHtVCrEwMQh-630tKvQWehc,91
73
+ python_semantic_release-9.17.0.dist-info/entry_points.txt,sha256=r2Jql3GTQyugQnvf34l2eXk1O_Qx6llR_xixG1ZWgD0,105
74
+ python_semantic_release-9.17.0.dist-info/top_level.txt,sha256=qYA24nyg3eP-ti5UW7Vuj2aXVmM0wqVHx4mREdRZNAA,17
75
+ python_semantic_release-9.17.0.dist-info/RECORD,,
@@ -24,7 +24,7 @@ from semantic_release.version import (
24
24
  tags_and_versions,
25
25
  )
26
26
 
27
- __version__ = "9.16.0"
27
+ __version__ = "9.17.0"
28
28
 
29
29
  __all__ = [
30
30
  "CommitParser",
@@ -8,6 +8,8 @@ from pathlib import Path
8
8
  from re import compile as regexp
9
9
  from typing import TYPE_CHECKING, Any, Callable, Literal
10
10
 
11
+ from semantic_release.helpers import sort_numerically
12
+
11
13
  if TYPE_CHECKING: # pragma: no cover
12
14
  from jinja2 import Environment
13
15
 
@@ -87,6 +89,7 @@ def make_changelog_context(
87
89
  read_file,
88
90
  convert_md_to_rst,
89
91
  autofit_text_width,
92
+ sort_numerically,
90
93
  ),
91
94
  )
92
95
 
@@ -102,75 +102,85 @@ class ReleaseHistory:
102
102
 
103
103
  released.setdefault(the_version, release)
104
104
 
105
- # mypy will be happy if we make this an explicit string
106
- commit_message = str(commit.message)
107
-
108
105
  log.info(
109
106
  "parsing commit [%s] %s",
110
107
  commit.hexsha[:8],
111
- commit_message.replace("\n", " ")[:54],
112
- )
113
- parse_result = commit_parser.parse(commit)
114
- commit_type = (
115
- "unknown" if isinstance(parse_result, ParseError) else parse_result.type
116
- )
117
-
118
- has_exclusion_match = any(
119
- pattern.match(commit_message) for pattern in exclude_commit_patterns
120
- )
121
-
122
- commit_level_bump = (
123
- LevelBump.NO_RELEASE
124
- if isinstance(parse_result, ParseError)
125
- else parse_result.bump
108
+ str(commit.message).replace("\n", " ")[:54],
126
109
  )
110
+ # returns a ParseResult or list of ParseResult objects,
111
+ # it is usually one, but we split a commit if a squashed merge is detected
112
+ parse_results = commit_parser.parse(commit)
113
+ if not isinstance(parse_results, list):
114
+ parse_results = [parse_results]
115
+
116
+ is_squash_commit = bool(len(parse_results) > 1)
117
+
118
+ # iterate through parsed commits to add to changelog definition
119
+ for parsed_result in parse_results:
120
+ commit_message = str(parsed_result.commit.message)
121
+ commit_type = (
122
+ "unknown"
123
+ if isinstance(parsed_result, ParseError)
124
+ else parsed_result.type
125
+ )
126
+ log.debug("commit has type '%s'", commit_type)
127
127
 
128
- # Skip excluded commits except for any commit causing a version bump
129
- # Reasoning: if a commit causes a version bump, and no other commits
130
- # are included, then the changelog will be empty. Even if ther was other
131
- # commits included, the true reason for a version bump would be missing.
132
- if has_exclusion_match and commit_level_bump == LevelBump.NO_RELEASE:
133
- log.info(
134
- "Excluding commit [%s] %s",
135
- commit.hexsha[:8],
136
- commit_message.replace("\n", " ")[:50],
128
+ has_exclusion_match = any(
129
+ pattern.match(commit_message) for pattern in exclude_commit_patterns
137
130
  )
138
- continue
139
131
 
140
- if (
141
- isinstance(parse_result, ParsedCommit)
142
- and not parse_result.include_in_changelog
143
- ):
144
- log.info(
145
- str.join(
146
- " ",
147
- [
148
- "Excluding commit %s (%s) because parser determined",
149
- "it should not included in the changelog",
150
- ],
151
- ),
152
- commit.hexsha[:8],
153
- commit_message.replace("\n", " ")[:20],
132
+ commit_level_bump = (
133
+ LevelBump.NO_RELEASE
134
+ if isinstance(parsed_result, ParseError)
135
+ else parsed_result.bump
154
136
  )
155
- continue
156
137
 
157
- if the_version is None:
138
+ # Skip excluded commits except for any commit causing a version bump
139
+ # Reasoning: if a commit causes a version bump, and no other commits
140
+ # are included, then the changelog will be empty. Even if ther was other
141
+ # commits included, the true reason for a version bump would be missing.
142
+ if has_exclusion_match and commit_level_bump == LevelBump.NO_RELEASE:
143
+ log.info(
144
+ "Excluding %s commit[%s] %s",
145
+ "piece of squashed" if is_squash_commit else "",
146
+ parsed_result.short_hash,
147
+ commit_message.split("\n", maxsplit=1)[0][:20],
148
+ )
149
+ continue
150
+
151
+ if (
152
+ isinstance(parsed_result, ParsedCommit)
153
+ and not parsed_result.include_in_changelog
154
+ ):
155
+ log.info(
156
+ str.join(
157
+ " ",
158
+ [
159
+ "Excluding commit[%s] because parser determined",
160
+ "it should not included in the changelog",
161
+ ],
162
+ ),
163
+ parsed_result.short_hash,
164
+ )
165
+ continue
166
+
167
+ if the_version is None:
168
+ log.info(
169
+ "[Unreleased] adding commit[%s] to unreleased '%s'",
170
+ parsed_result.short_hash,
171
+ commit_type,
172
+ )
173
+ unreleased[commit_type].append(parsed_result)
174
+ continue
175
+
158
176
  log.info(
159
- "[Unreleased] adding '%s' commit(%s) to list",
160
- commit.hexsha[:8],
177
+ "[%s] adding commit[%s] to release '%s'",
178
+ the_version,
179
+ parsed_result.short_hash,
161
180
  commit_type,
162
181
  )
163
- unreleased[commit_type].append(parse_result)
164
- continue
165
-
166
- log.info(
167
- "[%s] adding '%s' commit(%s) to release",
168
- the_version,
169
- commit_type,
170
- commit.hexsha[:8],
171
- )
172
182
 
173
- released[the_version]["elements"][commit_type].append(parse_result)
183
+ released[the_version]["elements"][commit_type].append(parsed_result)
174
184
 
175
185
  return cls(unreleased=unreleased, released=released)
176
186
 
@@ -54,7 +54,6 @@ def environment(
54
54
  autoescape_value = dynamic_import(autoescape)
55
55
  else:
56
56
  autoescape_value = autoescape
57
- log.debug("%s", locals())
58
57
 
59
58
  return ComplexDirectorySandboxedEnvironment(
60
59
  block_start_string=block_start_string,
@@ -24,6 +24,7 @@ from semantic_release.cli.const import (
24
24
  )
25
25
  from semantic_release.cli.util import noop_report
26
26
  from semantic_release.errors import InternalError
27
+ from semantic_release.helpers import sort_numerically
27
28
 
28
29
  if TYPE_CHECKING: # pragma: no cover
29
30
  from jinja2 import Environment
@@ -254,7 +255,11 @@ def generate_release_notes(
254
255
  version=release["version"],
255
256
  release=release,
256
257
  mask_initial_release=mask_initial_release,
257
- filters=(*hvcs_client.get_changelog_context_filters(), autofit_text_width),
258
+ filters=(
259
+ *hvcs_client.get_changelog_context_filters(),
260
+ autofit_text_width,
261
+ sort_numerically,
262
+ ),
258
263
  ).bind_to_environment(
259
264
  # Use a new, non-configurable environment for release notes -
260
265
  # not user-configurable at the moment
@@ -65,7 +65,7 @@ def is_forced_prerelease(
65
65
  log.debug(
66
66
  "%s: %s",
67
67
  is_forced_prerelease.__name__,
68
- ", ".join(f"{k} = {v}" for k, v in local_vars),
68
+ str.join(", ", iter(f"{k} = {v}" for k, v in local_vars)),
69
69
  )
70
70
  return (
71
71
  as_prerelease
@@ -602,7 +602,12 @@ class RuntimeContext:
602
602
  # Retrieve details from repository
603
603
  with Repo(str(raw.repo_dir)) as git_repo:
604
604
  try:
605
- remote_url = raw.remote.url or git_repo.remote(raw.remote.name).url
605
+ # Get the remote url by calling out to `git remote get-url`. This returns
606
+ # the expanded url, taking into account any insteadOf directives
607
+ # in the git configuration.
608
+ remote_url = raw.remote.url or git_repo.git.remote(
609
+ "get-url", raw.remote.name
610
+ )
606
611
  active_branch = git_repo.active_branch.name
607
612
  except ValueError as err:
608
613
  raise MissingGitRemote(
@@ -27,7 +27,7 @@ class MaskingFilter(logging.Filter):
27
27
 
28
28
  def add_mask_for(self, data: str, name: str = "redacted") -> MaskingFilter:
29
29
  if data and data not in self._UNWANTED:
30
- log.debug("Adding redact pattern %r to _redact_patterns", name)
30
+ log.debug("Adding redact pattern '%r' to redact_patterns", name)
31
31
  self._redact_patterns[name].add(data)
32
32
  return self
33
33
 
@@ -81,4 +81,4 @@ class CommitParser(ABC, Generic[_TT, _OPTS]):
81
81
  return self.parser_options() # type: ignore[return-value]
82
82
 
83
83
  @abstractmethod
84
- def parse(self, commit: Commit) -> _TT: ...
84
+ def parse(self, commit: Commit) -> _TT | list[_TT]: ...
@@ -10,8 +10,10 @@ import re
10
10
  from functools import reduce
11
11
  from itertools import zip_longest
12
12
  from re import compile as regexp
13
+ from textwrap import dedent
13
14
  from typing import TYPE_CHECKING, Tuple
14
15
 
16
+ from git.objects.commit import Commit
15
17
  from pydantic.dataclasses import dataclass
16
18
 
17
19
  from semantic_release.commit_parser._base import CommitParser, ParserOptions
@@ -23,11 +25,13 @@ from semantic_release.commit_parser.token import (
23
25
  )
24
26
  from semantic_release.commit_parser.util import (
25
27
  breaking_re,
28
+ deep_copy_commit,
29
+ force_str,
26
30
  parse_paragraphs,
27
- sort_numerically,
28
31
  )
29
32
  from semantic_release.enums import LevelBump
30
33
  from semantic_release.errors import InvalidParserOptions
34
+ from semantic_release.helpers import sort_numerically, text_reducer
31
35
 
32
36
  if TYPE_CHECKING: # pragma: no cover
33
37
  from git.objects.commit import Commit
@@ -93,6 +97,10 @@ class AngularParserOptions(ParserOptions):
93
97
  default_bump_level: LevelBump = LevelBump.NO_RELEASE
94
98
  """The minimum bump level to apply to valid commit message."""
95
99
 
100
+ # TODO: breaking change v10, change default to True
101
+ parse_squash_commits: bool = False
102
+ """Toggle flag for whether or not to parse squash commits"""
103
+
96
104
  @property
97
105
  def tag_to_level(self) -> dict[str, LevelBump]:
98
106
  """A mapping of commit tags to the level bump they should result in."""
@@ -142,14 +150,23 @@ class AngularCommitParser(CommitParser[ParseResult, AngularParserOptions]):
142
150
  )
143
151
  ) from err
144
152
 
145
- self.re_parser = regexp(
153
+ self.commit_prefix = regexp(
146
154
  str.join(
147
155
  "",
148
156
  [
149
- r"^" + commit_type_pattern.pattern,
157
+ f"^{commit_type_pattern.pattern}",
150
158
  r"(?:\((?P<scope>[^\n]+)\))?",
151
159
  # TODO: remove ! support as it is not part of the angular commit spec (its part of conventional commits spec)
152
160
  r"(?P<break>!)?:\s+",
161
+ ],
162
+ )
163
+ )
164
+
165
+ self.re_parser = regexp(
166
+ str.join(
167
+ "",
168
+ [
169
+ self.commit_prefix.pattern,
153
170
  r"(?P<subject>[^\n]+)",
154
171
  r"(?:\n\n(?P<text>.+))?", # commit body
155
172
  ],
@@ -171,6 +188,42 @@ class AngularCommitParser(CommitParser[ParseResult, AngularParserOptions]):
171
188
  ),
172
189
  flags=re.MULTILINE | re.IGNORECASE,
173
190
  )
191
+ self.filters = {
192
+ "typo-extra-spaces": (regexp(r"(\S) +(\S)"), r"\1 \2"),
193
+ "git-header-commit": (
194
+ regexp(r"^[\t ]*commit [0-9a-f]+$\n?", flags=re.MULTILINE),
195
+ "",
196
+ ),
197
+ "git-header-author": (
198
+ regexp(r"^[\t ]*Author: .+$\n?", flags=re.MULTILINE),
199
+ "",
200
+ ),
201
+ "git-header-date": (
202
+ regexp(r"^[\t ]*Date: .+$\n?", flags=re.MULTILINE),
203
+ "",
204
+ ),
205
+ "git-squash-heading": (
206
+ regexp(
207
+ r"^[\t ]*Squashed commit of the following:.*$\n?",
208
+ flags=re.MULTILINE,
209
+ ),
210
+ "",
211
+ ),
212
+ "git-squash-commit-prefix": (
213
+ regexp(
214
+ str.join(
215
+ "",
216
+ [
217
+ r"^(?:[\t ]*[*-][\t ]+|[\t ]+)?", # bullet points or indentation
218
+ commit_type_pattern.pattern + r"\b", # prior to commit type
219
+ ],
220
+ ),
221
+ flags=re.MULTILINE,
222
+ ),
223
+ # move commit type to the start of the line
224
+ r"\1",
225
+ ),
226
+ }
174
227
 
175
228
  @staticmethod
176
229
  def get_default_options() -> AngularParserOptions:
@@ -216,7 +269,7 @@ class AngularCommitParser(CommitParser[ParseResult, AngularParserOptions]):
216
269
  return None
217
270
 
218
271
  parsed_break = parsed.group("break")
219
- parsed_scope = parsed.group("scope")
272
+ parsed_scope = parsed.group("scope") or ""
220
273
  parsed_subject = parsed.group("subject")
221
274
  parsed_text = parsed.group("text")
222
275
  parsed_type = parsed.group("type")
@@ -262,24 +315,170 @@ class AngularCommitParser(CommitParser[ParseResult, AngularParserOptions]):
262
315
  linked_merge_request=linked_merge_request,
263
316
  )
264
317
 
318
+ def parse_commit(self, commit: Commit) -> ParseResult:
319
+ if not (parsed_msg_result := self.parse_message(force_str(commit.message))):
320
+ return _logged_parse_error(
321
+ commit,
322
+ f"Unable to parse commit message: {commit.message!r}",
323
+ )
324
+
325
+ return ParsedCommit.from_parsed_message_result(commit, parsed_msg_result)
326
+
265
327
  # Maybe this can be cached as an optimization, similar to how
266
328
  # mypy/pytest use their own caching directories, for very large commit
267
329
  # histories?
268
330
  # The problem is the cache likely won't be present in CI environments
269
- def parse(self, commit: Commit) -> ParseResult:
331
+ def parse(self, commit: Commit) -> ParseResult | list[ParseResult]:
270
332
  """
271
- Attempt to parse the commit message with a regular expression into a
272
- ParseResult
333
+ Parse a commit message
334
+
335
+ If the commit message is a squashed merge commit, it will be split into
336
+ multiple commits, each of which will be parsed separately. Single commits
337
+ will be returned as a list of a single ParseResult.
273
338
  """
274
- if not (pmsg_result := self.parse_message(str(commit.message))):
275
- return _logged_parse_error(
276
- commit, f"Unable to parse commit message: {commit.message!r}"
339
+ separate_commits: list[Commit] = (
340
+ self.unsquash_commit(commit)
341
+ if self.options.parse_squash_commits
342
+ else [commit]
343
+ )
344
+
345
+ # Parse each commit individually if there were more than one
346
+ parsed_commits: list[ParseResult] = list(
347
+ map(self.parse_commit, separate_commits)
348
+ )
349
+
350
+ def add_linked_merge_request(
351
+ parsed_result: ParseResult, mr_number: str
352
+ ) -> ParseResult:
353
+ return (
354
+ parsed_result
355
+ if not isinstance(parsed_result, ParsedCommit)
356
+ else ParsedCommit(
357
+ **{
358
+ **parsed_result._asdict(),
359
+ "linked_merge_request": mr_number,
360
+ }
361
+ )
362
+ )
363
+
364
+ # TODO: improve this for other VCS systems other than GitHub & BitBucket
365
+ # Github works as the first commit in a squash merge commit has the PR number
366
+ # appended to the first line of the commit message
367
+ lead_commit = next(iter(parsed_commits))
368
+
369
+ if isinstance(lead_commit, ParsedCommit) and lead_commit.linked_merge_request:
370
+ # If the first commit has linked merge requests, assume all commits
371
+ # are part of the same PR and add the linked merge requests to all
372
+ # parsed commits
373
+ parsed_commits = [
374
+ lead_commit,
375
+ *map(
376
+ lambda parsed_result, mr=lead_commit.linked_merge_request: ( # type: ignore[misc]
377
+ add_linked_merge_request(parsed_result, mr)
378
+ ),
379
+ parsed_commits[1:],
380
+ ),
381
+ ]
382
+
383
+ elif isinstance(lead_commit, ParseError) and (
384
+ mr_match := self.mr_selector.search(force_str(lead_commit.message))
385
+ ):
386
+ # Handle BitBucket Squash Merge Commits (see #1085), which have non angular commit
387
+ # format but include the PR number in the commit subject that we want to extract
388
+ linked_merge_request = mr_match.group("mr_number")
389
+
390
+ # apply the linked MR to all commits
391
+ parsed_commits = [
392
+ add_linked_merge_request(parsed_result, linked_merge_request)
393
+ for parsed_result in parsed_commits
394
+ ]
395
+
396
+ return parsed_commits
397
+
398
+ def unsquash_commit(self, commit: Commit) -> list[Commit]:
399
+ # GitHub EXAMPLE:
400
+ # feat(changelog): add autofit_text_width filter to template environment (#1062)
401
+ #
402
+ # This change adds an equivalent style formatter that can apply a text alignment
403
+ # to a maximum width and also maintain an indent over paragraphs of text
404
+ #
405
+ # * docs(changelog-templates): add definition & usage of autofit_text_width template filter
406
+ #
407
+ # * test(changelog-context): add test cases to check autofit_text_width filter use
408
+ #
409
+ # `git merge --squash` EXAMPLE:
410
+ # Squashed commit of the following:
411
+ #
412
+ # commit 63ec09b9e844e616dcaa7bae35a0b66671b59fbb
413
+ # Author: codejedi365 <codejedi365@gmail.com>
414
+ # Date: Sun Oct 13 12:05:23 2024 -0600
415
+ #
416
+ # feat(release-config): some commit subject
417
+ #
418
+
419
+ # Return a list of artificial commits (each with a single commit message)
420
+ return [
421
+ # create a artificial commit object (copy of original but with modified message)
422
+ Commit(
423
+ **{
424
+ **deep_copy_commit(commit),
425
+ "message": commit_msg,
426
+ }
277
427
  )
428
+ for commit_msg in self.unsquash_commit_message(force_str(commit.message))
429
+ ] or [commit]
278
430
 
279
- logger.debug(
280
- "commit %s introduces a %s level_bump",
281
- commit.hexsha[:8],
282
- pmsg_result.bump,
431
+ def unsquash_commit_message(self, message: str) -> list[str]:
432
+ normalized_message = message.replace("\r", "").strip()
433
+
434
+ # split by obvious separate commits (applies to manual git squash merges)
435
+ obvious_squashed_commits = self.filters["git-header-commit"][0].split(
436
+ normalized_message
437
+ )
438
+
439
+ separate_commit_msgs: list[str] = reduce(
440
+ lambda all_msgs, msgs: all_msgs + msgs,
441
+ map(self._find_squashed_commits_in_str, obvious_squashed_commits),
442
+ [],
283
443
  )
284
444
 
285
- return ParsedCommit.from_parsed_message_result(commit, pmsg_result)
445
+ return separate_commit_msgs
446
+
447
+ def _find_squashed_commits_in_str(self, message: str) -> list[str]:
448
+ separate_commit_msgs: list[str] = []
449
+ current_msg = ""
450
+
451
+ for paragraph in filter(None, message.strip().split("\n\n")):
452
+ # Apply filters to normalize the paragraph
453
+ clean_paragraph = reduce(text_reducer, self.filters.values(), paragraph)
454
+
455
+ # remove any filtered (and now empty) paragraphs (ie. the git headers)
456
+ if not clean_paragraph.strip():
457
+ continue
458
+
459
+ # Check if the paragraph is the start of a new angular commit
460
+ if not self.commit_prefix.search(clean_paragraph):
461
+ if not separate_commit_msgs and not current_msg:
462
+ # if there are no separate commit messages and no current message
463
+ # then this is the first commit message
464
+ current_msg = dedent(clean_paragraph)
465
+ continue
466
+
467
+ # append the paragraph as part of the previous commit message
468
+ if current_msg:
469
+ current_msg += f"\n\n{dedent(clean_paragraph)}"
470
+ # else: drop the paragraph
471
+ continue
472
+
473
+ # Since we found the start of the new commit, store any previous commit
474
+ # message separately and start the new commit message
475
+ if current_msg:
476
+ separate_commit_msgs.append(current_msg)
477
+
478
+ current_msg = clean_paragraph
479
+
480
+ # Store the last commit message (if its not empty)
481
+ if current_msg:
482
+ separate_commit_msgs.append(current_msg)
483
+
484
+ return separate_commit_msgs
@@ -7,6 +7,7 @@ import re
7
7
  from functools import reduce
8
8
  from itertools import zip_longest
9
9
  from re import compile as regexp
10
+ from textwrap import dedent
10
11
  from typing import Tuple
11
12
 
12
13
  from git.objects.commit import Commit
@@ -18,9 +19,14 @@ from semantic_release.commit_parser.token import (
18
19
  ParsedMessageResult,
19
20
  ParseResult,
20
21
  )
21
- from semantic_release.commit_parser.util import parse_paragraphs, sort_numerically
22
+ from semantic_release.commit_parser.util import (
23
+ deep_copy_commit,
24
+ force_str,
25
+ parse_paragraphs,
26
+ )
22
27
  from semantic_release.enums import LevelBump
23
28
  from semantic_release.errors import InvalidParserOptions
29
+ from semantic_release.helpers import sort_numerically, text_reducer
24
30
 
25
31
  logger = logging.getLogger(__name__)
26
32
 
@@ -60,7 +66,7 @@ class EmojiParserOptions(ParserOptions):
60
66
  )
61
67
  """Commit-type prefixes that should result in a patch release bump."""
62
68
 
63
- other_allowed_tags: Tuple[str, ...] = ()
69
+ other_allowed_tags: Tuple[str, ...] = (":memo:", ":checkmark:")
64
70
  """Commit-type prefixes that are allowed but do not result in a version bump."""
65
71
 
66
72
  allowed_tags: Tuple[str, ...] = (
@@ -74,11 +80,6 @@ class EmojiParserOptions(ParserOptions):
74
80
  default_bump_level: LevelBump = LevelBump.NO_RELEASE
75
81
  """The minimum bump level to apply to valid commit message."""
76
82
 
77
- @property
78
- def tag_to_level(self) -> dict[str, LevelBump]:
79
- """A mapping of commit tags to the level bump they should result in."""
80
- return self._tag_to_level
81
-
82
83
  parse_linked_issues: bool = False
83
84
  """
84
85
  Whether to parse linked issues from the commit message.
@@ -92,6 +93,15 @@ class EmojiParserOptions(ParserOptions):
92
93
  a whitespace separator.
93
94
  """
94
95
 
96
+ # TODO: breaking change v10, change default to True
97
+ parse_squash_commits: bool = False
98
+ """Toggle flag for whether or not to parse squash commits"""
99
+
100
+ @property
101
+ def tag_to_level(self) -> dict[str, LevelBump]:
102
+ """A mapping of commit tags to the level bump they should result in."""
103
+ return self._tag_to_level
104
+
95
105
  def __post_init__(self) -> None:
96
106
  self._tag_to_level: dict[str, LevelBump] = {
97
107
  str(tag): level
@@ -132,7 +142,7 @@ class EmojiCommitParser(CommitParser[ParseResult, EmojiParserOptions]):
132
142
  emojis_in_precedence_order = list(self.options.tag_to_level.keys())[::-1]
133
143
 
134
144
  try:
135
- self.emoji_selector = regexp(
145
+ highest_emoji_pattern = regexp(
136
146
  r"(?P<type>%s)" % str.join("|", emojis_in_precedence_order)
137
147
  )
138
148
  except re.error as err:
@@ -147,6 +157,16 @@ class EmojiCommitParser(CommitParser[ParseResult, EmojiParserOptions]):
147
157
  )
148
158
  ) from err
149
159
 
160
+ self.emoji_selector = regexp(
161
+ str.join(
162
+ "",
163
+ [
164
+ f"^{highest_emoji_pattern.pattern}",
165
+ r"(?:\((?P<scope>[^)]+)\))?:?",
166
+ ],
167
+ )
168
+ )
169
+
150
170
  # GitHub & Gitea use (#123), GitLab uses (!123), and BitBucket uses (pull request #123)
151
171
  self.mr_selector = regexp(
152
172
  r"[\t ]+\((?:pull request )?(?P<mr_number>[#!]\d+)\)[\t ]*$"
@@ -163,6 +183,44 @@ class EmojiCommitParser(CommitParser[ParseResult, EmojiParserOptions]):
163
183
  flags=re.MULTILINE | re.IGNORECASE,
164
184
  )
165
185
 
186
+ self.filters = {
187
+ "typo-extra-spaces": (regexp(r"(\S) +(\S)"), r"\1 \2"),
188
+ "git-header-commit": (
189
+ regexp(r"^[\t ]*commit [0-9a-f]+$\n?", flags=re.MULTILINE),
190
+ "",
191
+ ),
192
+ "git-header-author": (
193
+ regexp(r"^[\t ]*Author: .+$\n?", flags=re.MULTILINE),
194
+ "",
195
+ ),
196
+ "git-header-date": (
197
+ regexp(r"^[\t ]*Date: .+$\n?", flags=re.MULTILINE),
198
+ "",
199
+ ),
200
+ "git-squash-heading": (
201
+ regexp(
202
+ r"^[\t ]*Squashed commit of the following:.*$\n?",
203
+ flags=re.MULTILINE,
204
+ ),
205
+ "",
206
+ ),
207
+ "git-squash-commit-prefix": (
208
+ regexp(
209
+ str.join(
210
+ "",
211
+ [
212
+ r"^(?:[\t ]*[*-][\t ]+|[\t ]+)?", # bullet points or indentation
213
+ highest_emoji_pattern.pattern
214
+ + r"(\W)", # prior to commit type
215
+ ],
216
+ ),
217
+ flags=re.MULTILINE,
218
+ ),
219
+ # move commit type to the start of the line
220
+ r"\1\2",
221
+ ),
222
+ }
223
+
166
224
  @staticmethod
167
225
  def get_default_options() -> EmojiParserOptions:
168
226
  return EmojiParserOptions()
@@ -209,11 +267,9 @@ class EmojiCommitParser(CommitParser[ParseResult, EmojiParserOptions]):
209
267
  # subject = self.mr_selector.sub("", subject).strip()
210
268
 
211
269
  # Search for emoji of the highest importance in the subject
212
- primary_emoji = (
213
- match.group("type")
214
- if (match := self.emoji_selector.search(subject))
215
- else "Other"
216
- )
270
+ match = self.emoji_selector.search(subject)
271
+ primary_emoji = match.group("type") if match else "Other"
272
+ parsed_scope = (match.group("scope") if match else None) or ""
217
273
 
218
274
  level_bump = self.options.tag_to_level.get(
219
275
  primary_emoji, self.options.default_bump_level
@@ -235,7 +291,7 @@ class EmojiCommitParser(CommitParser[ParseResult, EmojiParserOptions]):
235
291
  bump=level_bump,
236
292
  type=primary_emoji,
237
293
  category=primary_emoji,
238
- scope="", # TODO: add scope support
294
+ scope=parsed_scope,
239
295
  # TODO: breaking change v10, removes breaking change footers from descriptions
240
296
  # descriptions=(
241
297
  # descriptions[:1] if level_bump is LevelBump.MAJOR else descriptions
@@ -248,17 +304,149 @@ class EmojiCommitParser(CommitParser[ParseResult, EmojiParserOptions]):
248
304
  linked_merge_request=linked_merge_request,
249
305
  )
250
306
 
251
- def parse(self, commit: Commit) -> ParseResult:
307
+ def parse_commit(self, commit: Commit) -> ParseResult:
308
+ return ParsedCommit.from_parsed_message_result(
309
+ commit, self.parse_message(force_str(commit.message))
310
+ )
311
+
312
+ def parse(self, commit: Commit) -> ParseResult | list[ParseResult]:
252
313
  """
253
- Attempt to parse the commit message with a regular expression into a
254
- ParseResult
314
+ Parse a commit message
315
+
316
+ If the commit message is a squashed merge commit, it will be split into
317
+ multiple commits, each of which will be parsed separately. Single commits
318
+ will be returned as a list of a single ParseResult.
255
319
  """
256
- pmsg_result = self.parse_message(str(commit.message))
320
+ separate_commits: list[Commit] = (
321
+ self.unsquash_commit(commit)
322
+ if self.options.parse_squash_commits
323
+ else [commit]
324
+ )
325
+
326
+ # Parse each commit individually if there were more than one
327
+ parsed_commits: list[ParseResult] = list(
328
+ map(self.parse_commit, separate_commits)
329
+ )
330
+
331
+ def add_linked_merge_request(
332
+ parsed_result: ParseResult, mr_number: str
333
+ ) -> ParseResult:
334
+ return (
335
+ parsed_result
336
+ if not isinstance(parsed_result, ParsedCommit)
337
+ else ParsedCommit(
338
+ **{
339
+ **parsed_result._asdict(),
340
+ "linked_merge_request": mr_number,
341
+ }
342
+ )
343
+ )
344
+
345
+ # TODO: improve this for other VCS systems other than GitHub & BitBucket
346
+ # Github works as the first commit in a squash merge commit has the PR number
347
+ # appended to the first line of the commit message
348
+ lead_commit = next(iter(parsed_commits))
349
+
350
+ if isinstance(lead_commit, ParsedCommit) and lead_commit.linked_merge_request:
351
+ # If the first commit has linked merge requests, assume all commits
352
+ # are part of the same PR and add the linked merge requests to all
353
+ # parsed commits
354
+ parsed_commits = [
355
+ lead_commit,
356
+ *map(
357
+ lambda parsed_result, mr=lead_commit.linked_merge_request: ( # type: ignore[misc]
358
+ add_linked_merge_request(parsed_result, mr)
359
+ ),
360
+ parsed_commits[1:],
361
+ ),
362
+ ]
257
363
 
258
- logger.debug(
259
- "commit %s introduces a %s level_bump",
260
- commit.hexsha[:8],
261
- pmsg_result.bump,
364
+ return parsed_commits
365
+
366
+ def unsquash_commit(self, commit: Commit) -> list[Commit]:
367
+ # GitHub EXAMPLE:
368
+ # ✨(changelog): add autofit_text_width filter to template environment (#1062)
369
+ #
370
+ # This change adds an equivalent style formatter that can apply a text alignment
371
+ # to a maximum width and also maintain an indent over paragraphs of text
372
+ #
373
+ # * 🌐 Support Japanese language
374
+ #
375
+ # * ✅(changelog-context): add test cases to check autofit_text_width filter use
376
+ #
377
+ # `git merge --squash` EXAMPLE:
378
+ # Squashed commit of the following:
379
+ #
380
+ # commit 63ec09b9e844e616dcaa7bae35a0b66671b59fbb
381
+ # Author: codejedi365 <codejedi365@gmail.com>
382
+ # Date: Sun Oct 13 12:05:23 2024 -0000
383
+ #
384
+ # ⚡️ (homepage): Lazyload home screen images
385
+ #
386
+ #
387
+ # Return a list of artificial commits (each with a single commit message)
388
+ return [
389
+ # create a artificial commit object (copy of original but with modified message)
390
+ Commit(
391
+ **{
392
+ **deep_copy_commit(commit),
393
+ "message": commit_msg,
394
+ }
395
+ )
396
+ for commit_msg in self.unsquash_commit_message(force_str(commit.message))
397
+ ] or [commit]
398
+
399
+ def unsquash_commit_message(self, message: str) -> list[str]:
400
+ normalized_message = message.replace("\r", "").strip()
401
+
402
+ # split by obvious separate commits (applies to manual git squash merges)
403
+ obvious_squashed_commits = self.filters["git-header-commit"][0].split(
404
+ normalized_message
262
405
  )
263
406
 
264
- return ParsedCommit.from_parsed_message_result(commit, pmsg_result)
407
+ separate_commit_msgs: list[str] = reduce(
408
+ lambda all_msgs, msgs: all_msgs + msgs,
409
+ map(self._find_squashed_commits_in_str, obvious_squashed_commits),
410
+ [],
411
+ )
412
+
413
+ return separate_commit_msgs
414
+
415
+ def _find_squashed_commits_in_str(self, message: str) -> list[str]:
416
+ separate_commit_msgs: list[str] = []
417
+ current_msg = ""
418
+
419
+ for paragraph in filter(None, message.strip().split("\n\n")):
420
+ # Apply filters to normalize the paragraph
421
+ clean_paragraph = reduce(text_reducer, self.filters.values(), paragraph)
422
+
423
+ # remove any filtered (and now empty) paragraphs (ie. the git headers)
424
+ if not clean_paragraph.strip():
425
+ continue
426
+
427
+ # Check if the paragraph is the start of a new angular commit
428
+ if not self.emoji_selector.search(clean_paragraph):
429
+ if not separate_commit_msgs and not current_msg:
430
+ # if there are no separate commit messages and no current message
431
+ # then this is the first commit message
432
+ current_msg = dedent(clean_paragraph)
433
+ continue
434
+
435
+ # append the paragraph as part of the previous commit message
436
+ if current_msg:
437
+ current_msg += f"\n\n{dedent(clean_paragraph)}"
438
+ # else: drop the paragraph
439
+ continue
440
+
441
+ # Since we found the start of the new commit, store any previous commit
442
+ # message separately and start the new commit message
443
+ if current_msg:
444
+ separate_commit_msgs.append(current_msg)
445
+
446
+ current_msg = clean_paragraph
447
+
448
+ # Store the last commit message (if its not empty)
449
+ if current_msg:
450
+ separate_commit_msgs.append(current_msg)
451
+
452
+ return separate_commit_msgs
@@ -74,21 +74,21 @@ def _logged_parse_error(commit: Commit, error: str) -> ParseError:
74
74
 
75
75
  tag_to_section = {
76
76
  "API": "breaking",
77
- "BENCH": "None",
77
+ "BENCH": "none",
78
78
  "BLD": "fix",
79
79
  "BUG": "fix",
80
80
  "DEP": "breaking",
81
- "DEV": "None",
81
+ "DEV": "none",
82
82
  "DOC": "documentation",
83
83
  "ENH": "feature",
84
84
  "MAINT": "fix",
85
- "REV": "Other",
86
- "STY": "None",
87
- "TST": "None",
88
- "REL": "None",
85
+ "REV": "other",
86
+ "STY": "none",
87
+ "TST": "none",
88
+ "REL": "none",
89
89
  # strictly speaking not part of the standard
90
90
  "FEAT": "feature",
91
- "TEST": "None",
91
+ "TEST": "none",
92
92
  }
93
93
 
94
94
 
@@ -1,5 +1,7 @@
1
1
  """Legacy commit parser from Python Semantic Release 1.0"""
2
2
 
3
+ from __future__ import annotations
4
+
3
5
  import logging
4
6
  import re
5
7
 
@@ -41,7 +43,7 @@ class TagCommitParser(CommitParser[ParseResult, TagParserOptions]):
41
43
  def get_default_options() -> TagParserOptions:
42
44
  return TagParserOptions()
43
45
 
44
- def parse(self, commit: Commit) -> ParseResult:
46
+ def parse(self, commit: Commit) -> ParseResult | list[ParseResult]:
45
47
  message = str(commit.message)
46
48
 
47
49
  # Attempt to parse the commit message with a regular expression
@@ -1,20 +1,27 @@
1
1
  from __future__ import annotations
2
2
 
3
+ from contextlib import suppress
4
+ from copy import deepcopy
3
5
  from functools import reduce
4
6
  from re import MULTILINE, compile as regexp
5
7
  from typing import TYPE_CHECKING
6
8
 
9
+ # TODO: remove in v10
10
+ from semantic_release.helpers import (
11
+ sort_numerically, # noqa: F401 # TODO: maintained for compatibility
12
+ )
13
+
7
14
  if TYPE_CHECKING: # pragma: no cover
8
15
  from re import Pattern
9
- from typing import Sequence, TypedDict
16
+ from typing import Any, TypedDict
17
+
18
+ from git import Commit
10
19
 
11
20
  class RegexReplaceDef(TypedDict):
12
21
  pattern: Pattern
13
22
  repl: str
14
23
 
15
24
 
16
- number_pattern = regexp(r"(\d+)")
17
-
18
25
  breaking_re = regexp(r"BREAKING[ -]CHANGE:\s?(.*)")
19
26
 
20
27
  un_word_wrap: RegexReplaceDef = {
@@ -73,5 +80,42 @@ def parse_paragraphs(text: str) -> list[str]:
73
80
  )
74
81
 
75
82
 
76
- def sort_numerically(iterable: Sequence[str] | set[str]) -> list[str]:
77
- return sorted(iterable, key=lambda x: int((number_pattern.search(x) or [-1])[0]))
83
+ def force_str(msg: str | bytes | bytearray | memoryview) -> str:
84
+ # This shouldn't be a thing but typing is being weird around what
85
+ # git.commit.message returns and the memoryview type won't go away
86
+ message = msg.tobytes() if isinstance(msg, memoryview) else msg
87
+ return (
88
+ message.decode("utf-8")
89
+ if isinstance(message, (bytes, bytearray))
90
+ else str(message)
91
+ )
92
+
93
+
94
+ def deep_copy_commit(commit: Commit) -> dict[str, Any]:
95
+ keys = [
96
+ "repo",
97
+ "binsha",
98
+ "author",
99
+ "authored_date",
100
+ "committer",
101
+ "committed_date",
102
+ "message",
103
+ "tree",
104
+ "parents",
105
+ "encoding",
106
+ "gpgsig",
107
+ "author_tz_offset",
108
+ "committer_tz_offset",
109
+ ]
110
+ kwargs = {}
111
+ for key in keys:
112
+ with suppress(ValueError):
113
+ if hasattr(commit, key) and (value := getattr(commit, key)) is not None:
114
+ if key in ["parents", "repo", "tree"]:
115
+ # These tend to have circular references so don't deepcopy them
116
+ kwargs[key] = value
117
+ continue
118
+
119
+ kwargs[key] = deepcopy(value)
120
+
121
+ return kwargs
@@ -1,16 +1,97 @@
1
+ from __future__ import annotations
2
+
1
3
  import importlib.util
2
4
  import logging
3
5
  import os
4
6
  import re
5
7
  import string
6
8
  import sys
7
- from functools import lru_cache, wraps
9
+ from functools import lru_cache, reduce, wraps
8
10
  from pathlib import Path, PurePosixPath
9
- from typing import Any, Callable, NamedTuple, TypeVar
11
+ from re import IGNORECASE, compile as regexp
12
+ from typing import TYPE_CHECKING, Any, Callable, NamedTuple, TypeVar
10
13
  from urllib.parse import urlsplit
11
14
 
15
+ if TYPE_CHECKING: # pragma: no cover
16
+ from re import Pattern
17
+ from typing import Iterable
18
+
19
+
12
20
  log = logging.getLogger(__name__)
13
21
 
22
+ number_pattern = regexp(r"(?P<prefix>\S*?)(?P<number>\d[\d,]*)\b")
23
+ hex_number_pattern = regexp(
24
+ r"(?P<prefix>\S*?)(?:0x)?(?P<number>[0-9a-f]+)\b", IGNORECASE
25
+ )
26
+
27
+
28
+ def get_number_from_str(
29
+ string: str, default: int = -1, interpret_hex: bool = False
30
+ ) -> int:
31
+ if interpret_hex and (match := hex_number_pattern.search(string)):
32
+ return abs(int(match.group("number"), 16))
33
+
34
+ if match := number_pattern.search(string):
35
+ return int(match.group("number"))
36
+
37
+ return default
38
+
39
+
40
+ def sort_numerically(
41
+ iterable: Iterable[str], reverse: bool = False, allow_hex: bool = False
42
+ ) -> list[str]:
43
+ # Alphabetically sort prefixes first, then sort by number
44
+ alphabetized_list = sorted(iterable)
45
+
46
+ # Extract prefixes in order to group items by prefix
47
+ unmatched_items = []
48
+ prefixes: dict[str, list[str]] = {}
49
+ for item in alphabetized_list:
50
+ if not (
51
+ pattern_match := (
52
+ (hex_number_pattern.search(item) if allow_hex else None)
53
+ or number_pattern.search(item)
54
+ )
55
+ ):
56
+ unmatched_items.append(item)
57
+ continue
58
+
59
+ prefix = prefix if (prefix := pattern_match.group("prefix")) else ""
60
+
61
+ if prefix not in prefixes:
62
+ prefixes[prefix] = []
63
+
64
+ prefixes[prefix].append(item)
65
+
66
+ # Sort prefixes and items by number mixing in unmatched items as alphabetized with other prefixes
67
+ return reduce(
68
+ lambda acc, next_item: acc + next_item,
69
+ [
70
+ (
71
+ sorted(
72
+ prefixes[prefix],
73
+ key=lambda x: get_number_from_str(
74
+ x, default=-1, interpret_hex=allow_hex
75
+ ),
76
+ reverse=reverse,
77
+ )
78
+ if prefix in prefixes
79
+ else [prefix]
80
+ )
81
+ for prefix in sorted([*prefixes.keys(), *unmatched_items])
82
+ ],
83
+ [],
84
+ )
85
+
86
+
87
+ def text_reducer(text: str, filter_pair: tuple[Pattern[str], str]) -> str:
88
+ """Reduce function to apply mulitple filters to a string"""
89
+ if not text: # abort if the paragraph is empty
90
+ return text
91
+
92
+ filter_pattern, replacement = filter_pair
93
+ return filter_pattern.sub(replacement, text)
94
+
14
95
 
15
96
  def format_arg(value: Any) -> str:
16
97
  """Helper to format an argument an argument for logging"""
@@ -69,7 +150,6 @@ def dynamic_import(import_path: str) -> Any:
69
150
  Dynamically import an object from a conventionally formatted "module:attribute"
70
151
  string
71
152
  """
72
- log.debug("Trying to import %s", import_path)
73
153
  module_name, attr = import_path.split(":", maxsplit=1)
74
154
 
75
155
  # Check if the module is a file path, if it can be resolved and exists on disk then import as a file
@@ -78,10 +158,11 @@ def dynamic_import(import_path: str) -> Any:
78
158
  module_path = (
79
159
  module_filepath.stem
80
160
  if Path(module_name).is_absolute()
81
- else str(Path(module_name).with_suffix("")).replace(os.sep, ".")
161
+ else str(Path(module_name).with_suffix("")).replace(os.sep, ".").lstrip(".")
82
162
  )
83
163
 
84
164
  if module_path not in sys.modules:
165
+ log.debug("Loading '%s' from file '%s'", module_path, module_filepath)
85
166
  spec = importlib.util.spec_from_file_location(
86
167
  module_path, str(module_filepath)
87
168
  )
@@ -96,7 +177,9 @@ def dynamic_import(import_path: str) -> Any:
96
177
 
97
178
  # Otherwise, import as a module
98
179
  try:
180
+ log.debug("Importing module '%s'", module_name)
99
181
  module = importlib.import_module(module_name)
182
+ log.debug("Loading '%s' from module '%s'", attr, module_name)
100
183
  return getattr(module, attr)
101
184
  except TypeError as err:
102
185
  raise ImportError(
@@ -30,7 +30,7 @@ class HvcsBase(metaclass=ABCMeta):
30
30
  """
31
31
 
32
32
  def __init__(self, remote_url: str, *args: Any, **kwargs: Any) -> None:
33
- self._remote_url = remote_url
33
+ self._remote_url = remote_url if parse_git_url(remote_url) else ""
34
34
  self._name: str | None = None
35
35
  self._owner: str | None = None
36
36
 
@@ -2,6 +2,7 @@ from __future__ import annotations
2
2
 
3
3
  import logging
4
4
  from contextlib import suppress
5
+ from functools import reduce
5
6
  from queue import LifoQueue
6
7
  from typing import TYPE_CHECKING, Iterable
7
8
 
@@ -89,7 +90,6 @@ def _traverse_graph_for_commits(
89
90
  # Add all parent commits to the stack from left to right so that the rightmost is popped first
90
91
  # as the left side is generally the merged into branch
91
92
  for parent in node.parents:
92
- logger.debug("queuing parent commit %s", parent.hexsha[:7])
93
93
  stack.put(parent)
94
94
 
95
95
  return commits
@@ -347,11 +347,25 @@ def next_version(
347
347
  )
348
348
 
349
349
  # Step 5. Parse the commits to determine the bump level that should be applied
350
- parsed_levels: set[LevelBump] = {
350
+ parsed_levels: set[LevelBump] = { # type: ignore[var-annotated] # too complex for type checkers
351
351
  parsed_result.bump # type: ignore[union-attr] # too complex for type checkers
352
352
  for parsed_result in filter(
353
- lambda parsed_result: isinstance(parsed_result, ParsedCommit),
354
- map(commit_parser.parse, commits_since_last_release),
353
+ # Filter out any non-ParsedCommit results (i.e. ParseErrors)
354
+ lambda parsed_result: isinstance(parsed_result, ParsedCommit), # type: ignore[arg-type]
355
+ reduce(
356
+ # Accumulate all parsed results into a single list
357
+ lambda accumulated_results, parsed_results: [
358
+ *accumulated_results,
359
+ *(
360
+ parsed_results
361
+ if isinstance(parsed_results, Iterable)
362
+ else [parsed_results] # type: ignore[list-item]
363
+ ),
364
+ ],
365
+ # apply the parser to each commit in the history (could return multiple results per commit)
366
+ map(commit_parser.parse, commits_since_last_release),
367
+ [],
368
+ ),
355
369
  )
356
370
  }
357
371