Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat(template): allow to override the template from cli, configuration and plugins #645

Merged
merged 6 commits into from
Oct 18, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
127 changes: 45 additions & 82 deletions commitizen/changelog.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand Down Expand Up @@ -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<title>#+)"
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
Expand All @@ -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):
Expand Down
90 changes: 90 additions & 0 deletions commitizen/changelog_formats/__init__.py
Original file line number Diff line number Diff line change
@@ -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

Check warning on line 26 in commitizen/changelog_formats/__init__.py

View check run for this annotation

Codecov / codecov/patch

commitizen/changelog_formats/__init__.py#L26

Added line #L26 was not covered by tests

@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
27 changes: 27 additions & 0 deletions commitizen/changelog_formats/asciidoc.py
Original file line number Diff line number Diff line change
@@ -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"))
81 changes: 81 additions & 0 deletions commitizen/changelog_formats/base.py
Original file line number Diff line number Diff line change
@@ -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"
)
Loading
Loading