python-semantic-release 9.9.0__py3-none-any.whl → 9.10.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 (27) hide show
  1. {python_semantic_release-9.9.0.dist-info → python_semantic_release-9.10.0.dist-info}/METADATA +1 -1
  2. {python_semantic_release-9.9.0.dist-info → python_semantic_release-9.10.0.dist-info}/RECORD +25 -19
  3. semantic_release/__init__.py +1 -1
  4. semantic_release/changelog/context.py +32 -3
  5. semantic_release/changelog/template.py +29 -8
  6. semantic_release/cli/changelog_writer.py +124 -36
  7. semantic_release/cli/commands/changelog.py +4 -1
  8. semantic_release/cli/commands/version.py +1 -0
  9. semantic_release/cli/config.py +45 -3
  10. semantic_release/cli/const.py +4 -0
  11. semantic_release/commit_parser/angular.py +11 -3
  12. semantic_release/data/templates/angular/md/.changelog_header.md.j2 +9 -0
  13. semantic_release/data/templates/angular/md/.changelog_init.md.j2 +22 -0
  14. semantic_release/data/templates/angular/md/.changelog_update.md.j2 +62 -0
  15. semantic_release/data/templates/angular/md/.changes.md.j2 +16 -0
  16. semantic_release/data/templates/angular/md/.unreleased_changes.md.j2 +7 -0
  17. semantic_release/data/templates/angular/md/.versioned_changes.md.j2 +14 -0
  18. semantic_release/data/templates/angular/md/CHANGELOG.md.j2 +24 -0
  19. semantic_release/data/templates/angular/release_notes.md.j2 +1 -0
  20. semantic_release/errors.py +4 -0
  21. semantic_release/data/templates/CHANGELOG.md.j2 +0 -21
  22. semantic_release/data/templates/release_notes.md.j2 +0 -8
  23. {python_semantic_release-9.9.0.dist-info → python_semantic_release-9.10.0.dist-info}/AUTHORS.rst +0 -0
  24. {python_semantic_release-9.9.0.dist-info → python_semantic_release-9.10.0.dist-info}/LICENSE +0 -0
  25. {python_semantic_release-9.9.0.dist-info → python_semantic_release-9.10.0.dist-info}/WHEEL +0 -0
  26. {python_semantic_release-9.9.0.dist-info → python_semantic_release-9.10.0.dist-info}/entry_points.txt +0 -0
  27. {python_semantic_release-9.9.0.dist-info → python_semantic_release-9.10.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.9.0
3
+ Version: 9.10.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,44 @@
1
- semantic_release/__init__.py,sha256=7HrApLLeToDPOB2RQ_5Gl3Qy0WOAYS2AN9Itlv02fvk,1228
1
+ semantic_release/__init__.py,sha256=C5zv3UkZdzyn-xzqjsl6ltjgVx_HcwOtrPym3F0BF5I,1229
2
2
  semantic_release/__main__.py,sha256=blPn7CMpuSe4Z-GBIDv0QA0OEDTMGU-7edsLRkkhPR4,120
3
3
  semantic_release/const.py,sha256=Z1o2QNh60wSLeF-_1TemMBjU3ZXbV0XghnUFsbTVfOs,831
4
4
  semantic_release/enums.py,sha256=D5B_reQGGKQQT22HO5PUtvn2Bok3fkht6TfJtXkmAUg,1020
5
- semantic_release/errors.py,sha256=bPQDMW9MVhCwkE_TrpsmsRYZRuk5iOnqi-hKeujXQc0,2676
5
+ semantic_release/errors.py,sha256=rco5-lwz_0JbJrDsQWmTvT_l3bA3HFkeTmBiZfvCx-Q,2799
6
6
  semantic_release/gitproject.py,sha256=izWc4NLdUzAwxGG_fJeqqHW9ivSrPcWBzSaOijQx4f8,8564
7
7
  semantic_release/helpers.py,sha256=d1jOX0SNyqPc_3wr14xR25FfpqhMd4Ev7MNBOWlScc0,5581
8
8
  semantic_release/changelog/__init__.py,sha256=Bg6Xe5Vt32rWoMscW-hd4sUwiZqzWmsg4CD1EhMesMY,262
9
- semantic_release/changelog/context.py,sha256=8cbOv9cB9Ji7xjRn4-YUUHBfg9LLfSwmmXuZpqEmvns,1653
9
+ semantic_release/changelog/context.py,sha256=zvkjQ9qv5Jk1IPZOnPPh1spM9AiWVYctJfMbI8L_qaI,2403
10
10
  semantic_release/changelog/release_history.py,sha256=9gJxqOKPF9HjmOdXW-k-dWbe2YGWxqZEOWSSef-qwoE,7050
11
- semantic_release/changelog/template.py,sha256=JqZcjdozM9-059Sa8PfiHxYNeyZFa0jxk_beyRLv5sU,4640
11
+ semantic_release/changelog/template.py,sha256=FfA5-6sWh3bYQ5sEK4fR414_WNxm3TPUXghqak_9eyk,5989
12
12
  semantic_release/cli/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
13
- semantic_release/cli/changelog_writer.py,sha256=OVuRI7RTMsMGIaxyEN4kb8N4j08OHNvSyRyj6iBR_ME,5162
13
+ semantic_release/cli/changelog_writer.py,sha256=tvUtUFfxP4mKHLfWiC2yGrdCNMtUHQrpFxfdNpGTam4,8630
14
14
  semantic_release/cli/cli_context.py,sha256=23eyV6KWIpUckUSWILEd7t9dixp-QCY5-kGZnyucEYY,4114
15
- semantic_release/cli/config.py,sha256=bI0v896k0mheNn6_yZpYp6D-q83hx0gcddqYkyJejdo,24845
16
- semantic_release/cli/const.py,sha256=S8oNNpSaKdZxcAs7PAKEjQHic0czoJBGd7XHo57FOzw,39
15
+ semantic_release/cli/config.py,sha256=wu_ytryKW9jOAb649pkjlrAfwlLDuE83GPODtdmXHpA,26632
16
+ semantic_release/cli/const.py,sha256=haTpNlJifrtbwICwF1u0MNyB0kDsqGUsfM2zwuDu4RM,162
17
17
  semantic_release/cli/github_actions_output.py,sha256=VYIOb5x5h8eEJdSlXM_mkhT9xXtYi-RgxvnoM7iUn8s,2288
18
18
  semantic_release/cli/masking_filter.py,sha256=DxqjiJyABlzwwwZ1r8JGQpb6QrF00StJFm0-2-s5Fv0,3071
19
19
  semantic_release/cli/util.py,sha256=FyXaBkeL7nXKjy3X9rQLEwvn7p46xPekp2V8Z-5MVrk,3755
20
20
  semantic_release/cli/commands/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
21
- semantic_release/cli/commands/changelog.py,sha256=bqnJwv0pgvEOroZEMixEa8IUby-cvgIirXRz31FlnV0,3716
21
+ semantic_release/cli/commands/changelog.py,sha256=kVHcGdfud74-M6hjWf1PS6l95gD4yUlu3CZGBkCt7aY,3765
22
22
  semantic_release/cli/commands/generate_config.py,sha256=2xZOu3NpyhBp0pWr7d8ugKl_kjqQgpSsSMHq5wHTfrE,1699
23
23
  semantic_release/cli/commands/main.py,sha256=kqO8bZw9Nv6T5QkIl53zzmH2rd4LuLoA8agweH0VYPY,4022
24
24
  semantic_release/cli/commands/publish.py,sha256=SZQlIewvqyIC14dkIIVVFetE0tPsKbO1cUyxnZsicrw,2845
25
- semantic_release/cli/commands/version.py,sha256=UjjBlv2IQs21LRQ3APeRUXwjZ20MXMBXBhykzGjj46c,24231
25
+ semantic_release/cli/commands/version.py,sha256=4ZfF2zbwnSNlVewfyXI1UrlvxJJFSI3A3xGo6Vg8CC0,24270
26
26
  semantic_release/commit_parser/__init__.py,sha256=cv5HFBdw7OJd4Laj4Ex8ZZ5Tml8GwXgQcXW6Pasr2Ao,615
27
27
  semantic_release/commit_parser/_base.py,sha256=t-Z9ALgAe7aZpYXz1mk3Fe-uAvipgKdNrq4Okg_WW9c,3026
28
- semantic_release/commit_parser/angular.py,sha256=ERWeTBTFNIBOaTIopY4Vff0KbeFwrSQWbADe3smVW-8,4579
28
+ semantic_release/commit_parser/angular.py,sha256=AbclYVP_aX_WKpwBCN7SCyJ3-aYf-SVOT4O5TOBLVwI,4857
29
29
  semantic_release/commit_parser/emoji.py,sha256=M6zgqsXqegLK5FxZGyfsOPM3vOsJkPKVAjnGATOhwa4,3452
30
30
  semantic_release/commit_parser/scipy.py,sha256=AH_GeMZ9hWiNQTL78bovpQy7mBeobGpUk5Shzyxxm6c,5951
31
31
  semantic_release/commit_parser/tag.py,sha256=SAop9INQvKOwktNmVypKFngd6xHmY_XZrLUWChUjz84,3353
32
32
  semantic_release/commit_parser/token.py,sha256=BB4ZCyt753CCaBFF95cQ4sFm6Au96wpO0YyTAWdcOvE,1609
33
33
  semantic_release/commit_parser/util.py,sha256=vLcVDErZrExM55jMffos0hyMbNVQoJ-PeeVDG1Ej51I,730
34
- semantic_release/data/templates/CHANGELOG.md.j2,sha256=4SNA1uTM2_qLA_3QTbS8JHZi9tB-21gCzLjkj_U20JI,1069
35
- semantic_release/data/templates/release_notes.md.j2,sha256=27uIHgG6iYmqdMaoWFP5LJRZcrO-3paqFzHHHsGpXXE,465
34
+ semantic_release/data/templates/angular/release_notes.md.j2,sha256=ftg0pFkmGYlb9UH2waSLc6neC8rPMMCqblJaQeq4vTg,46
35
+ semantic_release/data/templates/angular/md/.changelog_header.md.j2,sha256=qNxTuSr59CV_yyimVU_RYp5azCnK0l6nJ03Zf0u5Ugg,166
36
+ semantic_release/data/templates/angular/md/.changelog_init.md.j2,sha256=mIV6AuZmB6BGl_hBDGM9ea_CG5BV7HqI3Ko9jsn7zMs,602
37
+ semantic_release/data/templates/angular/md/.changelog_update.md.j2,sha256=DHY4K_EQPXGHNHVbsVI5l3x_IWNtYq3IyHFyFsNnLe0,2387
38
+ semantic_release/data/templates/angular/md/.changes.md.j2,sha256=oC2amekjxsNjgj0BkVzs6ANswxntOi3ItlQd9VtbVm8,339
39
+ semantic_release/data/templates/angular/md/.unreleased_changes.md.j2,sha256=8CGiPvJEzrjNyqdDFjRGNlLpjIFGiMccPKPmi-vdGTI,178
40
+ semantic_release/data/templates/angular/md/.versioned_changes.md.j2,sha256=K2J5GHTBUffPxZ-FSoewHkwNJtuPidZA83hEGtpk7rg,258
41
+ semantic_release/data/templates/angular/md/CHANGELOG.md.j2,sha256=LjSFwb3QROfj3PZY-2qeMBSlbnP7vf2L9Qc_4-FX68Y,816
36
42
  semantic_release/hvcs/__init__.py,sha256=JwoaLOF-12L-OBo_9-tOXXhdiHKeVungA9865to2oZk,494
37
43
  semantic_release/hvcs/_base.py,sha256=9-iTqTPSbiEevKbCBP9K2hq4c-2T4wPbeLWe-kAxBzo,2607
38
44
  semantic_release/hvcs/bitbucket.py,sha256=nqlOmeNda0sRSEBGWMluphy1KlpRTQrHV7itxf0IXE0,9266
@@ -47,10 +53,10 @@ semantic_release/version/algorithm.py,sha256=ofx_bIWq6ptJVr-ekI11IzxzDEctDKFiVwa
47
53
  semantic_release/version/declaration.py,sha256=f6Ld7hIhrqvDrRBapJHr-KDimuyo-4IG8009Zu9BIgU,7357
48
54
  semantic_release/version/translator.py,sha256=P1noIsVBn8u6zNOFjG0xKYOWapxqf_PHSMvMeLJ9kXg,3050
49
55
  semantic_release/version/version.py,sha256=6PCtSbLP88U1daoxnCwHc--YguZo4waGNLqJ5JfeczE,14175
50
- python_semantic_release-9.9.0.dist-info/AUTHORS.rst,sha256=XOReVvpymEFUPsS2QPH97jlfJBVrxwS2eu8-jVAe4gk,230
51
- python_semantic_release-9.9.0.dist-info/LICENSE,sha256=NE85nszX252sdQdu0xgS9qwfYES0k8qS6gW3uO4jRGE,1083
52
- python_semantic_release-9.9.0.dist-info/METADATA,sha256=Hz21ONNLpASUxwoTtHT3wiLWQm6Njzr72BaS9lP9cKc,3520
53
- python_semantic_release-9.9.0.dist-info/WHEEL,sha256=eOLhNAGa2EW3wWl_TU484h7q1UNgy0JXjjoqKoxAAQc,92
54
- python_semantic_release-9.9.0.dist-info/entry_points.txt,sha256=6bS6euSagjerp7onDtfI9_ZpczWreZF3gjHfGAegkbo,123
55
- python_semantic_release-9.9.0.dist-info/top_level.txt,sha256=qYA24nyg3eP-ti5UW7Vuj2aXVmM0wqVHx4mREdRZNAA,17
56
- python_semantic_release-9.9.0.dist-info/RECORD,,
56
+ python_semantic_release-9.10.0.dist-info/AUTHORS.rst,sha256=XOReVvpymEFUPsS2QPH97jlfJBVrxwS2eu8-jVAe4gk,230
57
+ python_semantic_release-9.10.0.dist-info/LICENSE,sha256=NE85nszX252sdQdu0xgS9qwfYES0k8qS6gW3uO4jRGE,1083
58
+ python_semantic_release-9.10.0.dist-info/METADATA,sha256=Oa_kotAU8xlkMsUejoXJQ2XEJwoPWw4HBWPhTtx-tEM,3521
59
+ python_semantic_release-9.10.0.dist-info/WHEEL,sha256=eOLhNAGa2EW3wWl_TU484h7q1UNgy0JXjjoqKoxAAQc,92
60
+ python_semantic_release-9.10.0.dist-info/entry_points.txt,sha256=6bS6euSagjerp7onDtfI9_ZpczWreZF3gjHfGAegkbo,123
61
+ python_semantic_release-9.10.0.dist-info/top_level.txt,sha256=qYA24nyg3eP-ti5UW7Vuj2aXVmM0wqVHx4mREdRZNAA,17
62
+ python_semantic_release-9.10.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.9.0"
27
+ __version__ = "9.10.0"
28
28
 
29
29
  __all__ = [
30
30
  "CommitParser",
@@ -1,7 +1,11 @@
1
1
  from __future__ import annotations
2
2
 
3
+ import logging
4
+ import os
3
5
  from dataclasses import dataclass
4
- from typing import TYPE_CHECKING, Any, Callable
6
+ from enum import Enum
7
+ from pathlib import Path
8
+ from typing import TYPE_CHECKING, Any, Callable, Literal
5
9
 
6
10
  if TYPE_CHECKING:
7
11
  from jinja2 import Environment
@@ -34,28 +38,53 @@ class ReleaseNotesContext:
34
38
  return env
35
39
 
36
40
 
41
+ class ChangelogMode(Enum):
42
+ INIT = "init"
43
+ UPDATE = "update"
44
+
45
+
37
46
  @dataclass
38
47
  class ChangelogContext:
39
48
  repo_name: str
40
49
  repo_owner: str
41
50
  hvcs_type: str
42
51
  history: ReleaseHistory
52
+ changelog_mode: Literal["update", "init"]
53
+ prev_changelog_file: str
54
+ changelog_insertion_flag: str
43
55
  filters: tuple[Callable[..., Any], ...] = ()
44
56
 
45
57
  def bind_to_environment(self, env: Environment) -> Environment:
46
58
  env.globals["context"] = self
59
+ env.globals["ctx"] = self
47
60
  for f in self.filters:
48
61
  env.filters[f.__name__] = f
49
62
  return env
50
63
 
51
64
 
52
65
  def make_changelog_context(
53
- hvcs_client: HvcsBase, release_history: ReleaseHistory
66
+ hvcs_client: HvcsBase,
67
+ release_history: ReleaseHistory,
68
+ mode: ChangelogMode,
69
+ prev_changelog_file: Path,
70
+ insertion_flag: str,
54
71
  ) -> ChangelogContext:
55
72
  return ChangelogContext(
56
73
  repo_name=hvcs_client.repo_name,
57
74
  repo_owner=hvcs_client.owner,
58
75
  history=release_history,
76
+ changelog_mode=mode.value,
77
+ changelog_insertion_flag=insertion_flag,
78
+ prev_changelog_file=str(prev_changelog_file),
59
79
  hvcs_type=hvcs_client.__class__.__name__.lower(),
60
- filters=(*hvcs_client.get_changelog_context_filters(),),
80
+ filters=(*hvcs_client.get_changelog_context_filters(), read_file),
61
81
  )
82
+
83
+
84
+ def read_file(filepath: str) -> str:
85
+ try:
86
+ with Path(filepath).open(newline=os.linesep) as rfd:
87
+ return rfd.read()
88
+ except FileNotFoundError as err:
89
+ logging.warning(err)
90
+ return ""
@@ -3,8 +3,8 @@ from __future__ import annotations
3
3
  import logging
4
4
  import os
5
5
  import shutil
6
- from pathlib import Path
7
- from typing import TYPE_CHECKING, Callable, Iterable
6
+ from pathlib import Path, PurePosixPath
7
+ from typing import TYPE_CHECKING
8
8
 
9
9
  from jinja2 import FileSystemLoader
10
10
  from jinja2.sandbox import SandboxedEnvironment
@@ -12,7 +12,7 @@ from jinja2.sandbox import SandboxedEnvironment
12
12
  from semantic_release.helpers import dynamic_import
13
13
 
14
14
  if TYPE_CHECKING:
15
- from typing import Literal
15
+ from typing import Callable, Iterable, Literal
16
16
 
17
17
  from jinja2 import Environment
18
18
 
@@ -56,7 +56,7 @@ def environment(
56
56
  autoescape_value = autoescape
57
57
  log.debug("%s", locals())
58
58
 
59
- return SandboxedEnvironment(
59
+ return ComplexDirectorySandboxedEnvironment(
60
60
  block_start_string=block_start_string,
61
61
  block_end_string=block_end_string,
62
62
  variable_start_string=variable_start_string,
@@ -75,6 +75,23 @@ def environment(
75
75
  )
76
76
 
77
77
 
78
+ class ComplexDirectorySandboxedEnvironment(SandboxedEnvironment):
79
+ def join_path(self, template: str, parent: str) -> str:
80
+ """
81
+ Add support for complex directory structures in the template directory.
82
+
83
+ This method overrides the default functionality of the SandboxedEnvironment
84
+ where all 'include' keywords expect to be in the same directory as the calling
85
+ template, however this is unintuitive when using a complex directory structure.
86
+
87
+ This override simulates the changing of directories when you include the template
88
+ from a child directory. When the child then includes a template, it will make the
89
+ path relative to the child directory rather than the top level template directory.
90
+ """
91
+ # Must be posixpath because jinja only knows how to handle posix path includes
92
+ return str(PurePosixPath(parent).parent / template)
93
+
94
+
78
95
  # pylint: disable=redefined-outer-name
79
96
  def recursive_render(
80
97
  template_dir: Path,
@@ -107,11 +124,15 @@ def recursive_render(
107
124
  src_file_path = str((root / file).relative_to(template_dir))
108
125
  output_file_path = str((output_path / output_filename).resolve())
109
126
 
127
+ # Although, file stream rendering is possible and preferred in most
128
+ # situations, here it is not desired as you cannot read the previous
129
+ # contents of a file during the rendering of the template. This mechanism
130
+ # is used for inserting into a current changelog. When using stream rendering
131
+ # of the same file, it always came back empty
110
132
  log.debug("rendering %s to %s", src_file_path, output_file_path)
111
- stream = environment.get_template(src_file_path).stream()
112
-
113
- with open(output_file_path, "wb+") as output_file:
114
- stream.dump(output_file, encoding="utf-8")
133
+ rendered_file = environment.get_template(src_file_path).render()
134
+ with open(output_file_path, "w", encoding="utf-8") as output_file:
135
+ output_file.write(rendered_file)
115
136
 
116
137
  rendered_paths.append(output_file_path)
117
138
  else:
@@ -1,60 +1,108 @@
1
1
  from __future__ import annotations
2
2
 
3
+ import os
4
+ from contextlib import suppress
3
5
  from logging import getLogger
4
- from os import listdir
5
6
  from pathlib import Path
6
7
  from typing import TYPE_CHECKING
7
8
 
8
9
  # NOTE: use backport with newer API than stdlib
9
10
  from importlib_resources import files
10
11
 
12
+ import semantic_release
11
13
  from semantic_release.changelog.context import (
12
14
  ReleaseNotesContext,
13
15
  make_changelog_context,
14
16
  )
15
17
  from semantic_release.changelog.template import environment, recursive_render
18
+ from semantic_release.cli.const import (
19
+ DEFAULT_CHANGELOG_NAME_STEM,
20
+ DEFAULT_RELEASE_NOTES_TPL_FILE,
21
+ JINJA2_EXTENSION,
22
+ )
16
23
  from semantic_release.cli.util import noop_report
24
+ from semantic_release.errors import InternalError
17
25
 
18
26
  if TYPE_CHECKING:
19
27
  from jinja2 import Environment
20
28
 
29
+ from semantic_release.changelog.context import ChangelogContext
21
30
  from semantic_release.changelog.release_history import Release, ReleaseHistory
22
- from semantic_release.cli.config import RuntimeContext
31
+ from semantic_release.cli.config import ChangelogOutputFormat, RuntimeContext
23
32
  from semantic_release.hvcs._base import HvcsBase
24
33
 
25
34
 
26
35
  log = getLogger(__name__)
27
36
 
28
37
 
29
- def get_release_notes_template(template_dir: Path) -> str:
30
- """Read the project's template for release notes, falling back to the default."""
31
- fname = template_dir / ".release_notes.md.j2"
32
- try:
33
- return fname.read_text(encoding="utf-8")
34
- except FileNotFoundError:
35
- return (
36
- files("semantic_release")
37
- .joinpath("data/templates/release_notes.md.j2")
38
- .read_text(encoding="utf-8")
38
+ def get_default_tpl_dir(style: str, sub_dir: str | None = None) -> Path:
39
+ module_base_path = Path(str(files(semantic_release.__name__)))
40
+ default_templates_path = module_base_path.joinpath(
41
+ f"data/templates/{style}",
42
+ "" if sub_dir is None else sub_dir.strip("/"),
43
+ )
44
+
45
+ if default_templates_path.is_dir():
46
+ return default_templates_path
47
+
48
+ raise InternalError(
49
+ str.join(
50
+ " ",
51
+ [
52
+ "Default template directory not found at",
53
+ f"{default_templates_path}. Installation corrupted!",
54
+ ],
39
55
  )
56
+ )
40
57
 
41
58
 
42
- def render_default_changelog_file(template_env: Environment) -> str:
43
- changelog_text = (
44
- files("semantic_release")
45
- .joinpath("data/templates/CHANGELOG.md.j2")
46
- .read_text(encoding="utf-8")
59
+ def render_default_changelog_file(
60
+ output_format: ChangelogOutputFormat,
61
+ changelog_context: ChangelogContext,
62
+ changelog_style: str,
63
+ ) -> str:
64
+ tpl_dir = get_default_tpl_dir(style=changelog_style, sub_dir=output_format.value)
65
+ changelog_tpl_file = Path(DEFAULT_CHANGELOG_NAME_STEM).with_suffix(
66
+ str.join(".", ["", output_format.value, JINJA2_EXTENSION.lstrip(".")])
67
+ )
68
+
69
+ # Create a new environment as we don't want user's configuration as it might
70
+ # not match our default template structure
71
+ template_env = changelog_context.bind_to_environment(
72
+ environment(
73
+ autoescape=False,
74
+ newline_sequence="\n",
75
+ template_dir=tpl_dir,
76
+ )
77
+ )
78
+
79
+ # Using the proper enviroment with the changelog context, render the template
80
+ template = template_env.get_template(str(changelog_tpl_file))
81
+ changelog_content = template.render().rstrip()
82
+
83
+ # Normalize line endings to ensure universal newlines because that is what is expected
84
+ # of the content when we write it to a file. When using pathlib.Path.write_text(), it
85
+ # will automatically normalize the file to the OS. At this point after render, we may
86
+ # have mixed line endings because of the read_file() call of the previous changelog
87
+ # (which may be /r/n or /n)
88
+ return str.join(
89
+ "\n", [line.replace("\r", "") for line in changelog_content.split("\n")]
47
90
  )
48
- template = template_env.from_string(changelog_text)
49
- return template.render().rstrip()
50
91
 
51
92
 
52
93
  def render_release_notes(
53
- release_notes_template: str,
94
+ release_notes_template_file: str,
54
95
  template_env: Environment,
55
96
  ) -> str:
56
- template = template_env.from_string(release_notes_template)
57
- return template.render().rstrip()
97
+ # NOTE: release_notes_template_file must be a relative path to the template directory
98
+ # because jinja2's filtering and template loading filter is janky
99
+ template = template_env.get_template(release_notes_template_file)
100
+ release_notes = template.render().rstrip() + os.linesep
101
+
102
+ # Normalize line endings to match the current platform
103
+ return str.join(
104
+ os.linesep, [line.replace("\r", "") for line in release_notes.split("\n")]
105
+ )
58
106
 
59
107
 
60
108
  def apply_user_changelog_template_directory(
@@ -85,7 +133,9 @@ def apply_user_changelog_template_directory(
85
133
  def write_default_changelog(
86
134
  changelog_file: Path,
87
135
  destination_dir: Path,
88
- environment: Environment,
136
+ output_format: ChangelogOutputFormat,
137
+ changelog_context: ChangelogContext,
138
+ changelog_style: str,
89
139
  noop: bool = False,
90
140
  ) -> str:
91
141
  if noop:
@@ -98,9 +148,15 @@ def write_default_changelog(
98
148
  ],
99
149
  )
100
150
  )
101
- else:
102
- changelog_text = render_default_changelog_file(environment)
103
- changelog_file.write_text(f"{changelog_text}\n", encoding="utf-8")
151
+ return str(changelog_file)
152
+
153
+ changelog_text = render_default_changelog_file(
154
+ output_format=output_format,
155
+ changelog_context=changelog_context,
156
+ changelog_style=changelog_style,
157
+ )
158
+ # write_text() will automatically normalize newlines to the OS, so we just use an universal newline here
159
+ changelog_file.write_text(f"{changelog_text}\n", encoding="utf-8")
104
160
 
105
161
  return str(changelog_file)
106
162
 
@@ -117,19 +173,34 @@ def write_changelog_files(
117
173
  changelog_context = make_changelog_context(
118
174
  hvcs_client=hvcs_client,
119
175
  release_history=release_history,
176
+ mode=runtime_ctx.changelog_mode,
177
+ insertion_flag=runtime_ctx.changelog_insertion_flag,
178
+ prev_changelog_file=runtime_ctx.changelog_file,
120
179
  )
121
180
 
122
- changelog_context.bind_to_environment(runtime_ctx.template_environment)
181
+ user_templates = []
123
182
 
124
- use_user_template_dir = bool(
125
- # Directory exists and directory is not empty
126
- template_dir.exists() and template_dir.is_dir() and listdir(template_dir)
127
- )
183
+ # Update known templates list if Directory exists and directory has actual files to render
184
+ if template_dir.is_dir():
185
+ user_templates.extend(
186
+ [
187
+ f
188
+ for f in template_dir.rglob("*")
189
+ if f.is_file() and f.suffix == JINJA2_EXTENSION
190
+ ]
191
+ )
128
192
 
129
- if use_user_template_dir:
193
+ with suppress(ValueError):
194
+ # do not include a release notes override when considering number of changelog templates
195
+ user_templates.remove(template_dir / f".{DEFAULT_RELEASE_NOTES_TPL_FILE}")
196
+
197
+ # Render user templates if found
198
+ if len(user_templates) > 0:
130
199
  return apply_user_changelog_template_directory(
131
200
  template_dir=template_dir,
132
- environment=runtime_ctx.template_environment,
201
+ environment=changelog_context.bind_to_environment(
202
+ runtime_ctx.template_environment
203
+ ),
133
204
  destination_dir=project_dir,
134
205
  noop=noop,
135
206
  )
@@ -139,7 +210,9 @@ def write_changelog_files(
139
210
  write_default_changelog(
140
211
  changelog_file=runtime_ctx.changelog_file,
141
212
  destination_dir=project_dir,
142
- environment=runtime_ctx.template_environment,
213
+ output_format=runtime_ctx.changelog_output_format,
214
+ changelog_context=changelog_context,
215
+ changelog_style=runtime_ctx.changelog_style,
143
216
  noop=noop,
144
217
  )
145
218
  ]
@@ -150,7 +223,22 @@ def generate_release_notes(
150
223
  release: Release,
151
224
  template_dir: Path,
152
225
  history: ReleaseHistory,
226
+ style: str,
153
227
  ) -> str:
228
+ users_tpl_file = template_dir / f".{DEFAULT_RELEASE_NOTES_TPL_FILE}"
229
+
230
+ # Determine if the user has a custom release notes template or we should use
231
+ # the default template directory with our default release notes template
232
+ tpl_dir = (
233
+ template_dir if users_tpl_file.is_file() else get_default_tpl_dir(style=style)
234
+ )
235
+
236
+ release_notes_tpl_file = (
237
+ users_tpl_file.name
238
+ if users_tpl_file.is_file()
239
+ else DEFAULT_RELEASE_NOTES_TPL_FILE
240
+ )
241
+
154
242
  release_notes_env = ReleaseNotesContext(
155
243
  repo_name=hvcs_client.repo_name,
156
244
  repo_owner=hvcs_client.owner,
@@ -161,7 +249,7 @@ def generate_release_notes(
161
249
  ).bind_to_environment(
162
250
  # Use a new, non-configurable environment for release notes -
163
251
  # not user-configurable at the moment
164
- environment(template_dir=template_dir)
252
+ environment(autoescape=False, template_dir=tpl_dir)
165
253
  )
166
254
 
167
255
  # TODO: Remove in v10
@@ -170,6 +258,6 @@ def generate_release_notes(
170
258
  }
171
259
 
172
260
  return render_release_notes(
173
- release_notes_template=get_release_notes_template(template_dir),
261
+ release_notes_template_file=release_notes_tpl_file,
174
262
  template_env=release_notes_env,
175
263
  )
@@ -43,7 +43,9 @@ def post_release_notes(
43
43
  return
44
44
 
45
45
  hvcs_client.create_or_update_release(
46
- release_tag, f"{release_notes}\n", prerelease=prerelease
46
+ release_tag,
47
+ release_notes,
48
+ prerelease=prerelease,
47
49
  )
48
50
 
49
51
 
@@ -116,6 +118,7 @@ def changelog(cli_ctx: CliContextObj, release_tag: str | None) -> None:
116
118
  release,
117
119
  runtime.template_dir,
118
120
  release_history,
121
+ style=runtime.changelog_style,
119
122
  )
120
123
 
121
124
  try:
@@ -706,6 +706,7 @@ def version( # noqa: C901
706
706
  release_history.released[new_version],
707
707
  runtime.template_dir,
708
708
  history=release_history,
709
+ style=runtime.changelog_style,
709
710
  )
710
711
 
711
712
  exception: Exception | None = None
@@ -27,6 +27,7 @@ from urllib3.util.url import parse_url
27
27
 
28
28
  from semantic_release import hvcs
29
29
  from semantic_release.changelog import environment
30
+ from semantic_release.changelog.context import ChangelogMode
30
31
  from semantic_release.cli.const import DEFAULT_CONFIG_FILE
31
32
  from semantic_release.cli.masking_filter import MaskingFilter
32
33
  from semantic_release.commit_parser import (
@@ -94,6 +95,13 @@ class EnvConfigVar(BaseModel):
94
95
  MaybeFromEnv = Union[EnvConfigVar, str]
95
96
 
96
97
 
98
+ class ChangelogOutputFormat(str, Enum):
99
+ """Supported changelog output formats when using the default templates."""
100
+
101
+ MARKDOWN = "md"
102
+ # RESTRUCTURED_TEXT = "rst"
103
+
104
+
97
105
  class ChangelogEnvironmentConfig(BaseModel):
98
106
  block_start_string: str = "{%"
99
107
  block_end_string: str = "%}"
@@ -108,14 +116,26 @@ class ChangelogEnvironmentConfig(BaseModel):
108
116
  newline_sequence: Literal["\n", "\r", "\r\n"] = "\n"
109
117
  keep_trailing_newline: bool = False
110
118
  extensions: Tuple[str, ...] = ()
111
- autoescape: Union[bool, str] = True
119
+ autoescape: Union[bool, str] = False
120
+
121
+
122
+ class DefaultChangelogTemplatesConfig(BaseModel):
123
+ # TODO: BREAKING CHANGE v10
124
+ # changelog_file: str = "CHANGELOG.md"
125
+ output_format: ChangelogOutputFormat = ChangelogOutputFormat.MARKDOWN
112
126
 
113
127
 
114
128
  class ChangelogConfig(BaseModel):
115
- template_dir: str = "templates"
129
+ # TODO: BREAKING CHANGE v10, move to DefaultChangelogTemplatesConfig
116
130
  changelog_file: str = "CHANGELOG.md"
117
- exclude_commit_patterns: Tuple[str, ...] = ()
131
+ default_templates: DefaultChangelogTemplatesConfig = (
132
+ DefaultChangelogTemplatesConfig()
133
+ )
118
134
  environment: ChangelogEnvironmentConfig = ChangelogEnvironmentConfig()
135
+ exclude_commit_patterns: Tuple[str, ...] = ()
136
+ mode: ChangelogMode = ChangelogMode.INIT
137
+ insertion_flag: str = "<!-- version list -->"
138
+ template_dir: str = "templates"
119
139
 
120
140
 
121
141
  class BranchConfig(BaseModel):
@@ -386,7 +406,11 @@ class RuntimeContext:
386
406
  changelog_excluded_commit_patterns: Tuple[re.Pattern[str], ...]
387
407
  version_declarations: Tuple[VersionDeclarationABC, ...]
388
408
  hvcs_client: hvcs.HvcsBase
409
+ changelog_insertion_flag: str
410
+ changelog_mode: ChangelogMode
389
411
  changelog_file: Path
412
+ changelog_style: str
413
+ changelog_output_format: ChangelogOutputFormat
390
414
  ignore_token_for_push: bool
391
415
  template_environment: Environment
392
416
  template_dir: Path
@@ -640,6 +664,18 @@ class RuntimeContext:
640
664
 
641
665
  build_cmd_env[name] = env_val
642
666
 
667
+ # TODO: better support for custom parsers that actually just extend defaults
668
+ #
669
+ # Here we just assume the desired changelog style matches the parser name
670
+ # as we provide templates specific to each parser type. Unfortunately if the user has
671
+ # provided a custom parser, it would be up to the user to provide custom templates
672
+ # but we just assume the base template is angular
673
+ # changelog_style = (
674
+ # raw.commit_parser
675
+ # if raw.commit_parser in _known_commit_parsers
676
+ # else "angular"
677
+ # )
678
+
643
679
  self = cls(
644
680
  repo_dir=raw.repo_dir,
645
681
  commit_parser=commit_parser,
@@ -651,10 +687,16 @@ class RuntimeContext:
651
687
  version_declarations=tuple(version_declarations),
652
688
  hvcs_client=hvcs_client,
653
689
  changelog_file=changelog_file,
690
+ changelog_mode=raw.changelog.mode,
691
+ changelog_insertion_flag=raw.changelog.insertion_flag,
654
692
  assets=raw.assets,
655
693
  commit_author=commit_author,
656
694
  commit_message=raw.commit_message,
657
695
  changelog_excluded_commit_patterns=changelog_excluded_commit_patterns,
696
+ # TODO: change when we have other styles per parser
697
+ # changelog_style=changelog_style,
698
+ changelog_style="angular",
699
+ changelog_output_format=raw.changelog.default_templates.output_format,
658
700
  prerelease=branch_config.prerelease,
659
701
  ignore_token_for_push=raw.remote.ignore_token_for_push,
660
702
  template_dir=template_dir,
@@ -1 +1,5 @@
1
1
  DEFAULT_CONFIG_FILE = "pyproject.toml"
2
+ DEFAULT_RELEASE_NOTES_TPL_FILE = "release_notes.md.j2"
3
+ DEFAULT_CHANGELOG_NAME_STEM = "CHANGELOG"
4
+
5
+ JINJA2_EXTENSION = ".j2"
@@ -27,11 +27,19 @@ def _logged_parse_error(commit: Commit, error: str) -> ParseError:
27
27
  return ParseError(commit, error=error)
28
28
 
29
29
 
30
+ # TODO: Remove from here, allow for user customization instead via options
30
31
  # types with long names in changelog
31
32
  LONG_TYPE_NAMES = {
32
- "feat": "feature",
33
+ "build": "build system",
34
+ "ci": "continuous integration",
35
+ "chore": "chores",
33
36
  "docs": "documentation",
34
- "perf": "performance",
37
+ "feat": "features",
38
+ "fix": "fixes",
39
+ "perf": "performance improvements",
40
+ "refactor": "refactoring",
41
+ "style": "code style",
42
+ "test": "testing",
35
43
  }
36
44
 
37
45
 
@@ -82,7 +90,7 @@ class AngularCommitParser(CommitParser[ParseResult, AngularParserOptions]):
82
90
  def get_default_options() -> AngularParserOptions:
83
91
  return AngularParserOptions()
84
92
 
85
- # Maybe this can be cached as an optimisation, similar to how
93
+ # Maybe this can be cached as an optimization, similar to how
86
94
  # mypy/pytest use their own caching directories, for very large commit
87
95
  # histories?
88
96
  # The problem is the cache likely won't be present in CI environments
@@ -0,0 +1,9 @@
1
+ # CHANGELOG
2
+
3
+ {% if ctx.changelog_mode == "update"
4
+ %}{# # IMPORTANT: add insertion flag for next version update
5
+ #}{{
6
+ insertion_flag ~ "\n"
7
+
8
+ }}{% endif
9
+ %}
@@ -0,0 +1,22 @@
1
+ {#
2
+ This changelog template initializes a full changelog for the project,
3
+ it follows the following logic:
4
+ 1. Header
5
+ 2. Any Unreleased Details (uncommon)
6
+ 3. all previous releases
7
+
8
+ #}{#
9
+ # Header
10
+ #}{% include ".changelog_header.md.j2"
11
+ -%}{#
12
+ # Any Unreleased Details (uncommon)
13
+ #}{% include ".unreleased_changes.md.j2"
14
+ -%}{#
15
+ # Since this is initialization, we are generating all the previous
16
+ # release notes per version.
17
+ #}{% for release in context.history.released.values()
18
+ %}{{ "\n"
19
+ }}{% include ".versioned_changes.md.j2"
20
+ -%}{{ "\n"
21
+ }}{% endfor
22
+ %}
@@ -0,0 +1,62 @@
1
+ {#
2
+ This Update changelog template uses the following logic:
3
+
4
+ 1. Read previous changelog file (ex. project_root/CHANGELOG.md)
5
+ 2. Split on insertion flag (ex. <!-- version list -->)
6
+ 3. Print top half of previous changelog
7
+ 3. New Changes (unreleased commits & newly released)
8
+ 4. Print bottom half of previous changelog
9
+
10
+ Note: if a previous file was not found, it does not write anything at the bottom
11
+ but render does NOT fail
12
+
13
+ #}{% set prev_changelog_contents = prev_changelog_file | read_file | safe
14
+ %}{% set changelog_parts = prev_changelog_contents.split(insertion_flag, maxsplit=1)
15
+ %}{#
16
+ #}{% if changelog_parts | length < 2
17
+ %}{# # insertion flag was not found, check if the file was empty or did not exist
18
+ #}{% if prev_changelog_contents | length > 0
19
+ %}{# # File has content but no insertion flag, therefore, file will not be updated
20
+ #}{{ changelog_parts[0]
21
+ }}{% else
22
+ %}{# # File was empty or did not exist, therefore, it will be created from scratch
23
+ #}{% include ".changelog_init.md.j2"
24
+ %}{% endif
25
+ %}{% else
26
+ %}{#
27
+ # Previous Changelog Header
28
+ # - Depending if there is header content, then it will separate the insertion flag
29
+ # with a newline from header content, otherwise it will just print the insertion flag
30
+ #}{% set prev_changelog_top = changelog_parts[0] | trim
31
+ %}{% if prev_changelog_top | length > 0
32
+ %}{{
33
+ "%s\n\n%s\n" | format(prev_changelog_top, insertion_flag | trim)
34
+
35
+ }}{% else
36
+ %}{{
37
+ "%s\n" | format(insertion_flag | trim)
38
+
39
+ }}{% endif
40
+ %}{#
41
+ # Any Unreleased Details (uncommon)
42
+ #}{% include ".unreleased_changes.md.j2"
43
+ -%}{#
44
+ # Latest Release Details
45
+ #}{% for release in new_releases
46
+ %}{# # Check if the release version is already in the changelog and if not, add it
47
+ #}{% if "# " ~ release.version.as_semver_tag() ~ " " not in changelog_parts[1]
48
+ %}{{ "\n"
49
+ }}{% include ".versioned_changes.md.j2"
50
+ -%}{{ "\n"
51
+ }}{% endif
52
+ %}{% endfor
53
+ %}{#
54
+ # Previous Changelog Footer
55
+ # - skips printing footer if empty, which happens when the insertion_flag
56
+ # was at the end of the file (ignoring whitespace)
57
+ #}{% set previous_changelog_bottom = changelog_parts[1] | trim
58
+ %}{% if previous_changelog_bottom | length > 0
59
+ %}{{ "\n%s\n" | format(previous_changelog_bottom)
60
+ }}{% endif
61
+ %}{% endif
62
+ %}
@@ -0,0 +1,16 @@
1
+ {#
2
+ #}{% for type_, commits in commit_objects
3
+ %}{{
4
+ "\n### %s\n" | format(type_ | title)
5
+
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
+ )
13
+
14
+ }}{% endfor
15
+ %}{% endfor
16
+ %}
@@ -0,0 +1,7 @@
1
+ {% if unreleased_commits | length > 0
2
+ %}{{ "\n## Unreleased\n"
3
+ }}{% set commit_objects = unreleased_commits
4
+ %}{% include ".changes.md.j2"
5
+ -%}{{ "\n"
6
+ }}{% endif
7
+ %}
@@ -0,0 +1,14 @@
1
+ {#
2
+
3
+ ## vX.X.X (YYYY-MMM-DD)
4
+
5
+ #}{{
6
+
7
+ "## %s (%s)\n" | format(
8
+ release.version.as_semver_tag(),
9
+ release.tagged_date.strftime("%Y-%m-%d")
10
+ )
11
+
12
+ }}{% set commit_objects = release["elements"] | dictsort
13
+ %}{% include ".changes.md.j2"
14
+ -%}
@@ -0,0 +1,24 @@
1
+ {#
2
+ This changelog template controls which changelog creation occurs
3
+ based on which mode is provided.
4
+
5
+ Modes:
6
+ - init: Initialize a full changelog from scratch
7
+ - update: Insert new version details where the placeholder exists in the current changelog
8
+
9
+ #}{% set insertion_flag = ctx.changelog_insertion_flag
10
+ %}{% set unreleased_commits = ctx.history.unreleased | dictsort
11
+ %}{#
12
+ #}{% if ctx.changelog_mode == "init"
13
+ %}{% include ".changelog_init.md.j2"
14
+ %}{#
15
+ #}{% elif ctx.changelog_mode == "update"
16
+ %}{% set prev_changelog_file = ctx.prev_changelog_file
17
+ %}{% set new_releases = []
18
+ %}{% if ctx.history.released.values() | length > 0
19
+ %}{% set new_releases = [ctx.history.released.values() | first]
20
+ %}{% endif
21
+ %}{% include ".changelog_update.md.j2"
22
+ %}{#
23
+ #}{% endif
24
+ %}
@@ -0,0 +1 @@
1
+ {% include "./md/.versioned_changes.md.j2" %}
@@ -8,6 +8,10 @@ class SemanticReleaseBaseError(Exception):
8
8
  """
9
9
 
10
10
 
11
+ class InternalError(SemanticReleaseBaseError):
12
+ """Raised when an internal error occurs, which should never happen"""
13
+
14
+
11
15
  class InvalidConfiguration(SemanticReleaseBaseError):
12
16
  """Raised when configuration is deemed invalid"""
13
17
 
@@ -1,21 +0,0 @@
1
- # CHANGELOG
2
- {% if context.history.unreleased | length > 0 -%}
3
- {# UNRELEASED #}
4
- ## Unreleased
5
- {% for type_, commits in context.history.unreleased | dictsort %}
6
- ### {{ type_ | capitalize }}
7
- {% for commit in commits %}{% if type_ != "unknown" %}
8
- * {{ commit.message.rstrip() }} ([`{{ commit.short_hash }}`]({{ commit.hexsha | commit_hash_url }}))
9
- {% else %}
10
- * {{ commit.message.rstrip() }} ([`{{ commit.short_hash }}`]({{ commit.hexsha | commit_hash_url }}))
11
- {% endif %}{% endfor %}{% endfor %}{% endif -%}
12
- {% for version, release in context.history.released.items() -%}
13
- {# RELEASED #}
14
- ## {{ version.as_semver_tag() }} ({{ release.tagged_date.strftime("%Y-%m-%d") }})
15
- {% for type_, commits in release["elements"] | dictsort %}
16
- ### {{ type_ | capitalize }}
17
- {% for commit in commits %}{% if type_ != "unknown" %}
18
- * {{ commit.message.rstrip() }} ([`{{ commit.short_hash }}`]({{ commit.hexsha | commit_hash_url }}))
19
- {% else %}
20
- * {{ commit.message.rstrip() }} ([`{{ commit.short_hash }}`]({{ commit.hexsha | commit_hash_url }}))
21
- {% endif %}{% endfor %}{% endfor %}{% endfor %}
@@ -1,8 +0,0 @@
1
- # {{ version.as_tag() }} ({{ release.tagged_date.strftime("%Y-%m-%d") }})
2
- {% for type_, commits in release["elements"] | dictsort %}
3
- ## {{ type_ | capitalize }}
4
- {% for commit in commits %}{% if type_ != "unknown" %}
5
- * {{ commit.message.rstrip() }} ([`{{ commit.short_hash }}`]({{ commit.hexsha | commit_hash_url }}))
6
- {% else %}
7
- * {{ commit.message.rstrip() }} ([`{{ commit.short_hash }}`]({{ commit.hexsha | commit_hash_url }}))
8
- {% endif %}{% endfor %}{% endfor %}