From f9afbd4fd4900d038879f222aa3c93632655789e Mon Sep 17 00:00:00 2001 From: Yu-Ting Hsiung Date: Fri, 12 Dec 2025 19:27:41 +0800 Subject: [PATCH] feat(version): add MANUAL_VERSION, --next and --patch to version command, remove type alias --- commitizen/bump.py | 6 +- commitizen/cli.py | 46 +++++++++--- commitizen/commands/init.py | 10 ++- commitizen/commands/version.py | 72 ++++++++++++++----- commitizen/out.py | 22 +++--- commitizen/tags.py | 15 ++-- commitizen/version_increment.py | 28 ++++++++ commitizen/version_schemes.py | 18 ++--- docs/commands/version.md | 2 + tests/commands/test_version_command.py | 66 +++++++++++++++-- ...shows_description_when_use_help_option.txt | 39 ++++++---- 11 files changed, 243 insertions(+), 81 deletions(-) create mode 100644 commitizen/version_increment.py diff --git a/commitizen/bump.py b/commitizen/bump.py index cb572d3612..030c8f1e5b 100644 --- a/commitizen/bump.py +++ b/commitizen/bump.py @@ -15,7 +15,7 @@ if TYPE_CHECKING: from collections.abc import Generator, Iterable - from commitizen.version_schemes import Increment, Version + from commitizen.version_schemes import Increment, VersionProtocol VERSION_TYPES = [None, PATCH, MINOR, MAJOR] @@ -131,8 +131,8 @@ def _resolve_files_and_regexes( def create_commit_message( - current_version: Version | str, - new_version: Version | str, + current_version: VersionProtocol | str, + new_version: VersionProtocol | str, message_template: str | None = None, ) -> str: if message_template is None: diff --git a/commitizen/cli.py b/commitizen/cli.py index e5538aeb49..015ef33978 100644 --- a/commitizen/cli.py +++ b/commitizen/cli.py @@ -20,6 +20,7 @@ InvalidCommandArgumentError, NoCommandFoundError, ) +from commitizen.version_increment import VersionIncrement logger = logging.getLogger(__name__) @@ -505,37 +506,38 @@ def __call__( { "name": ["version"], "description": ( - "get the version of the installed commitizen or the current project" - " (default: installed commitizen)" + "Shows version of Commitizen or the version of the current project. " + "Combine with other options to simulate the version increment process and play with the selected version scheme. " + "If no arguments are provided, just show the installed Commitizen version." ), "help": ( - "get the version of the installed commitizen or the current project" - " (default: installed commitizen)" + "Shows version of Commitizen or the version of the current project. " + "Combine with other options to simulate the version increment process and play with the selected version scheme." ), "func": commands.Version, "arguments": [ { "name": ["-r", "--report"], - "help": "get system information for reporting bugs", + "help": "Get system information for reporting bugs", "action": "store_true", "exclusive_group": "group1", }, { "name": ["-p", "--project"], - "help": "get the version of the current project", + "help": "Get the version of the current project", "action": "store_true", "exclusive_group": "group1", }, { "name": ["-c", "--commitizen"], - "help": "get the version of the installed commitizen", + "help": "Get the version of the installed commitizen", "action": "store_true", "exclusive_group": "group1", }, { "name": ["-v", "--verbose"], "help": ( - "get the version of both the installed commitizen " + "Get the version of both the installed commitizen " "and the current project" ), "action": "store_true", @@ -543,16 +545,40 @@ def __call__( }, { "name": ["--major"], - "help": "get just the major version. Need to be used with --project or --verbose.", + "help": "Output the major version only. Need to be used with MANUAL_VERSION, --project or --verbose.", "action": "store_true", "exclusive_group": "group2", }, { "name": ["--minor"], - "help": "get just the minor version. Need to be used with --project or --verbose.", + "help": "Output the minor version only. Need to be used with MANUAL_VERSION, --project or --verbose.", "action": "store_true", "exclusive_group": "group2", }, + { + "name": ["--patch"], + "help": "Output the patch version only. Need to be used with MANUAL_VERSION, --project or --verbose.", + "action": "store_true", + "exclusive_group": "group2", + }, + { + "name": ["--next"], + "help": "Output the next version.", + "type": str, + "nargs": "?", + "default": None, + "const": "USE_GIT_COMMITS", + "choices": ["USE_GIT_COMMITS"] + + [str(increment) for increment in VersionIncrement], + "exclusive_group": "group2", + }, + { + "name": "manual_version", + "type": str, + "nargs": "?", + "help": "Use the version provided instead of the version from the project. Can be used to test the selected version scheme.", + "metavar": "MANUAL_VERSION", + }, ], }, ], diff --git a/commitizen/commands/init.py b/commitizen/commands/init.py index 62678a2244..b03a4c82e3 100644 --- a/commitizen/commands/init.py +++ b/commitizen/commands/init.py @@ -13,7 +13,11 @@ from commitizen.defaults import CONFIG_FILES, DEFAULT_SETTINGS from commitizen.exceptions import InitFailedError, NoAnswersError from commitizen.git import get_latest_tag_name, get_tag_names, smart_open -from commitizen.version_schemes import KNOWN_SCHEMES, Version, get_version_scheme +from commitizen.version_schemes import ( + KNOWN_SCHEMES, + VersionProtocol, + get_version_scheme, +) if TYPE_CHECKING: from commitizen.config import ( @@ -238,7 +242,7 @@ def _ask_version_scheme(self) -> str: ).unsafe_ask() return scheme - def _ask_major_version_zero(self, version: Version) -> bool: + def _ask_major_version_zero(self, version: VersionProtocol) -> bool: """Ask for setting: major_version_zero""" if version.major > 0: return False @@ -295,7 +299,7 @@ def _write_config_to_file( cz_name: str, version_provider: str, version_scheme: str, - version: Version, + version: VersionProtocol, tag_format: str, update_changelog_on_bump: bool, major_version_zero: bool, diff --git a/commitizen/commands/version.py b/commitizen/commands/version.py index 9290e80b8f..8fbccf8b58 100644 --- a/commitizen/commands/version.py +++ b/commitizen/commands/version.py @@ -2,21 +2,31 @@ import sys from typing import TypedDict +from packaging.version import InvalidVersion + from commitizen import out from commitizen.__version__ import __version__ from commitizen.config import BaseConfig from commitizen.exceptions import NoVersionSpecifiedError, VersionSchemeUnknown from commitizen.providers import get_provider +from commitizen.version_increment import VersionIncrement from commitizen.version_schemes import get_version_scheme class VersionArgs(TypedDict, total=False): + manual_version: str | None + next: str | None + + # Exclusive groups 1 commitizen: bool report: bool project: bool verbose: bool + + # Exclusive groups 2 major: bool minor: bool + patch: bool class Version: @@ -41,24 +51,53 @@ def __call__(self) -> None: if self.arguments.get("verbose"): out.write(f"Installed Commitizen Version: {__version__}") - if not self.arguments.get("commitizen") and ( - self.arguments.get("project") or self.arguments.get("verbose") + if self.arguments.get("commitizen"): + out.write(__version__) + return + + if ( + self.arguments.get("project") + or self.arguments.get("verbose") + or self.arguments.get("next") + or self.arguments.get("manual_version") ): + version_str = self.arguments.get("manual_version") + if version_str is None: + try: + version_str = get_provider(self.config).get_version() + except NoVersionSpecifiedError: + out.error("No project information in this project.") + return try: - version = get_provider(self.config).get_version() - except NoVersionSpecifiedError: - out.error("No project information in this project.") - return - try: - version_scheme = get_version_scheme(self.config.settings)(version) + version_scheme = get_version_scheme(self.config.settings) except VersionSchemeUnknown: out.error("Unknown version scheme.") return + try: + version = version_scheme(version_str) + except InvalidVersion: + out.error(f"Invalid version: '{version_str}'") + return + + if next_increment_str := self.arguments.get("next"): + if next_increment_str == "USE_GIT_COMMITS": + # TODO: implement this + raise NotImplementedError("USE_GIT_COMMITS is not implemented") + + next_increment = VersionIncrement.safe_cast(next_increment_str) + # TODO: modify the interface of bump to accept VersionIncrement + version = version.bump(increment=str(next_increment)) # type: ignore[arg-type] + if self.arguments.get("major"): - version = f"{version_scheme.major}" - elif self.arguments.get("minor"): - version = f"{version_scheme.minor}" + out.write(version.major) + return + if self.arguments.get("minor"): + out.write(version.minor) + return + if self.arguments.get("patch"): + out.write(version.micro) + return out.write( f"Project Version: {version}" @@ -67,11 +106,12 @@ def __call__(self) -> None: ) return - if self.arguments.get("major") or self.arguments.get("minor"): - out.error( - "Major or minor version can only be used with --project or --verbose." - ) - return + for argument in ("major", "minor", "patch"): + if self.arguments.get(argument): + out.error( + f"{argument} can only be used with MANUAL_VERSION, --project or --verbose." + ) + return # If no arguments are provided, just show the installed commitizen version out.write(__version__) diff --git a/commitizen/out.py b/commitizen/out.py index 1bbfe4329d..cdc80cf521 100644 --- a/commitizen/out.py +++ b/commitizen/out.py @@ -9,35 +9,35 @@ sys.stdout.reconfigure(encoding="utf-8") -def write(value: str, *args: object) -> None: +def write(value: object, *args: object) -> None: """Intended to be used when value is multiline.""" print(value, *args) -def line(value: str, *args: object, **kwargs: Any) -> None: +def line(value: object, *args: object, **kwargs: Any) -> None: """Wrapper in case I want to do something different later.""" print(value, *args, **kwargs) -def error(value: str) -> None: - message = colored(value, "red") +def error(value: object) -> None: + message = colored(str(value), "red") line(message, file=sys.stderr) -def success(value: str) -> None: - message = colored(value, "green") +def success(value: object) -> None: + message = colored(str(value), "green") line(message) -def info(value: str) -> None: - message = colored(value, "blue") +def info(value: object) -> None: + message = colored(str(value), "blue") line(message) -def diagnostic(value: str) -> None: +def diagnostic(value: object) -> None: line(value, file=sys.stderr) -def warn(value: str) -> None: - message = colored(value, "magenta") +def warn(value: object) -> None: + message = colored(str(value), "magenta") line(message, file=sys.stderr) diff --git a/commitizen/tags.py b/commitizen/tags.py index 68c74a72e6..89e78971b2 100644 --- a/commitizen/tags.py +++ b/commitizen/tags.py @@ -14,8 +14,7 @@ from commitizen.version_schemes import ( DEFAULT_SCHEME, InvalidVersion, - Version, - VersionScheme, + VersionProtocol, get_version_scheme, ) @@ -23,8 +22,6 @@ import sys from collections.abc import Iterable, Sequence - from commitizen.version_schemes import VersionScheme - # Self is Python 3.11+ but backported in typing-extensions if sys.version_info < (3, 11): from typing_extensions import Self @@ -75,7 +72,7 @@ class TagRules: assert not rules.is_version_tag("warn1.0.0", warn=True) # Does warn assert rules.search_version("# My v1.0.0 version").version == "1.0.0" - assert rules.extract_version("v1.0.0") == Version("1.0.0") + assert rules.extract_version("v1.0.0") == VersionProtocol("1.0.0") try: assert rules.extract_version("not-a-v1.0.0") except InvalidVersion: @@ -83,7 +80,7 @@ class TagRules: ``` """ - scheme: VersionScheme = DEFAULT_SCHEME + scheme: type[VersionProtocol] = DEFAULT_SCHEME tag_format: str = DEFAULT_SETTINGS["tag_format"] legacy_tag_formats: Sequence[str] = field(default_factory=list) ignored_tag_formats: Sequence[str] = field(default_factory=list) @@ -145,7 +142,7 @@ def get_version_tags( """Filter in version tags and warn on unexpected tags""" return [tag for tag in tags if self.is_version_tag(tag, warn)] - def extract_version(self, tag: GitTag) -> Version: + def extract_version(self, tag: GitTag) -> VersionProtocol: """ Extract a version from the tag as defined in tag formats. @@ -211,7 +208,7 @@ def search_version(self, text: str, last: bool = False) -> VersionTag | None: return VersionTag(version, match.group(0)) def normalize_tag( - self, version: Version | str, tag_format: str | None = None + self, version: VersionProtocol | str, tag_format: str | None = None ) -> str: """ The tag and the software version might be different. @@ -241,7 +238,7 @@ def normalize_tag( ) def find_tag_for( - self, tags: Iterable[GitTag], version: Version | str + self, tags: Iterable[GitTag], version: VersionProtocol | str ) -> GitTag | None: """Find the first matching tag for a given version.""" version = self.scheme(version) if isinstance(version, str) else version diff --git a/commitizen/version_increment.py b/commitizen/version_increment.py new file mode 100644 index 0000000000..6e689133de --- /dev/null +++ b/commitizen/version_increment.py @@ -0,0 +1,28 @@ +from enum import IntEnum + + +class VersionIncrement(IntEnum): + """An enumeration representing semantic versioning increments. + This class defines the four types of version increments according to semantic versioning: + - NONE: For commits that don't require a version bump (docs, style, etc.) + - PATCH: For backwards-compatible bug fixes + - MINOR: For backwards-compatible functionality additions + - MAJOR: For incompatible API changes + """ + + NONE = 0 + PATCH = 1 + MINOR = 2 + MAJOR = 3 + + def __str__(self) -> str: + return self.name + + @classmethod + def safe_cast(cls, value: object) -> "VersionIncrement": + if not isinstance(value, str): + return VersionIncrement.NONE + try: + return cls[value] + except KeyError: + return VersionIncrement.NONE diff --git a/commitizen/version_schemes.py b/commitizen/version_schemes.py index c03d908aab..52ac7c38b5 100644 --- a/commitizen/version_schemes.py +++ b/commitizen/version_schemes.py @@ -31,7 +31,7 @@ from typing import Self -Increment: TypeAlias = Literal["MAJOR", "MINOR", "PATCH"] +Increment: TypeAlias = Literal["MAJOR", "MINOR", "PATCH"] # TODO: deprecate Prerelease: TypeAlias = Literal["alpha", "beta", "rc"] _DEFAULT_VERSION_PARSER = re.compile( r"v?(?P([0-9]+)\.([0-9]+)(?:\.([0-9]+))?(?:-([0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*))?(?:\+[0-9A-Za-z.]+)?(\w+)?)" @@ -56,7 +56,7 @@ def __str__(self) -> str: raise NotImplementedError("must be implemented") @property - def scheme(self) -> VersionScheme: + def scheme(self) -> type[VersionProtocol]: """The version scheme this version follows.""" raise NotImplementedError("must be implemented") @@ -140,9 +140,7 @@ def bump( """ -# With PEP 440 and SemVer semantic, Scheme is the type, Version is an instance -Version: TypeAlias = VersionProtocol -VersionScheme: TypeAlias = type[VersionProtocol] +# Note: version schemes are classes that initialize a VersionProtocol instance class BaseVersion(_BaseVersion): @@ -154,7 +152,7 @@ class BaseVersion(_BaseVersion): """Regex capturing this version scheme into a `version` group""" @property - def scheme(self) -> VersionScheme: + def scheme(self) -> type[VersionProtocol]: return self.__class__ @property @@ -390,7 +388,7 @@ def _get_prerelease(self) -> str: return ".".join(prerelease_parts) -DEFAULT_SCHEME: VersionScheme = Pep440 +DEFAULT_SCHEME: type[VersionProtocol] = Pep440 SCHEMES_ENTRYPOINT = "commitizen.scheme" """Schemes entrypoints group""" @@ -399,7 +397,9 @@ def _get_prerelease(self) -> str: """All known registered version schemes""" -def get_version_scheme(settings: Settings, name: str | None = None) -> VersionScheme: +def get_version_scheme( + settings: Settings, name: str | None = None +) -> type[VersionProtocol]: """ Get the version scheme as defined in the configuration or from an overridden `name`. @@ -425,7 +425,7 @@ def get_version_scheme(settings: Settings, name: str | None = None) -> VersionSc (ep,) = metadata.entry_points(name=name, group=SCHEMES_ENTRYPOINT) except ValueError: raise VersionSchemeUnknown(f'Version scheme "{name}" unknown.') - scheme = cast("VersionScheme", ep.load()) + scheme = cast("type[VersionProtocol]", ep.load()) if not isinstance(scheme, VersionProtocol): warnings.warn(f"Version scheme {name} does not implement the VersionProtocol") diff --git a/docs/commands/version.md b/docs/commands/version.md index 4d2e6a0323..8a5b73a971 100644 --- a/docs/commands/version.md +++ b/docs/commands/version.md @@ -3,3 +3,5 @@ Get the version of the installed Commitizen or the current project (default: ins ## Usage ![cz version --help](../images/cli_help/cz_version___help.svg) + + diff --git a/tests/commands/test_version_command.py b/tests/commands/test_version_command.py index a5faf4e16d..d0659b6f0e 100644 --- a/tests/commands/test_version_command.py +++ b/tests/commands/test_version_command.py @@ -22,13 +22,13 @@ def test_version_for_showing_project_version_error(config, capsys): def test_version_for_showing_project_version(config, capsys): - config.settings["version"] = "v0.0.1" + config.settings["version"] = "0.0.1" commands.Version( config, {"project": True}, )() captured = capsys.readouterr() - assert "v0.0.1" in captured.out + assert "0.0.1" in captured.out @pytest.mark.parametrize("project", (True, False)) @@ -52,14 +52,14 @@ def test_version_for_showing_both_versions_no_project(config, capsys): def test_version_for_showing_both_versions(config, capsys): - config.settings["version"] = "v0.0.1" + config.settings["version"] = "0.0.1" commands.Version( config, {"verbose": True}, )() captured = capsys.readouterr() expected_out = ( - f"Installed Commitizen Version: {__version__}\nProject Version: v0.0.1" + f"Installed Commitizen Version: {__version__}\nProject Version: 0.0.1" ) assert expected_out in captured.out @@ -174,7 +174,7 @@ def test_version_just_minor(config, capsys, version: str, expected_version: str) assert expected_version == captured.out -@pytest.mark.parametrize("argument", ("major", "minor")) +@pytest.mark.parametrize("argument", ("major", "minor", "patch")) def test_version_just_major_error_no_project(config, capsys, argument: str): commands.Version( config, @@ -185,6 +185,58 @@ def test_version_just_major_error_no_project(config, capsys, argument: str): captured = capsys.readouterr() assert not captured.out assert ( - "Major or minor version can only be used with --project or --verbose." - in captured.err + "can only be used with MANUAL_VERSION, --project or --verbose." in captured.err ) + + +@pytest.mark.parametrize( + "next_increment, current_version, expected_version", + [ + ("MAJOR", "1.1.0", "2.0.0"), + ("MAJOR", "1.0.0", "2.0.0"), + ("MAJOR", "0.0.1", "1.0.0"), + ("MINOR", "1.1.0", "1.2.0"), + ("MINOR", "1.0.0", "1.1.0"), + ("MINOR", "0.0.1", "0.1.0"), + ("PATCH", "1.1.0", "1.1.1"), + ("PATCH", "1.0.0", "1.0.1"), + ("PATCH", "0.0.1", "0.0.2"), + ("NONE", "1.0.0", "1.0.0"), + ], +) +def test_next_version_major( + config, capsys, next_increment: str, current_version: str, expected_version: str +): + config.settings["version"] = current_version + for project in (True, False): + commands.Version( + config, + { + "next": next_increment, + "project": project, + }, + )() + captured = capsys.readouterr() + assert expected_version in captured.out + + # Use the same settings to test the manual version + commands.Version( + config, + { + "manual_version": current_version, + "next": next_increment, + }, + )() + captured = capsys.readouterr() + assert expected_version in captured.out + + +def test_next_version_invalid_version(config, capsys): + commands.Version( + config, + { + "manual_version": "INVALID", + }, + )() + captured = capsys.readouterr() + assert "Invalid version: 'INVALID'" in captured.err diff --git a/tests/commands/test_version_command/test_version_command_shows_description_when_use_help_option.txt b/tests/commands/test_version_command/test_version_command_shows_description_when_use_help_option.txt index a194615a98..4e2a314a78 100644 --- a/tests/commands/test_version_command/test_version_command_shows_description_when_use_help_option.txt +++ b/tests/commands/test_version_command/test_version_command_shows_description_when_use_help_option.txt @@ -1,16 +1,29 @@ -usage: cz version [-h] [-r | -p | -c | -v] [--major | --minor] +usage: cz version [-h] [-r | -p | -c | -v] [--major | --minor | --patch | + --next [{USE_GIT_COMMITS,NONE,PATCH,MINOR,MAJOR}]] + [MANUAL_VERSION] -get the version of the installed commitizen or the current project (default: -installed commitizen) +Shows version of Commitizen or the version of the current project. Combine +with other options to simulate the version increment process and play with the +selected version scheme. If no arguments are provided, just show the installed +Commitizen version. + +positional arguments: + MANUAL_VERSION Use the version provided instead of the version from + the project. Can be used to test the selected version + scheme. options: - -h, --help show this help message and exit - -r, --report get system information for reporting bugs - -p, --project get the version of the current project - -c, --commitizen get the version of the installed commitizen - -v, --verbose get the version of both the installed commitizen and the - current project - --major get just the major version. Need to be used with --project - or --verbose. - --minor get just the minor version. Need to be used with --project - or --verbose. + -h, --help show this help message and exit + -r, --report Get system information for reporting bugs + -p, --project Get the version of the current project + -c, --commitizen Get the version of the installed commitizen + -v, --verbose Get the version of both the installed commitizen and + the current project + --major Output the major version only. Need to be used with + MANUAL_VERSION, --project or --verbose. + --minor Output the minor version only. Need to be used with + MANUAL_VERSION, --project or --verbose. + --patch Output the patch version only. Need to be used with + MANUAL_VERSION, --project or --verbose. + --next [{USE_GIT_COMMITS,NONE,PATCH,MINOR,MAJOR}] + Output the next version.