Skip to content
Draft
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
2 changes: 1 addition & 1 deletion .github/workflows/pythonpackage.yml
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ jobs:
python-check:
strategy:
matrix:
python-version: ["3.9", "3.10", "3.11", "3.12", "3.13"]
python-version: ["3.9", "3.10", "3.11", "3.12", "3.13", "3.14"]
platform: [ubuntu-22.04, macos-latest, windows-latest]
runs-on: ${{ matrix.platform }}
steps:
Expand Down
2 changes: 1 addition & 1 deletion .pre-commit-config.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -48,7 +48,7 @@ repos:
- tomli

- repo: https://github.com/commitizen-tools/commitizen
rev: v4.10.0 # automatically updated by Commitizen
rev: v4.10.1 # automatically updated by Commitizen
hooks:
- id: commitizen
- id: commitizen-branch
Expand Down
22 changes: 22 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,3 +1,25 @@
## v4.10.1 (2025-12-11)

### Fix

- **version**: fix the behavior of cz version --major
- **cli**: debug and no_raise can be used together in sys.excepthook
- **git**: replace lstrip with strip for compatibility issue
- **bump**: remove NotAllowed related to --get-next option, other related refactoring

### Refactor

- **version**: rename class member to align with other classes
- **cargo_provider**: cleanup and get rid of potential type errors
- **bump**: extract option validation and new version resolution to new functions
- **changelog**: raise NotAllow when file_name not passed instead of using assert
- **bump**: rename parameter and variables

### Perf

- **ruff**: enable ruff rules TC001~TC006
- add TYPE_CHECKING to CzQuestion imports

## v4.10.0 (2025-11-10)

### Feat
Expand Down
2 changes: 1 addition & 1 deletion commitizen/__version__.py
Original file line number Diff line number Diff line change
@@ -1 +1 @@
__version__ = "4.10.0"
__version__ = "4.10.1"
15 changes: 9 additions & 6 deletions commitizen/bump.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,16 +3,19 @@
import os
import re
from collections import OrderedDict
from collections.abc import Generator, Iterable
from glob import iglob
from logging import getLogger
from string import Template
from typing import cast
from typing import TYPE_CHECKING, cast

from commitizen.defaults import BUMP_MESSAGE, MAJOR, MINOR, PATCH
from commitizen.exceptions import CurrentVersionNotFoundError
from commitizen.git import GitCommit, smart_open
from commitizen.version_schemes import Increment, Version

if TYPE_CHECKING:
from collections.abc import Generator, Iterable

from commitizen.version_schemes import Increment, Version

VERSION_TYPES = [None, PATCH, MINOR, MAJOR]

