diff --git a/commitizen/changelog.py b/commitizen/changelog.py index 8c86596d1c..cd9e76a320 100644 --- a/commitizen/changelog.py +++ b/commitizen/changelog.py @@ -26,30 +26,46 @@ """ 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 jinja2 import Environment, PackageLoader +from typing import TYPE_CHECKING, Callable, Iterable + +from jinja2 import ( + BaseLoader, + ChoiceLoader, + Environment, + FileSystemLoader, + 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 +@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: return next((tag for tag in tags if tag.rev == commit.rev), None) @@ -187,84 +203,31 @@ def order_changelog_tree(tree: Iterable, change_type_order: list[str]) -> Iterab return sorted_tree -def render_changelog(tree: Iterable) -> str: - loader = PackageLoader("commitizen", "templates") +def get_changelog_template(loader: BaseLoader, template: str) -> Template: + loader = ChoiceLoader( + [ + FileSystemLoader("."), + loader, + ] + ) env = Environment(loader=loader, trim_blocks=True) - jinja_template = env.get_template("keep_a_changelog_template.j2") - changelog: str = jinja_template.render(tree=tree) - return changelog - + return env.get_template(template) -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 render_changelog( + tree: Iterable, + loader: BaseLoader, + template: str, + **kwargs, +) -> str: + jinja_template = get_changelog_template(loader, template) + changelog: str = jinja_template.render(tree=tree, **kwargs) + return changelog -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 @@ -278,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<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 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<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 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<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 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<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 212c89b6f7..19d6b1b80a 100644 --- a/commitizen/cli.py +++ b/commitizen/cli.py @@ -3,9 +3,11 @@ import argparse import logging import sys +from copy import deepcopy from functools import partial from pathlib import Path from types import TracebackType +from typing import Any, Sequence import argcomplete from decli import cli @@ -20,6 +22,67 @@ ) logger = logging.getLogger(__name__) + + +class ParseKwargs(argparse.Action): + """ + Parse arguments in the for `key=value`. + + Quoted strings are automatically unquoted. + Can be submitted multiple times: + + ex: + -k key=value -k double-quotes="value" -k single-quotes='value' + + will result in + + namespace["opt"] == { + "key": "value", + "double-quotes": "value", + "single-quotes": "value", + } + """ + + 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) + + +tpl_arguments = ( + { + "name": ["--template", "-t"], + "help": ( + "changelog template file name " + "(relative to the current working directory)" + ), + }, + { + "name": ["--extra", "-e"], + "action": ParseKwargs, + "dest": "extras", + "metavar": "EXTRA", + "help": "a changelog extra variable (in the form 'key=value')", + }, +) + data = { "prog": "cz", "description": ( @@ -210,6 +273,11 @@ "default": None, "help": "keep major version at zero, even for breaking changes", }, + *deepcopy(tpl_arguments), + { + "name": "--file-name", + "help": "file name of changelog (default: 'CHANGELOG.md')", + }, { "name": ["--prerelease-offset"], "type": int, @@ -299,6 +367,12 @@ "default": None, "choices": version_schemes.KNOWN_SCHEMES, }, + { + "name": "--export-template", + "default": None, + "help": "Export the changelog template into this file instead of rendering it", + }, + *deepcopy(tpl_arguments), ], }, { diff --git a/commitizen/commands/bump.py b/commitizen/commands/bump.py index 346673c505..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 @@ -51,6 +52,8 @@ def __init__(self, config: BaseConfig, arguments: dict): "annotated_tag", "major_version_zero", "prerelease_offset", + "template", + "file_name", ] if arguments[key] is not None }, @@ -77,6 +80,17 @@ def __init__(self, config: BaseConfig, arguments: dict): self.scheme = get_version_scheme( self.config, arguments["version_scheme"] or deprecated_version_type ) + 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: """Check if reading the whole git tree up to HEAD is needed.""" @@ -259,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, }, @@ -273,6 +289,9 @@ def __call__(self): # noqa: C901 "unreleased_version": new_tag_version, "incremental": True, "dry_run": dry_run, + "template": self.template, + "extras": self.extras, + "file_name": self.file_name, }, ) changelog_cmd() diff --git a/commitizen/commands/changelog.py b/commitizen/commands/changelog.py index 6279d65e91..8b3c309636 100644 --- a/commitizen/commands/changelog.py +++ b/commitizen/commands/changelog.py @@ -3,9 +3,11 @@ import os.path from difflib import SequenceMatcher from operator import itemgetter +from pathlib import Path from typing import Callable from commitizen import bump, changelog, defaults, factory, git, out + from commitizen.config import BaseConfig from commitizen.exceptions import ( DryRunExit, @@ -15,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 @@ -36,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" ) @@ -65,6 +76,14 @@ def __init__(self, config: BaseConfig, args): "merge_prerelease" ) or self.config.settings.get("changelog_merge_prerelease") + self.template = ( + args.get("template") + or self.config.settings.get("template") + or self.changelog_format.template + ) + self.extras = args.get("extras") or {} + self.export_template_to = args.get("export_template") + def _find_incremental_rev(self, latest_version: str, tags: list[GitTag]) -> str: """Try to find the 'start_rev'. @@ -92,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 @@ -115,18 +127,26 @@ def write_changelog( changelog_out = changelog_hook(changelog_out, partial_changelog) changelog_file.write(changelog_out) + def export_template(self): + tpl = changelog.get_changelog_template(self.cz.template_loader, self.template) + src = Path(tpl.filename) + Path(self.export_template_to).write_text(src.read_text()) + def __call__(self): commit_parser = self.cz.commit_parser 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: + return self.export_template() + if not changelog_pattern or not commit_parser: raise NoPatternMapError( f"'{self.config.settings['name']}' rule does not support changelog" @@ -142,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, ) @@ -183,7 +198,13 @@ def __call__(self): ) if self.change_type_order: tree = changelog.order_changelog_tree(tree, self.change_type_order) - changelog_out = changelog.render_changelog(tree) + + extras = self.cz.template_extras.copy() + extras.update(self.config.settings["extras"]) + extras.update(self.extras) + changelog_out = changelog.render_changelog( + tree, loader=self.cz.template_loader, template=self.template, **extras + ) changelog_out = changelog_out.lstrip("\n") if self.dry_run: diff --git a/commitizen/cz/base.py b/commitizen/cz/base.py index 900e62d6b8..d9bf5ea4a0 100644 --- a/commitizen/cz/base.py +++ b/commitizen/cz/base.py @@ -1,8 +1,9 @@ from __future__ import annotations from abc import ABCMeta, abstractmethod -from typing import Callable +from typing import Any, Callable +from jinja2 import BaseLoader, PackageLoader from prompt_toolkit.styles import Style, merge_styles from commitizen import git @@ -43,6 +44,10 @@ class BaseCommitizen(metaclass=ABCMeta): # Executed only at the end of the changelog generation changelog_hook: Callable[[str, str | None], str] | 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): self.config = config if not self.config.settings.get("style"): 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 101f5aaf32..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 @@ -53,6 +54,8 @@ class Settings(TypedDict, total=False): prerelease_offset: int encoding: str always_signoff: bool + template: str | None + extras: dict[str, Any] name: str = "cz_conventional_commits" @@ -83,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, @@ -94,12 +98,16 @@ class Settings(TypedDict, total=False): "prerelease_offset": 0, "encoding": encoding, "always_signoff": False, + "template": None, # default provided by plugin + "extras": {}, } MAJOR = "MAJOR" MINOR = "MINOR" PATCH = "PATCH" +CHANGELOG_FORMAT = "markdown" + bump_pattern = r"^((BREAKING[\-\ ]CHANGE|\w+)(\(.+\))?!?):" bump_map = OrderedDict( ( 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/keep_a_changelog_template.j2 b/commitizen/templates/CHANGELOG.md.j2 similarity index 100% rename from commitizen/templates/keep_a_changelog_template.j2 rename to commitizen/templates/CHANGELOG.md.j2 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/bump.md b/docs/bump.md index e58a11e18d..8e0980ec82 100644 --- a/docs/bump.md +++ b/docs/bump.md @@ -58,6 +58,7 @@ usage: cz bump [-h] [--dry-run] [--files-only] [--local-version] [--changelog] [--devrelease DEVRELEASE] [--increment {MAJOR,MINOR,PATCH}] [--check-consistency] [--annotated-tag] [--gpg-sign] [--changelog-to-stdout] [--git-output-to-stderr] [--retry] [--major-version-zero] + [--template TEMPLATE] [--extra EXTRA] [MANUAL_VERSION] positional arguments: @@ -99,6 +100,10 @@ options: --version-scheme {pep440,semver} choose version scheme + --template TEMPLATE, -t TEMPLATE + changelog template file name (relative to the current working directory) + --extra EXTRA, -e EXTRA + a changelog extra variable (in the form 'key=value') ``` ### `--files-only` @@ -250,6 +255,21 @@ Can I transition from one to the other? Yes, you shouldn't have any issues. +### `--template` + +Provides your own changelog jinja template. +See [the template customization section](customization.md#customizing-the-changelog-template) + +### `--extra` + +Provides your own changelog extra variables by using the `extras` settings or the `--extra/-e` parameter. + +```bash +cz bump --changelog --extra key=value -e short="quoted value" +``` + +See [the template customization section](customization.md#customizing-the-changelog-template). + ## Avoid raising errors Some situations from commitizen raise an exit code different than 0. diff --git a/docs/changelog.md b/docs/changelog.md index d6799e198f..029882c12b 100644 --- a/docs/changelog.md +++ b/docs/changelog.md @@ -14,6 +14,7 @@ update_changelog_on_bump = true ```bash $ cz changelog --help usage: cz changelog [-h] [--dry-run] [--file-name FILE_NAME] [--unreleased-version UNRELEASED_VERSION] [--incremental] [--start-rev START_REV] + [--template TEMPLATE] [--extra EXTRA] [rev_range] positional arguments: @@ -31,6 +32,11 @@ optional arguments: start rev of the changelog. If not set, it will generate changelog from the start --merge-prerelease collect all changes from prereleases into next non-prerelease. If not set, it will include prereleases in the changelog + start rev of the changelog.If not set, it will generate changelog from the start + --template TEMPLATE, -t TEMPLATE + changelog template file name (relative to the current working directory) + --extra EXTRA, -e EXTRA + a changelog extra variable (in the form 'key=value') ``` ### Examples @@ -186,6 +192,21 @@ cz changelog --merge-prerelease changelog_merge_prerelease = true ``` +### `template` + +Provides your own changelog jinja template by using the `template` settings or the `--template` parameter. +See [the template customization section](customization.md#customizing-the-changelog-template) + +### `extras` + +Provides your own changelog extra variables by using the `extras` settings or the `--extra/-e` parameter. + +```bash +cz changelog --extra key=value -e short="quoted value" +``` + +See [the template customization section](customization.md#customizing-the-changelog-template) + ## Hooks Supported hook methods: diff --git a/docs/config.md b/docs/config.md index 85661c12b7..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` @@ -166,7 +174,7 @@ Type: `int` Default: `0` -In some circumstances, a prerelease cannot start with a 0, e.g. in an embedded project individual characters are encoded as bytes. This can be done by specifying an offset from which to start counting. [prerelease-offset] | +In some circumstances, a prerelease cannot start with a 0, e.g. in an embedded project individual characters are encoded as bytes. This can be done by specifying an offset from which to start counting. [prerelease-offset] ### `pre_bump_hooks` @@ -192,6 +200,22 @@ Default: `utf-8` Sets the character encoding to be used when parsing commit messages. [Read more][encoding] +### `template` + +Type: `str` + +Default: `None` (provided by plugin) + +Provide custom changelog jinja template path relative to the current working directory. [Read more][template-customization] + +### `extras` + +Type: `dict[str, Any]` + +Default: `{}` + +Provide extra variables to the changelog template. [Read more][template-customization] + ## Configuration file ### pyproject.toml or .cz.toml @@ -364,5 +388,6 @@ setup( [additional-features]: https://github.com/tmbo/questionary#additional-features [customization]: customization.md [shortcuts]: customization.md#shortcut-keys +[template-customization]: customization.md#customizing-the-changelog-template [annotated-tags-vs-lightweight]: https://stackoverflow.com/a/11514139/2047185 [encoding]: tutorials/writing_commits.md#writing-commits diff --git a/docs/customization.md b/docs/customization.md index 610d1f0452..0c86f60d20 100644 --- a/docs/customization.md +++ b/docs/customization.md @@ -431,3 +431,84 @@ setup( ``` Then your plugin will be available under the name `plugin`. + +## Customizing the changelog template + +Commitizen gives you the possibility to provide your own changelog template, by: + +- providing one with your customization class +- providing one from the current working directory and setting it: + - as [configuration][template-config] + - as `--template` parameter to both `bump` and `changelog` commands +- either by providing a template with the same name as the default template + +By default, the template used is the `CHANGELOG.md.j2` file from the commitizen repository. + +### Providing a template with your customization class + +There is 3 parameters available to change the template rendering from your custom `BaseCommitizen`. + +| Parameter | Type | Default | Description | +| ----------------- | ------ | ------- | ----------------------------------------------------------------------------------------------------- | +| `template` | `str` | `None` | Provide your own template name (default to `CHANGELOG.md.j2`) | +| `template_loader` | `str` | `None` | Override the default template loader (so you can provide template from you customization class) | +| `template_extras` | `dict` | `None` | Provide some extra template parameters | + +Let's see an example. + +```python +from commitizen.cz.base import BaseCommitizen +from jinja2 import PackageLoader + + +class MyPlugin(BaseCommitizen): + template = "CHANGELOG.md.jinja" + template_loader = PackageLoader("my_plugin", "templates") + template_extras = {"key": "value"} +``` + +This snippet will: + +- use `CHANGELOG.md.jinja` as template name +- search for it in the `templates` directory for `my_plugin` package +- add the `key=value` variable in the template + +### Providing a template from the current working directory + +Users can provides their own template from their current working directory (your project root) by: + +- providing a template with the same name (`CHANGELOG.md.j2` unless overridden by your custom class) +- setting your template path as `template` configuration +- giving your template path as `--template` parameter to `bump` and `changelog` commands + +!!! Note + The path is relative to the current working directory, aka. your project root most of the time. + +### Template variables + +The default template use a single `tree` variable which is a list of entries (a release) with the following format: + +| Name | Type | Description | +| ---- | ---- | ----------- | +| version | `str` | The release version | +| date | `datetime` | The release date | +| changes | `list[tuple[str, list[Change]]]` | The release sorted changes list in the form `(type, changes)` | + +Each `Change` has the following fields: + +| Name | Type | Description | +| ---- | ---- | ----------- | +| scope | `str | None` | An optional scope | +| message | `str` | The commit message body | + +!!! Note + The field values depend on the customization class and/or the settings you provide + +When using another template (either provided by a plugin or by yourself), you can also pass extra template variables +by: + +- defining them in your configuration with the [`extras` settings][extras-config] +- providing them on the commandline with the `--extra/-e` parameter to `bump` and `changelog` commands + +[template-config]: config.md#template +[extras-config]: config.md#extras 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 8952bef440..0ae7d1e509 100644 --- a/tests/commands/test_bump_command.py +++ b/tests/commands/test_bump_command.py @@ -3,6 +3,8 @@ import inspect import re import sys +from pathlib import Path +from textwrap import dedent from unittest.mock import MagicMock, call import pytest @@ -10,6 +12,7 @@ import commitizen.commands.bump as bump from commitizen import cli, cmd, git, hooks +from commitizen.cz.base import BaseCommitizen from commitizen.exceptions import ( BumpTagFailedError, CommitizenException, @@ -25,6 +28,7 @@ NotAllowed, NoVersionSpecifiedError, ) +from commitizen.changelog_formats import ChangelogFormat from tests.utils import create_file_and_commit, create_tag @@ -1099,3 +1103,131 @@ def test_bump_command_version_scheme_priority_over_version_type(mocker: MockFixt cli.main() assert git.tag_exist("0.2.0a0") + + +@pytest.mark.parametrize( + "arg, cfg, expected", + ( + pytest.param("", "", "default", id="default"), + pytest.param("", "changelog.cfg", "from config", id="from-config"), + pytest.param( + "--template=changelog.cmd", "changelog.cfg", "from cmd", id="from-command" + ), + ), +) +def test_bump_template_option_precedance( + mocker: MockFixture, + tmp_commitizen_project: Path, + any_changelog_format: ChangelogFormat, + arg: str, + cfg: str, + expected: str, +): + project_root = Path(tmp_commitizen_project) + cfg_template = project_root / "changelog.cfg" + cmd_template = project_root / "changelog.cmd" + 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") + default_template.write_text("default") + + create_file_and_commit("feat: new file") + + if cfg: + pyproject = project_root / "pyproject.toml" + pyproject.write_text( + dedent( + f"""\ + [tool.commitizen] + version = "0.1.0" + template = "{cfg}" + """ + ) + ) + + testargs = ["cz", "bump", "--yes", "--changelog"] + if arg: + testargs.append(arg) + mocker.patch.object(sys, "argv", testargs + ["0.1.1"]) + cli.main() + + out = changelog.read_text() + assert out == expected + + +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 / any_changelog_format.template + changelog_tpl.write_text("{{first}} - {{second}} - {{third}}") + + mock_plugin.template_extras = dict( + first="from-plugin", second="from-plugin", third="from-plugin" + ) + + pyproject = project_root / "pyproject.toml" + pyproject.write_text( + dedent( + """\ + [tool.commitizen] + version = "0.1.0" + [tool.commitizen.extras] + first = "from-config" + second = "from-config" + """ + ) + ) + + create_file_and_commit("feat: new file") + + testargs = [ + "cz", + "bump", + "--yes", + "--changelog", + "--extra", + "first=from-command", + "0.1.1", + ] + mocker.patch.object(sys, "argv", testargs) + cli.main() + + 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 / any_changelog_format.template + changelog_tpl.write_text("{{first}} - {{second}} - {{third}}") + + create_file_and_commit("feat: new file") + + testargs = [ + "cz", + "bump", + "--changelog", + "--yes", + "-e", + "first=no-quote", + "-e", + "second='single quotes'", + "-e", + 'third="double quotes"', + "0.1.1", + ] + mocker.patch.object(sys, "argv", testargs) + cli.main() + + 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 535c8f4a4d..a0183e1cd9 100644 --- a/tests/commands/test_changelog_command.py +++ b/tests/commands/test_changelog_command.py @@ -1,20 +1,27 @@ import itertools import sys from datetime import datetime +from pathlib import Path +from textwrap import dedent import pytest from dateutil import relativedelta +from jinja2 import FileSystemLoader from pytest_mock import MockFixture +from commitizen import __file__ as commitizen_init from commitizen import cli, git 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, @@ -67,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() @@ -102,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") @@ -1359,3 +1400,209 @@ def test_changelog_from_current_version_tag_with_nonversion_tag( - commit 1\n" write_patch.assert_called_with(full_changelog) + + +@pytest.mark.parametrize( + "arg,cfg,expected", + ( + pytest.param("", "", "default", id="default"), + pytest.param("", "changelog.cfg", "from config", id="from-config"), + pytest.param( + "--template=changelog.cmd", "changelog.cfg", "from cmd", id="from-command" + ), + ), +) +def test_changelog_template_option_precedance( + mocker: MockFixture, + tmp_commitizen_project: Path, + any_changelog_format: ChangelogFormat, + arg: str, + cfg: str, + expected: str, +): + project_root = Path(tmp_commitizen_project) + cfg_template = project_root / "changelog.cfg" + cmd_template = project_root / "changelog.cmd" + 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") + default_template.write_text("default") + + create_file_and_commit("feat: new file") + + if cfg: + pyproject = project_root / "pyproject.toml" + pyproject.write_text( + dedent( + f"""\ + [tool.commitizen] + version = "0.1.0" + template = "{cfg}" + """ + ) + ) + + testargs = ["cz", "changelog"] + if arg: + testargs.append(arg) + mocker.patch.object(sys, "argv", testargs) + cli.main() + + out = changelog.read_text() + assert out == expected + + +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 / any_changelog_format.template + changelog_tpl.write_text("{{first}} - {{second}} - {{third}}") + + mock_plugin.template_extras = dict( + first="from-plugin", second="from-plugin", third="from-plugin" + ) + + pyproject = project_root / "pyproject.toml" + pyproject.write_text( + dedent( + """\ + [tool.commitizen] + version = "0.1.0" + [tool.commitizen.extras] + first = "from-config" + second = "from-config" + """ + ) + ) + + create_file_and_commit("feat: new file") + + testargs = ["cz", "changelog", "--extra", "first=from-command"] + mocker.patch.object(sys, "argv", testargs) + cli.main() + + 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 / any_changelog_format.template + changelog_tpl.write_text("{{first}} - {{second}} - {{third}}") + + create_file_and_commit("feat: new file") + + testargs = [ + "cz", + "changelog", + "-e", + "first=no-quote", + "-e", + "second='single quotes'", + "-e", + 'third="double quotes"', + ] + mocker.patch.object(sys, "argv", testargs) + cli.main() + + 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" / any_changelog_format.template + + args = ["cz", "changelog", "--export-template", str(target)] + + mocker.patch.object(sys, "argv", args) + cli.main() + + assert target.exists() + assert target.read_text() == src.read_text() + + +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" + src = tmp_path / changelog_format.template + tpl = "I am a custom template" + src.write_text(tpl) + mock_plugin.template_loader = FileSystemLoader(tmp_path) + + args = ["cz", "changelog", "--export-template", str(target)] + + mocker.patch.object(sys, "argv", args) + cli.main() + + assert target.exists() + assert target.read_text() == tpl 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 98b56df158..76d2e53fb7 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -4,13 +4,19 @@ import re import tempfile from pathlib import Path +from typing import Iterator import pytest +from pytest_mock import MockerFixture from commitizen import cmd, defaults from commitizen.config import BaseConfig -from commitizen.cz.base import BaseCommitizen 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" @@ -37,6 +43,14 @@ def git_sandbox(monkeypatch: pytest.MonkeyPatch, tmp_path: Path): cmd.run("git config --global init.defaultBranch master") +@pytest.fixture +def chdir(tmp_path: Path) -> Iterator[Path]: + cwd = os.getcwd() + os.chdir(tmp_path) + yield tmp_path + os.chdir(cwd) + + @pytest.fixture(scope="function") def tmp_git_project(tmpdir): with tmpdir.as_cwd(): @@ -200,3 +214,42 @@ def message(self, answers: dict) -> str: def use_cz_semver(mocker): new_cz = {**registry, "cz_semver": SemverCommitizen} mocker.patch.dict("commitizen.cz.registry", new_cz) + + +class MockPlugin(BaseCommitizen): + def questions(self) -> defaults.Questions: + return [] + + def message(self, answers: dict) -> str: + return "" + + +@pytest.fixture +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_bump_create_commit_message.py b/tests/test_bump_create_commit_message.py index e3035ba550..c096f23c39 100644 --- a/tests/test_bump_create_commit_message.py +++ b/tests/test_bump_create_commit_message.py @@ -40,7 +40,7 @@ def test_bump_pre_commit_changelog(mocker: MockFixture, freezer, retry): """\ repos: - repo: https://github.com/pre-commit/mirrors-prettier - rev: v2.6.2 + rev: v3.0.3 hooks: - id: prettier stages: [commit] diff --git a/tests/test_changelog.py b/tests/test_changelog.py index faf6ee0c43..8aef10a31f 100644 --- a/tests/test_changelog.py +++ b/tests/test_changelog.py @@ -1,10 +1,14 @@ +from pathlib import Path + import pytest +from jinja2 import FileSystemLoader from commitizen import changelog, defaults, git from commitizen.cz.conventional_commits.conventional_commits import ( ConventionalCommitsCz, ) from commitizen.exceptions import InvalidConfigurationError +from commitizen.changelog_formats import ChangelogFormat COMMITS_DATA = [ { @@ -1137,57 +1141,162 @@ 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_unreleased(gitcommits): - some_commits = gitcommits[:7] +def test_render_changelog_from_default_plugin_values( + gitcommits, tags, changelog_content, any_changelog_format: ChangelogFormat +): + parser = ConventionalCommitsCz.commit_parser + 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, 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 / template).write_text(tpl) + parser = ConventionalCommitsCz.commit_parser + changelog_pattern = ConventionalCommitsCz.changelog_pattern + tree = changelog.generate_tree_from_commits( + gitcommits, tags, parser, changelog_pattern + ) + result = changelog.render_changelog(tree, loader, template) + assert result == tpl + + +def test_render_changelog_override_template_from_cwd( + gitcommits, tags, chdir: Path, any_changelog_format: ChangelogFormat +): + tpl = "overridden from cwd" + template = any_changelog_format.template + (chdir / template).write_text(tpl) + parser = ConventionalCommitsCz.commit_parser + 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, loader, template) + assert result == tpl + + +def test_render_changelog_override_template_from_cwd_with_custom_name( + gitcommits, tags, chdir: Path +): + tpl = "template overridden from cwd" + tpl_name = "tpl.j2" + (chdir / tpl_name).write_text(tpl) + parser = ConventionalCommitsCz.commit_parser + 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, loader, tpl_name) + assert result == tpl + + +def test_render_changelog_override_loader_and_template( + gitcommits, tags, tmp_path: Path +): + loader = FileSystemLoader(tmp_path) + tpl = "loader and template overridden" + tpl_name = "tpl.j2" + (tmp_path / tpl_name).write_text(tpl) parser = ConventionalCommitsCz.commit_parser changelog_pattern = ConventionalCommitsCz.bump_pattern + tree = changelog.generate_tree_from_commits( + gitcommits, tags, parser, changelog_pattern + ) + result = changelog.render_changelog(tree, loader, tpl_name) + assert result == tpl + + +def test_render_changelog_support_arbitrary_kwargs(gitcommits, tags, tmp_path: Path): + loader = FileSystemLoader(tmp_path) + tpl_name = "tpl.j2" + (tmp_path / tpl_name).write_text("{{ key }}") + parser = ConventionalCommitsCz.commit_parser + changelog_pattern = ConventionalCommitsCz.changelog_pattern + tree = changelog.generate_tree_from_commits( + gitcommits, tags, parser, changelog_pattern + ) + result = changelog.render_changelog(tree, loader, tpl_name, key="value") + assert result == "value" + + +def test_render_changelog_unreleased(gitcommits, any_changelog_format: ChangelogFormat): + some_commits = gitcommits[:7] + parser = ConventionalCommitsCz.commit_parser + 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" @@ -1195,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, @@ -1203,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 <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 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 5a7a899e7c..dcac8e015c 100644 --- a/tests/test_conf.py +++ b/tests/test_conf.py @@ -1,6 +1,9 @@ +from __future__ import annotations + import json import os from pathlib import Path +from typing import Any import pytest import yaml @@ -42,7 +45,7 @@ } -_settings = { +_settings: dict[str, Any] = { "name": "cz_jira", "version": "1.0.0", "version_provider": "commitizen", @@ -54,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, @@ -65,9 +69,11 @@ "prerelease_offset": 0, "encoding": "utf-8", "always_signoff": False, + "template": None, + "extras": {}, } -_new_settings = { +_new_settings: dict[str, Any] = { "name": "cz_jira", "version": "2.0.0", "version_provider": "commitizen", @@ -79,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, @@ -90,16 +97,8 @@ "prerelease_offset": 0, "encoding": "utf-8", "always_signoff": False, -} - -_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"], + "template": None, + "extras": {}, }