diff --git a/commitizen/changelog.py b/commitizen/changelog.py index 704efe607..d5fa9727c 100644 --- a/commitizen/changelog.py +++ b/commitizen/changelog.py @@ -29,10 +29,10 @@ import re from collections import OrderedDict, defaultdict -from collections.abc import Iterable +from collections.abc import Generator, Iterable, Mapping from dataclasses import dataclass from datetime import date -from typing import TYPE_CHECKING +from typing import TYPE_CHECKING, Any from jinja2 import ( BaseLoader, @@ -42,8 +42,9 @@ Template, ) +from commitizen.containers import UniqueList from commitizen.cz.base import ChangelogReleaseHook -from commitizen.exceptions import InvalidConfigurationError, NoCommitsFoundError +from commitizen.exceptions import NoCommitsFoundError from commitizen.git import GitCommit, GitTag from commitizen.tags import TagRules @@ -84,7 +85,7 @@ def generate_tree_from_commits( changelog_message_builder_hook: MessageBuilderHook | None = None, changelog_release_hook: ChangelogReleaseHook | None = None, rules: TagRules | None = None, -) -> Iterable[dict]: +) -> Generator[dict[str, Any], None, None]: pat = re.compile(changelog_pattern) map_pat = re.compile(commit_parser, re.MULTILINE) body_map_pat = re.compile(commit_parser, re.MULTILINE | re.DOTALL) @@ -187,24 +188,19 @@ def process_commit_message( changes[change_type].append(msg) -def order_changelog_tree(tree: Iterable, change_type_order: list[str]) -> Iterable: - if len(set(change_type_order)) != len(change_type_order): - raise InvalidConfigurationError( - f"Change types contain duplicates types ({change_type_order})" - ) - - sorted_tree = [] +def order_changelog_tree( + tree: Iterable[Mapping[str, Any]], change_type_order: UniqueList[str] +) -> Generator[dict[str, Any], None, None]: for entry in tree: - ordered_change_types = change_type_order + sorted( - set(entry["changes"].keys()) - set(change_type_order) - ) - changes = [ - (ct, entry["changes"][ct]) - for ct in ordered_change_types - if ct in entry["changes"] - ] - sorted_tree.append({**entry, **{"changes": OrderedDict(changes)}}) - return sorted_tree + yield { + **entry, + "changes": OrderedDict( + (ct, entry["changes"][ct]) + for ct in change_type_order + + sorted(set(entry["changes"].keys()) - set(change_type_order)) + if ct in entry["changes"] + ), + } def get_changelog_template(loader: BaseLoader, template: str) -> Template: diff --git a/commitizen/commands/changelog.py b/commitizen/commands/changelog.py index 0e4efabfa..8da954166 100644 --- a/commitizen/commands/changelog.py +++ b/commitizen/commands/changelog.py @@ -10,6 +10,7 @@ from commitizen import changelog, defaults, factory, git, out from commitizen.changelog_formats import get_changelog_format from commitizen.config import BaseConfig +from commitizen.containers import UniqueList from commitizen.cz.base import ChangelogReleaseHook, MessageBuilderHook from commitizen.cz.utils import strip_local_version from commitizen.exceptions import ( @@ -75,11 +76,6 @@ def __init__(self, config: BaseConfig, args): self.change_type_map = ( self.config.settings.get("change_type_map") or self.cz.change_type_map ) - self.change_type_order = ( - self.config.settings.get("change_type_order") - or self.cz.change_type_order - or defaults.CHANGE_TYPE_ORDER - ) self.rev_range = args.get("rev_range") self.tag_format: str = ( args.get("tag_format") or self.config.settings["tag_format"] @@ -101,6 +97,14 @@ def __init__(self, config: BaseConfig, args): self.extras = args.get("extras") or {} self.export_template_to = args.get("export_template") + @property + def change_type_order(self) -> UniqueList[str]: + return UniqueList( + self.config.settings.get("change_type_order") # type: ignore + or self.cz.change_type_order + or defaults.CHANGE_TYPE_ORDER + ) + def _find_incremental_rev(self, latest_version: str, tags: list[GitTag]) -> str: """Try to find the 'start_rev'. diff --git a/commitizen/containers.py b/commitizen/containers.py new file mode 100644 index 000000000..4d574fda7 --- /dev/null +++ b/commitizen/containers.py @@ -0,0 +1,38 @@ +from collections.abc import Iterable +from typing import Generic, TypeVar + +T = TypeVar("T") + + +class UniqueList(Generic[T]): + def __init__(self, items: list[T]): + if len(items) != len(set(items)): + raise ValueError("Items must be unique") + self._items = items + + def __iter__(self): + return iter(self._items) + + def __getitem__(self, index): + return self._items[index] + + def __repr__(self): + return f"UniqueList({self._items})" + + def __add__(self, other: Iterable[T]) -> "UniqueList[T]": + # Support UniqueList + list or UniqueList + UniqueList + combined = self._items + list(other) + return UniqueList(combined) + + def __radd__(self, other: Iterable[T]) -> "UniqueList[T]": + combined = list(other) + self._items + return UniqueList(combined) + + def __len__(self) -> int: + return len(self._items) + + def __contains__(self, item: T) -> bool: + return item in self._items + + def __eq__(self, other: object) -> bool: + return isinstance(other, UniqueList) and self._items == other._items diff --git a/tests/test_changelog.py b/tests/test_changelog.py index b1c7c802e..2ec3e2c44 100644 --- a/tests/test_changelog.py +++ b/tests/test_changelog.py @@ -17,7 +17,6 @@ from commitizen.cz.conventional_commits.conventional_commits import ( ConventionalCommitsCz, ) -from commitizen.exceptions import InvalidConfigurationError from commitizen.version_schemes import Pep440 COMMITS_DATA: list[dict[str, Any]] = [ @@ -1219,24 +1218,16 @@ def test_order_changelog_tree(change_type_order, expected_reordering): tree = changelog.order_changelog_tree(COMMITS_TREE, change_type_order) for index, entry in enumerate(tuple(tree)): - version = tree[index]["version"] + version = entry["version"] if version in expected_reordering: # Verify that all keys are present - assert [*tree[index].keys()] == [*COMMITS_TREE[index].keys()] + assert [*entry.keys()] == [*COMMITS_TREE[index].keys()] # Verify that the reorder only impacted the returned dict and not the original expected = expected_reordering[version] - assert [*tree[index]["changes"].keys()] == expected["sorted"] + assert [*entry["changes"].keys()] == expected["sorted"] assert [*COMMITS_TREE[index]["changes"].keys()] == expected["original"] else: - assert [*entry["changes"].keys()] == [*tree[index]["changes"].keys()] - - -def test_order_changelog_tree_raises(): - change_type_order = ["BREAKING CHANGE", "feat", "refactor", "feat"] - with pytest.raises(InvalidConfigurationError) as excinfo: - changelog.order_changelog_tree(COMMITS_TREE, change_type_order) - - assert "Change types contain duplicates types" in str(excinfo) + assert [*entry["changes"].keys()] == [*entry["changes"].keys()] def test_render_changelog( diff --git a/tests/test_containers.py b/tests/test_containers.py new file mode 100644 index 000000000..151fb8c4a --- /dev/null +++ b/tests/test_containers.py @@ -0,0 +1,42 @@ +import pytest + +from commitizen.containers import UniqueList + + +def test_unique_list(): + # Test initialization with unique items + unique_list = UniqueList([1, 2, 3]) + assert list(unique_list) == [1, 2, 3] + + # Test initialization with duplicate items + with pytest.raises(ValueError, match="Items must be unique"): + UniqueList([1, 1, 2]) + + # Test iteration + items = [1, 2, 3] + unique_list = UniqueList(items) + assert [x for x in unique_list] == items + + # Test indexing + assert unique_list[0] == 1 + assert unique_list[1] == 2 + assert unique_list[2] == 3 + + # Test string representation + assert repr(unique_list) == "UniqueList([1, 2, 3])" + + # Test with different types + string_list = UniqueList(["a", "b", "c"]) + assert list(string_list) == ["a", "b", "c"] + + # Test add + assert unique_list + [4, 5, 6] == UniqueList([1, 2, 3, 4, 5, 6]) + assert [4, 5, 6] + unique_list == UniqueList([4, 5, 6, 1, 2, 3]) + + # Test contains + assert 1 in unique_list + assert 3 in unique_list + assert 7 not in unique_list + + # Test len + assert len(unique_list) == 3