From e61aceb57502c2ca4677ccd22f30f7e70e772117 Mon Sep 17 00:00:00 2001 From: Axel H Date: Sat, 29 Apr 2023 20:14:52 +0200 Subject: [PATCH] feat(formats): expose some new customizable changelog formats on the `commitizen.changelog_format` endpoint (Textile, AsciiDoc and RestructuredText) --- commitizen/changelog.py | 112 ++----- commitizen/changelog_formats/__init__.py | 90 +++++ commitizen/changelog_formats/asciidoc.py | 27 ++ commitizen/changelog_formats/base.py | 81 +++++ commitizen/changelog_formats/markdown.py | 28 ++ .../changelog_formats/restructuredtext.py | 95 ++++++ commitizen/changelog_formats/textile.py | 25 ++ commitizen/changelog_parser.py | 135 -------- commitizen/cli.py | 19 +- commitizen/commands/bump.py | 11 +- commitizen/commands/changelog.py | 39 +-- commitizen/cz/base.py | 7 +- .../cz/conventional_commits/__init__.py | 2 +- commitizen/defaults.py | 8 +- commitizen/exceptions.py | 6 + commitizen/templates/CHANGELOG.adoc.j2 | 19 ++ commitizen/templates/CHANGELOG.rst.j2 | 23 ++ commitizen/templates/CHANGELOG.textile.j2 | 19 ++ docs/config.md | 8 + docs/exit_codes.md | 5 +- pyproject.toml | 6 + tests/commands/test_bump_command.py | 21 +- tests/commands/test_changelog_command.py | 129 ++++++-- .../test_changelog_from_start_asciidoc_.adoc | 9 + ...=> test_changelog_from_start_markdown_.md} | 0 ...changelog_from_start_restructuredtext_.rst | 12 + ...test_changelog_from_start_textile_.textile | 9 + ...nreleased_using_incremental_asciidoc_.adoc | 19 ++ ...unreleased_using_incremental_markdown_.md} | 0 ...ed_using_incremental_restructuredtext_.rst | 25 ++ ...eleased_using_incremental_textile_.textile | 19 ++ tests/conftest.py | 28 ++ tests/test_changelog.py | 89 +++-- tests/test_changelog_format_asciidoc.py | 138 ++++++++ tests/test_changelog_format_markdown.py | 138 ++++++++ .../test_changelog_format_restructuredtext.py | 310 ++++++++++++++++++ tests/test_changelog_format_textile.py | 138 ++++++++ tests/test_changelog_formats.py | 58 ++++ tests/test_changelog_meta.py | 165 ---------- tests/test_changelog_parser.py | 196 ----------- tests/test_conf.py | 12 +- 41 files changed, 1592 insertions(+), 688 deletions(-) create mode 100644 commitizen/changelog_formats/__init__.py create mode 100644 commitizen/changelog_formats/asciidoc.py create mode 100644 commitizen/changelog_formats/base.py create mode 100644 commitizen/changelog_formats/markdown.py create mode 100644 commitizen/changelog_formats/restructuredtext.py create mode 100644 commitizen/changelog_formats/textile.py delete mode 100644 commitizen/changelog_parser.py create mode 100644 commitizen/templates/CHANGELOG.adoc.j2 create mode 100644 commitizen/templates/CHANGELOG.rst.j2 create mode 100644 commitizen/templates/CHANGELOG.textile.j2 create mode 100644 tests/commands/test_changelog_command/test_changelog_from_start_asciidoc_.adoc rename tests/commands/test_changelog_command/{test_changelog_from_start.md => test_changelog_from_start_markdown_.md} (100%) create mode 100644 tests/commands/test_changelog_command/test_changelog_from_start_restructuredtext_.rst create mode 100644 tests/commands/test_changelog_command/test_changelog_from_start_textile_.textile create mode 100644 tests/commands/test_changelog_command/test_changelog_replacing_unreleased_using_incremental_asciidoc_.adoc rename tests/commands/test_changelog_command/{test_changelog_replacing_unreleased_using_incremental.md => test_changelog_replacing_unreleased_using_incremental_markdown_.md} (100%) create mode 100644 tests/commands/test_changelog_command/test_changelog_replacing_unreleased_using_incremental_restructuredtext_.rst create mode 100644 tests/commands/test_changelog_command/test_changelog_replacing_unreleased_using_incremental_textile_.textile create mode 100644 tests/test_changelog_format_asciidoc.py create mode 100644 tests/test_changelog_format_markdown.py create mode 100644 tests/test_changelog_format_restructuredtext.py create mode 100644 tests/test_changelog_format_textile.py create mode 100644 tests/test_changelog_formats.py delete mode 100644 tests/test_changelog_meta.py delete mode 100644 tests/test_changelog_parser.py diff --git a/commitizen/changelog.py b/commitizen/changelog.py index e839efca8..cd9e76a32 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 000000000..85df4b514 --- /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 000000000..d738926f6 --- /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<level>=+) (?P<title>.*)$") + + 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 000000000..d0dfd9ec5 --- /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 000000000..a5a0f42de --- /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<level>#+) (?P<title>.*)$") + + 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 000000000..a252fe151 --- /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 000000000..80118cdb3 --- /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<level>\d)\. (?P<title>.*)$") + + 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 51b39fb95..000000000 --- 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<version>[a-zA-Z0-9.+]+)\s?\(?(?P<date>[0-9-]+)?\)?" -MD_CHANGE_TYPE_RE = r"^###\s(?P<change_type>[a-zA-Z0-9.+\s]+)" -MD_MESSAGE_RE = ( - r"^-\s(\*{2}(?P<scope>[a-zA-Z0-9]+)\*{2}:\s)?(?P<message>.+)(?P<breaking>!)?" -) -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 ce0d72b70..19d6b1b80 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 3a942d79d..f1b681356 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 fb58161d4..8b3c30963 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 f0cf4aa58..d9bf5ea4a 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 4ee406ca1..52624d2dd 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 59b867c12..7aa2c793c 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"^((?P<change_type>feat|fix|refactor|perf|BREAKING CHANGE)(?:\((?P<scope>[^()\r\n]*)\)|\()?(?P<breaking>!)?|\w+!):\s(?P<message>.*)?" # noqa -version_parser = r"(?P<version>([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 467582ee6..31b867b8f 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 000000000..fe16c5de3 --- /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 000000000..4287108b5 --- /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 000000000..db55f4caa --- /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 2ae2b5cc3..391c20f0f 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 c7b510ea3..af9cb8362 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 9e37e6b4e..48133f1e1 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 a97faee2b..0ae7d1e50 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 f474a1b27..a0183e1cd 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 000000000..842e120ba --- /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 000000000..555f5bc64 --- /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 000000000..e71fb99cf --- /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 000000000..2e789bcf2 --- /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 000000000..ca0077a2d --- /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 000000000..07f2ba5ed --- /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 980c4d053..76d2e53fb 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 7b2d45683..8aef10a31 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 000000000..df9c28f9d --- /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 000000000..2e1ee6997 --- /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 000000000..7c5969f51 --- /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 <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. + """, + 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 000000000..5176243ba --- /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 000000000..7da87f16c --- /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 4b3b9caa0..000000000 --- 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 f0f413e7b..000000000 --- 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 21a48fd2d..dcac8e015 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):