Skip to content

Commit 547a19d

Browse files
committed
feat(formats): expose some new customizable changelog formats on the commitizen.formats endpoint (Textile, AsciiDoc and RestructuredText)
1 parent 23abab6 commit 547a19d

39 files changed

+1592
-357
lines changed

Diff for: commitizen/changelog.py

+25-87
Original file line numberDiff line numberDiff line change
@@ -26,37 +26,44 @@
2626
"""
2727
from __future__ import annotations
2828

29-
import os
3029
import re
3130
from collections import OrderedDict, defaultdict
31+
from dataclasses import dataclass
3232
from datetime import date
33-
from typing import TYPE_CHECKING, Callable, Iterable, cast
33+
from typing import TYPE_CHECKING, Callable, Iterable
3434

3535
from jinja2 import (
3636
BaseLoader,
3737
ChoiceLoader,
3838
Environment,
3939
FileSystemLoader,
40-
PackageLoader,
4140
Template,
4241
)
4342

4443
from commitizen import out
4544
from commitizen.bump import normalize_tag
46-
from commitizen.defaults import encoding
4745
from commitizen.exceptions import InvalidConfigurationError, NoCommitsFoundError
4846
from commitizen.git import GitCommit, GitTag
4947
from commitizen.version_schemes import (
5048
DEFAULT_SCHEME,
5149
BaseVersion,
5250
InvalidVersion,
53-
Pep440,
5451
)
5552

5653
if TYPE_CHECKING:
5754
from commitizen.version_schemes import VersionScheme
5855

59-
DEFAULT_TEMPLATE = "CHANGELOG.md.j2"
56+
57+
@dataclass
58+
class Metadata:
59+
"""
60+
Metadata extracted from the changelog produced by a plugin
61+
"""
62+
63+
unreleased_start: int | None = None
64+
unreleased_end: int | None = None
65+
latest_version: str | None = None
66+
latest_version_position: int | None = None
6067

6168

6269
def get_commit_tag(commit: GitCommit, tags: list[GitTag]) -> GitTag | None:
@@ -196,100 +203,31 @@ def order_changelog_tree(tree: Iterable, change_type_order: list[str]) -> Iterab
196203
return sorted_tree
197204

198205

199-
def get_changelog_template(
200-
loader: BaseLoader | None = None, template: str | None = None
201-
) -> Template:
206+
def get_changelog_template(loader: BaseLoader, template: str) -> Template:
202207
loader = ChoiceLoader(
203208
[
204209
FileSystemLoader("."),
205-
loader or PackageLoader("commitizen", "templates"),
210+
loader,
206211
]
207212
)
208213
env = Environment(loader=loader, trim_blocks=True)
209-
return env.get_template(template or DEFAULT_TEMPLATE)
214+
return env.get_template(template)
210215

211216

212217
def render_changelog(
213218
tree: Iterable,
214-
loader: BaseLoader | None = None,
215-
template: str | None = None,
219+
loader: BaseLoader,
220+
template: str,
216221
**kwargs,
217222
) -> str:
218-
jinja_template = get_changelog_template(loader, template or DEFAULT_TEMPLATE)
223+
jinja_template = get_changelog_template(loader, template)
219224
changelog: str = jinja_template.render(tree=tree, **kwargs)
220225
return changelog
221226

222227

223-
def parse_version_from_markdown(
224-
value: str, scheme: VersionScheme = Pep440
225-
) -> str | None:
226-
if not value.startswith("#"):
227-
return None
228-
m = scheme.parser.search(value)
229-
if not m:
230-
return None
231-
return cast(str, m.group("version"))
232-
233-
234-
def parse_title_type_of_line(value: str) -> str | None:
235-
md_title_parser = r"^(?P<title>#+)"
236-
m = re.search(md_title_parser, value)
237-
if not m:
238-
return None
239-
return m.groupdict().get("title")
240-
241-
242-
def get_metadata(
243-
filepath: str, scheme: VersionScheme = Pep440, encoding: str = encoding
244-
) -> dict:
245-
unreleased_start: int | None = None
246-
unreleased_end: int | None = None
247-
unreleased_title: str | None = None
248-
latest_version: str | None = None
249-
latest_version_position: int | None = None
250-
if not os.path.isfile(filepath):
251-
return {
252-
"unreleased_start": None,
253-
"unreleased_end": None,
254-
"latest_version": None,
255-
"latest_version_position": None,
256-
}
257-
258-
with open(filepath, encoding=encoding) as changelog_file:
259-
for index, line in enumerate(changelog_file):
260-
line = line.strip().lower()
261-
262-
unreleased: str | None = None
263-
if "unreleased" in line:
264-
unreleased = parse_title_type_of_line(line)
265-
# Try to find beginning and end lines of the unreleased block
266-
if unreleased:
267-
unreleased_start = index
268-
unreleased_title = unreleased
269-
continue
270-
elif (
271-
isinstance(unreleased_title, str)
272-
and parse_title_type_of_line(line) == unreleased_title
273-
):
274-
unreleased_end = index
275-
276-
# Try to find the latest release done
277-
version = parse_version_from_markdown(line, scheme)
278-
if version:
279-
latest_version = version
280-
latest_version_position = index
281-
break # there's no need for more info
282-
if unreleased_start is not None and unreleased_end is None:
283-
unreleased_end = index
284-
return {
285-
"unreleased_start": unreleased_start,
286-
"unreleased_end": unreleased_end,
287-
"latest_version": latest_version,
288-
"latest_version_position": latest_version_position,
289-
}
290-
291-
292-
def incremental_build(new_content: str, lines: list[str], metadata: dict) -> list[str]:
228+
def incremental_build(
229+
new_content: str, lines: list[str], metadata: Metadata
230+
) -> list[str]:
293231
"""Takes the original lines and updates with new_content.
294232
295233
The metadata governs how to remove the old unreleased section and where to place the
@@ -303,9 +241,9 @@ def incremental_build(new_content: str, lines: list[str], metadata: dict) -> lis
303241
Returns:
304242
Updated lines
305243
"""
306-
unreleased_start = metadata.get("unreleased_start")
307-
unreleased_end = metadata.get("unreleased_end")
308-
latest_version_position = metadata.get("latest_version_position")
244+
unreleased_start = metadata.unreleased_start
245+
unreleased_end = metadata.unreleased_end
246+
latest_version_position = metadata.latest_version_position
309247
skip = False
310248
output_lines: list[str] = []
311249
for index, line in enumerate(lines):

Diff for: commitizen/changelog_formats/__init__.py

+90
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,90 @@
1+
from __future__ import annotations
2+
3+
from typing import ClassVar, Protocol
4+
5+
import importlib_metadata as metadata
6+
7+
from commitizen.changelog import Metadata
8+
from commitizen.exceptions import ChangelogFormatUnknown
9+
from commitizen.config.base_config import BaseConfig
10+
11+
12+
CHANGELOG_FORMAT_ENTRYPOINT = "commitizen.changelog_format"
13+
TEMPLATE_EXTENSION = "j2"
14+
15+
16+
class ChangelogFormat(Protocol):
17+
extension: ClassVar[str]
18+
"""Standard known extension associated with this format"""
19+
20+
alternative_extensions: ClassVar[set[str]]
21+
"""Known alternatives extensions for this format"""
22+
23+
config: BaseConfig
24+
25+
def __init__(self, config: BaseConfig):
26+
self.config = config
27+
28+
@property
29+
def ext(self) -> str:
30+
"""Dotted version of extensions, as in `pathlib` and `os` modules"""
31+
return f".{self.extension}"
32+
33+
@property
34+
def template(self) -> str:
35+
"""Expected template name for this format"""
36+
return f"CHANGELOG.{self.extension}.{TEMPLATE_EXTENSION}"
37+
38+
@property
39+
def default_changelog_file(self) -> str:
40+
return f"CHANGELOG.{self.extension}"
41+
42+
def get_metadata(self, filepath: str) -> Metadata:
43+
"""
44+
Extract the changelog metadata.
45+
"""
46+
raise NotImplementedError
47+
48+
49+
KNOWN_CHANGELOG_FORMATS: dict[str, type[ChangelogFormat]] = {
50+
ep.name: ep.load()
51+
for ep in metadata.entry_points(group=CHANGELOG_FORMAT_ENTRYPOINT)
52+
}
53+
54+
55+
def get_changelog_format(
56+
config: BaseConfig, filename: str | None = None
57+
) -> ChangelogFormat:
58+
"""
59+
Get a format from its name
60+
61+
:raises FormatUnknown: if a non-empty name is provided but cannot be found in the known formats
62+
"""
63+
name: str | None = config.settings.get("changelog_format")
64+
format: type[ChangelogFormat] | None = guess_changelog_format(filename)
65+
66+
if name and name in KNOWN_CHANGELOG_FORMATS:
67+
format = KNOWN_CHANGELOG_FORMATS[name]
68+
69+
if not format:
70+
raise ChangelogFormatUnknown(f"Unknown changelog format '{name}'")
71+
72+
return format(config)
73+
74+
75+
def guess_changelog_format(filename: str | None) -> type[ChangelogFormat] | None:
76+
"""
77+
Try guessing the file format from the filename.
78+
79+
Algorithm is basic, extension-based, and won't work
80+
for extension-less file names like `CHANGELOG` or `NEWS`.
81+
"""
82+
if not filename or not isinstance(filename, str):
83+
return None
84+
for format in KNOWN_CHANGELOG_FORMATS.values():
85+
if filename.endswith(f".{format.extension}"):
86+
return format
87+
for alt_extension in format.alternative_extensions:
88+
if filename.endswith(f".{alt_extension}"):
89+
return format
90+
return None

Diff for: commitizen/changelog_formats/asciidoc.py

+27
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
from __future__ import annotations
2+
3+
import re
4+
5+
from .base import BaseFormat
6+
7+
8+
class AsciiDoc(BaseFormat):
9+
extension = "adoc"
10+
11+
RE_TITLE = re.compile(r"^(?P<level>=+) (?P<title>.*)$")
12+
13+
def parse_version_from_title(self, line: str) -> str | None:
14+
m = self.RE_TITLE.match(line)
15+
if not m:
16+
return None
17+
# Capture last match as AsciiDoc use postfixed URL labels
18+
matches = list(re.finditer(self.version_parser, m.group("title")))
19+
if not matches:
20+
return None
21+
return matches[-1].group("version")
22+
23+
def parse_title_level(self, line: str) -> int | None:
24+
m = self.RE_TITLE.match(line)
25+
if not m:
26+
return None
27+
return len(m.group("level"))

Diff for: commitizen/changelog_formats/base.py

+81
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,81 @@
1+
from __future__ import annotations
2+
3+
import os
4+
from abc import ABCMeta
5+
from re import Pattern
6+
from typing import IO, Any, ClassVar
7+
8+
from commitizen.changelog import Metadata
9+
from commitizen.config.base_config import BaseConfig
10+
from commitizen.version_schemes import get_version_scheme
11+
12+
from . import ChangelogFormat
13+
14+
15+
class BaseFormat(ChangelogFormat, metaclass=ABCMeta):
16+
"""
17+
Base class to extend to implement a changelog file format.
18+
"""
19+
20+
extension: ClassVar[str] = ""
21+
alternative_extensions: ClassVar[set[str]] = set()
22+
23+
def __init__(self, config: BaseConfig):
24+
# Constructor needs to be redefined because `Protocol` prevent instantiation by default
25+
# See: https://bugs.python.org/issue44807
26+
self.config = config
27+
28+
@property
29+
def version_parser(self) -> Pattern:
30+
return get_version_scheme(self.config).parser
31+
32+
def get_metadata(self, filepath: str) -> Metadata:
33+
if not os.path.isfile(filepath):
34+
return Metadata()
35+
36+
with open(filepath) as changelog_file:
37+
return self.get_metadata_from_file(changelog_file)
38+
39+
def get_metadata_from_file(self, file: IO[Any]) -> Metadata:
40+
meta = Metadata()
41+
unreleased_level: int | None = None
42+
for index, line in enumerate(file):
43+
line = line.strip().lower()
44+
45+
unreleased: int | None = None
46+
if "unreleased" in line:
47+
unreleased = self.parse_title_level(line)
48+
# Try to find beginning and end lines of the unreleased block
49+
if unreleased:
50+
meta.unreleased_start = index
51+
unreleased_level = unreleased
52+
continue
53+
elif unreleased_level and self.parse_title_level(line) == unreleased_level:
54+
meta.unreleased_end = index
55+
56+
# Try to find the latest release done
57+
version = self.parse_version_from_title(line)
58+
if version:
59+
meta.latest_version = version
60+
meta.latest_version_position = index
61+
break # there's no need for more info
62+
if meta.unreleased_start is not None and meta.unreleased_end is None:
63+
meta.unreleased_end = index
64+
65+
return meta
66+
67+
def parse_version_from_title(self, line: str) -> str | None:
68+
"""
69+
Extract the version from a title line if any
70+
"""
71+
raise NotImplementedError(
72+
"Default `get_metadata_from_file` requires `parse_version_from_changelog` to be implemented"
73+
)
74+
75+
def parse_title_level(self, line: str) -> int | None:
76+
"""
77+
Get the title level/type of a line if any
78+
"""
79+
raise NotImplementedError(
80+
"Default `get_metadata_from_file` requires `parse_title_type_of_line` to be implemented"
81+
)

0 commit comments

Comments
 (0)