Skip to content

Navigation Menu

Sign in
Appearance settings

Search code, repositories, users, issues, pull requests...

Provide feedback

We read every piece of feedback, and take your input very seriously.

Saved searches

Use saved searches to filter your results more quickly

Sign up
Appearance settings

Commit 212b6d6

Browse files
noirbizarreLee-W
authored andcommitted
feat(formats): expose some new customizable changelog formats on the commitizen.changelog_format endpoint (Textile, AsciiDoc and RestructuredText)
1 parent be59145 commit 212b6d6

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

41 files changed

+1592
-688
lines changed

‎commitizen/changelog.py‎

Lines changed: 25 additions & 87 deletions
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-
loaderorPackageLoader("commitizen", "templates"),
210+
loader,
206211
]
207212
)
208213
env = Environment(loader=loader, trim_blocks=True)
209-
return env.get_template(templateorDEFAULT_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, templateorDEFAULT_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):
Lines changed: 90 additions & 0 deletions
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
Lines changed: 27 additions & 0 deletions
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"))

‎commitizen/changelog_formats/base.py‎

Lines changed: 81 additions & 0 deletions
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)

AltStyle によって変換されたページ (->オリジナル) /