Expand Down Expand Up @@ -56,13 +59,13 @@ def find_increment(
if increment == MAJOR:
break

return cast(Increment, increment)
return cast("Increment", increment)


def update_version_in_files(
current_version: str,
new_version: str,
files: Iterable[str],
version_files: Iterable[str],
*,
check_consistency: bool,
encoding: str,
Expand All @@ -77,7 +80,7 @@ def update_version_in_files(
"""
updated_files = []

for path, pattern in _resolve_files_and_regexes(files, current_version):
for path, pattern in _resolve_files_and_regexes(version_files, current_version):
current_version_found = False
bumped_lines = []

Expand Down
26 changes: 20 additions & 6 deletions commitizen/changelog.py
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,6 @@

import re
from collections import OrderedDict, defaultdict
from collections.abc import Generator, Iterable, Mapping, MutableMapping, Sequence
from dataclasses import dataclass
from datetime import date
from itertools import chain
Expand All @@ -44,13 +43,14 @@
Template,
)

from commitizen.cz.base import ChangelogReleaseHook
from commitizen.exceptions import InvalidConfigurationError, NoCommitsFoundError
from commitizen.git import GitCommit, GitTag
from commitizen.tags import TagRules

if TYPE_CHECKING:
from commitizen.cz.base import MessageBuilderHook
from collections.abc import Generator, Iterable, Mapping, MutableMapping, Sequence

from commitizen.cz.base import ChangelogReleaseHook, MessageBuilderHook
from commitizen.git import GitCommit, GitTag


@dataclass
Expand All @@ -72,6 +72,17 @@ def __post_init__(self) -> None:
self.latest_version_tag = self.latest_version


@dataclass
class IncrementalMergeInfo:
"""
Information regarding the last non-pre-release, parsed from the changelog. Required to merge pre-releases on bump.
Separate from Metadata to not mess with the interface.
"""

name: str | None = None
index: 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 All @@ -86,15 +97,18 @@ def generate_tree_from_commits(
changelog_message_builder_hook: MessageBuilderHook | None = None,
changelog_release_hook: ChangelogReleaseHook | None = None,
rules: TagRules | None = None,
during_version_bump: bool = False,
) -> 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)
rules = rules or TagRules()

# Check if the latest commit is not tagged

current_tag = get_commit_tag(commits[0], tags) if commits else None
if during_version_bump and rules.merge_prereleases:
current_tag = None
else:
current_tag = get_commit_tag(commits[0], tags) if commits else None
Comment on lines +108 to +111
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
if during_version_bump and rules.merge_prereleases:
current_tag = None
else:
current_tag = get_commit_tag(commits[0], tags) if commits else None
if during_version_bump and rules.merge_prereleases and not commits:
current_tag = None
else:
# Check if the latest commit is not tagged
current_tag = get_commit_tag(commits[0], tags)

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think it would be or not commits, in which case I find it more readable as is.

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes, thanks

current_tag_name = unreleased_version or "Unreleased"
current_tag_date = (
date.today().isoformat() if unreleased_version is not None else ""
Expand Down
13 changes: 11 additions & 2 deletions commitizen/changelog_formats/__init__.py
Original file line number Diff line number Diff line change
@@ -1,17 +1,20 @@
from __future__ import annotations

import sys
from typing import Callable, ClassVar, Protocol
from typing import TYPE_CHECKING, Callable, ClassVar, Protocol

if sys.version_info >= (3, 10):
from importlib import metadata
else:
import importlib_metadata as metadata

from commitizen.changelog import Metadata
from commitizen.config.base_config import BaseConfig
from commitizen.exceptions import ChangelogFormatUnknown

if TYPE_CHECKING:
from commitizen.changelog import IncrementalMergeInfo, Metadata
from commitizen.config.base_config import BaseConfig

CHANGELOG_FORMAT_ENTRYPOINT = "commitizen.changelog_format"
TEMPLATE_EXTENSION = "j2"

Expand Down Expand Up @@ -48,6 +51,12 @@ def get_metadata(self, filepath: str) -> Metadata:
"""
raise NotImplementedError

def get_latest_full_release(self, filepath: str) -> IncrementalMergeInfo:
"""
Extract metadata for the last non-pre-release.
"""
raise NotImplementedError


KNOWN_CHANGELOG_FORMATS: dict[str, type[ChangelogFormat]] = {
ep.name: ep.load()
Expand Down
41 changes: 35 additions & 6 deletions commitizen/changelog_formats/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,15 +2,19 @@

import os
from abc import ABCMeta
from typing import IO, Any, ClassVar
from typing import IO, TYPE_CHECKING, Any, ClassVar

from commitizen.changelog import Metadata
from commitizen.changelog import IncrementalMergeInfo, Metadata
from commitizen.config.base_config import BaseConfig
from commitizen.git import GitTag
from commitizen.tags import TagRules, VersionTag
from commitizen.version_schemes import get_version_scheme

from . import ChangelogFormat

if TYPE_CHECKING:
from commitizen.config.base_config import BaseConfig


class BaseFormat(ChangelogFormat, metaclass=ABCMeta):
"""
Expand Down Expand Up @@ -58,17 +62,42 @@ def get_metadata_from_file(self, file: IO[Any]) -> Metadata:
meta.unreleased_end = index

# Try to find the latest release done
parsed = self.parse_version_from_title(line)
if parsed:
meta.latest_version = parsed.version
meta.latest_version_tag = parsed.tag
parsed_version = self.parse_version_from_title(line)
if parsed_version:
meta.latest_version = parsed_version.version
meta.latest_version_tag = parsed_version.tag
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 get_latest_full_release(self, filepath: str) -> IncrementalMergeInfo:
if not os.path.isfile(filepath):
return IncrementalMergeInfo()

with open(
filepath, encoding=self.config.settings["encoding"]
) as changelog_file:
return self.get_latest_full_release_from_file(changelog_file)

def get_latest_full_release_from_file(self, file: IO[Any]) -> IncrementalMergeInfo:
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why extract this function? I don't see any benefits.

You could put the whole function body under with open block and the logic is still clear.

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I followed the same pattern as with metadata, which also has an interface calling the get function, which in turn calls the get_from_file function. I think both works, but I would prefer to leave it as is to stay consistent.

latest_version_index: int | None = None
for index, line in enumerate(file):
latest_version_index = index
line = line.strip().lower()

parsed_version = self.parse_version_from_title(line)
if (
parsed_version
and not self.tag_rules.extract_version(
GitTag(parsed_version.tag, "", "")
).is_prerelease
):
return IncrementalMergeInfo(name=parsed_version.tag, index=index)
return IncrementalMergeInfo(index=latest_version_index)

def parse_version_from_title(self, line: str) -> VersionTag | None:
"""
Extract the version from a title line if any
Expand Down
32 changes: 10 additions & 22 deletions commitizen/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -543,13 +543,13 @@ def __call__(
},
{
"name": ["--major"],
"help": "get just the major version",
"help": "get just the major version. Need to be used with --project or --verbose.",
"action": "store_true",
"exclusive_group": "group2",
},
{
"name": ["--minor"],
"help": "get just the minor version",
"help": "get just the minor version. Need to be used with --project or --verbose.",
"action": "store_true",
"exclusive_group": "group2",
},
Expand All @@ -559,8 +559,6 @@ def __call__(
},
}

original_excepthook = sys.excepthook


def commitizen_excepthook(
type: type[BaseException],
Expand All @@ -571,26 +569,19 @@ def commitizen_excepthook(
) -> None:
traceback = traceback if isinstance(traceback, TracebackType) else None
if not isinstance(value, CommitizenException):
original_excepthook(type, value, traceback)
sys.__excepthook__(type, value, traceback)
return

if not no_raise:
no_raise = []
if value.message:
value.output_method(value.message)
if debug:
original_excepthook(type, value, traceback)
sys.__excepthook__(type, value, traceback)
exit_code = value.exit_code
if exit_code in no_raise:
exit_code = ExitCode.EXPECTED_EXIT
if no_raise is not None and exit_code in no_raise:
sys.exit(ExitCode.EXPECTED_EXIT)
sys.exit(exit_code)


commitizen_debug_excepthook = partial(commitizen_excepthook, debug=True)

sys.excepthook = commitizen_excepthook


def parse_no_raise(comma_separated_no_raise: str) -> list[int]:
"""Convert the given string to exit codes.

Expand Down Expand Up @@ -682,15 +673,12 @@ def main() -> None:
elif not conf.path:
conf.update({"name": "cz_conventional_commits"})

sys.excepthook = commitizen_excepthook
if args.debug:
logging.getLogger("commitizen").setLevel(logging.DEBUG)
sys.excepthook = commitizen_debug_excepthook
elif args.no_raise:
no_raise_exit_codes = parse_no_raise(args.no_raise)
no_raise_debug_excepthook = partial(
commitizen_excepthook, no_raise=no_raise_exit_codes
)
sys.excepthook = no_raise_debug_excepthook
sys.excepthook = partial(sys.excepthook, debug=True)
if args.no_raise:
sys.excepthook = partial(sys.excepthook, no_raise=parse_no_raise(args.no_raise))

args.func(conf, arguments)() # type: ignore[arg-type]

Expand Down
6 changes: 4 additions & 2 deletions commitizen/cmd.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,13 +2,15 @@

import os
import subprocess
from collections.abc import Mapping
from typing import NamedTuple
from typing import TYPE_CHECKING, NamedTuple

from charset_normalizer import from_bytes

from commitizen.exceptions import CharacterSetDecodeError

if TYPE_CHECKING:
from collections.abc import Mapping


class Command(NamedTuple):
out: str
Expand Down
Loading