Skip to content

Commit

Permalink
feat(formats): expose some new customizable changelog formats on the …
Browse files Browse the repository at this point in the history
…`commitizen.changelog_format` endpoint (Textile, AsciiDoc and RestructuredText)
  • Loading branch information
noirbizarre committed Oct 17, 2023
1 parent 4aa2bf7 commit e61aceb
Show file tree
Hide file tree
Showing 41 changed files with 1,592 additions and 688 deletions.
112 changes: 25 additions & 87 deletions commitizen/changelog.py
Original file line number Diff line number Diff line change
Expand Up @@ -26,37 +26,44 @@
"""
from __future__ import annotations

import os
import re
from collections import OrderedDict, defaultdict
from dataclasses import dataclass
from datetime import date
from typing import TYPE_CHECKING, Callable, Iterable, cast
from typing import TYPE_CHECKING, Callable, Iterable

from jinja2 import (
BaseLoader,
ChoiceLoader,
Environment,
FileSystemLoader,
PackageLoader,
Template,
)

from commitizen import out
from commitizen.bump import normalize_tag
from commitizen.defaults import encoding
from commitizen.exceptions import InvalidConfigurationError, NoCommitsFoundError
from commitizen.git import GitCommit, GitTag
from commitizen.version_schemes import (
DEFAULT_SCHEME,
BaseVersion,
InvalidVersion,
Pep440,
)

if TYPE_CHECKING:
from commitizen.version_schemes import VersionScheme

DEFAULT_TEMPLATE = "CHANGELOG.md.j2"

@dataclass
class Metadata:
"""
Metadata extracted from the changelog produced by a plugin
"""

unreleased_start: int | None = None
unreleased_end: int | None = None
latest_version: str | None = None
latest_version_position: int | None = None


def get_commit_tag(commit: GitCommit, tags: list[GitTag]) -> GitTag | None:
Expand Down Expand Up @@ -196,100 +203,31 @@ def order_changelog_tree(tree: Iterable, change_type_order: list[str]) -> Iterab
return sorted_tree


def get_changelog_template(
loader: BaseLoader | None = None, template: str | None = None
) -> Template:
def get_changelog_template(loader: BaseLoader, template: str) -> Template:
loader = ChoiceLoader(
[
FileSystemLoader("."),
loader or PackageLoader("commitizen", "templates"),
loader,
]
)
env = Environment(loader=loader, trim_blocks=True)
return env.get_template(template or DEFAULT_TEMPLATE)
return env.get_template(template)


def render_changelog(
tree: Iterable,
loader: BaseLoader | None = None,
template: str | None = None,
loader: BaseLoader,
template: str,
**kwargs,
) -> str:
jinja_template = get_changelog_template(loader, template or DEFAULT_TEMPLATE)
jinja_template = get_changelog_template(loader, template)
changelog: str = jinja_template.render(tree=tree, **kwargs)
return changelog


def parse_version_from_markdown(
value: str, scheme: VersionScheme = Pep440
) -> str | None:
if not value.startswith("#"):
return None
m = scheme.parser.search(value)
if not m:
return None
return cast(str, m.group("version"))


def parse_title_type_of_line(value: str) -> str | None:
md_title_parser = r"^(?P<title>#+)"
m = re.search(md_title_parser, value)
if not m:
return None
return m.groupdict().get("title")


def get_metadata(
filepath: str, scheme: VersionScheme = Pep440, encoding: str = encoding
) -> dict:
unreleased_start: int | None = None
unreleased_end: int | None = None
unreleased_title: str | None = None
latest_version: str | None = None
latest_version_position: int | None = None
if not os.path.isfile(filepath):
return {
"unreleased_start": None,
"unreleased_end": None,
"latest_version": None,
"latest_version_position": None,
}

with open(filepath, encoding=encoding) as changelog_file:
for index, line in enumerate(changelog_file):
line = line.strip().lower()

unreleased: str | None = None
if "unreleased" in line:
unreleased = parse_title_type_of_line(line)
# Try to find beginning and end lines of the unreleased block
if unreleased:
unreleased_start = index
unreleased_title = unreleased
continue
elif (
isinstance(unreleased_title, str)
and parse_title_type_of_line(line) == unreleased_title
):
unreleased_end = index

# Try to find the latest release done
version = parse_version_from_markdown(line, scheme)
if version:
latest_version = version
latest_version_position = index
break # there's no need for more info
if unreleased_start is not None and unreleased_end is None:
unreleased_end = index
return {
"unreleased_start": unreleased_start,
"unreleased_end": unreleased_end,
"latest_version": latest_version,
"latest_version_position": latest_version_position,
}


def incremental_build(new_content: str, lines: list[str], metadata: dict) -> list[str]:
def incremental_build(
new_content: str, lines: list[str], metadata: Metadata
) -> list[str]:
"""Takes the original lines and updates with new_content.
The metadata governs how to remove the old unreleased section and where to place the
Expand All @@ -303,9 +241,9 @@ def incremental_build(new_content: str, lines: list[str], metadata: dict) -> lis
Returns:
Updated lines
"""
unreleased_start = metadata.get("unreleased_start")
unreleased_end = metadata.get("unreleased_end")
latest_version_position = metadata.get("latest_version_position")
unreleased_start = metadata.unreleased_start
unreleased_end = metadata.unreleased_end
latest_version_position = metadata.latest_version_position
skip = False
output_lines: list[str] = []
for index, line in enumerate(lines):
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

0 comments on commit e61aceb

Please sign in to comment.