diff --git a/commitizen/changelog.py b/commitizen/changelog.py
index e839efca8a..cd9e76a320 100644
--- a/commitizen/changelog.py
+++ b/commitizen/changelog.py
@@ -26,37 +26,44 @@
"""
from __future__ import annotations
-import os
import re
from collections import OrderedDict, defaultdict
+from dataclasses import dataclass
from datetime import date
-from typing import TYPE_CHECKING, Callable, Iterable, cast
+from typing import TYPE_CHECKING, Callable, Iterable
from jinja2 import (
BaseLoader,
ChoiceLoader,
Environment,
FileSystemLoader,
- PackageLoader,
Template,
)
from commitizen import out
from commitizen.bump import normalize_tag
-from commitizen.defaults import encoding
from commitizen.exceptions import InvalidConfigurationError, NoCommitsFoundError
from commitizen.git import GitCommit, GitTag
from commitizen.version_schemes import (
DEFAULT_SCHEME,
BaseVersion,
InvalidVersion,
- Pep440,
)
if TYPE_CHECKING:
from commitizen.version_schemes import VersionScheme
-DEFAULT_TEMPLATE = "CHANGELOG.md.j2"
+
+@dataclass
+class Metadata:
+ """
+ Metadata extracted from the changelog produced by a plugin
+ """
+
+ unreleased_start: int | None = None
+ unreleased_end: int | None = None
+ latest_version: str | None = None
+ latest_version_position: int | None = None
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
return sorted_tree
-def get_changelog_template(
- loader: BaseLoader | None = None, template: str | None = None
-) -> Template:
+def get_changelog_template(loader: BaseLoader, template: str) -> Template:
loader = ChoiceLoader(
[
FileSystemLoader("."),
- loader or PackageLoader("commitizen", "templates"),
+ loader,
]
)
env = Environment(loader=loader, trim_blocks=True)
- return env.get_template(template or DEFAULT_TEMPLATE)
+ return env.get_template(template)
def render_changelog(
tree: Iterable,
- loader: BaseLoader | None = None,
- template: str | None = None,
+ loader: BaseLoader,
+ template: str,
**kwargs,
) -> str:
- jinja_template = get_changelog_template(loader, template or DEFAULT_TEMPLATE)
+ jinja_template = get_changelog_template(loader, template)
changelog: str = jinja_template.render(tree=tree, **kwargs)
return changelog
-def parse_version_from_markdown(
- value: str, scheme: VersionScheme = Pep440
-) -> str | None:
- if not value.startswith("#"):
- return None
- m = scheme.parser.search(value)
- if not m:
- return None
- return cast(str, m.group("version"))
-
-
-def parse_title_type_of_line(value: str) -> str | None:
- md_title_parser = r"^(?P
#+)"
- m = re.search(md_title_parser, value)
- if not m:
- return None
- return m.groupdict().get("title")
-
-
-def get_metadata(
- filepath: str, scheme: VersionScheme = Pep440, encoding: str = encoding
-) -> dict:
- unreleased_start: int | None = None
- unreleased_end: int | None = None
- unreleased_title: str | None = None
- latest_version: str | None = None
- latest_version_position: int | None = None
- if not os.path.isfile(filepath):
- return {
- "unreleased_start": None,
- "unreleased_end": None,
- "latest_version": None,
- "latest_version_position": None,
- }
-
- with open(filepath, encoding=encoding) as changelog_file:
- for index, line in enumerate(changelog_file):
- line = line.strip().lower()
-
- unreleased: str | None = None
- if "unreleased" in line:
- unreleased = parse_title_type_of_line(line)
- # Try to find beginning and end lines of the unreleased block
- if unreleased:
- unreleased_start = index
- unreleased_title = unreleased
- continue
- elif (
- isinstance(unreleased_title, str)
- and parse_title_type_of_line(line) == unreleased_title
- ):
- unreleased_end = index
-
- # Try to find the latest release done
- version = parse_version_from_markdown(line, scheme)
- if version:
- latest_version = version
- latest_version_position = index
- break # there's no need for more info
- if unreleased_start is not None and unreleased_end is None:
- unreleased_end = index
- return {
- "unreleased_start": unreleased_start,
- "unreleased_end": unreleased_end,
- "latest_version": latest_version,
- "latest_version_position": latest_version_position,
- }
-
-
-def incremental_build(new_content: str, lines: list[str], metadata: dict) -> list[str]:
+def incremental_build(
+ new_content: str, lines: list[str], metadata: Metadata
+) -> list[str]:
"""Takes the original lines and updates with new_content.
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
Returns:
Updated lines
"""
- unreleased_start = metadata.get("unreleased_start")
- unreleased_end = metadata.get("unreleased_end")
- latest_version_position = metadata.get("latest_version_position")
+ unreleased_start = metadata.unreleased_start
+ unreleased_end = metadata.unreleased_end
+ latest_version_position = metadata.latest_version_position
skip = False
output_lines: list[str] = []
for index, line in enumerate(lines):
diff --git a/commitizen/changelog_formats/__init__.py b/commitizen/changelog_formats/__init__.py
new file mode 100644
index 0000000000..85df4b5144
--- /dev/null
+++ b/commitizen/changelog_formats/__init__.py
@@ -0,0 +1,90 @@
+from __future__ import annotations
+
+from typing import ClassVar, Protocol
+
+import importlib_metadata as metadata
+
+from commitizen.changelog import Metadata
+from commitizen.exceptions import ChangelogFormatUnknown
+from commitizen.config.base_config import BaseConfig
+
+
+CHANGELOG_FORMAT_ENTRYPOINT = "commitizen.changelog_format"
+TEMPLATE_EXTENSION = "j2"
+
+
+class ChangelogFormat(Protocol):
+ extension: ClassVar[str]
+ """Standard known extension associated with this format"""
+
+ alternative_extensions: ClassVar[set[str]]
+ """Known alternatives extensions for this format"""
+
+ config: BaseConfig
+
+ def __init__(self, config: BaseConfig):
+ self.config = config
+
+ @property
+ def ext(self) -> str:
+ """Dotted version of extensions, as in `pathlib` and `os` modules"""
+ return f".{self.extension}"
+
+ @property
+ def template(self) -> str:
+ """Expected template name for this format"""
+ return f"CHANGELOG.{self.extension}.{TEMPLATE_EXTENSION}"
+
+ @property
+ def default_changelog_file(self) -> str:
+ return f"CHANGELOG.{self.extension}"
+
+ def get_metadata(self, filepath: str) -> Metadata:
+ """
+ Extract the changelog metadata.
+ """
+ raise NotImplementedError
+
+
+KNOWN_CHANGELOG_FORMATS: dict[str, type[ChangelogFormat]] = {
+ ep.name: ep.load()
+ for ep in metadata.entry_points(group=CHANGELOG_FORMAT_ENTRYPOINT)
+}
+
+
+def get_changelog_format(
+ config: BaseConfig, filename: str | None = None
+) -> ChangelogFormat:
+ """
+ Get a format from its name
+
+ :raises FormatUnknown: if a non-empty name is provided but cannot be found in the known formats
+ """
+ name: str | None = config.settings.get("changelog_format")
+ format: type[ChangelogFormat] | None = guess_changelog_format(filename)
+
+ if name and name in KNOWN_CHANGELOG_FORMATS:
+ format = KNOWN_CHANGELOG_FORMATS[name]
+
+ if not format:
+ raise ChangelogFormatUnknown(f"Unknown changelog format '{name}'")
+
+ return format(config)
+
+
+def guess_changelog_format(filename: str | None) -> type[ChangelogFormat] | None:
+ """
+ Try guessing the file format from the filename.
+
+ Algorithm is basic, extension-based, and won't work
+ for extension-less file names like `CHANGELOG` or `NEWS`.
+ """
+ if not filename or not isinstance(filename, str):
+ return None
+ for format in KNOWN_CHANGELOG_FORMATS.values():
+ if filename.endswith(f".{format.extension}"):
+ return format
+ for alt_extension in format.alternative_extensions:
+ if filename.endswith(f".{alt_extension}"):
+ return format
+ return None
diff --git a/commitizen/changelog_formats/asciidoc.py b/commitizen/changelog_formats/asciidoc.py
new file mode 100644
index 0000000000..d738926f6e
--- /dev/null
+++ b/commitizen/changelog_formats/asciidoc.py
@@ -0,0 +1,27 @@
+from __future__ import annotations
+
+import re
+
+from .base import BaseFormat
+
+
+class AsciiDoc(BaseFormat):
+ extension = "adoc"
+
+ RE_TITLE = re.compile(r"^(?P=+) (?P.*)$")
+
+ def parse_version_from_title(self, line: str) -> str | None:
+ m = self.RE_TITLE.match(line)
+ if not m:
+ return None
+ # Capture last match as AsciiDoc use postfixed URL labels
+ matches = list(re.finditer(self.version_parser, m.group("title")))
+ if not matches:
+ return None
+ return matches[-1].group("version")
+
+ def parse_title_level(self, line: str) -> int | None:
+ m = self.RE_TITLE.match(line)
+ if not m:
+ return None
+ return len(m.group("level"))
diff --git a/commitizen/changelog_formats/base.py b/commitizen/changelog_formats/base.py
new file mode 100644
index 0000000000..d0dfd9ec55
--- /dev/null
+++ b/commitizen/changelog_formats/base.py
@@ -0,0 +1,81 @@
+from __future__ import annotations
+
+import os
+from abc import ABCMeta
+from re import Pattern
+from typing import IO, Any, ClassVar
+
+from commitizen.changelog import Metadata
+from commitizen.config.base_config import BaseConfig
+from commitizen.version_schemes import get_version_scheme
+
+from . import ChangelogFormat
+
+
+class BaseFormat(ChangelogFormat, metaclass=ABCMeta):
+ """
+ Base class to extend to implement a changelog file format.
+ """
+
+ extension: ClassVar[str] = ""
+ alternative_extensions: ClassVar[set[str]] = set()
+
+ def __init__(self, config: BaseConfig):
+ # Constructor needs to be redefined because `Protocol` prevent instantiation by default
+ # See: https://bugs.python.org/issue44807
+ self.config = config
+
+ @property
+ def version_parser(self) -> Pattern:
+ return get_version_scheme(self.config).parser
+
+ def get_metadata(self, filepath: str) -> Metadata:
+ if not os.path.isfile(filepath):
+ return Metadata()
+
+ with open(filepath) as changelog_file:
+ return self.get_metadata_from_file(changelog_file)
+
+ def get_metadata_from_file(self, file: IO[Any]) -> Metadata:
+ meta = Metadata()
+ unreleased_level: int | None = None
+ for index, line in enumerate(file):
+ line = line.strip().lower()
+
+ unreleased: int | None = None
+ if "unreleased" in line:
+ unreleased = self.parse_title_level(line)
+ # Try to find beginning and end lines of the unreleased block
+ if unreleased:
+ meta.unreleased_start = index
+ unreleased_level = unreleased
+ continue
+ elif unreleased_level and self.parse_title_level(line) == unreleased_level:
+ meta.unreleased_end = index
+
+ # Try to find the latest release done
+ version = self.parse_version_from_title(line)
+ if version:
+ meta.latest_version = version
+ meta.latest_version_position = index
+ break # there's no need for more info
+ if meta.unreleased_start is not None and meta.unreleased_end is None:
+ meta.unreleased_end = index
+
+ return meta
+
+ def parse_version_from_title(self, line: str) -> str | None:
+ """
+ Extract the version from a title line if any
+ """
+ raise NotImplementedError(
+ "Default `get_metadata_from_file` requires `parse_version_from_changelog` to be implemented"
+ )
+
+ def parse_title_level(self, line: str) -> int | None:
+ """
+ Get the title level/type of a line if any
+ """
+ raise NotImplementedError(
+ "Default `get_metadata_from_file` requires `parse_title_type_of_line` to be implemented"
+ )
diff --git a/commitizen/changelog_formats/markdown.py b/commitizen/changelog_formats/markdown.py
new file mode 100644
index 0000000000..a5a0f42de3
--- /dev/null
+++ b/commitizen/changelog_formats/markdown.py
@@ -0,0 +1,28 @@
+from __future__ import annotations
+
+import re
+
+from .base import BaseFormat
+
+
+class Markdown(BaseFormat):
+ extension = "md"
+
+ alternative_extensions = {"markdown", "mkd"}
+
+ RE_TITLE = re.compile(r"^(?P#+) (?P.*)$")
+
+ def parse_version_from_title(self, line: str) -> str | None:
+ m = self.RE_TITLE.match(line)
+ if not m:
+ return None
+ m = re.search(self.version_parser, m.group("title"))
+ if not m:
+ return None
+ return m.group("version")
+
+ def parse_title_level(self, line: str) -> int | None:
+ m = self.RE_TITLE.match(line)
+ if not m:
+ return None
+ return len(m.group("level"))
diff --git a/commitizen/changelog_formats/restructuredtext.py b/commitizen/changelog_formats/restructuredtext.py
new file mode 100644
index 0000000000..a252fe1514
--- /dev/null
+++ b/commitizen/changelog_formats/restructuredtext.py
@@ -0,0 +1,95 @@
+from __future__ import annotations
+
+import re
+import sys
+from itertools import zip_longest
+from typing import IO, TYPE_CHECKING, Any, Union, Tuple
+
+from commitizen.changelog import Metadata
+
+from .base import BaseFormat
+
+if TYPE_CHECKING:
+ # TypeAlias is Python 3.10+ but backported in typing-extensions
+ if sys.version_info >= (3, 10):
+ from typing import TypeAlias
+ else:
+ from typing_extensions import TypeAlias
+
+
+# Can't use `|` operator and native type because of https://bugs.python.org/issue42233 only fixed in 3.10
+TitleKind: TypeAlias = Union[str, Tuple[str, str]]
+
+
+class RestructuredText(BaseFormat):
+ extension = "rst"
+
+ def get_metadata_from_file(self, file: IO[Any]) -> Metadata:
+ """
+ RestructuredText section titles are not one-line-based,
+ they spread on 2 or 3 lines and levels are not predefined
+ but determined byt their occurrence order.
+
+ It requires its own algorithm.
+
+ For a more generic approach, you need to rely on `docutils`.
+ """
+ meta = Metadata()
+ unreleased_title_kind: TitleKind | None = None
+ in_overlined_title = False
+ lines = file.readlines()
+ for index, (first, second, third) in enumerate(
+ zip_longest(lines, lines[1:], lines[2:], fillvalue="")
+ ):
+ first = first.strip().lower()
+ second = second.strip().lower()
+ third = third.strip().lower()
+ title: str | None = None
+ kind: TitleKind | None = None
+
+ if self.is_overlined_title(first, second, third):
+ title = second
+ kind = (first[0], third[0])
+ in_overlined_title = True
+ elif not in_overlined_title and self.is_underlined_title(first, second):
+ title = first
+ kind = second[0]
+ else:
+ in_overlined_title = False
+
+ if title:
+ if "unreleased" in title:
+ unreleased_title_kind = kind
+ meta.unreleased_start = index
+ continue
+ elif unreleased_title_kind and unreleased_title_kind == kind:
+ meta.unreleased_end = index
+ # Try to find the latest release done
+ m = re.search(self.version_parser, title)
+ if m:
+ version = m.group("version")
+ meta.latest_version = version
+ meta.latest_version_position = index
+ break # there's no need for more info
+ if meta.unreleased_start is not None and meta.unreleased_end is None:
+ meta.unreleased_end = (
+ meta.latest_version_position if meta.latest_version else index + 1
+ )
+
+ return meta
+
+ def is_overlined_title(self, first: str, second: str, third: str) -> bool:
+ return (
+ len(first) >= len(second)
+ and len(first) == len(third)
+ and all(char == first[0] for char in first[1:])
+ and first[0] == third[0]
+ and self.is_underlined_title(second, third)
+ )
+
+ def is_underlined_title(self, first: str, second: str) -> bool:
+ return (
+ len(second) >= len(first)
+ and not second.isalnum()
+ and all(char == second[0] for char in second[1:])
+ )
diff --git a/commitizen/changelog_formats/textile.py b/commitizen/changelog_formats/textile.py
new file mode 100644
index 0000000000..80118cdb3c
--- /dev/null
+++ b/commitizen/changelog_formats/textile.py
@@ -0,0 +1,25 @@
+from __future__ import annotations
+
+import re
+
+from .base import BaseFormat
+
+
+class Textile(BaseFormat):
+ extension = "textile"
+
+ RE_TITLE = re.compile(r"^h(?P\d)\. (?P.*)$")
+
+ def parse_version_from_title(self, line: str) -> str | None:
+ if not self.RE_TITLE.match(line):
+ return None
+ m = re.search(self.version_parser, line)
+ if not m:
+ return None
+ return m.group("version")
+
+ def parse_title_level(self, line: str) -> int | None:
+ m = self.RE_TITLE.match(line)
+ if not m:
+ return None
+ return int(m.group("level"))
diff --git a/commitizen/changelog_parser.py b/commitizen/changelog_parser.py
deleted file mode 100644
index 51b39fb956..0000000000
--- a/commitizen/changelog_parser.py
+++ /dev/null
@@ -1,135 +0,0 @@
-"""CHNAGLOG PARSER DESIGN
-
-## Parse CHANGELOG.md
-
-1. Get LATEST VERSION from CONFIG
-1. Parse the file version to version
-2. Build a dict (tree) of that particular version
-3. Transform tree into markdown again
-"""
-from __future__ import annotations
-
-import re
-from collections import defaultdict
-from typing import Generator, Iterable
-
-from commitizen.defaults import encoding
-
-MD_VERSION_RE = r"^##\s(?P[a-zA-Z0-9.+]+)\s?\(?(?P[0-9-]+)?\)?"
-MD_CHANGE_TYPE_RE = r"^###\s(?P[a-zA-Z0-9.+\s]+)"
-MD_MESSAGE_RE = (
- r"^-\s(\*{2}(?P[a-zA-Z0-9]+)\*{2}:\s)?(?P.+)(?P!)?"
-)
-md_version_c = re.compile(MD_VERSION_RE)
-md_change_type_c = re.compile(MD_CHANGE_TYPE_RE)
-md_message_c = re.compile(MD_MESSAGE_RE)
-
-
-CATEGORIES = [
- ("fix", "fix"),
- ("breaking", "BREAKING CHANGES"),
- ("feat", "feat"),
- ("refactor", "refactor"),
- ("perf", "perf"),
- ("test", "test"),
- ("build", "build"),
- ("ci", "ci"),
- ("chore", "chore"),
-]
-
-
-def find_version_blocks(filepath: str, encoding: str = encoding) -> Generator:
- """Find version block (version block: contains all the information about a version.)
-
- E.g:
- ```
- ## 1.2.1 (2019-07-20)
-
- ### Fix
-
- - username validation not working
-
- ### Feat
-
- - new login system
-
- ```
- """
- with open(filepath, encoding=encoding) as f:
- block: list = []
- for line in f:
- line = line.strip("\n")
- if not line:
- continue
-
- if line.startswith("## "):
- if len(block) > 0:
- yield block
- block = [line]
- else:
- block.append(line)
- yield block
-
-
-def parse_md_version(md_version: str) -> dict:
- m = md_version_c.match(md_version)
- if not m:
- return {}
- return m.groupdict()
-
-
-def parse_md_change_type(md_change_type: str) -> dict:
- m = md_change_type_c.match(md_change_type)
- if not m:
- return {}
- return m.groupdict()
-
-
-def parse_md_message(md_message: str) -> dict:
- m = md_message_c.match(md_message)
- if not m:
- return {}
- return m.groupdict()
-
-
-def transform_change_type(change_type: str) -> str:
- # TODO: Use again to parse, for this we have to wait until the maps get
- # defined again.
- _change_type_lower = change_type.lower()
- for match_value, output in CATEGORIES:
- if re.search(match_value, _change_type_lower):
- return output
- else:
- raise ValueError(f"Could not match a change_type with {change_type}")
-
-
-def generate_block_tree(block: list[str]) -> dict:
- # tree: Dict = {"commits": []}
- changes: dict = defaultdict(list)
- tree: dict = {"changes": changes}
-
- change_type = None
- for line in block:
- if line.startswith("## "):
- # version identified
- change_type = None
- tree = {**tree, **parse_md_version(line)}
- elif line.startswith("### "):
- # change_type identified
- result = parse_md_change_type(line)
- if not result:
- continue
- change_type = result.get("change_type", "").lower()
-
- elif line.startswith("- "):
- # message identified
- commit = parse_md_message(line)
- changes[change_type].append(commit)
- else:
- print("it's something else: ", line)
- return tree
-
-
-def generate_full_tree(blocks: Iterable) -> Iterable[dict]:
- for block in blocks:
- yield generate_block_tree(block)
diff --git a/commitizen/cli.py b/commitizen/cli.py
index ce0d72b70f..19d6b1b80a 100644
--- a/commitizen/cli.py
+++ b/commitizen/cli.py
@@ -7,6 +7,7 @@
from functools import partial
from pathlib import Path
from types import TracebackType
+from typing import Any, Sequence
import argcomplete
from decli import cli
@@ -42,9 +43,25 @@ class ParseKwargs(argparse.Action):
}
"""
- def __call__(self, parser, namespace, kwarg, option_string=None):
+ def __call__(
+ self,
+ parser: argparse.ArgumentParser,
+ namespace: argparse.Namespace,
+ kwarg: str | Sequence[Any] | None,
+ option_string: str | None = None,
+ ):
+ if not isinstance(kwarg, str):
+ return
+ if "=" not in kwarg:
+ raise InvalidCommandArgumentError(
+ f"Option {option_string} expect a key=value format"
+ )
kwargs = getattr(namespace, self.dest, None) or {}
key, value = kwarg.split("=", 1)
+ if not key:
+ raise InvalidCommandArgumentError(
+ f"Option {option_string} expect a key=value format"
+ )
kwargs[key] = value.strip("'\"")
setattr(namespace, self.dest, kwargs)
diff --git a/commitizen/commands/bump.py b/commitizen/commands/bump.py
index 3a942d79d3..f1b6813566 100644
--- a/commitizen/commands/bump.py
+++ b/commitizen/commands/bump.py
@@ -22,6 +22,7 @@
NotAllowed,
NoVersionSpecifiedError,
)
+from commitizen.changelog_formats import get_changelog_format
from commitizen.providers import get_provider
from commitizen.version_schemes import InvalidVersion, get_version_scheme
@@ -79,10 +80,16 @@ def __init__(self, config: BaseConfig, arguments: dict):
self.scheme = get_version_scheme(
self.config, arguments["version_scheme"] or deprecated_version_type
)
- self.template = arguments["template"] or self.config.settings.get("template")
self.file_name = arguments["file_name"] or self.config.settings.get(
"changelog_file"
)
+ self.changelog_format = get_changelog_format(self.config, self.file_name)
+
+ self.template = (
+ arguments["template"]
+ or self.config.settings.get("template")
+ or self.changelog_format.template
+ )
self.extras = arguments["extras"]
def is_initial_tag(self, current_tag_version: str, is_yes: bool = False) -> bool:
@@ -266,6 +273,8 @@ def __call__(self): # noqa: C901
self.config,
{
"unreleased_version": new_tag_version,
+ "template": self.template,
+ "extras": self.extras,
"incremental": True,
"dry_run": True,
},
diff --git a/commitizen/commands/changelog.py b/commitizen/commands/changelog.py
index fb58161d4d..8b3c309636 100644
--- a/commitizen/commands/changelog.py
+++ b/commitizen/commands/changelog.py
@@ -17,6 +17,7 @@
NotAGitProjectError,
NotAllowed,
)
+from commitizen.changelog_formats import get_changelog_format
from commitizen.git import GitTag, smart_open
from commitizen.version_schemes import get_version_scheme
@@ -38,6 +39,14 @@ def __init__(self, config: BaseConfig, args):
self.file_name = args.get("file_name") or self.config.settings.get(
"changelog_file"
)
+ if not isinstance(self.file_name, str):
+ raise NotAllowed(
+ "Changelog file name is broken.\n"
+ "Check the flag `--file-name` in the terminal "
+ f"or the setting `changelog_file` in {self.config.path}"
+ )
+ self.changelog_format = get_changelog_format(self.config, self.file_name)
+
self.incremental = args["incremental"] or self.config.settings.get(
"changelog_incremental"
)
@@ -70,7 +79,7 @@ def __init__(self, config: BaseConfig, args):
self.template = (
args.get("template")
or self.config.settings.get("template")
- or self.cz.template
+ or self.changelog_format.template
)
self.extras = args.get("extras") or {}
self.export_template_to = args.get("export_template")
@@ -102,15 +111,8 @@ def _find_incremental_rev(self, latest_version: str, tags: list[GitTag]) -> str:
return start_rev
def write_changelog(
- self, changelog_out: str, lines: list[str], changelog_meta: dict
+ self, changelog_out: str, lines: list[str], changelog_meta: changelog.Metadata
):
- if not isinstance(self.file_name, str):
- raise NotAllowed(
- "Changelog file name is broken.\n"
- "Check the flag `--file-name` in the terminal "
- f"or the setting `changelog_file` in {self.config.path}"
- )
-
changelog_hook: Callable | None = self.cz.changelog_hook
with smart_open(self.file_name, "w", encoding=self.encoding) as changelog_file:
partial_changelog: str | None = None
@@ -135,11 +137,11 @@ def __call__(self):
changelog_pattern = self.cz.changelog_pattern
start_rev = self.start_rev
unreleased_version = self.unreleased_version
- changelog_meta: dict = {}
+ changelog_meta = changelog.Metadata()
change_type_map: dict | None = self.change_type_map
- changelog_message_builder_hook: None | (
- Callable
- ) = self.cz.changelog_message_builder_hook
+ changelog_message_builder_hook: Callable | None = (
+ self.cz.changelog_message_builder_hook
+ )
merge_prerelease = self.merge_prerelease
if self.export_template_to:
@@ -160,15 +162,10 @@ def __call__(self):
end_rev = ""
if self.incremental:
- changelog_meta = changelog.get_metadata(
- self.file_name,
- self.scheme,
- encoding=self.encoding,
- )
- latest_version = changelog_meta.get("latest_version")
- if latest_version:
+ changelog_meta = self.changelog_format.get_metadata(self.file_name)
+ if changelog_meta.latest_version:
latest_tag_version: str = bump.normalize_tag(
- latest_version,
+ changelog_meta.latest_version,
tag_format=self.tag_format,
scheme=self.scheme,
)
diff --git a/commitizen/cz/base.py b/commitizen/cz/base.py
index f0cf4aa581..d9bf5ea4a0 100644
--- a/commitizen/cz/base.py
+++ b/commitizen/cz/base.py
@@ -3,11 +3,10 @@
from abc import ABCMeta, abstractmethod
from typing import Any, Callable
-from jinja2 import BaseLoader
+from jinja2 import BaseLoader, PackageLoader
from prompt_toolkit.styles import Style, merge_styles
from commitizen import git
-from commitizen.changelog import DEFAULT_TEMPLATE
from commitizen.config.base_config import BaseConfig
from commitizen.defaults import Questions
@@ -45,8 +44,8 @@ class BaseCommitizen(metaclass=ABCMeta):
# Executed only at the end of the changelog generation
changelog_hook: Callable[[str, str | None], str] | None = None
- template: str = DEFAULT_TEMPLATE
- template_loader: BaseLoader | None = None
+ # Plugins can override templates and provide extra template data
+ template_loader: BaseLoader = PackageLoader("commitizen", "templates")
template_extras: dict[str, Any] = {}
def __init__(self, config: BaseConfig):
diff --git a/commitizen/cz/conventional_commits/__init__.py b/commitizen/cz/conventional_commits/__init__.py
index 4ee406ca1a..52624d2ddb 100644
--- a/commitizen/cz/conventional_commits/__init__.py
+++ b/commitizen/cz/conventional_commits/__init__.py
@@ -1 +1 @@
-from .conventional_commits import ConventionalCommitsCz # noqa
+from .conventional_commits import ConventionalCommitsCz # noqa: F401
diff --git a/commitizen/defaults.py b/commitizen/defaults.py
index 59b867c12a..7aa2c793c5 100644
--- a/commitizen/defaults.py
+++ b/commitizen/defaults.py
@@ -40,6 +40,7 @@ class Settings(TypedDict, total=False):
allow_abort: bool
allowed_prefixes: list[str]
changelog_file: str
+ changelog_format: str | None
changelog_incremental: bool
changelog_start_rev: str | None
changelog_merge_prerelease: bool
@@ -52,6 +53,7 @@ class Settings(TypedDict, total=False):
post_bump_hooks: list[str] | None
prerelease_offset: int
encoding: str
+ always_signoff: bool
template: str | None
extras: dict[str, Any]
@@ -84,6 +86,7 @@ class Settings(TypedDict, total=False):
"squash!",
],
"changelog_file": "CHANGELOG.md",
+ "changelog_format": None, # default guessed from changelog_file
"changelog_incremental": False,
"changelog_start_rev": None,
"changelog_merge_prerelease": False,
@@ -95,7 +98,7 @@ class Settings(TypedDict, total=False):
"prerelease_offset": 0,
"encoding": encoding,
"always_signoff": False,
- "template": None,
+ "template": None, # default provided by plugin
"extras": {},
}
@@ -103,6 +106,8 @@ class Settings(TypedDict, total=False):
MINOR = "MINOR"
PATCH = "PATCH"
+CHANGELOG_FORMAT = "markdown"
+
bump_pattern = r"^((BREAKING[\-\ ]CHANGE|\w+)(\(.+\))?!?):"
bump_map = OrderedDict(
(
@@ -128,4 +133,3 @@ class Settings(TypedDict, total=False):
bump_message = "bump: version $current_version → $new_version"
commit_parser = r"^((?Pfeat|fix|refactor|perf|BREAKING CHANGE)(?:\((?P[^()\r\n]*)\)|\()?(?P!)?|\w+!):\s(?P.*)?" # noqa
-version_parser = r"(?P([0-9]+)\.([0-9]+)\.([0-9]+)(?:-([0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*))?(?:\+[0-9A-Za-z-]+)?(\w+)?)"
diff --git a/commitizen/exceptions.py b/commitizen/exceptions.py
index 467582ee6c..31b867b8f9 100644
--- a/commitizen/exceptions.py
+++ b/commitizen/exceptions.py
@@ -33,6 +33,7 @@ class ExitCode(enum.IntEnum):
RUN_HOOK_FAILED = 26
VERSION_PROVIDER_UNKNOWN = 27
VERSION_SCHEME_UNKNOWN = 28
+ CHANGELOG_FORMAT_UNKNOWN = 29
class CommitizenException(Exception):
@@ -183,3 +184,8 @@ class VersionProviderUnknown(CommitizenException):
class VersionSchemeUnknown(CommitizenException):
exit_code = ExitCode.VERSION_SCHEME_UNKNOWN
+
+
+class ChangelogFormatUnknown(CommitizenException):
+ exit_code = ExitCode.CHANGELOG_FORMAT_UNKNOWN
+ message = "Unknown changelog format identifier"
diff --git a/commitizen/templates/CHANGELOG.adoc.j2 b/commitizen/templates/CHANGELOG.adoc.j2
new file mode 100644
index 0000000000..fe16c5de3d
--- /dev/null
+++ b/commitizen/templates/CHANGELOG.adoc.j2
@@ -0,0 +1,19 @@
+{% for entry in tree %}
+
+== {{ entry.version }}{% if entry.date %} ({{ entry.date }}){% endif %}
+
+{% for change_key, changes in entry.changes.items() %}
+
+{% if change_key %}
+=== {{ change_key }}
+{% endif %}
+
+{% for change in changes %}
+{% if change.scope %}
+* *{{ change.scope }}*: {{ change.message }}
+{% elif change.message %}
+* {{ change.message }}
+{% endif %}
+{% endfor %}
+{% endfor %}
+{% endfor %}
diff --git a/commitizen/templates/CHANGELOG.rst.j2 b/commitizen/templates/CHANGELOG.rst.j2
new file mode 100644
index 0000000000..4287108b55
--- /dev/null
+++ b/commitizen/templates/CHANGELOG.rst.j2
@@ -0,0 +1,23 @@
+{% for entry in tree %}
+
+{% set entry_title -%}
+{{ entry.version }}{% if entry.date %} ({{ entry.date }}){% endif -%}
+{%- endset %}
+{{ entry_title }}
+{{ "=" * entry_title|length }}
+{% for change_key, changes in entry.changes.items() %}
+
+{% if change_key -%}
+{{ change_key }}
+{{ "-" * change_key|length }}
+{% endif %}
+
+{% for change in changes %}
+{% if change.scope %}
+- **{{ change.scope }}**: {{ change.message }}
+{% elif change.message %}
+- {{ change.message }}
+{% endif %}
+{% endfor %}
+{% endfor %}
+{% endfor %}
diff --git a/commitizen/templates/CHANGELOG.textile.j2 b/commitizen/templates/CHANGELOG.textile.j2
new file mode 100644
index 0000000000..db55f4caad
--- /dev/null
+++ b/commitizen/templates/CHANGELOG.textile.j2
@@ -0,0 +1,19 @@
+{% for entry in tree %}
+
+h2. {{ entry.version }}{% if entry.date %} ({{ entry.date }}){% endif %}
+
+{% for change_key, changes in entry.changes.items() %}
+
+{% if change_key %}
+h3. {{ change_key }}
+{% endif %}
+
+{% for change in changes %}
+{% if change.scope %}
+- *{{ change.scope }}*: {{ change.message }}
+{% elif change.message %}
+- {{ change.message }}
+{% endif %}
+{% endfor %}
+{% endfor %}
+{% endfor %}
diff --git a/docs/config.md b/docs/config.md
index 2ae2b5cc38..391c20f0fc 100644
--- a/docs/config.md
+++ b/docs/config.md
@@ -104,6 +104,14 @@ Default: `CHANGELOG.md`
Filename of exported changelog
+### `changelog_format`
+
+Type: `str`
+
+Default: None
+
+Format used to parse and generate the changelog, If not specified, guessed from [`changelog_file`](#changelog_file).
+
### `changelog_incremental`
Type: `bool`
diff --git a/docs/exit_codes.md b/docs/exit_codes.md
index c7b510ea3a..af9cb83627 100644
--- a/docs/exit_codes.md
+++ b/docs/exit_codes.md
@@ -33,4 +33,7 @@ These exit codes can be found in `commitizen/exceptions.py::ExitCode`.
| GitCommandError | 23 | Unexpected failure while calling a git command |
| InvalidManualVersion | 24 | Manually provided version is invalid |
| InitFailedError | 25 | Failed to initialize pre-commit |
-| VersionProviderUnknown | 26 | `version_provider` setting is set to an unknown version provider identifier |
+| RunHookError | 26 | An error occurred during a hook execution |
+| VersionProviderUnknown | 27 | `version_provider` setting is set to an unknown version provider identifier |
+| VersionSchemeUnknown | 28 | `version_scheme` setting is set to an unknown version scheme identifier |
+| ChangelogFormatUnknown | 29 | `changelog_format` setting is set to an unknown version scheme identifier or could not be guessed |
diff --git a/pyproject.toml b/pyproject.toml
index 9e37e6b4ed..48133f1e11 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -82,6 +82,12 @@ cz_conventional_commits = "commitizen.cz.conventional_commits:ConventionalCommit
cz_jira = "commitizen.cz.jira:JiraSmartCz"
cz_customize = "commitizen.cz.customize:CustomizeCommitsCz"
+[tool.poetry.plugins."commitizen.changelog_format"]
+markdown = "commitizen.changelog_formats.markdown:Markdown"
+asciidoc = "commitizen.changelog_formats.asciidoc:AsciiDoc"
+textile = "commitizen.changelog_formats.textile:Textile"
+restructuredtext = "commitizen.changelog_formats.restructuredtext:RestructuredText"
+
[tool.poetry.plugins."commitizen.provider"]
cargo = "commitizen.providers:CargoProvider"
commitizen = "commitizen.providers:CommitizenProvider"
diff --git a/tests/commands/test_bump_command.py b/tests/commands/test_bump_command.py
index a97faee2b0..0ae7d1e509 100644
--- a/tests/commands/test_bump_command.py
+++ b/tests/commands/test_bump_command.py
@@ -12,7 +12,6 @@
import commitizen.commands.bump as bump
from commitizen import cli, cmd, git, hooks
-from commitizen.changelog import DEFAULT_TEMPLATE
from commitizen.cz.base import BaseCommitizen
from commitizen.exceptions import (
BumpTagFailedError,
@@ -29,6 +28,7 @@
NotAllowed,
NoVersionSpecifiedError,
)
+from commitizen.changelog_formats import ChangelogFormat
from tests.utils import create_file_and_commit, create_tag
@@ -1106,7 +1106,7 @@ def test_bump_command_version_scheme_priority_over_version_type(mocker: MockFixt
@pytest.mark.parametrize(
- "arg,cfg,expected",
+ "arg, cfg, expected",
(
pytest.param("", "", "default", id="default"),
pytest.param("", "changelog.cfg", "from config", id="from-config"),
@@ -1118,7 +1118,7 @@ def test_bump_command_version_scheme_priority_over_version_type(mocker: MockFixt
def test_bump_template_option_precedance(
mocker: MockFixture,
tmp_commitizen_project: Path,
- mock_plugin: BaseCommitizen,
+ any_changelog_format: ChangelogFormat,
arg: str,
cfg: str,
expected: str,
@@ -1126,8 +1126,8 @@ def test_bump_template_option_precedance(
project_root = Path(tmp_commitizen_project)
cfg_template = project_root / "changelog.cfg"
cmd_template = project_root / "changelog.cmd"
- default_template = project_root / mock_plugin.template
- changelog = project_root / "CHANGELOG.md"
+ default_template = project_root / any_changelog_format.template
+ changelog = project_root / any_changelog_format.default_changelog_file
cfg_template.write_text("from config")
cmd_template.write_text("from cmd")
@@ -1160,10 +1160,11 @@ def test_bump_template_option_precedance(
def test_bump_template_extras_precedance(
mocker: MockFixture,
tmp_commitizen_project: Path,
+ any_changelog_format: ChangelogFormat,
mock_plugin: BaseCommitizen,
):
project_root = Path(tmp_commitizen_project)
- changelog_tpl = project_root / DEFAULT_TEMPLATE
+ changelog_tpl = project_root / any_changelog_format.template
changelog_tpl.write_text("{{first}} - {{second}} - {{third}}")
mock_plugin.template_extras = dict(
@@ -1197,16 +1198,17 @@ def test_bump_template_extras_precedance(
mocker.patch.object(sys, "argv", testargs)
cli.main()
- changelog = project_root / "CHANGELOG.md"
+ changelog = project_root / any_changelog_format.default_changelog_file
assert changelog.read_text() == "from-command - from-config - from-plugin"
def test_bump_template_extra_quotes(
mocker: MockFixture,
tmp_commitizen_project: Path,
+ any_changelog_format: ChangelogFormat,
):
project_root = Path(tmp_commitizen_project)
- changelog_tpl = project_root / DEFAULT_TEMPLATE
+ changelog_tpl = project_root / any_changelog_format.template
changelog_tpl.write_text("{{first}} - {{second}} - {{third}}")
create_file_and_commit("feat: new file")
@@ -1222,9 +1224,10 @@ def test_bump_template_extra_quotes(
"second='single quotes'",
"-e",
'third="double quotes"',
+ "0.1.1",
]
mocker.patch.object(sys, "argv", testargs)
cli.main()
- changelog = project_root / "CHANGELOG.md"
+ changelog = project_root / any_changelog_format.default_changelog_file
assert changelog.read_text() == "no-quote - single quotes - double quotes"
diff --git a/tests/commands/test_changelog_command.py b/tests/commands/test_changelog_command.py
index f474a1b275..a0183e1cd9 100644
--- a/tests/commands/test_changelog_command.py
+++ b/tests/commands/test_changelog_command.py
@@ -11,16 +11,17 @@
from commitizen import __file__ as commitizen_init
from commitizen import cli, git
-from commitizen.changelog import DEFAULT_TEMPLATE
from commitizen.commands.changelog import Changelog
from commitizen.cz.base import BaseCommitizen
from commitizen.exceptions import (
DryRunExit,
+ InvalidCommandArgumentError,
NoCommitsFoundError,
NoRevisionError,
NotAGitProjectError,
NotAllowed,
)
+from commitizen.changelog_formats import ChangelogFormat
from tests.utils import (
create_branch,
create_file_and_commit,
@@ -73,34 +74,60 @@ def test_changelog_with_different_cz(mocker: MockFixture, capsys, file_regressio
@pytest.mark.usefixtures("tmp_commitizen_project")
def test_changelog_from_start(
- mocker: MockFixture, capsys, changelog_path, file_regression
+ mocker: MockFixture, capsys, changelog_format: ChangelogFormat, file_regression
):
create_file_and_commit("feat: new file")
create_file_and_commit("refactor: is in changelog")
create_file_and_commit("Merge into master")
+ changelog_file = f"CHANGELOG.{changelog_format.extension}"
+ template = f"CHANGELOG.{changelog_format.extension}.j2"
- testargs = ["cz", "changelog"]
+ testargs = [
+ "cz",
+ "changelog",
+ "--file-name",
+ changelog_file,
+ "--template",
+ template,
+ ]
mocker.patch.object(sys, "argv", testargs)
cli.main()
- with open(changelog_path, encoding="utf-8") as f:
+ with open(changelog_file, encoding="utf-8") as f:
out = f.read()
- file_regression.check(out, extension=".md")
+ file_regression.check(out, extension=changelog_format.ext)
@pytest.mark.usefixtures("tmp_commitizen_project")
def test_changelog_replacing_unreleased_using_incremental(
- mocker: MockFixture, capsys, changelog_path, file_regression
+ mocker: MockFixture, capsys, changelog_format: ChangelogFormat, file_regression
):
create_file_and_commit("feat: add new output")
create_file_and_commit("fix: output glitch")
create_file_and_commit("Merge into master")
+ changelog_file = f"CHANGELOG.{changelog_format.extension}"
+ template = f"CHANGELOG.{changelog_format.extension}.j2"
- testargs = ["cz", "changelog"]
+ testargs = [
+ "cz",
+ "changelog",
+ "--file-name",
+ changelog_file,
+ "--template",
+ template,
+ ]
mocker.patch.object(sys, "argv", testargs)
cli.main()
- testargs = ["cz", "bump", "--yes"]
+ testargs = [
+ "cz",
+ "bump",
+ "--yes",
+ "--file-name",
+ changelog_file,
+ "--template",
+ template,
+ ]
mocker.patch.object(sys, "argv", testargs)
cli.main()
@@ -108,16 +135,24 @@ def test_changelog_replacing_unreleased_using_incremental(
create_file_and_commit("feat: add more stuff")
create_file_and_commit("Merge into master")
- testargs = ["cz", "changelog", "--incremental"]
+ testargs = [
+ "cz",
+ "changelog",
+ "--incremental",
+ "--file-name",
+ changelog_file,
+ "--template",
+ template,
+ ]
mocker.patch.object(sys, "argv", testargs)
cli.main()
- with open(changelog_path, encoding="utf-8") as f:
+ with open(changelog_file, encoding="utf-8") as f:
out = f.read().replace(
datetime.strftime(datetime.now(), "%Y-%m-%d"), "2022-08-14"
)
- file_regression.check(out, extension=".md")
+ file_regression.check(out, extension=changelog_format.ext)
@pytest.mark.usefixtures("tmp_commitizen_project")
@@ -1380,7 +1415,7 @@ def test_changelog_from_current_version_tag_with_nonversion_tag(
def test_changelog_template_option_precedance(
mocker: MockFixture,
tmp_commitizen_project: Path,
- mock_plugin: BaseCommitizen,
+ any_changelog_format: ChangelogFormat,
arg: str,
cfg: str,
expected: str,
@@ -1388,8 +1423,8 @@ def test_changelog_template_option_precedance(
project_root = Path(tmp_commitizen_project)
cfg_template = project_root / "changelog.cfg"
cmd_template = project_root / "changelog.cmd"
- default_template = project_root / mock_plugin.template
- changelog = project_root / "CHANGELOG.md"
+ default_template = project_root / any_changelog_format.template
+ changelog = project_root / any_changelog_format.default_changelog_file
cfg_template.write_text("from config")
cmd_template.write_text("from cmd")
@@ -1423,9 +1458,10 @@ def test_changelog_template_extras_precedance(
mocker: MockFixture,
tmp_commitizen_project: Path,
mock_plugin: BaseCommitizen,
+ any_changelog_format: ChangelogFormat,
):
project_root = Path(tmp_commitizen_project)
- changelog_tpl = project_root / mock_plugin.template
+ changelog_tpl = project_root / any_changelog_format.template
changelog_tpl.write_text("{{first}} - {{second}} - {{third}}")
mock_plugin.template_extras = dict(
@@ -1451,16 +1487,17 @@ def test_changelog_template_extras_precedance(
mocker.patch.object(sys, "argv", testargs)
cli.main()
- changelog = project_root / "CHANGELOG.md"
+ changelog = project_root / any_changelog_format.default_changelog_file
assert changelog.read_text() == "from-command - from-config - from-plugin"
def test_changelog_template_extra_quotes(
mocker: MockFixture,
tmp_commitizen_project: Path,
+ any_changelog_format: ChangelogFormat,
):
project_root = Path(tmp_commitizen_project)
- changelog_tpl = project_root / DEFAULT_TEMPLATE
+ changelog_tpl = project_root / any_changelog_format.template
changelog_tpl.write_text("{{first}} - {{second}} - {{third}}")
create_file_and_commit("feat: new file")
@@ -1478,17 +1515,66 @@ def test_changelog_template_extra_quotes(
mocker.patch.object(sys, "argv", testargs)
cli.main()
- changelog = project_root / "CHANGELOG.md"
+ changelog = project_root / any_changelog_format.default_changelog_file
assert changelog.read_text() == "no-quote - single quotes - double quotes"
+@pytest.mark.parametrize(
+ "extra, expected",
+ (
+ pytest.param("key=value=", "value=", id="2-equals"),
+ pytest.param("key==value", "=value", id="2-consecutives-equals"),
+ pytest.param("key==value==", "=value==", id="multiple-equals"),
+ ),
+)
+def test_changelog_template_extra_weird_but_valid(
+ mocker: MockFixture,
+ tmp_commitizen_project: Path,
+ any_changelog_format: ChangelogFormat,
+ extra: str,
+ expected,
+):
+ project_root = Path(tmp_commitizen_project)
+ changelog_tpl = project_root / any_changelog_format.template
+ changelog_tpl.write_text("{{key}}")
+
+ create_file_and_commit("feat: new file")
+
+ testargs = ["cz", "changelog", "-e", extra]
+ mocker.patch.object(sys, "argv", testargs)
+ cli.main()
+
+ changelog = project_root / any_changelog_format.default_changelog_file
+ assert changelog.read_text() == expected
+
+
+@pytest.mark.parametrize("extra", ("no-equal", "", "=no-key"))
+def test_changelog_template_extra_bad_format(
+ mocker: MockFixture,
+ tmp_commitizen_project: Path,
+ any_changelog_format: ChangelogFormat,
+ extra: str,
+):
+ project_root = Path(tmp_commitizen_project)
+ changelog_tpl = project_root / any_changelog_format.template
+ changelog_tpl.write_text("")
+
+ create_file_and_commit("feat: new file")
+
+ testargs = ["cz", "changelog", "-e", extra]
+ mocker.patch.object(sys, "argv", testargs)
+ with pytest.raises(InvalidCommandArgumentError):
+ cli.main()
+
+
def test_export_changelog_template_from_default(
mocker: MockFixture,
tmp_commitizen_project: Path,
+ any_changelog_format: ChangelogFormat,
):
project_root = Path(tmp_commitizen_project)
target = project_root / "changelog.jinja"
- src = Path(commitizen_init).parent / "templates" / DEFAULT_TEMPLATE
+ src = Path(commitizen_init).parent / "templates" / any_changelog_format.template
args = ["cz", "changelog", "--export-template", str(target)]
@@ -1503,16 +1589,15 @@ def test_export_changelog_template_from_plugin(
mocker: MockFixture,
tmp_commitizen_project: Path,
mock_plugin: BaseCommitizen,
+ changelog_format: ChangelogFormat,
tmp_path: Path,
):
project_root = Path(tmp_commitizen_project)
target = project_root / "changelog.jinja"
- filename = "template.jinja"
- src = tmp_path / filename
+ src = tmp_path / changelog_format.template
tpl = "I am a custom template"
src.write_text(tpl)
mock_plugin.template_loader = FileSystemLoader(tmp_path)
- mock_plugin.template = filename
args = ["cz", "changelog", "--export-template", str(target)]
diff --git a/tests/commands/test_changelog_command/test_changelog_from_start_asciidoc_.adoc b/tests/commands/test_changelog_command/test_changelog_from_start_asciidoc_.adoc
new file mode 100644
index 0000000000..842e120ba8
--- /dev/null
+++ b/tests/commands/test_changelog_command/test_changelog_from_start_asciidoc_.adoc
@@ -0,0 +1,9 @@
+== Unreleased
+
+=== Feat
+
+* new file
+
+=== Refactor
+
+* is in changelog
diff --git a/tests/commands/test_changelog_command/test_changelog_from_start.md b/tests/commands/test_changelog_command/test_changelog_from_start_markdown_.md
similarity index 100%
rename from tests/commands/test_changelog_command/test_changelog_from_start.md
rename to tests/commands/test_changelog_command/test_changelog_from_start_markdown_.md
diff --git a/tests/commands/test_changelog_command/test_changelog_from_start_restructuredtext_.rst b/tests/commands/test_changelog_command/test_changelog_from_start_restructuredtext_.rst
new file mode 100644
index 0000000000..555f5bc64d
--- /dev/null
+++ b/tests/commands/test_changelog_command/test_changelog_from_start_restructuredtext_.rst
@@ -0,0 +1,12 @@
+Unreleased
+==========
+
+Feat
+----
+
+- new file
+
+Refactor
+--------
+
+- is in changelog
diff --git a/tests/commands/test_changelog_command/test_changelog_from_start_textile_.textile b/tests/commands/test_changelog_command/test_changelog_from_start_textile_.textile
new file mode 100644
index 0000000000..e71fb99cf5
--- /dev/null
+++ b/tests/commands/test_changelog_command/test_changelog_from_start_textile_.textile
@@ -0,0 +1,9 @@
+h2. Unreleased
+
+h3. Feat
+
+- new file
+
+h3. Refactor
+
+- is in changelog
diff --git a/tests/commands/test_changelog_command/test_changelog_replacing_unreleased_using_incremental_asciidoc_.adoc b/tests/commands/test_changelog_command/test_changelog_replacing_unreleased_using_incremental_asciidoc_.adoc
new file mode 100644
index 0000000000..2e789bcf2f
--- /dev/null
+++ b/tests/commands/test_changelog_command/test_changelog_replacing_unreleased_using_incremental_asciidoc_.adoc
@@ -0,0 +1,19 @@
+== Unreleased
+
+=== Feat
+
+* add more stuff
+
+=== Fix
+
+* mama gotta work
+
+== 0.2.0 (2022-08-14)
+
+=== Feat
+
+* add new output
+
+=== Fix
+
+* output glitch
diff --git a/tests/commands/test_changelog_command/test_changelog_replacing_unreleased_using_incremental.md b/tests/commands/test_changelog_command/test_changelog_replacing_unreleased_using_incremental_markdown_.md
similarity index 100%
rename from tests/commands/test_changelog_command/test_changelog_replacing_unreleased_using_incremental.md
rename to tests/commands/test_changelog_command/test_changelog_replacing_unreleased_using_incremental_markdown_.md
diff --git a/tests/commands/test_changelog_command/test_changelog_replacing_unreleased_using_incremental_restructuredtext_.rst b/tests/commands/test_changelog_command/test_changelog_replacing_unreleased_using_incremental_restructuredtext_.rst
new file mode 100644
index 0000000000..ca0077a2d8
--- /dev/null
+++ b/tests/commands/test_changelog_command/test_changelog_replacing_unreleased_using_incremental_restructuredtext_.rst
@@ -0,0 +1,25 @@
+Unreleased
+==========
+
+Feat
+----
+
+- add more stuff
+
+Fix
+---
+
+- mama gotta work
+
+0.2.0 (2022-08-14)
+==================
+
+Feat
+----
+
+- add new output
+
+Fix
+---
+
+- output glitch
diff --git a/tests/commands/test_changelog_command/test_changelog_replacing_unreleased_using_incremental_textile_.textile b/tests/commands/test_changelog_command/test_changelog_replacing_unreleased_using_incremental_textile_.textile
new file mode 100644
index 0000000000..07f2ba5ed0
--- /dev/null
+++ b/tests/commands/test_changelog_command/test_changelog_replacing_unreleased_using_incremental_textile_.textile
@@ -0,0 +1,19 @@
+h2. Unreleased
+
+h3. Feat
+
+- add more stuff
+
+h3. Fix
+
+- mama gotta work
+
+h2. 0.2.0 (2022-08-14)
+
+h3. Feat
+
+- add new output
+
+h3. Fix
+
+- output glitch
diff --git a/tests/conftest.py b/tests/conftest.py
index 980c4d053e..76d2e53fb7 100644
--- a/tests/conftest.py
+++ b/tests/conftest.py
@@ -13,6 +13,10 @@
from commitizen.config import BaseConfig
from commitizen.cz import registry
from commitizen.cz.base import BaseCommitizen
+from commitizen.changelog_formats import (
+ ChangelogFormat,
+ get_changelog_format,
+)
from tests.utils import create_file_and_commit
SIGNER = "GitHub Action"
@@ -225,3 +229,27 @@ def mock_plugin(mocker: MockerFixture, config: BaseConfig) -> BaseCommitizen:
mock = MockPlugin(config)
mocker.patch("commitizen.factory.commiter_factory", return_value=mock)
return mock
+
+
+SUPPORTED_FORMATS = ("markdown", "textile", "asciidoc", "restructuredtext")
+
+
+@pytest.fixture(params=SUPPORTED_FORMATS)
+def changelog_format(
+ config: BaseConfig, request: pytest.FixtureRequest
+) -> ChangelogFormat:
+ """For tests relying on formats specifics"""
+ format: str = request.param
+ config.settings["changelog_format"] = format
+ if "tmp_commitizen_project" in request.fixturenames:
+ tmp_commitizen_project = request.getfixturevalue("tmp_commitizen_project")
+ pyproject = tmp_commitizen_project / "pyproject.toml"
+ pyproject.write(f"{pyproject.read()}\n" f'changelog_format = "{format}"\n')
+ return get_changelog_format(config)
+
+
+@pytest.fixture
+def any_changelog_format(config: BaseConfig) -> ChangelogFormat:
+ """For test not relying on formats specifics, use the default"""
+ config.settings["changelog_format"] = defaults.CHANGELOG_FORMAT
+ return get_changelog_format(config)
diff --git a/tests/test_changelog.py b/tests/test_changelog.py
index 7b2d45683c..8aef10a31f 100644
--- a/tests/test_changelog.py
+++ b/tests/test_changelog.py
@@ -8,6 +8,7 @@
ConventionalCommitsCz,
)
from commitizen.exceptions import InvalidConfigurationError
+from commitizen.changelog_formats import ChangelogFormat
COMMITS_DATA = [
{
@@ -1140,52 +1141,61 @@ def test_order_changelog_tree_raises():
assert "Change types contain duplicates types" in str(excinfo)
-def test_render_changelog(gitcommits, tags, changelog_content):
+def test_render_changelog(
+ gitcommits, tags, changelog_content, any_changelog_format: ChangelogFormat
+):
parser = ConventionalCommitsCz.commit_parser
- changelog_pattern = ConventionalCommitsCz.bump_pattern
+ changelog_pattern = ConventionalCommitsCz.changelog_pattern
+ loader = ConventionalCommitsCz.template_loader
+ template = any_changelog_format.template
tree = changelog.generate_tree_from_commits(
gitcommits, tags, parser, changelog_pattern
)
- result = changelog.render_changelog(tree)
+ result = changelog.render_changelog(tree, loader, template)
assert result == changelog_content
def test_render_changelog_from_default_plugin_values(
- gitcommits, tags, changelog_content
+ gitcommits, tags, changelog_content, any_changelog_format: ChangelogFormat
):
parser = ConventionalCommitsCz.commit_parser
- changelog_pattern = ConventionalCommitsCz.bump_pattern
+ changelog_pattern = ConventionalCommitsCz.changelog_pattern
loader = ConventionalCommitsCz.template_loader
- template = ConventionalCommitsCz.template
+ template = any_changelog_format.template
tree = changelog.generate_tree_from_commits(
gitcommits, tags, parser, changelog_pattern
)
- result = changelog.render_changelog(tree, loader=loader, template=template)
+ result = changelog.render_changelog(tree, loader, template)
assert result == changelog_content
def test_render_changelog_override_loader(gitcommits, tags, tmp_path: Path):
loader = FileSystemLoader(tmp_path)
+ template = "tpl.j2"
tpl = "loader overridden"
- (tmp_path / changelog.DEFAULT_TEMPLATE).write_text(tpl)
+ (tmp_path / template).write_text(tpl)
parser = ConventionalCommitsCz.commit_parser
- changelog_pattern = ConventionalCommitsCz.bump_pattern
+ changelog_pattern = ConventionalCommitsCz.changelog_pattern
tree = changelog.generate_tree_from_commits(
gitcommits, tags, parser, changelog_pattern
)
- result = changelog.render_changelog(tree, loader=loader)
+ result = changelog.render_changelog(tree, loader, template)
assert result == tpl
-def test_render_changelog_override_template_from_cwd(gitcommits, tags, chdir: Path):
+def test_render_changelog_override_template_from_cwd(
+ gitcommits, tags, chdir: Path, any_changelog_format: ChangelogFormat
+):
tpl = "overridden from cwd"
- (chdir / changelog.DEFAULT_TEMPLATE).write_text(tpl)
+ template = any_changelog_format.template
+ (chdir / template).write_text(tpl)
parser = ConventionalCommitsCz.commit_parser
- changelog_pattern = ConventionalCommitsCz.bump_pattern
+ changelog_pattern = ConventionalCommitsCz.changelog_pattern
+ loader = ConventionalCommitsCz.template_loader
tree = changelog.generate_tree_from_commits(
gitcommits, tags, parser, changelog_pattern
)
- result = changelog.render_changelog(tree)
+ result = changelog.render_changelog(tree, loader, template)
assert result == tpl
@@ -1196,11 +1206,12 @@ def test_render_changelog_override_template_from_cwd_with_custom_name(
tpl_name = "tpl.j2"
(chdir / tpl_name).write_text(tpl)
parser = ConventionalCommitsCz.commit_parser
- changelog_pattern = ConventionalCommitsCz.bump_pattern
+ changelog_pattern = ConventionalCommitsCz.changelog_pattern
+ loader = ConventionalCommitsCz.template_loader
tree = changelog.generate_tree_from_commits(
gitcommits, tags, parser, changelog_pattern
)
- result = changelog.render_changelog(tree, template=tpl_name)
+ result = changelog.render_changelog(tree, loader, tpl_name)
assert result == tpl
@@ -1216,7 +1227,7 @@ def test_render_changelog_override_loader_and_template(
tree = changelog.generate_tree_from_commits(
gitcommits, tags, parser, changelog_pattern
)
- result = changelog.render_changelog(tree, loader=loader, template=tpl_name)
+ result = changelog.render_changelog(tree, loader, tpl_name)
assert result == tpl
@@ -1225,57 +1236,67 @@ def test_render_changelog_support_arbitrary_kwargs(gitcommits, tags, tmp_path: P
tpl_name = "tpl.j2"
(tmp_path / tpl_name).write_text("{{ key }}")
parser = ConventionalCommitsCz.commit_parser
- changelog_pattern = ConventionalCommitsCz.bump_pattern
+ changelog_pattern = ConventionalCommitsCz.changelog_pattern
tree = changelog.generate_tree_from_commits(
gitcommits, tags, parser, changelog_pattern
)
- result = changelog.render_changelog(
- tree, loader=loader, template=tpl_name, key="value"
- )
+ result = changelog.render_changelog(tree, loader, tpl_name, key="value")
assert result == "value"
-def test_render_changelog_unreleased(gitcommits):
+def test_render_changelog_unreleased(gitcommits, any_changelog_format: ChangelogFormat):
some_commits = gitcommits[:7]
parser = ConventionalCommitsCz.commit_parser
- changelog_pattern = ConventionalCommitsCz.bump_pattern
+ changelog_pattern = ConventionalCommitsCz.changelog_pattern
+ loader = ConventionalCommitsCz.template_loader
+ template = any_changelog_format.template
tree = changelog.generate_tree_from_commits(
some_commits, [], parser, changelog_pattern
)
- result = changelog.render_changelog(tree)
+ result = changelog.render_changelog(tree, loader, template)
assert "Unreleased" in result
-def test_render_changelog_tag_and_unreleased(gitcommits, tags):
+def test_render_changelog_tag_and_unreleased(
+ gitcommits, tags, any_changelog_format: ChangelogFormat
+):
some_commits = gitcommits[:7]
single_tag = [
tag for tag in tags if tag.rev == "56c8a8da84e42b526bcbe130bd194306f7c7e813"
]
parser = ConventionalCommitsCz.commit_parser
- changelog_pattern = ConventionalCommitsCz.bump_pattern
+ changelog_pattern = ConventionalCommitsCz.changelog_pattern
+ loader = ConventionalCommitsCz.template_loader
+ template = any_changelog_format.template
tree = changelog.generate_tree_from_commits(
some_commits, single_tag, parser, changelog_pattern
)
- result = changelog.render_changelog(tree)
+ result = changelog.render_changelog(tree, loader, template)
assert "Unreleased" in result
assert "## v1.1.1" in result
-def test_render_changelog_with_change_type(gitcommits, tags):
+def test_render_changelog_with_change_type(
+ gitcommits, tags, any_changelog_format: ChangelogFormat
+):
new_title = ":some-emoji: feature"
change_type_map = {"feat": new_title}
parser = ConventionalCommitsCz.commit_parser
- changelog_pattern = ConventionalCommitsCz.bump_pattern
+ changelog_pattern = ConventionalCommitsCz.changelog_pattern
+ loader = ConventionalCommitsCz.template_loader
+ template = any_changelog_format.template
tree = changelog.generate_tree_from_commits(
gitcommits, tags, parser, changelog_pattern, change_type_map=change_type_map
)
- result = changelog.render_changelog(tree)
+ result = changelog.render_changelog(tree, loader, template)
assert new_title in result
-def test_render_changelog_with_changelog_message_builder_hook(gitcommits, tags):
+def test_render_changelog_with_changelog_message_builder_hook(
+ gitcommits, tags, any_changelog_format: ChangelogFormat
+):
def changelog_message_builder_hook(message: dict, commit: git.GitCommit) -> dict:
message[
"message"
@@ -1283,7 +1304,9 @@ def changelog_message_builder_hook(message: dict, commit: git.GitCommit) -> dict
return message
parser = ConventionalCommitsCz.commit_parser
- changelog_pattern = ConventionalCommitsCz.bump_pattern
+ changelog_pattern = ConventionalCommitsCz.changelog_pattern
+ loader = ConventionalCommitsCz.template_loader
+ template = any_changelog_format.template
tree = changelog.generate_tree_from_commits(
gitcommits,
tags,
@@ -1291,7 +1314,7 @@ def changelog_message_builder_hook(message: dict, commit: git.GitCommit) -> dict
changelog_pattern,
changelog_message_builder_hook=changelog_message_builder_hook,
)
- result = changelog.render_changelog(tree)
+ result = changelog.render_changelog(tree, loader, template)
assert "[link](github.com/232323232) Commitizen author@cz.dev" in result
diff --git a/tests/test_changelog_format_asciidoc.py b/tests/test_changelog_format_asciidoc.py
new file mode 100644
index 0000000000..df9c28f9d7
--- /dev/null
+++ b/tests/test_changelog_format_asciidoc.py
@@ -0,0 +1,138 @@
+from __future__ import annotations
+
+from pathlib import Path
+
+import pytest
+
+from commitizen.changelog import Metadata
+from commitizen.config.base_config import BaseConfig
+from commitizen.changelog_formats.asciidoc import AsciiDoc
+
+
+CHANGELOG_A = """
+= Changelog
+
+All notable changes to this project will be documented in this file.
+
+The format is based on https://keepachangelog.com/en/1.0.0/[Keep a Changelog],
+and this project adheres to https://semver.org/spec/v2.0.0.html[Semantic Versioning].
+
+== [Unreleased]
+* Start using "changelog" over "change log" since it's the common usage.
+
+== [1.0.0] - 2017-06-20
+=== Added
+* New visual identity by https://github.com/tylerfortune8[@tylerfortune8].
+* Version navigation.
+""".strip()
+
+EXPECTED_A = Metadata(
+ latest_version="1.0.0",
+ latest_version_position=10,
+ unreleased_end=10,
+ unreleased_start=7,
+)
+
+
+CHANGELOG_B = """
+== [Unreleased]
+* Start using "changelog" over "change log" since it's the common usage.
+
+== 1.2.0
+""".strip()
+
+EXPECTED_B = Metadata(
+ latest_version="1.2.0",
+ latest_version_position=3,
+ unreleased_end=3,
+ unreleased_start=0,
+)
+
+
+CHANGELOG_C = """
+= Unreleased
+
+== v1.0.0
+"""
+EXPECTED_C = Metadata(
+ latest_version="1.0.0",
+ latest_version_position=3,
+ unreleased_end=3,
+ unreleased_start=1,
+)
+
+CHANGELOG_D = """
+== Unreleased
+* Start using "changelog" over "change log" since it's the common usage.
+"""
+
+EXPECTED_D = Metadata(
+ latest_version=None,
+ latest_version_position=None,
+ unreleased_end=2,
+ unreleased_start=1,
+)
+
+
+@pytest.fixture
+def format(config: BaseConfig) -> AsciiDoc:
+ return AsciiDoc(config)
+
+
+VERSIONS_EXAMPLES = [
+ ("== [1.0.0] - 2017-06-20", "1.0.0"),
+ (
+ "= https://github.com/angular/angular/compare/10.0.0-next.2...10.0.0-next.3[10.0.0-next.3] (2020-04-22)",
+ "10.0.0-next.3",
+ ),
+ ("=== 0.19.1 (Jan 7, 2020)", "0.19.1"),
+ ("== 1.0.0", "1.0.0"),
+ ("== v1.0.0", "1.0.0"),
+ ("== v1.0.0 - (2012-24-32)", "1.0.0"),
+ ("= version 2020.03.24", "2020.03.24"),
+ ("== [Unreleased]", None),
+ ("All notable changes to this project will be documented in this file.", None),
+ ("= Changelog", None),
+ ("=== Bug Fixes", None),
+]
+
+
+@pytest.mark.parametrize("line_from_changelog,output_version", VERSIONS_EXAMPLES)
+def test_changelog_detect_version(
+ line_from_changelog: str, output_version: str, format: AsciiDoc
+):
+ version = format.parse_version_from_title(line_from_changelog)
+ assert version == output_version
+
+
+TITLES_EXAMPLES = [
+ ("== [1.0.0] - 2017-06-20", 2),
+ ("== [Unreleased]", 2),
+ ("= Unreleased", 1),
+]
+
+
+@pytest.mark.parametrize("line_from_changelog,output_title", TITLES_EXAMPLES)
+def test_parse_title_type_of_line(
+ line_from_changelog: str, output_title: str, format: AsciiDoc
+):
+ title = format.parse_title_level(line_from_changelog)
+ assert title == output_title
+
+
+@pytest.mark.parametrize(
+ "content, expected",
+ (
+ pytest.param(CHANGELOG_A, EXPECTED_A, id="A"),
+ pytest.param(CHANGELOG_B, EXPECTED_B, id="B"),
+ pytest.param(CHANGELOG_C, EXPECTED_C, id="C"),
+ pytest.param(CHANGELOG_D, EXPECTED_D, id="D"),
+ ),
+)
+def test_get_matadata(
+ tmp_path: Path, format: AsciiDoc, content: str, expected: Metadata
+):
+ changelog = tmp_path / format.default_changelog_file
+ changelog.write_text(content)
+
+ assert format.get_metadata(str(changelog)) == expected
diff --git a/tests/test_changelog_format_markdown.py b/tests/test_changelog_format_markdown.py
new file mode 100644
index 0000000000..2e1ee69977
--- /dev/null
+++ b/tests/test_changelog_format_markdown.py
@@ -0,0 +1,138 @@
+from __future__ import annotations
+
+from pathlib import Path
+
+import pytest
+
+from commitizen.changelog import Metadata
+from commitizen.config.base_config import BaseConfig
+from commitizen.changelog_formats.markdown import Markdown
+
+
+CHANGELOG_A = """
+# Changelog
+
+All notable changes to this project will be documented in this file.
+
+The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
+and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
+
+## [Unreleased]
+- Start using "changelog" over "change log" since it's the common usage.
+
+## [1.0.0] - 2017-06-20
+### Added
+- New visual identity by [@tylerfortune8](https://github.com/tylerfortune8).
+- Version navigation.
+""".strip()
+
+EXPECTED_A = Metadata(
+ latest_version="1.0.0",
+ latest_version_position=10,
+ unreleased_end=10,
+ unreleased_start=7,
+)
+
+
+CHANGELOG_B = """
+## [Unreleased]
+- Start using "changelog" over "change log" since it's the common usage.
+
+## 1.2.0
+""".strip()
+
+EXPECTED_B = Metadata(
+ latest_version="1.2.0",
+ latest_version_position=3,
+ unreleased_end=3,
+ unreleased_start=0,
+)
+
+
+CHANGELOG_C = """
+# Unreleased
+
+## v1.0.0
+"""
+EXPECTED_C = Metadata(
+ latest_version="1.0.0",
+ latest_version_position=3,
+ unreleased_end=3,
+ unreleased_start=1,
+)
+
+CHANGELOG_D = """
+## Unreleased
+- Start using "changelog" over "change log" since it's the common usage.
+"""
+
+EXPECTED_D = Metadata(
+ latest_version=None,
+ latest_version_position=None,
+ unreleased_end=2,
+ unreleased_start=1,
+)
+
+
+@pytest.fixture
+def format(config: BaseConfig) -> Markdown:
+ return Markdown(config)
+
+
+VERSIONS_EXAMPLES = [
+ ("## [1.0.0] - 2017-06-20", "1.0.0"),
+ (
+ "# [10.0.0-next.3](https://github.com/angular/angular/compare/10.0.0-next.2...10.0.0-next.3) (2020-04-22)",
+ "10.0.0-next.3",
+ ),
+ ("### 0.19.1 (Jan 7, 2020)", "0.19.1"),
+ ("## 1.0.0", "1.0.0"),
+ ("## v1.0.0", "1.0.0"),
+ ("## v1.0.0 - (2012-24-32)", "1.0.0"),
+ ("# version 2020.03.24", "2020.03.24"),
+ ("## [Unreleased]", None),
+ ("All notable changes to this project will be documented in this file.", None),
+ ("# Changelog", None),
+ ("### Bug Fixes", None),
+]
+
+
+@pytest.mark.parametrize("line_from_changelog,output_version", VERSIONS_EXAMPLES)
+def test_changelog_detect_version(
+ line_from_changelog: str, output_version: str, format: Markdown
+):
+ version = format.parse_version_from_title(line_from_changelog)
+ assert version == output_version
+
+
+TITLES_EXAMPLES = [
+ ("## [1.0.0] - 2017-06-20", 2),
+ ("## [Unreleased]", 2),
+ ("# Unreleased", 1),
+]
+
+
+@pytest.mark.parametrize("line_from_changelog,output_title", TITLES_EXAMPLES)
+def test_parse_title_type_of_line(
+ line_from_changelog: str, output_title: str, format: Markdown
+):
+ title = format.parse_title_level(line_from_changelog)
+ assert title == output_title
+
+
+@pytest.mark.parametrize(
+ "content, expected",
+ (
+ pytest.param(CHANGELOG_A, EXPECTED_A, id="A"),
+ pytest.param(CHANGELOG_B, EXPECTED_B, id="B"),
+ pytest.param(CHANGELOG_C, EXPECTED_C, id="C"),
+ pytest.param(CHANGELOG_D, EXPECTED_D, id="D"),
+ ),
+)
+def test_get_matadata(
+ tmp_path: Path, format: Markdown, content: str, expected: Metadata
+):
+ changelog = tmp_path / format.default_changelog_file
+ changelog.write_text(content)
+
+ assert format.get_metadata(str(changelog)) == expected
diff --git a/tests/test_changelog_format_restructuredtext.py b/tests/test_changelog_format_restructuredtext.py
new file mode 100644
index 0000000000..7c5969f51d
--- /dev/null
+++ b/tests/test_changelog_format_restructuredtext.py
@@ -0,0 +1,310 @@
+from __future__ import annotations
+
+from pathlib import Path
+from textwrap import dedent
+from typing import TYPE_CHECKING
+
+import pytest
+
+from commitizen.changelog import Metadata
+from commitizen.config.base_config import BaseConfig
+from commitizen.changelog_formats.restructuredtext import RestructuredText
+
+if TYPE_CHECKING:
+ from _pytest.mark.structures import ParameterSet
+
+
+CASES: list[ParameterSet] = []
+
+
+def case(
+ id: str,
+ content: str,
+ latest_version: str | None = None,
+ latest_version_position: int | None = None,
+ unreleased_start: int | None = None,
+ unreleased_end: int | None = None,
+):
+ CASES.append(
+ pytest.param(
+ dedent(content).strip(),
+ Metadata(
+ latest_version=latest_version,
+ latest_version_position=latest_version_position,
+ unreleased_start=unreleased_start,
+ unreleased_end=unreleased_end,
+ ),
+ id=id,
+ )
+ )
+
+
+case(
+ "underlined title with intro and unreleased section",
+ """
+ Changelog
+ #########
+
+ All notable changes to this project will be documented in this file.
+
+ The format is based on `Keep a Changelog `,
+ and this project adheres to `Semantic Versioning `.
+
+ Unreleased
+ ==========
+ * Start using "changelog" over "change log" since it's the common usage.
+
+ 1.0.0 - 2017-06-20
+ ==================
+ Added
+ -----
+ * New visual identity by `@tylerfortune8 `.
+ * Version navigation.
+ """,
+ latest_version="1.0.0",
+ latest_version_position=12,
+ unreleased_start=8,
+ unreleased_end=12,
+)
+
+case(
+ "unreleased section without preamble",
+ """
+ Unreleased
+ ==========
+ * Start using "changelog" over "change log" since it's the common usage.
+
+ 1.2.0
+ =====
+ """,
+ latest_version="1.2.0",
+ latest_version_position=4,
+ unreleased_start=0,
+ unreleased_end=4,
+)
+
+case(
+ "basic underlined titles with v-prefixed version",
+ """
+ Unreleased
+ ==========
+
+ v1.0.0
+ ======
+ """,
+ latest_version="1.0.0",
+ latest_version_position=3,
+ unreleased_start=0,
+ unreleased_end=3,
+)
+
+case(
+ "intermediate section in unreleased",
+ """
+ Unreleased
+ ==========
+
+ intermediate
+ ------------
+
+ 1.0.0
+ =====
+ """,
+ latest_version="1.0.0",
+ latest_version_position=6,
+ unreleased_start=0,
+ unreleased_end=6,
+)
+
+case(
+ "weird section with different level than versions",
+ """
+ Unreleased
+ ##########
+
+ 1.0.0
+ =====
+ """,
+ latest_version="1.0.0",
+ latest_version_position=3,
+ unreleased_start=0,
+ unreleased_end=3,
+)
+
+case(
+ "overlined title without release and intro",
+ """
+ ==========
+ Unreleased
+ ==========
+ * Start using "changelog" over "change log" since it's the common usage.
+ """,
+ unreleased_start=0,
+ unreleased_end=4,
+)
+
+case(
+ "underlined title with date",
+ """
+ 1.0.0 - 2017-06-20
+ ==================
+ """,
+ latest_version="1.0.0",
+ latest_version_position=0,
+)
+
+
+UNDERLINED_TITLES = (
+ """
+ title
+ =====
+ """,
+ """
+ title
+ ======
+ """,
+ """
+ title
+ #####
+ """,
+ """
+ title
+ .....
+ """,
+ """
+ title
+ !!!!!
+ """,
+)
+
+NOT_UNDERLINED_TITLES = (
+ """
+ title
+ =.=.=
+ """,
+ """
+ title
+ ====
+ """,
+ """
+ title
+ aaaaa
+ """,
+ """
+ title
+
+ """,
+)
+
+
+OVERLINED_TITLES = (
+ """
+ =====
+ title
+ =====
+ """,
+ """
+ ======
+ title
+ ======
+ """,
+ """
+ #####
+ title
+ #####
+ """,
+ """
+ .....
+ title
+ .....
+ """,
+)
+
+NOT_OVERLINED_TITLES = (
+ """
+ ====
+ title
+ =====
+ """,
+ """
+ =====
+ title
+ ====
+ """,
+ """
+ ====
+ title
+ ====
+ """,
+ """
+ =====
+ title
+ #####
+ """,
+ """
+ #####
+ title
+ =====
+ """,
+ """
+ =.=.=
+ title
+ =====
+ """,
+ """
+ =====
+ title
+ =.=.=
+ """,
+ """
+
+ title
+ =====
+ """,
+ """
+ =====
+ title
+
+ """,
+ """
+ aaaaa
+ title
+ aaaaa
+ """,
+)
+
+
+@pytest.fixture
+def format(config: BaseConfig) -> RestructuredText:
+ return RestructuredText(config)
+
+
+@pytest.mark.parametrize("content, expected", CASES)
+def test_get_matadata(
+ tmp_path: Path, format: RestructuredText, content: str, expected: Metadata
+):
+ changelog = tmp_path / format.default_changelog_file
+ changelog.write_text(content)
+
+ assert format.get_metadata(str(changelog)) == expected
+
+
+@pytest.mark.parametrize(
+ "text, expected",
+ [(text, True) for text in UNDERLINED_TITLES]
+ + [(text, False) for text in NOT_UNDERLINED_TITLES],
+)
+def test_is_underlined_title(format: RestructuredText, text: str, expected: bool):
+ _, first, second = dedent(text).splitlines()
+ assert format.is_underlined_title(first, second) is expected
+
+
+@pytest.mark.parametrize(
+ "text, expected",
+ [(text, True) for text in OVERLINED_TITLES]
+ + [(text, False) for text in NOT_OVERLINED_TITLES],
+)
+def test_is_overlined_title(format: RestructuredText, text: str, expected: bool):
+ _, first, second, third = dedent(text).splitlines()
+
+ assert format.is_overlined_title(first, second, third) is expected
diff --git a/tests/test_changelog_format_textile.py b/tests/test_changelog_format_textile.py
new file mode 100644
index 0000000000..5176243ba0
--- /dev/null
+++ b/tests/test_changelog_format_textile.py
@@ -0,0 +1,138 @@
+from __future__ import annotations
+
+from pathlib import Path
+
+import pytest
+
+from commitizen.changelog import Metadata
+from commitizen.config.base_config import BaseConfig
+from commitizen.changelog_formats.textile import Textile
+
+
+CHANGELOG_A = """
+h1. Changelog
+
+All notable changes to this project will be documented in this file.
+
+The format is based on "Keep a Changelog":https://keepachangelog.com/en/1.0.0/,
+and this project adheres to "Semantic Versioning":https://semver.org/spec/v2.0.0.html.
+
+h2. [Unreleased]
+- Start using "changelog" over "change log" since it's the common usage.
+
+h2. [1.0.0] - 2017-06-20
+h3. Added
+* New visual identity by [@tylerfortune8](https://github.com/tylerfortune8).
+* Version navigation.
+""".strip()
+
+EXPECTED_A = Metadata(
+ latest_version="1.0.0",
+ latest_version_position=10,
+ unreleased_end=10,
+ unreleased_start=7,
+)
+
+
+CHANGELOG_B = """
+h2. [Unreleased]
+* Start using "changelog" over "change log" since it's the common usage.
+
+h2. 1.2.0
+""".strip()
+
+EXPECTED_B = Metadata(
+ latest_version="1.2.0",
+ latest_version_position=3,
+ unreleased_end=3,
+ unreleased_start=0,
+)
+
+
+CHANGELOG_C = """
+h1. Unreleased
+
+h2. v1.0.0
+"""
+EXPECTED_C = Metadata(
+ latest_version="1.0.0",
+ latest_version_position=3,
+ unreleased_end=3,
+ unreleased_start=1,
+)
+
+CHANGELOG_D = """
+h2. Unreleased
+* Start using "changelog" over "change log" since it's the common usage.
+"""
+
+EXPECTED_D = Metadata(
+ latest_version=None,
+ latest_version_position=None,
+ unreleased_end=2,
+ unreleased_start=1,
+)
+
+
+@pytest.fixture
+def format(config: BaseConfig) -> Textile:
+ return Textile(config)
+
+
+VERSIONS_EXAMPLES = [
+ ("h2. [1.0.0] - 2017-06-20", "1.0.0"),
+ (
+ 'h1. "10.0.0-next.3":https://github.com/angular/angular/compare/10.0.0-next.2...10.0.0-next.3 (2020-04-22)',
+ "10.0.0-next.3",
+ ),
+ ("h3. 0.19.1 (Jan 7, 2020)", "0.19.1"),
+ ("h2. 1.0.0", "1.0.0"),
+ ("h2. v1.0.0", "1.0.0"),
+ ("h2. v1.0.0 - (2012-24-32)", "1.0.0"),
+ ("h1. version 2020.03.24", "2020.03.24"),
+ ("h2. [Unreleased]", None),
+ ("All notable changes to this project will be documented in this file.", None),
+ ("h1. Changelog", None),
+ ("h3. Bug Fixes", None),
+]
+
+
+@pytest.mark.parametrize("line_from_changelog,output_version", VERSIONS_EXAMPLES)
+def test_changelog_detect_version(
+ line_from_changelog: str, output_version: str, format: Textile
+):
+ version = format.parse_version_from_title(line_from_changelog)
+ assert version == output_version
+
+
+TITLES_EXAMPLES = [
+ ("h2. [1.0.0] - 2017-06-20", 2),
+ ("h2. [Unreleased]", 2),
+ ("h1. Unreleased", 1),
+]
+
+
+@pytest.mark.parametrize("line_from_changelog,output_title", TITLES_EXAMPLES)
+def test_parse_title_type_of_line(
+ line_from_changelog: str, output_title: str, format: Textile
+):
+ title = format.parse_title_level(line_from_changelog)
+ assert title == output_title
+
+
+@pytest.mark.parametrize(
+ "content, expected",
+ (
+ pytest.param(CHANGELOG_A, EXPECTED_A, id="A"),
+ pytest.param(CHANGELOG_B, EXPECTED_B, id="B"),
+ pytest.param(CHANGELOG_C, EXPECTED_C, id="C"),
+ pytest.param(CHANGELOG_D, EXPECTED_D, id="D"),
+ ),
+)
+def test_get_matadata(
+ tmp_path: Path, format: Textile, content: str, expected: Metadata
+):
+ changelog = tmp_path / format.default_changelog_file
+ changelog.write_text(content)
+
+ assert format.get_metadata(str(changelog)) == expected
diff --git a/tests/test_changelog_formats.py b/tests/test_changelog_formats.py
new file mode 100644
index 0000000000..7da87f16ca
--- /dev/null
+++ b/tests/test_changelog_formats.py
@@ -0,0 +1,58 @@
+from __future__ import annotations
+
+import pytest
+from commitizen import defaults
+from commitizen.config.base_config import BaseConfig
+from commitizen.changelog_formats import (
+ KNOWN_CHANGELOG_FORMATS,
+ ChangelogFormat,
+ get_changelog_format,
+ guess_changelog_format,
+)
+from commitizen.exceptions import ChangelogFormatUnknown
+
+
+@pytest.mark.parametrize("format", KNOWN_CHANGELOG_FORMATS.values())
+def test_guess_format(format: type[ChangelogFormat]):
+ assert guess_changelog_format(f"CHANGELOG.{format.extension}") is format
+ for ext in format.alternative_extensions:
+ assert guess_changelog_format(f"CHANGELOG.{ext}") is format
+
+
+@pytest.mark.parametrize("filename", ("CHANGELOG", "NEWS", "file.unknown", None))
+def test_guess_format_unknown(filename: str):
+ assert guess_changelog_format(filename) is None
+
+
+@pytest.mark.parametrize(
+ "name, expected",
+ [
+ pytest.param(name, format, id=name)
+ for name, format in KNOWN_CHANGELOG_FORMATS.items()
+ ],
+)
+def test_get_format(config: BaseConfig, name: str, expected: type[ChangelogFormat]):
+ config.settings["changelog_format"] = name
+ assert isinstance(get_changelog_format(config), expected)
+
+
+@pytest.mark.parametrize("filename", (None, ""))
+def test_get_format_empty_filename(config: BaseConfig, filename: str | None):
+ config.settings["changelog_format"] = defaults.CHANGELOG_FORMAT
+ assert isinstance(
+ get_changelog_format(config, filename),
+ KNOWN_CHANGELOG_FORMATS[defaults.CHANGELOG_FORMAT],
+ )
+
+
+@pytest.mark.parametrize("filename", (None, ""))
+def test_get_format_empty_filename_no_setting(config: BaseConfig, filename: str | None):
+ config.settings["changelog_format"] = None
+ with pytest.raises(ChangelogFormatUnknown):
+ get_changelog_format(config, filename)
+
+
+@pytest.mark.parametrize("filename", ("extensionless", "file.unknown"))
+def test_get_format_unknown(config: BaseConfig, filename: str | None):
+ with pytest.raises(ChangelogFormatUnknown):
+ get_changelog_format(config, filename)
diff --git a/tests/test_changelog_meta.py b/tests/test_changelog_meta.py
deleted file mode 100644
index 4b3b9caa01..0000000000
--- a/tests/test_changelog_meta.py
+++ /dev/null
@@ -1,165 +0,0 @@
-import os
-
-import pytest
-
-from commitizen import changelog
-
-CHANGELOG_A = """
-# Changelog
-
-All notable changes to this project will be documented in this file.
-
-The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
-and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
-
-## [Unreleased]
-- Start using "changelog" over "change log" since it's the common usage.
-
-## [1.0.0] - 2017-06-20
-### Added
-- New visual identity by [@tylerfortune8](https://github.com/tylerfortune8).
-- Version navigation.
-""".strip()
-
-CHANGELOG_B = """
-## [Unreleased]
-- Start using "changelog" over "change log" since it's the common usage.
-
-## 1.2.0
-""".strip()
-
-CHANGELOG_C = """
-# Unreleased
-
-## v1.0.0
-"""
-
-CHANGELOG_D = """
-## Unreleased
-- Start using "changelog" over "change log" since it's the common usage.
-"""
-
-
-@pytest.fixture
-def changelog_a_file():
- changelog_path = "tests/CHANGELOG.md"
-
- with open(changelog_path, "w", encoding="utf-8") as f:
- f.write(CHANGELOG_A)
-
- yield changelog_path
-
- os.remove(changelog_path)
-
-
-@pytest.fixture
-def changelog_b_file():
- changelog_path = "tests/CHANGELOG.md"
-
- with open(changelog_path, "w", encoding="utf-8") as f:
- f.write(CHANGELOG_B)
-
- yield changelog_path
-
- os.remove(changelog_path)
-
-
-@pytest.fixture
-def changelog_c_file():
- changelog_path = "tests/CHANGELOG.md"
-
- with open(changelog_path, "w", encoding="utf-8") as f:
- f.write(CHANGELOG_C)
-
- yield changelog_path
-
- os.remove(changelog_path)
-
-
-@pytest.fixture
-def changelog_d_file():
- changelog_path = "tests/CHANGELOG.md"
-
- with open(changelog_path, "w", encoding="utf-8") as f:
- f.write(CHANGELOG_D)
-
- yield changelog_path
-
- os.remove(changelog_path)
-
-
-VERSIONS_EXAMPLES = [
- ("## [1.0.0] - 2017-06-20", "1.0.0"),
- (
- "# [10.0.0-next.3](https://github.com/angular/angular/compare/10.0.0-next.2...10.0.0-next.3) (2020-04-22)",
- "10.0.0-next.3",
- ),
- ("### 0.19.1 (Jan 7, 2020)", "0.19.1"),
- ("## 1.0.0", "1.0.0"),
- ("## v1.0.0", "1.0.0"),
- ("## v1.0.0 - (2012-24-32)", "1.0.0"),
- ("# version 2020.03.24", "2020.03.24"),
- ("## [Unreleased]", None),
- ("All notable changes to this project will be documented in this file.", None),
- ("# Changelog", None),
- ("### Bug Fixes", None),
-]
-
-
-@pytest.mark.parametrize("line_from_changelog,output_version", VERSIONS_EXAMPLES)
-def test_changelog_detect_version(line_from_changelog, output_version):
- version = changelog.parse_version_from_markdown(line_from_changelog)
- assert version == output_version
-
-
-TITLES_EXAMPLES = [
- ("## [1.0.0] - 2017-06-20", "##"),
- ("## [Unreleased]", "##"),
- ("# Unreleased", "#"),
-]
-
-
-@pytest.mark.parametrize("line_from_changelog,output_title", TITLES_EXAMPLES)
-def test_parse_title_type_of_line(line_from_changelog, output_title):
- title = changelog.parse_title_type_of_line(line_from_changelog)
- assert title == output_title
-
-
-def test_get_metadata_from_a(changelog_a_file):
- meta = changelog.get_metadata(changelog_a_file, encoding="utf-8")
- assert meta == {
- "latest_version": "1.0.0",
- "latest_version_position": 10,
- "unreleased_end": 10,
- "unreleased_start": 7,
- }
-
-
-def test_get_metadata_from_b(changelog_b_file):
- meta = changelog.get_metadata(changelog_b_file, encoding="utf-8")
- assert meta == {
- "latest_version": "1.2.0",
- "latest_version_position": 3,
- "unreleased_end": 3,
- "unreleased_start": 0,
- }
-
-
-def test_get_metadata_from_c(changelog_c_file):
- meta = changelog.get_metadata(changelog_c_file, encoding="utf-8")
- assert meta == {
- "latest_version": "1.0.0",
- "latest_version_position": 3,
- "unreleased_end": 3,
- "unreleased_start": 1,
- }
-
-
-def test_get_metadata_from_d(changelog_d_file):
- meta = changelog.get_metadata(changelog_d_file, encoding="utf-8")
- assert meta == {
- "latest_version": None,
- "latest_version_position": None,
- "unreleased_end": 2,
- "unreleased_start": 1,
- }
diff --git a/tests/test_changelog_parser.py b/tests/test_changelog_parser.py
deleted file mode 100644
index f0f413e7b2..0000000000
--- a/tests/test_changelog_parser.py
+++ /dev/null
@@ -1,196 +0,0 @@
-import os
-
-import pytest
-
-from commitizen import changelog_parser
-
-CHANGELOG_TEMPLATE = """
-## 1.0.0 (2019-07-12)
-
-### Fix
-
-- issue in poetry add preventing the installation in py36
-- **users**: lorem ipsum apap
-
-### Feat
-
-- it is possible to specify a pattern to be matched in configuration files bump.
-
-## 0.9 (2019-07-11)
-
-### Fix
-
-- holis
-
-"""
-
-
-@pytest.fixture
-def changelog_content() -> str:
- changelog_path = "tests/CHANGELOG_FOR_TEST.md"
- with open(changelog_path, encoding="utf-8") as f:
- return f.read()
-
-
-@pytest.fixture
-def existing_changelog_file(tmpdir):
- with tmpdir.as_cwd():
- changelog_path = os.path.join(os.getcwd(), "CHANGELOG.md")
- # changelog_path = "tests/CHANGELOG.md"
-
- with open(changelog_path, "w", encoding="utf-8") as f:
- f.write(CHANGELOG_TEMPLATE)
-
- yield changelog_path
-
- os.remove(changelog_path)
-
-
-def test_read_changelog_blocks(existing_changelog_file):
- blocks = changelog_parser.find_version_blocks(
- existing_changelog_file, encoding="utf-8"
- )
- blocks = list(blocks)
- amount_of_blocks = len(blocks)
- assert amount_of_blocks == 2
-
-
-VERSION_CASES: list = [
- ("## 1.0.0 (2019-07-12)", {"version": "1.0.0", "date": "2019-07-12"}),
- ("## 2.3.0a0", {"version": "2.3.0a0", "date": None}),
- ("## 0.10.0a0", {"version": "0.10.0a0", "date": None}),
- ("## 1.0.0rc0", {"version": "1.0.0rc0", "date": None}),
- ("## 1beta", {"version": "1beta", "date": None}),
- (
- "## 1.0.0rc1+e20d7b57f3eb (2019-3-24)",
- {"version": "1.0.0rc1+e20d7b57f3eb", "date": "2019-3-24"},
- ),
- ("### Bug fixes", {}),
- ("- issue in poetry add preventing the installation in py36", {}),
-]
-
-CATEGORIES_CASES: list = [
- ("## 1.0.0 (2019-07-12)", {}),
- ("## 2.3.0a0", {}),
- ("### Bug fixes", {"change_type": "Bug fixes"}),
- ("### Features", {"change_type": "Features"}),
- ("- issue in poetry add preventing the installation in py36", {}),
-]
-
-CATEGORIES_TRANSFORMATIONS: list = [
- ("Bug fixes", "fix"),
- ("Features", "feat"),
- ("BREAKING CHANGES", "BREAKING CHANGES"),
-]
-
-MESSAGES_CASES: list = [
- ("## 1.0.0 (2019-07-12)", {}),
- ("## 2.3.0a0", {}),
- ("### Fix", {}),
- (
- "- name no longer accept invalid chars",
- {
- "message": "name no longer accept invalid chars",
- "scope": None,
- "breaking": None,
- },
- ),
- (
- "- **users**: lorem ipsum apap",
- {"message": "lorem ipsum apap", "scope": "users", "breaking": None},
- ),
-]
-
-
-@pytest.mark.parametrize("test_input,expected", VERSION_CASES)
-def test_parse_md_version(test_input, expected):
- assert changelog_parser.parse_md_version(test_input) == expected
-
-
-@pytest.mark.parametrize("test_input,expected", CATEGORIES_CASES)
-def test_parse_md_change_type(test_input, expected):
- assert changelog_parser.parse_md_change_type(test_input) == expected
-
-
-@pytest.mark.parametrize("test_input,expected", CATEGORIES_TRANSFORMATIONS)
-def test_transform_change_type(test_input, expected):
- assert changelog_parser.transform_change_type(test_input) == expected
-
-
-@pytest.mark.parametrize("test_input,expected", MESSAGES_CASES)
-def test_parse_md_message(test_input, expected):
- assert changelog_parser.parse_md_message(test_input) == expected
-
-
-def test_transform_change_type_fail():
- with pytest.raises(ValueError) as excinfo:
- changelog_parser.transform_change_type("Bugs")
- assert "Could not match a change_type" in str(excinfo.value)
-
-
-def test_generate_block_tree(existing_changelog_file):
- blocks = changelog_parser.find_version_blocks(
- existing_changelog_file, encoding="utf-8"
- )
- block = next(blocks)
- tree = changelog_parser.generate_block_tree(block)
- assert tree == {
- "changes": {
- "fix": [
- {
- "scope": None,
- "breaking": None,
- "message": "issue in poetry add preventing the installation in py36",
- },
- {"scope": "users", "breaking": None, "message": "lorem ipsum apap"},
- ],
- "feat": [
- {
- "scope": None,
- "breaking": None,
- "message": (
- "it is possible to specify a pattern to be matched "
- "in configuration files bump."
- ),
- }
- ],
- },
- "version": "1.0.0",
- "date": "2019-07-12",
- }
-
-
-def test_generate_full_tree(existing_changelog_file):
- blocks = changelog_parser.find_version_blocks(
- existing_changelog_file, encoding="utf-8"
- )
- tree = list(changelog_parser.generate_full_tree(blocks))
-
- assert tree == [
- {
- "changes": {
- "fix": [
- {
- "scope": None,
- "message": "issue in poetry add preventing the installation in py36",
- "breaking": None,
- },
- {"scope": "users", "message": "lorem ipsum apap", "breaking": None},
- ],
- "feat": [
- {
- "scope": None,
- "message": "it is possible to specify a pattern to be matched in configuration files bump.",
- "breaking": None,
- }
- ],
- },
- "version": "1.0.0",
- "date": "2019-07-12",
- },
- {
- "changes": {"fix": [{"scope": None, "message": "holis", "breaking": None}]},
- "version": "0.9",
- "date": "2019-07-11",
- },
- ]
diff --git a/tests/test_conf.py b/tests/test_conf.py
index 21a48fd2d6..dcac8e015c 100644
--- a/tests/test_conf.py
+++ b/tests/test_conf.py
@@ -57,6 +57,7 @@
"version_files": ["commitizen/__version__.py", "pyproject.toml"],
"style": [["pointer", "reverse"], ["question", "underline"]],
"changelog_file": "CHANGELOG.md",
+ "changelog_format": None,
"changelog_incremental": False,
"changelog_start_rev": None,
"changelog_merge_prerelease": False,
@@ -84,6 +85,7 @@
"version_files": ["commitizen/__version__.py", "pyproject.toml"],
"style": [["pointer", "reverse"], ["question", "underline"]],
"changelog_file": "CHANGELOG.md",
+ "changelog_format": None,
"changelog_incremental": False,
"changelog_start_rev": None,
"changelog_merge_prerelease": False,
@@ -99,16 +101,6 @@
"extras": {},
}
-_read_settings = {
- "name": "cz_jira",
- "version": "1.0.0",
- "version_files": ["commitizen/__version__.py", "pyproject.toml"],
- "style": [["pointer", "reverse"], ["question", "underline"]],
- "changelog_file": "CHANGELOG.md",
- "pre_bump_hooks": ["scripts/generate_documentation.sh"],
- "post_bump_hooks": ["scripts/slack_notification.sh"],
-}
-
@pytest.fixture
def config_files_manager(request, tmpdir):