Skip to content
Open
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
27 changes: 24 additions & 3 deletions commitizen/bump.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,16 +4,37 @@
import re
from glob import iglob
from string import Template
from typing import TYPE_CHECKING
from typing import TYPE_CHECKING, cast

from commitizen.defaults import BUMP_MESSAGE
from commitizen.exceptions import CurrentVersionNotFoundError
from commitizen.git import smart_open
from commitizen.version_increment import VersionIncrement

if TYPE_CHECKING:
from collections.abc import Generator, Iterable
from collections.abc import Generator, Iterable, Mapping

from commitizen.version_schemes import VersionProtocol
from commitizen.git import GitCommit
from commitizen.version_schemes import Increment, VersionProtocol


def find_increment(
commits: list[GitCommit], regex: str, increments_map: Mapping[str, object]
) -> Increment | None:
"""Preserve the legacy bump helper as a compatibility shim.

This public helper remains available for integrations importing
``commitizen.bump.find_increment`` while delegating the real increment
comparison and extraction logic to ``VersionIncrement``.
"""
increment = VersionIncrement.get_highest_by_messages(
(commit.message for commit in commits),
regex,
increments_map,
)
if increment == VersionIncrement.NONE:
return None
return cast("Increment", str(increment))


def update_version_in_files(
Expand Down
26 changes: 15 additions & 11 deletions docs/customization/python_class.md
Original file line number Diff line number Diff line change
Expand Up @@ -101,21 +101,25 @@ You need to define 2 parameters inside your custom `BaseCommitizen`.
| `bump_pattern` | `str` | `None` | Regex to extract information from commit (subject and body) |
| `bump_map` | `dict` | `None` | Dictionary mapping the extracted information to a `SemVer` increment type (`MAJOR`, `MINOR`, `PATCH`). Use `None` when a matched rule should not bump the version. |

Let's see an example.

```python title="cz_strange.py"
from commitizen.cz.base import BaseCommitizen


class StrangeCommitizen(BaseCommitizen):
bump_pattern = r"^(break|new|fix|hotfix)"
bump_map = {"break": "MAJOR", "new": "MINOR", "fix": "PATCH", "hotfix": "PATCH"}
If you only need to customize bump behavior, subclassing an existing rule set
keeps the example executable while still overriding the bump rules. The module
below is the same file exercised by Commitizen's test suite and type-checked by
mypy.

<!-- blacken-docs:off -->
```python title="cz_docs_only.py"
--8<-- "docs/examples/cz_docs_only.py"
```
<!-- blacken-docs:on -->

That's it, your Commitizen now supports custom rules, and you can run.
Package and install `cz_docs_only.py` just like the earlier `cz_jira.py`
example, and expose it through the same `commitizen.plugin` entry point group,
for example with `cz_docs_only = cz_docs_only:DocsOnlyPatchCommitizen`.
After installing that package, your Commitizen now supports custom rules, and
you can run:

```bash
cz -n cz_strange bump
cz -n cz_docs_only bump
```

### Filter commits before bump and changelog generation
Expand Down
21 changes: 21 additions & 0 deletions docs/examples/cz_docs_only.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
"""Executable custom bump-rule example published in the documentation."""

from __future__ import annotations

from commitizen.cz.conventional_commits import ConventionalCommitsCz


class DocsOnlyPatchCommitizen(ConventionalCommitsCz):
"""Skip version bumps for docs commits while patch-bumping fixes.

Example:
Configure the plugin as ``cz_docs_only`` to ignore ``docs:`` commits for
version bumps while still treating ``fix:`` commits as patch releases.

Attributes:
bump_pattern: Extracts the commit types that participate in bump logic.
bump_map: Maps ``docs`` to no increment and ``fix`` to a patch bump.
"""

bump_pattern = r"^(docs|fix)(?:\([^()\r\n]*\))?:"
bump_map = {"docs": None, "fix": "PATCH"}
1 change: 1 addition & 0 deletions mkdocs.yml
Original file line number Diff line number Diff line change
Expand Up @@ -101,6 +101,7 @@ markdown_extensions:
- codehilite
- extra
- pymdownx.highlight
- pymdownx.snippets
- pymdownx.superfences
- toc:
permalink: true
Expand Down
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -253,7 +253,7 @@ known-first-party = ["commitizen", "tests"]
convention = "google"

[tool.mypy]
files = ["commitizen", "tests", "scripts"]
files = ["commitizen", "tests", "scripts", "docs/examples"]
disallow_untyped_decorators = true
disallow_subclassing_any = true
warn_return_any = true
Expand Down
120 changes: 120 additions & 0 deletions tests/commands/test_documented_python_class_examples.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,120 @@
"""Tests for executable custom-plugin examples published in the docs."""

from __future__ import annotations

import importlib.util
from pathlib import Path
from typing import TYPE_CHECKING, cast

import pytest

from commitizen import git
from commitizen.cz import registry
from commitizen.exceptions import NoneIncrementExit

if TYPE_CHECKING:
from pytest_mock import MockFixture

from commitizen.cz.base import BaseCommitizen
from tests.utils import UtilFixture


DOCUMENTED_PLUGIN_PATH = (
Path(__file__).resolve().parents[2] / "docs" / "examples" / "cz_docs_only.py"
)


def _load_documented_plugin() -> type[BaseCommitizen]:
"""Load the custom plugin example from the file embedded in the docs."""
spec = importlib.util.spec_from_file_location(
"tests_documented_cz_docs_only", DOCUMENTED_PLUGIN_PATH
)
assert spec is not None
assert spec.loader is not None

module = importlib.util.module_from_spec(spec)
spec.loader.exec_module(module)

return cast("type[BaseCommitizen]", getattr(module, "DocsOnlyPatchCommitizen"))


@pytest.fixture
def documented_plugin_name(mocker: MockFixture) -> str:
"""Expose the documented example through Commitizen's plugin registry."""
plugin_name = "cz_docs_only"
plugin_class = _load_documented_plugin()
mocker.patch.dict("commitizen.cz.registry", {**registry, plugin_name: plugin_class})
return plugin_name


@pytest.mark.parametrize("major_version_zero", [False, True])
@pytest.mark.parametrize(
"commit_message",
["fix: ship executable docs example", "fix(api): ship executable docs example"],
)
@pytest.mark.usefixtures("tmp_commitizen_project")
def test_documented_python_plugin_fix_commit_bumps_patch(
util: UtilFixture,
documented_plugin_name: str,
major_version_zero: bool,
commit_message: str,
) -> None:
"""The documented example bumps a fix commit as a patch release."""
util.create_file_and_commit(commit_message)

args = ["--name", documented_plugin_name, "bump", "--yes"]
if major_version_zero:
args.append("--major-version-zero")
util.run_cli(*args)

assert git.tag_exist("0.1.1") is True


@pytest.mark.parametrize("major_version_zero", [False, True])
@pytest.mark.parametrize(
"commit_message",
["docs: expand plugin guide", "docs(api): expand plugin guide"],
)
@pytest.mark.usefixtures("tmp_commitizen_project")
def test_documented_python_plugin_docs_commit_does_not_bump(
util: UtilFixture,
documented_plugin_name: str,
major_version_zero: bool,
commit_message: str,
) -> None:
"""The documented example treats docs commits as a no-bump match."""
first_bump_args = ["--name", documented_plugin_name, "bump", "--yes"]
if major_version_zero:
first_bump_args.append("--major-version-zero")

util.create_file_and_commit("fix: seed release")
util.run_cli(*first_bump_args)
util.create_file_and_commit(commit_message)

with pytest.raises(NoneIncrementExit):
util.run_cli(*first_bump_args)

assert git.tag_exist("0.1.2") is False


@pytest.mark.parametrize(
"commit_message",
[
"fix!: drop old API",
"fixup! rebase cleanup",
"fixture: rename helper",
],
)
@pytest.mark.usefixtures("tmp_commitizen_project")
def test_documented_python_plugin_ignores_nonmatching_fix_prefixes(
util: UtilFixture, documented_plugin_name: str, commit_message: str
) -> None:
"""Only plain fix commits with an optional scope participate in bumping."""
util.create_file_and_commit("fix: seed release")
util.run_cli("--name", documented_plugin_name, "bump", "--yes")
util.create_file_and_commit(commit_message)

with pytest.raises(NoneIncrementExit):
util.run_cli("--name", documented_plugin_name, "bump", "--yes")

assert git.tag_exist("0.1.2") is False
40 changes: 40 additions & 0 deletions tests/test_bump_find_increment.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
"""Regression tests for the public ``commitizen.bump.find_increment`` helper."""

from commitizen import bump
from commitizen.cz.conventional_commits import ConventionalCommitsCz
from commitizen.git import GitCommit


def test_find_increment_keeps_public_api_behavior() -> None:
"""The compatibility shim still returns the highest named increment."""
# Arrange
commits = [
GitCommit(rev="test", title="fix: correct typo"),
GitCommit(rev="test", title="feat(api): add endpoint"),
]

# Act
increment = bump.find_increment(
commits,
regex=ConventionalCommitsCz.bump_pattern,
increments_map=ConventionalCommitsCz.bump_map,
)

# Assert
assert increment == "MINOR"


def test_find_increment_supports_custom_none_mapping() -> None:
"""A matched custom rule can still opt out of bumping entirely."""
# Arrange
commits = [GitCommit(rev="test", title="docs: expand usage guide")]

# Act
increment = bump.find_increment(
commits,
regex=r"^(docs)",
increments_map={"docs": None},
)

# Assert
assert increment is None
Loading