diff --git a/commitizen/bump.py b/commitizen/bump.py index e8e9dcedc3..7134ef6a6d 100644 --- a/commitizen/bump.py +++ b/commitizen/bump.py @@ -2,23 +2,13 @@ import os import re -import sys -import typing from collections import OrderedDict -from itertools import zip_longest from string import Template -from packaging.version import Version - -from commitizen.defaults import MAJOR, MINOR, PATCH, bump_message +from commitizen.defaults import bump_message from commitizen.exceptions import CurrentVersionNotFoundError from commitizen.git import GitCommit, smart_open - -if sys.version_info >= (3, 8): - from commitizen.version_types import VersionProtocol -else: - # workaround mypy issue for 3.7 python - VersionProtocol = typing.Any +from commitizen.version_schemes import DEFAULT_SCHEME, VersionScheme, Version def find_increment( @@ -54,115 +44,6 @@ def find_increment( return increment -def prerelease_generator( - current_version: str, prerelease: str | None = None, offset: int = 0 -) -> str: - """Generate prerelease - - X.YaN # Alpha release - X.YbN # Beta release - X.YrcN # Release Candidate - X.Y # Final - - This function might return something like 'alpha1' - but it will be handled by Version. - """ - if not prerelease: - return "" - - version = Version(current_version) - # version.pre is needed for mypy check - if version.is_prerelease and version.pre and prerelease.startswith(version.pre[0]): - prev_prerelease: int = version.pre[1] - new_prerelease_number = prev_prerelease + 1 - else: - new_prerelease_number = offset - pre_version = f"{prerelease}{new_prerelease_number}" - return pre_version - - -def devrelease_generator(devrelease: int = None) -> str: - """Generate devrelease - - The devrelease version should be passed directly and is not - inferred based on the previous version. - """ - if devrelease is None: - return "" - - return f"dev{devrelease}" - - -def semver_generator(current_version: str, increment: str = None) -> str: - version = Version(current_version) - prev_release = list(version.release) - increments = [MAJOR, MINOR, PATCH] - increments_version = dict(zip_longest(increments, prev_release, fillvalue=0)) - - # This flag means that current version - # must remove its prerelease tag, - # so it doesn't matter the increment. - # Example: 1.0.0a0 with PATCH/MINOR -> 1.0.0 - if not version.is_prerelease: - if increment == MAJOR: - increments_version[MAJOR] += 1 - increments_version[MINOR] = 0 - increments_version[PATCH] = 0 - elif increment == MINOR: - increments_version[MINOR] += 1 - increments_version[PATCH] = 0 - elif increment == PATCH: - increments_version[PATCH] += 1 - - return str( - f"{increments_version['MAJOR']}." - f"{increments_version['MINOR']}." - f"{increments_version['PATCH']}" - ) - - -def generate_version( - current_version: str, - increment: str, - prerelease: str | None = None, - prerelease_offset: int = 0, - devrelease: int | None = None, - is_local_version: bool = False, - version_type_cls: type[VersionProtocol] | None = None, -) -> VersionProtocol: - """Based on the given increment a proper semver will be generated. - - For now the rules and versioning scheme is based on - python's PEP 0440. - More info: https://www.python.org/dev/peps/pep-0440/ - - Example: - PATCH 1.0.0 -> 1.0.1 - MINOR 1.0.0 -> 1.1.0 - MAJOR 1.0.0 -> 2.0.0 - """ - if version_type_cls is None: - version_type_cls = Version - if is_local_version: - version = version_type_cls(current_version) - dev_version = devrelease_generator(devrelease=devrelease) - pre_version = prerelease_generator( - str(version.local), prerelease=prerelease, offset=prerelease_offset - ) - semver = semver_generator(str(version.local), increment=increment) - - return version_type_cls(f"{version.public}+{semver}{pre_version}{dev_version}") - else: - dev_version = devrelease_generator(devrelease=devrelease) - pre_version = prerelease_generator( - current_version, prerelease=prerelease, offset=prerelease_offset - ) - semver = semver_generator(current_version, increment=increment) - - # TODO: post version - return version_type_cls(f"{semver}{pre_version}{dev_version}") - - def update_version_in_files( current_version: str, new_version: str, files: list[str], *, check_consistency=False ) -> None: @@ -219,9 +100,9 @@ def _version_to_regex(version: str) -> str: def normalize_tag( - version: VersionProtocol | str, + version: Version | str, tag_format: str | None = None, - version_type_cls: type[VersionProtocol] | None = None, + scheme: VersionScheme | None = None, ) -> str: """The tag and the software version might be different. @@ -234,19 +115,14 @@ def normalize_tag( | ver1.0.0 | 1.0.0 | | ver1.0.0.a0 | 1.0.0a0 | """ - if version_type_cls is None: - version_type_cls = Version - if isinstance(version, str): - version = version_type_cls(version) + scheme = scheme or DEFAULT_SCHEME + version = scheme(version) if isinstance(version, str) else version if not tag_format: return str(version) major, minor, patch = version.release - prerelease = "" - # version.pre is needed for mypy check - if version.is_prerelease and version.pre: - prerelease = f"{version.pre[0]}{version.pre[1]}" + prerelease = version.prerelease or "" t = Template(tag_format) return t.safe_substitute( @@ -257,7 +133,7 @@ def normalize_tag( def create_commit_message( current_version: Version | str, new_version: Version | str, - message_template: str = None, + message_template: str | None = None, ) -> str: if message_template is None: message_template = bump_message diff --git a/commitizen/changelog.py b/commitizen/changelog.py index 91b605b5ad..669f551a0d 100644 --- a/commitizen/changelog.py +++ b/commitizen/changelog.py @@ -24,53 +24,41 @@ - [x] hook after changelog is generated (api calls) - [x] add support for change_type maps """ - from __future__ import annotations import os import re -import sys -import typing from collections import OrderedDict, defaultdict from datetime import date -from typing import Callable, Iterable +from typing import TYPE_CHECKING, Callable, Iterable, cast from jinja2 import Environment, PackageLoader -from packaging.version import InvalidVersion, Version -from commitizen import defaults from commitizen.bump import normalize_tag from commitizen.exceptions import InvalidConfigurationError, NoCommitsFoundError from commitizen.git import GitCommit, GitTag +from commitizen.version_schemes import DEFAULT_SCHEME, Pep440, InvalidVersion -if sys.version_info >= (3, 8): - from commitizen.version_types import VersionProtocol -else: - # workaround mypy issue for 3.7 python - VersionProtocol = typing.Any +if TYPE_CHECKING: + from commitizen.version_schemes import VersionScheme def get_commit_tag(commit: GitCommit, tags: list[GitTag]) -> GitTag | None: return next((tag for tag in tags if tag.rev == commit.rev), None) -def get_version(tag: GitTag) -> Version | None: - version = None - try: - version = Version(tag.name) - except InvalidVersion: - pass - return version - - def tag_included_in_changelog( - tag: GitTag, used_tags: list, merge_prerelease: bool + tag: GitTag, + used_tags: list, + merge_prerelease: bool, + scheme: VersionScheme = DEFAULT_SCHEME, ) -> bool: if tag in used_tags: return False - version = get_version(tag) - if version is None: + try: + version = scheme(tag.name) + except InvalidVersion: return False if merge_prerelease and version.is_prerelease: @@ -88,6 +76,7 @@ def generate_tree_from_commits( change_type_map: dict[str, str] | None = None, changelog_message_builder_hook: Callable | None = None, merge_prerelease: bool = False, + scheme: VersionScheme = DEFAULT_SCHEME, ) -> Iterable[dict]: pat = re.compile(changelog_pattern) map_pat = re.compile(commit_parser, re.MULTILINE) @@ -113,7 +102,7 @@ def generate_tree_from_commits( commit_tag = get_commit_tag(commit, tags) if commit_tag is not None and tag_included_in_changelog( - commit_tag, used_tags, merge_prerelease + commit_tag, used_tags, merge_prerelease, scheme=scheme ): used_tags.append(commit_tag) yield { @@ -186,13 +175,15 @@ def render_changelog(tree: Iterable) -> str: return changelog -def parse_version_from_markdown(value: str) -> str | None: +def parse_version_from_markdown( + value: str, scheme: VersionScheme = Pep440 +) -> str | None: if not value.startswith("#"): return None - m = re.search(defaults.version_parser, value) + m = scheme.parser.search(value) if not m: return None - return m.groupdict().get("version") + return cast(str, m.group("version")) def parse_title_type_of_line(value: str) -> str | None: @@ -203,7 +194,7 @@ def parse_title_type_of_line(value: str) -> str | None: return m.groupdict().get("title") -def get_metadata(filepath: str) -> dict: +def get_metadata(filepath: str, scheme: VersionScheme = Pep440) -> dict: unreleased_start: int | None = None unreleased_end: int | None = None unreleased_title: str | None = None @@ -236,7 +227,7 @@ def get_metadata(filepath: str) -> dict: unreleased_end = index # Try to find the latest release done - version = parse_version_from_markdown(line) + version = parse_version_from_markdown(line, scheme) if version: latest_version = version latest_version_position = index @@ -328,7 +319,7 @@ def get_oldest_and_newest_rev( tags: list[GitTag], version: str, tag_format: str, - version_type_cls: type[VersionProtocol] | None = None, + scheme: VersionScheme | None = None, ) -> tuple[str | None, str | None]: """Find the tags for the given version. @@ -343,15 +334,11 @@ def get_oldest_and_newest_rev( except ValueError: newest = version - newest_tag = normalize_tag( - newest, tag_format=tag_format, version_type_cls=version_type_cls - ) + newest_tag = normalize_tag(newest, tag_format=tag_format, scheme=scheme) oldest_tag = None if oldest: - oldest_tag = normalize_tag( - oldest, tag_format=tag_format, version_type_cls=version_type_cls - ) + oldest_tag = normalize_tag(oldest, tag_format=tag_format, scheme=scheme) tags_range = get_smart_tag_range(tags, newest=newest_tag, oldest=oldest_tag) if not tags_range: diff --git a/commitizen/cli.py b/commitizen/cli.py index e6d96b656b..e00a528213 100644 --- a/commitizen/cli.py +++ b/commitizen/cli.py @@ -10,7 +10,7 @@ import argcomplete from decli import cli -from commitizen import commands, config, out, version_types +from commitizen import commands, config, out, version_schemes from commitizen.exceptions import ( CommitizenException, ExitCode, @@ -204,6 +204,18 @@ "default": None, "help": "start pre-releases with this offset", }, + { + "name": ["--version-scheme"], + "help": "choose version scheme", + "default": None, + "choices": version_schemes.KNOWN_SCHEMES, + }, + { + "name": ["--version-type"], + "help": "Deprecated, use --version-scheme", + "default": None, + "choices": version_schemes.KNOWN_SCHEMES, + }, { "name": "manual_version", "type": str, @@ -211,12 +223,6 @@ "help": "bump to the given version (e.g: 1.5.3)", "metavar": "MANUAL_VERSION", }, - { - "name": ["--version-type"], - "help": "choose version type", - "default": None, - "choices": version_types.VERSION_TYPES, - }, ], }, { @@ -275,6 +281,12 @@ "If not set, it will include prereleases in the changelog" ), }, + { + "name": ["--version-scheme"], + "help": "choose version scheme", + "default": None, + "choices": version_schemes.KNOWN_SCHEMES, + }, ], }, { @@ -354,7 +366,7 @@ def commitizen_excepthook( - type, value, traceback, debug=False, no_raise: list[int] = None + type, value, traceback, debug=False, no_raise: list[int] | None = None ): traceback = traceback if isinstance(traceback, TracebackType) else None if not no_raise: diff --git a/commitizen/commands/bump.py b/commitizen/commands/bump.py index 3d6679a518..a5b73aca4b 100644 --- a/commitizen/commands/bump.py +++ b/commitizen/commands/bump.py @@ -2,9 +2,11 @@ import os from logging import getLogger +import warnings import questionary -from commitizen import bump, cmd, factory, git, hooks, out, version_types + +from commitizen import bump, cmd, factory, git, hooks, out from commitizen.commands.changelog import Changelog from commitizen.config import BaseConfig from commitizen.exceptions import ( @@ -21,7 +23,7 @@ NoVersionSpecifiedError, ) from commitizen.providers import get_provider -from packaging.version import InvalidVersion, Version +from commitizen.version_schemes import get_version_scheme, InvalidVersion logger = getLogger("commitizen") @@ -62,10 +64,17 @@ def __init__(self, config: BaseConfig, arguments: dict): self.retry = arguments["retry"] self.pre_bump_hooks = self.config.settings["pre_bump_hooks"] self.post_bump_hooks = self.config.settings["post_bump_hooks"] - version_type = arguments["version_type"] or self.config.settings.get( - "version_type" + deprecated_version_type = arguments.get("version_type") + if deprecated_version_type: + warnings.warn( + DeprecationWarning( + "`--version-type` parameter is deprecated and will be removed in commitizen 4. " + "Please use `--version-scheme` instead" + ) + ) + self.scheme = get_version_scheme( + self.config, arguments["version_scheme"] or deprecated_version_type ) - self.version_type = version_type and version_types.VERSION_TYPES[version_type] def is_initial_tag(self, current_tag_version: str, is_yes: bool = False) -> bool: """Check if reading the whole git tree up to HEAD is needed.""" @@ -108,10 +117,9 @@ def find_increment(self, commits: list[git.GitCommit]) -> str | None: def __call__(self): # noqa: C901 """Steps executed to bump.""" provider = get_provider(self.config) - current_version: str = provider.get_version() try: - current_version_instance: Version = Version(current_version) + current_version = self.scheme(provider.get_version()) except TypeError: raise NoVersionSpecifiedError() @@ -156,7 +164,7 @@ def __call__(self): # noqa: C901 ) if major_version_zero: - if not current_version.startswith("0."): + if not current_version.release[0] == 0: raise NotAllowed( f"--major-version-zero is meaningless for current version {current_version}" ) @@ -164,7 +172,7 @@ def __call__(self): # noqa: C901 current_tag_version: str = bump.normalize_tag( current_version, tag_format=tag_format, - version_type_cls=self.version_type, + scheme=self.scheme, ) is_initial = self.is_initial_tag(current_tag_version, is_yes) @@ -180,12 +188,12 @@ def __call__(self): # noqa: C901 # No commits, there is no need to create an empty tag. # Unless we previously had a prerelease. - if not commits and not current_version_instance.is_prerelease: + if not commits and not current_version.is_prerelease: raise NoCommitsFoundError("[NO_COMMITS_FOUND]\n" "No new commits found.") if manual_version: try: - new_version = Version(manual_version) + new_version = self.scheme(manual_version) except InvalidVersion as exc: raise InvalidManualVersion( "[INVALID_MANUAL_VERSION]\n" @@ -197,11 +205,7 @@ def __call__(self): # noqa: C901 # It may happen that there are commits, but they are not eligible # for an increment, this generates a problem when using prerelease (#281) - if ( - prerelease - and increment is None - and not current_version_instance.is_prerelease - ): + if prerelease and increment is None and not current_version.is_prerelease: raise NoCommitsFoundError( "[NO_COMMITS_FOUND]\n" "No commits found to generate a pre-release.\n" @@ -210,23 +214,21 @@ def __call__(self): # noqa: C901 # Increment is removed when current and next version # are expected to be prereleases. - if prerelease and current_version_instance.is_prerelease: + if prerelease and current_version.is_prerelease: increment = None - new_version = bump.generate_version( - current_version, + new_version = current_version.bump( increment, prerelease=prerelease, prerelease_offset=prerelease_offset, devrelease=devrelease, is_local_version=is_local_version, - version_type_cls=self.version_type, ) new_tag_version = bump.normalize_tag( new_version, tag_format=tag_format, - version_type_cls=self.version_type, + scheme=self.scheme, ) message = bump.create_commit_message( current_version, new_version, bump_commit_message @@ -291,7 +293,7 @@ def __call__(self): # noqa: C901 raise DryRunExit() bump.update_version_in_files( - current_version, + str(current_version), str(new_version), version_files, check_consistency=self.check_consistency, @@ -304,7 +306,7 @@ def __call__(self): # noqa: C901 self.pre_bump_hooks, _env_prefix="CZ_PRE_", is_initial=is_initial, - current_version=current_version, + current_version=str(current_version), current_tag_version=current_tag_version, new_version=new_version.public, new_tag_version=new_tag_version, @@ -346,7 +348,7 @@ def __call__(self): # noqa: C901 self.post_bump_hooks, _env_prefix="CZ_POST_", was_initial=is_initial, - previous_version=current_version, + previous_version=str(current_version), previous_tag_version=current_tag_version, current_version=new_version.public, current_tag_version=new_tag_version, diff --git a/commitizen/commands/changelog.py b/commitizen/commands/changelog.py index d4b577846e..ca65590cdd 100644 --- a/commitizen/commands/changelog.py +++ b/commitizen/commands/changelog.py @@ -5,7 +5,7 @@ from operator import itemgetter from typing import Callable -from commitizen import bump, changelog, defaults, factory, git, out, version_types +from commitizen import bump, changelog, defaults, factory, git, out from commitizen.config import BaseConfig from commitizen.exceptions import ( DryRunExit, @@ -16,7 +16,7 @@ NotAllowed, ) from commitizen.git import GitTag, smart_open -from packaging.version import parse +from commitizen.version_schemes import get_version_scheme class Changelog: @@ -40,12 +40,13 @@ def __init__(self, config: BaseConfig, args): ) self.dry_run = args["dry_run"] - self.current_version = ( - args.get("current_version") or self.config.settings.get("version") or "" - ) - self.current_version_instance = ( - parse(self.current_version) if self.current_version else None + self.scheme = get_version_scheme(self.config, args.get("version_scheme")) + + current_version = ( + args.get("current_version", config.settings.get("version")) or "" ) + self.current_version = self.scheme(current_version) if current_version else None + self.unreleased_version = args["unreleased_version"] self.change_type_map = ( self.config.settings.get("change_type_map") or self.cz.change_type_map @@ -63,9 +64,6 @@ def __init__(self, config: BaseConfig, args): "merge_prerelease" ) or self.config.settings.get("changelog_merge_prerelease") - version_type = self.config.settings.get("version_type") - self.version_type = version_type and version_types.VERSION_TYPES[version_type] - def _find_incremental_rev(self, latest_version: str, tags: list[GitTag]) -> str: """Try to find the 'start_rev'. @@ -146,13 +144,13 @@ def __call__(self): end_rev = "" if self.incremental: - changelog_meta = changelog.get_metadata(self.file_name) + changelog_meta = changelog.get_metadata(self.file_name, self.scheme) latest_version = changelog_meta.get("latest_version") if latest_version: latest_tag_version: str = bump.normalize_tag( latest_version, tag_format=self.tag_format, - version_type_cls=self.version_type, + scheme=self.scheme, ) start_rev = self._find_incremental_rev(latest_tag_version, tags) @@ -161,13 +159,12 @@ def __call__(self): tags, version=self.rev_range, tag_format=self.tag_format, - version_type_cls=self.version_type, + scheme=self.scheme, ) commits = git.get_commits(start=start_rev, end=end_rev, args="--topo-order") if not commits and ( - self.current_version_instance is None - or not self.current_version_instance.is_prerelease + self.current_version is None or not self.current_version.is_prerelease ): raise NoCommitsFoundError("No commits found") @@ -180,6 +177,7 @@ def __call__(self): change_type_map=change_type_map, changelog_message_builder_hook=changelog_message_builder_hook, merge_prerelease=merge_prerelease, + scheme=self.scheme, ) if self.change_type_order: tree = changelog.order_changelog_tree(tree, self.change_type_order) diff --git a/commitizen/commands/init.py b/commitizen/commands/init.py index 52ca081423..b9803d0713 100644 --- a/commitizen/commands/init.py +++ b/commitizen/commands/init.py @@ -13,8 +13,7 @@ from commitizen.defaults import config_files from commitizen.exceptions import InitFailedError, NoAnswersError from commitizen.git import get_latest_tag_name, get_tag_names, smart_open -from commitizen.version_types import VERSION_TYPES -from packaging.version import Version +from commitizen.version_schemes import KNOWN_SCHEMES, Version, get_version_scheme class ProjectInfo: @@ -96,9 +95,9 @@ def __call__(self): cz_name = self._ask_name() # select version_provider = self._ask_version_provider() # select tag = self._ask_tag() # confirm & select - version = Version(tag) + version_scheme = self._ask_version_scheme() # select + version = get_version_scheme(self.config, version_scheme)(tag) tag_format = self._ask_tag_format(tag) # confirm & text - version_type = self._ask_version_type() # select update_changelog_on_bump = self._ask_update_changelog_on_bump() # confirm major_version_zero = self._ask_major_version_zero(version) # confirm except KeyboardInterrupt: @@ -114,7 +113,7 @@ def __call__(self): values_to_add = {} values_to_add["name"] = cz_name values_to_add["tag_format"] = tag_format - values_to_add["version_type"] = version_type + values_to_add["version_scheme"] = version_scheme if version_provider == "commitizen": values_to_add["version"] = version.public @@ -253,19 +252,19 @@ def _ask_version_provider(self) -> str: ).unsafe_ask() return version_provider - def _ask_version_type(self) -> str: - """Ask for setting: version_type""" + def _ask_version_scheme(self) -> str: + """Ask for setting: version_scheme""" default = "semver" if self.project_info.is_python: default = "pep440" - version_type: str = questionary.select( - "Choose version type scheme: ", - choices=[*VERSION_TYPES], + scheme: str = questionary.select( + "Choose version scheme: ", + choices=list(KNOWN_SCHEMES), style=self.cz.style, default=default, ).unsafe_ask() - return version_type + return scheme def _ask_major_version_zero(self, version: Version) -> bool: """Ask for setting: major_version_zero""" diff --git a/commitizen/cz/conventional_commits/conventional_commits.py b/commitizen/cz/conventional_commits/conventional_commits.py index cd26e4c44f..8cb27eaf81 100644 --- a/commitizen/cz/conventional_commits/conventional_commits.py +++ b/commitizen/cz/conventional_commits/conventional_commits.py @@ -32,7 +32,6 @@ class ConventionalCommitsCz(BaseCommitizen): bump_map = defaults.bump_map bump_map_major_version_zero = defaults.bump_map_major_version_zero commit_parser = defaults.commit_parser - version_parser = defaults.version_parser change_type_map = { "feat": "Feat", "fix": "Fix", diff --git a/commitizen/defaults.py b/commitizen/defaults.py index bc0b558148..9f27e6527b 100644 --- a/commitizen/defaults.py +++ b/commitizen/defaults.py @@ -37,6 +37,8 @@ class Settings(TypedDict, total=False): version: str | None version_files: list[str] version_provider: str | None + version_scheme: str | None + version_type: str | None tag_format: str | None bump_message: str | None allow_abort: bool @@ -52,7 +54,6 @@ class Settings(TypedDict, total=False): pre_bump_hooks: list[str] | None post_bump_hooks: list[str] | None prerelease_offset: int - version_type: str | None name: str = "cz_conventional_commits" @@ -70,6 +71,7 @@ class Settings(TypedDict, total=False): "version": None, "version_files": [], "version_provider": "commitizen", + "version_scheme": None, "tag_format": None, # example v$version "bump_message": None, # bumped v$current_version to $new_version "allow_abort": False, @@ -83,7 +85,6 @@ class Settings(TypedDict, total=False): "pre_bump_hooks": [], "post_bump_hooks": [], "prerelease_offset": 0, - "version_type": None, } MAJOR = "MAJOR" @@ -115,4 +116,3 @@ class Settings(TypedDict, total=False): bump_message = "bump: version $current_version → $new_version" commit_parser = r"^((?Pfeat|fix|refactor|perf|BREAKING CHANGE)(?:\((?P[^()\r\n]*)\)|\()?(?P!)?|\w+!):\s(?P.*)?" # noqa -version_parser = r"(?P([0-9]+)\.([0-9]+)\.([0-9]+)(?:-([0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*))?(?:\+[0-9A-Za-z-]+)?(\w+)?)" diff --git a/commitizen/exceptions.py b/commitizen/exceptions.py index ba4aca1397..467582ee6c 100644 --- a/commitizen/exceptions.py +++ b/commitizen/exceptions.py @@ -32,6 +32,7 @@ class ExitCode(enum.IntEnum): INIT_FAILED = 25 RUN_HOOK_FAILED = 26 VERSION_PROVIDER_UNKNOWN = 27 + VERSION_SCHEME_UNKNOWN = 28 class CommitizenException(Exception): @@ -178,3 +179,7 @@ class RunHookError(CommitizenException): class VersionProviderUnknown(CommitizenException): exit_code = ExitCode.VERSION_PROVIDER_UNKNOWN + + +class VersionSchemeUnknown(CommitizenException): + exit_code = ExitCode.VERSION_SCHEME_UNKNOWN diff --git a/commitizen/providers.py b/commitizen/providers.py index 5c4849a635..6d45475070 100644 --- a/commitizen/providers.py +++ b/commitizen/providers.py @@ -8,11 +8,11 @@ import importlib_metadata as metadata import tomlkit -from packaging.version import VERSION_PATTERN, Version from commitizen.config.base_config import BaseConfig from commitizen.exceptions import VersionProviderUnknown from commitizen.git import get_tags +from commitizen.version_schemes import get_version_scheme PROVIDER_ENTRYPOINT = "commitizen.provider" DEFAULT_PROVIDER = "commitizen" @@ -35,14 +35,12 @@ def get_version(self) -> str: """ Get the current version """ - ... @abstractmethod def set_version(self, version: str): """ Set the new current version """ - ... class CommitizenProvider(VersionProvider): @@ -185,7 +183,10 @@ class ScmProvider(VersionProvider): } def _tag_format_matcher(self) -> Callable[[str], str | None]: - pattern = self.config.settings.get("tag_format") or VERSION_PATTERN + version_scheme = get_version_scheme(self.config) + pattern = ( + self.config.settings.get("tag_format") or version_scheme.parser.pattern + ) for var, tag_pattern in self.TAG_FORMAT_REGEXS.items(): pattern = pattern.replace(var, tag_pattern) @@ -208,8 +209,8 @@ def matcher(tag: str) -> str | None: groups["devrelease"] if groups.get("devrelease") else "", ) ) - elif pattern == VERSION_PATTERN: - return str(Version(tag)) + elif pattern == version_scheme.parser.pattern: + return str(version_scheme(tag)) return None return matcher diff --git a/commitizen/version_schemes.py b/commitizen/version_schemes.py new file mode 100644 index 0000000000..897dd40779 --- /dev/null +++ b/commitizen/version_schemes.py @@ -0,0 +1,307 @@ +from __future__ import annotations + +from itertools import zip_longest +import re +import sys + +from typing import TYPE_CHECKING, ClassVar, Type, cast +import warnings +from commitizen.config.base_config import BaseConfig +from commitizen.exceptions import VersionSchemeUnknown + +import importlib_metadata as metadata +from packaging.version import Version as _BaseVersion +from packaging.version import InvalidVersion # noqa: F401: Rexpose the common exception +from commitizen.defaults import MAJOR, MINOR, PATCH + +if sys.version_info >= (3, 8): + from typing import Protocol, runtime_checkable +else: + from typing_extensions import Protocol, runtime_checkable + +if TYPE_CHECKING: + # TypeAlias is Python 3.10+ but backported in typing-extensions + if sys.version_info >= (3, 10): + from typing import TypeAlias + else: + from typing_extensions import TypeAlias + + # Self is Python 3.11+ but backported in typing-extensions + if sys.version_info < (3, 11): + from typing_extensions import Self + + +DEFAULT_VERSION_PARSER = r"v?(?P([0-9]+)\.([0-9]+)\.([0-9]+)(?:-([0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*))?(?:\+[0-9A-Za-z-]+)?(\w+)?)" + + +@runtime_checkable +class VersionProtocol(Protocol): + parser: ClassVar[re.Pattern] + """Regex capturing this version scheme into a `version` group""" + + def __init__(self, version: str): + """ + Initialize a version object from its string representation. + + :raises InvalidVersion: If the ``version`` does not conform to the scheme in any way. + """ + raise NotImplementedError("must be implemented") + + def __str__(self) -> str: + """A string representation of the version that can be rounded-tripped.""" + raise NotImplementedError("must be implemented") + + @property + def scheme(self) -> VersionScheme: + """The version scheme this version follows.""" + raise NotImplementedError("must be implemented") + + @property + def release(self) -> tuple[int, ...]: + """The components of the "release" segment of the version.""" + raise NotImplementedError("must be implemented") + + @property + def is_prerelease(self) -> bool: + """Whether this version is a pre-release.""" + raise NotImplementedError("must be implemented") + + @property + def prerelease(self) -> str | None: + """The prelease potion of the version is this is a prerelease.""" + raise NotImplementedError("must be implemented") + + @property + def public(self) -> str: + """The public portion of the version.""" + raise NotImplementedError("must be implemented") + + @property + def local(self) -> str | None: + """The local version segment of the version.""" + raise NotImplementedError("must be implemented") + + @property + def major(self) -> int: + """The first item of :attr:`release` or ``0`` if unavailable.""" + raise NotImplementedError("must be implemented") + + @property + def minor(self) -> int: + """The second item of :attr:`release` or ``0`` if unavailable.""" + raise NotImplementedError("must be implemented") + + @property + def micro(self) -> int: + """The third item of :attr:`release` or ``0`` if unavailable.""" + raise NotImplementedError("must be implemented") + + def bump( + self, + increment: str, + prerelease: str | None = None, + prerelease_offset: int = 0, + devrelease: int | None = None, + is_local_version: bool = False, + ) -> Self: # type: ignore + """ + Based on the given increment, generate the next bumped version according to the version scheme + """ + + +# With PEP 440 and SemVer semantic, Scheme is the type, Version is an instance +Version: TypeAlias = VersionProtocol +VersionScheme: TypeAlias = Type[VersionProtocol] + + +class BaseVersion(_BaseVersion): + """ + A base class implementing the `VersionProtocol` for PEP440-like versions. + """ + + parser: ClassVar[re.Pattern] = re.compile(DEFAULT_VERSION_PARSER) + """Regex capturing this version scheme into a `version` group""" + + @property + def scheme(self) -> VersionScheme: + return self.__class__ + + @property + def prerelease(self) -> str | None: + # version.pre is needed for mypy check + if self.is_prerelease and self.pre: + return f"{self.pre[0]}{self.pre[1]}" + return None + + def generate_prerelease( + self, prerelease: str | None = None, offset: int = 0 + ) -> str: + """Generate prerelease + + X.YaN # Alpha release + X.YbN # Beta release + X.YrcN # Release Candidate + X.Y # Final + + This function might return something like 'alpha1' + but it will be handled by Version. + """ + if not prerelease: + return "" + + # version.pre is needed for mypy check + if self.is_prerelease and self.pre and prerelease.startswith(self.pre[0]): + prev_prerelease: int = self.pre[1] + new_prerelease_number = prev_prerelease + 1 + else: + new_prerelease_number = offset + pre_version = f"{prerelease}{new_prerelease_number}" + return pre_version + + def generate_devrelease(self, devrelease: int | None) -> str: + """Generate devrelease + + The devrelease version should be passed directly and is not + inferred based on the previous version. + """ + if devrelease is None: + return "" + + return f"dev{devrelease}" + + def increment_base(self, increment: str | None = None) -> str: + prev_release = list(self.release) + increments = [MAJOR, MINOR, PATCH] + base = dict(zip_longest(increments, prev_release, fillvalue=0)) + + # This flag means that current version + # must remove its prerelease tag, + # so it doesn't matter the increment. + # Example: 1.0.0a0 with PATCH/MINOR -> 1.0.0 + if not self.is_prerelease: + if increment == MAJOR: + base[MAJOR] += 1 + base[MINOR] = 0 + base[PATCH] = 0 + elif increment == MINOR: + base[MINOR] += 1 + base[PATCH] = 0 + elif increment == PATCH: + base[PATCH] += 1 + + return f"{base['MAJOR']}.{base['MINOR']}.{base['PATCH']}" + + def bump( + self, + increment: str, + prerelease: str | None = None, + prerelease_offset: int = 0, + devrelease: int | None = None, + is_local_version: bool = False, + ) -> Self: # type: ignore + """Based on the given increment a proper semver will be generated. + + For now the rules and versioning scheme is based on + python's PEP 0440. + More info: https://www.python.org/dev/peps/pep-0440/ + + Example: + PATCH 1.0.0 -> 1.0.1 + MINOR 1.0.0 -> 1.1.0 + MAJOR 1.0.0 -> 2.0.0 + """ + + if self.local and is_local_version: + local_version = self.scheme(self.local).bump(increment) + return self.scheme(f"{self.public}+{local_version}") + else: + base = self.increment_base(increment) + dev_version = self.generate_devrelease(devrelease) + pre_version = self.generate_prerelease(prerelease, offset=prerelease_offset) + # TODO: post version + return self.scheme(f"{base}{pre_version}{dev_version}") + + +class Pep440(BaseVersion): + """ + PEP 440 Version Scheme + + See: https://peps.python.org/pep-0440/ + """ + + +class SemVer(BaseVersion): + """ + Semantic Versioning (SemVer) scheme + + See: https://semver.org/ + """ + + def __str__(self) -> str: + parts = [] + + # Epoch + if self.epoch != 0: + parts.append(f"{self.epoch}!") + + # Release segment + parts.append(".".join(str(x) for x in self.release)) + + # Pre-release + if self.pre: + pre = "".join(str(x) for x in self.pre) + parts.append(f"-{pre}") + + # Post-release + if self.post is not None: + parts.append(f"-post{self.post}") + + # Development release + if self.dev is not None: + parts.append(f"-dev{self.dev}") + + # Local version segment + if self.local: + parts.append(f"+{self.local}") + + return "".join(parts) + + +DEFAULT_SCHEME: VersionScheme = Pep440 + +SCHEMES_ENTRYPOINT = "commitizen.scheme" +"""Schemes entrypoints group""" + +KNOWN_SCHEMES = {ep.name for ep in metadata.entry_points(group=SCHEMES_ENTRYPOINT)} +"""All known registered version schemes""" + + +def get_version_scheme(config: BaseConfig, name: str | None = None) -> VersionScheme: + """ + Get the version scheme as defined in the configuration + or from an overridden `name` + + :raises VersionSchemeUnknown: if the version scheme is not found. + """ + deprecated_setting: str | None = config.settings.get("version_type") + if deprecated_setting: + warnings.warn( + DeprecationWarning( + "`version_type` setting is deprecated and will be removed in commitizen 4. " + "Please use `version_scheme` instead" + ) + ) + name = name or config.settings.get("version_scheme") or deprecated_setting + if not name: + return DEFAULT_SCHEME + + try: + (ep,) = metadata.entry_points(name=name, group=SCHEMES_ENTRYPOINT) + except ValueError: + raise VersionSchemeUnknown(f'Version scheme "{name}" unknown.') + scheme = cast(VersionScheme, ep.load()) + + if not isinstance(scheme, VersionProtocol): + warnings.warn(f"Version scheme {name} does not implement the VersionProtocol") + + return scheme diff --git a/commitizen/version_types.py b/commitizen/version_types.py deleted file mode 100644 index 4eaa0f3519..0000000000 --- a/commitizen/version_types.py +++ /dev/null @@ -1,100 +0,0 @@ -from __future__ import annotations - -import sys - -if sys.version_info >= (3, 8): - from typing import Protocol as _Protocol -else: - _Protocol = object - -from packaging.version import Version - - -class VersionProtocol(_Protocol): - def __init__(self, _version: Version | str): - raise NotImplementedError("must be implemented") - - def __str__(self) -> str: - raise NotImplementedError("must be implemented") - - @property - def release(self) -> tuple[int, ...]: - raise NotImplementedError("must be implemented") - - @property - def is_prerelease(self) -> bool: - raise NotImplementedError("must be implemented") - - @property - def pre(self) -> tuple[str, int] | None: - raise NotImplementedError("must be implemented") - - @property - def local(self) -> str | None: - raise NotImplementedError("must be implemented") - - @property - def public(self) -> str: - raise NotImplementedError("must be implemented") - - -class SemVerVersion(VersionProtocol): - def __init__(self, version: str): - self._version = Version(version) - - @property - def release(self) -> tuple[int, ...]: - return self._version.release - - @property - def is_prerelease(self) -> bool: - return self._version.is_prerelease - - @property - def pre(self) -> tuple[str, int] | None: - return self._version.pre - - @property - def local(self) -> str | None: - return self._version.local - - @property - def public(self) -> str: - return self._version.public - - def __str__(self) -> str: - parts = [] - - version = self._version - - # Epoch - if version.epoch != 0: - parts.append(f"{version.epoch}!") - - # Release segment - parts.append(".".join(str(x) for x in version.release)) - - # Pre-release - if version.pre: - pre = "".join(str(x) for x in version.pre) - parts.append(f"-{pre}") - - # Post-release - if version.post is not None: - parts.append(f"-post{version.post}") - - # Development release - if version.dev is not None: - parts.append(f"-dev{version.dev}") - - # Local version segment - if version.local: - parts.append(f"+{version.local}") - - return "".join(parts) - - -VERSION_TYPES = { - "semver": SemVerVersion, - "pep440": Version, -} diff --git a/docs/bump.md b/docs/bump.md index 35c7daab12..28b7f25efa 100644 --- a/docs/bump.md +++ b/docs/bump.md @@ -20,10 +20,10 @@ This means `MAJOR.MINOR.PATCH` | `MINOR` | New features | `feat` | | `PATCH` | Fixes | `fix` + everything else | -[PEP 0440][pep440] is the default, you can switch by using the setting `version_type` or the cli: +[PEP 0440][pep440] is the default, you can switch by using the setting `version_scheme` or the cli: ```sh -cz bump --version-type semver +cz bump --version-scheme semver ``` Some examples of pep440: @@ -94,8 +94,8 @@ options: --retry retry commit if it fails the 1st time --major-version-zero keep major version at zero, even for breaking changes --prerelease-offset start pre-releases with this offset - --version-type {pep440,semver} - choose version type + --version-scheme {pep440,semver} + choose version scheme ``` @@ -215,7 +215,7 @@ We recommend setting `major_version_zero = true` in your configuration file whil is in its initial development. Remove that configuration using a breaking-change commit to bump your project’s major version to `v1.0.0` once your project has reached maturity. -### `--version-type` +### `--version-scheme` Choose the version format, options: `pep440`, `semver`. @@ -225,12 +225,12 @@ Recommended for python: `pep440` Recommended for other: `semver` -You can also set this in the [configuration](#version_type) with `version_type = "semver"`. +You can also set this in the [configuration](#version_scheme) with `version_scheme = "semver"`. [pep440][pep440] and [semver][semver] are quite similar, their difference lies in how the prereleases look. -| types | pep440 | semver | +| schemes | pep440 | semver | | -------------- | -------------- | --------------- | | non-prerelease | `0.1.0` | `0.1.0` | | prerelease | `0.3.1a0` | `0.3.1-a0` | @@ -505,11 +505,11 @@ Defaults to: `0` prerelease_offset = 1 ``` -### `version_type` +### `version_scheme` -Choose version type +Choose version scheme -| types | pep440 | semver | +| schemes | pep440 | semver | | -------------- | -------------- | --------------- | | non-prerelease | `0.1.0` | `0.1.0` | | prerelease | `0.3.1a0` | `0.3.1-a0` | @@ -522,7 +522,7 @@ Defaults to: `pep440` ```toml [tool.commitizen] -version_type = "semver" +version_scheme = "semver" ``` ## Custom bump diff --git a/docs/config.md b/docs/config.md index f93aca60e7..96ac8c6e36 100644 --- a/docs/config.md +++ b/docs/config.md @@ -34,6 +34,14 @@ Default: `commitizen` Version provider used to read and write version [Read more](#version-providers) +### `version_scheme` + +Type: `str` + +Default: `pep440` + +Select a version scheme from the following options [`pep440`, `semver`]. Useful for non-python projects. [Read more][version-scheme] + ### `tag_format` Type: `str` @@ -154,14 +162,6 @@ Default: `0` In some circumstances, a prerelease cannot start with a 0, e.g. in an embedded project individual characters are encoded as bytes. This can be done by specifying an offset from which to start counting. [prerelease-offset] | -### `version_type` - -Type: `str` - -Default: `pep440` - -Select a version type from the following options [`pep440`, `semver`]. Useful for non-python projects. [Read more][version_type] - ### `pre_bump_hooks` Type: `list[str]` @@ -343,7 +343,7 @@ setup( [major-version-zero]: bump.md#-major-version-zero [prerelease-offset]: bump.md#-prerelease_offset [allow_abort]: check.md#allow-abort -[version_type]: bump.md#version_type +[version-scheme]: bump.md#version-scheme [pre_bump_hooks]: bump.md#pre_bump_hooks [post_bump_hooks]: bump.md#post_bump_hooks [additional-features]: https://github.com/tmbo/questionary#additional-features diff --git a/pyproject.toml b/pyproject.toml index c0d6311bf7..537f7c31d9 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -89,6 +89,10 @@ pep621 = "commitizen.providers:Pep621Provider" poetry = "commitizen.providers:PoetryProvider" scm = "commitizen.providers:ScmProvider" +[tool.poetry.plugins."commitizen.scheme"] +pep440 = "commitizen.version_schemes:Pep440" +semver = "commitizen.version_schemes:SemVer" + [tool.coverage] [tool.coverage.report] show_missing = true @@ -106,7 +110,8 @@ scm = "commitizen.providers:ScmProvider" # Don't complain if non-runnable code isn't run: 'if 0:', - 'if __name__ == .__main__.:' + 'if __name__ == .__main__.:', + 'if TYPE_CHECKING:', ] omit = [ 'env/*', @@ -146,3 +151,7 @@ warn_return_any = true warn_redundant_casts = true warn_unused_ignores = true warn_unused_configs = true + +[[tool.mypy.overrides]] +module = "py.*" # Legacy pytest dependencies +ignore_missing_imports = true diff --git a/tests/commands/test_bump_command.py b/tests/commands/test_bump_command.py index c6d41da97e..baa2a4249b 100644 --- a/tests/commands/test_bump_command.py +++ b/tests/commands/test_bump_command.py @@ -856,7 +856,7 @@ def test_bump_use_version_provider(mocker: MockFixture): mock.set_version.assert_called_once_with("0.0.1") -def test_bump_command_prelease_version_type_via_cli( +def test_bump_command_prelease_scheme_via_cli( tmp_commitizen_project_initial, mocker: MockFixture ): tmp_commitizen_project = tmp_commitizen_project_initial() @@ -869,7 +869,7 @@ def test_bump_command_prelease_version_type_via_cli( "--prerelease", "alpha", "--yes", - "--version-type", + "--version-scheme", "semver", ] mocker.patch.object(sys, "argv", testargs) @@ -895,11 +895,11 @@ def test_bump_command_prelease_version_type_via_cli( assert "0.2.0" in f.read() -def test_bump_command_prelease_version_type_via_config( +def test_bump_command_prelease_scheme_via_config( tmp_commitizen_project_initial, mocker: MockFixture ): tmp_commitizen_project = tmp_commitizen_project_initial( - config_extra='version_type = "semver"\n', + config_extra='version_scheme = "semver"\n', ) tmp_version_file = tmp_commitizen_project.join("__version__.py") tmp_commitizen_cfg_file = tmp_commitizen_project.join("pyproject.toml") @@ -939,11 +939,11 @@ def test_bump_command_prelease_version_type_via_config( assert "0.2.0" in f.read() -def test_bump_command_prelease_version_type_check_old_tags( +def test_bump_command_prelease_scheme_check_old_tags( tmp_commitizen_project_initial, mocker: MockFixture ): tmp_commitizen_project = tmp_commitizen_project_initial( - config_extra=('tag_format = "v$version"\nversion_type = "semver"\n'), + config_extra=('tag_format = "v$version"\nversion_scheme = "semver"\n'), ) tmp_version_file = tmp_commitizen_project.join("__version__.py") tmp_commitizen_cfg_file = tmp_commitizen_project.join("pyproject.toml") @@ -1025,3 +1025,45 @@ def test_bump_with_major_version_zero_with_plugin( tag_exists = git.tag_exist(expected_tag) assert tag_exists is True + + +@pytest.mark.usefixtures("tmp_commitizen_project") +def test_bump_command_version_type_deprecation(mocker: MockFixture): + create_file_and_commit("feat: check deprecation on --version-type") + + testargs = [ + "cz", + "bump", + "--prerelease", + "alpha", + "--yes", + "--version-type", + "semver", + ] + mocker.patch.object(sys, "argv", testargs) + with pytest.warns(DeprecationWarning): + cli.main() + + assert git.tag_exist("0.2.0-a0") + + +@pytest.mark.usefixtures("tmp_commitizen_project") +def test_bump_command_version_scheme_priority_over_version_type(mocker: MockFixture): + create_file_and_commit("feat: check deprecation on --version-type") + + testargs = [ + "cz", + "bump", + "--prerelease", + "alpha", + "--yes", + "--version-type", + "semver", + "--version-scheme", + "pep440", + ] + mocker.patch.object(sys, "argv", testargs) + with pytest.warns(DeprecationWarning): + cli.main() + + assert git.tag_exist("0.2.0a0") diff --git a/tests/commands/test_changelog_command.py b/tests/commands/test_changelog_command.py index cb7298c0e6..2ceb3a239f 100644 --- a/tests/commands/test_changelog_command.py +++ b/tests/commands/test_changelog_command.py @@ -1214,13 +1214,13 @@ def test_empty_commit_list(mocker): @pytest.mark.usefixtures("tmp_commitizen_project") @pytest.mark.freeze_time("2022-02-13") -def test_changelog_prerelease_rev_with_use_version_type_semver( +def test_changelog_prerelease_rev_with_use_scheme_semver( mocker: MockFixture, capsys, config_path, changelog_path, file_regression ): mocker.patch("commitizen.git.GitTag.date", "2022-02-13") with open(config_path, "a") as f: - f.write('tag_format = "$version"\n' 'version_type = "semver"') + f.write('tag_format = "$version"\n' 'version_scheme = "semver"') # create commit and tag create_file_and_commit("feat: new file") diff --git a/tests/commands/test_changelog_command/test_changelog_prerelease_rev_with_use_version_type_semver.md b/tests/commands/test_changelog_command/test_changelog_prerelease_rev_with_use_scheme_semver.md similarity index 100% rename from tests/commands/test_changelog_command/test_changelog_prerelease_rev_with_use_version_type_semver.md rename to tests/commands/test_changelog_command/test_changelog_prerelease_rev_with_use_scheme_semver.md diff --git a/tests/commands/test_changelog_command/test_changelog_prerelease_rev_with_use_version_type_semver.second-prerelease.md b/tests/commands/test_changelog_command/test_changelog_prerelease_rev_with_use_scheme_semver.second-prerelease.md similarity index 100% rename from tests/commands/test_changelog_command/test_changelog_prerelease_rev_with_use_version_type_semver.second-prerelease.md rename to tests/commands/test_changelog_command/test_changelog_prerelease_rev_with_use_scheme_semver.second-prerelease.md diff --git a/tests/commands/test_init_command.py b/tests/commands/test_init_command.py index 812accb69f..d5c8b5965b 100644 --- a/tests/commands/test_init_command.py +++ b/tests/commands/test_init_command.py @@ -38,7 +38,7 @@ def unsafe_ask(self): "[tool.commitizen]\n" 'name = "cz_conventional_commits"\n' 'tag_format = "$version"\n' - 'version_type = "semver"\n' + 'version_scheme = "semver"\n' 'version = "0.0.1"\n' "update_changelog_on_bump = true\n" "major_version_zero = true\n" @@ -48,7 +48,7 @@ def unsafe_ask(self): "commitizen": { "name": "cz_conventional_commits", "tag_format": "$version", - "version_type": "semver", + "version_scheme": "semver", "version": "0.0.1", "update_changelog_on_bump": True, "major_version_zero": True, diff --git a/tests/test_bump_create_commit_message.py b/tests/test_bump_create_commit_message.py index 0609e1a236..e3035ba550 100644 --- a/tests/test_bump_create_commit_message.py +++ b/tests/test_bump_create_commit_message.py @@ -3,7 +3,6 @@ from textwrap import dedent import pytest -from packaging.version import Version from pytest_mock import MockFixture from commitizen import bump, cli, cmd, exceptions @@ -21,9 +20,7 @@ @pytest.mark.parametrize("test_input,expected", conversion) def test_create_tag(test_input, expected): current_version, new_version, message_template = test_input - new_tag = bump.create_commit_message( - Version(current_version), Version(new_version), message_template - ) + new_tag = bump.create_commit_message(current_version, new_version, message_template) assert new_tag == expected diff --git a/tests/test_bump_normalize_tag.py b/tests/test_bump_normalize_tag.py index 3bc9828a2f..c1eb696afd 100644 --- a/tests/test_bump_normalize_tag.py +++ b/tests/test_bump_normalize_tag.py @@ -1,5 +1,4 @@ import pytest -from packaging.version import Version from commitizen import bump @@ -19,5 +18,5 @@ @pytest.mark.parametrize("test_input,expected", conversion) def test_create_tag(test_input, expected): version, format = test_input - new_tag = bump.normalize_tag(Version(version), format) + new_tag = bump.normalize_tag(version, format) assert new_tag == expected diff --git a/tests/test_conf.py b/tests/test_conf.py index 4226096371..b2527bded3 100644 --- a/tests/test_conf.py +++ b/tests/test_conf.py @@ -45,6 +45,7 @@ "name": "cz_jira", "version": "1.0.0", "version_provider": "commitizen", + "version_scheme": None, "tag_format": None, "bump_message": None, "allow_abort": False, @@ -60,13 +61,13 @@ "pre_bump_hooks": ["scripts/generate_documentation.sh"], "post_bump_hooks": ["scripts/slack_notification.sh"], "prerelease_offset": 0, - "version_type": None, } _new_settings = { "name": "cz_jira", "version": "2.0.0", "version_provider": "commitizen", + "version_scheme": None, "tag_format": None, "bump_message": None, "allow_abort": False, @@ -82,7 +83,6 @@ "pre_bump_hooks": ["scripts/generate_documentation.sh"], "post_bump_hooks": ["scripts/slack_notification.sh"], "prerelease_offset": 0, - "version_type": None, } _read_settings = { diff --git a/tests/test_bump_find_version.py b/tests/test_version_scheme_pep440.py similarity index 77% rename from tests/test_bump_find_version.py rename to tests/test_version_scheme_pep440.py index 394c8fd61a..89f5a137ad 100644 --- a/tests/test_bump_find_version.py +++ b/tests/test_version_scheme_pep440.py @@ -1,9 +1,7 @@ import itertools import pytest -from packaging.version import Version - -from commitizen.bump import generate_version +from commitizen.version_schemes import Pep440, VersionProtocol simple_flow = [ (("0.1.0", "PATCH", None, 0, None), "0.1.1"), @@ -85,37 +83,51 @@ "test_input,expected", itertools.chain(tdd_cases, weird_cases, simple_flow, unexpected_cases), ) -def test_generate_version(test_input, expected): +def test_bump_pep440_version(test_input, expected): current_version = test_input[0] increment = test_input[1] prerelease = test_input[2] prerelease_offset = test_input[3] devrelease = test_input[4] - assert generate_version( - current_version, - increment=increment, - prerelease=prerelease, - prerelease_offset=prerelease_offset, - devrelease=devrelease, - ) == Version(expected) + assert ( + str( + Pep440(current_version).bump( + increment=increment, + prerelease=prerelease, + prerelease_offset=prerelease_offset, + devrelease=devrelease, + ) + ) + == expected + ) -@pytest.mark.parametrize( - "test_input,expected", - itertools.chain(local_versions), -) -def test_generate_version_local(test_input, expected): +@pytest.mark.parametrize("test_input,expected", local_versions) +def test_bump_pep440_version_local(test_input, expected): current_version = test_input[0] increment = test_input[1] prerelease = test_input[2] prerelease_offset = test_input[3] devrelease = test_input[4] is_local_version = True - assert generate_version( - current_version, - increment=increment, - prerelease=prerelease, - prerelease_offset=prerelease_offset, - devrelease=devrelease, - is_local_version=is_local_version, - ) == Version(expected) + assert ( + str( + Pep440(current_version).bump( + increment=increment, + prerelease=prerelease, + prerelease_offset=prerelease_offset, + devrelease=devrelease, + is_local_version=is_local_version, + ) + ) + == expected + ) + + +def test_pep440_scheme_property(): + version = Pep440("0.0.1") + assert version.scheme is Pep440 + + +def test_pep440_implement_version_protocol(): + assert isinstance(Pep440("0.0.1"), VersionProtocol) diff --git a/tests/test_bump_find_version_type_semver.py b/tests/test_version_scheme_semver.py similarity index 75% rename from tests/test_bump_find_version_type_semver.py rename to tests/test_version_scheme_semver.py index 633442f9ee..82ed33c229 100644 --- a/tests/test_bump_find_version_type_semver.py +++ b/tests/test_version_scheme_semver.py @@ -2,8 +2,7 @@ import pytest -from commitizen.bump import generate_version -from commitizen.version_types import SemVerVersion +from commitizen.version_schemes import SemVer, VersionProtocol simple_flow = [ (("0.1.0", "PATCH", None, 0, None), "0.1.1"), @@ -39,6 +38,12 @@ (("1.2.1", "MAJOR", None, 0, None), "2.0.0"), ] +local_versions = [ + (("4.5.0+0.1.0", "PATCH", None, 0, None), "4.5.0+0.1.1"), + (("4.5.0+0.1.1", "MINOR", None, 0, None), "4.5.0+0.2.0"), + (("4.5.0+0.2.0", "MAJOR", None, 0, None), "4.5.0+1.0.0"), +] + # this cases should be handled gracefully unexpected_cases = [ (("0.1.1rc0", None, "alpha", 0, None), "0.1.1-a0"), @@ -81,7 +86,7 @@ "test_input, expected", itertools.chain(tdd_cases, weird_cases, simple_flow, unexpected_cases), ) -def test_generate_version_type(test_input, expected): +def test_bump_semver_version(test_input, expected): current_version = test_input[0] increment = test_input[1] prerelease = test_input[2] @@ -89,14 +94,43 @@ def test_generate_version_type(test_input, expected): devrelease = test_input[4] assert ( str( - generate_version( - current_version, + SemVer(current_version).bump( increment=increment, prerelease=prerelease, prerelease_offset=prerelease_offset, devrelease=devrelease, - version_type_cls=SemVerVersion, ) ) == expected ) + + +@pytest.mark.parametrize("test_input,expected", local_versions) +def test_bump_semver_version_local(test_input, expected): + current_version = test_input[0] + increment = test_input[1] + prerelease = test_input[2] + prerelease_offset = test_input[3] + devrelease = test_input[4] + is_local_version = True + assert ( + str( + SemVer(current_version).bump( + increment=increment, + prerelease=prerelease, + prerelease_offset=prerelease_offset, + devrelease=devrelease, + is_local_version=is_local_version, + ) + ) + == expected + ) + + +def test_semver_scheme_property(): + version = SemVer("0.0.1") + assert version.scheme is SemVer + + +def test_semver_implement_version_protocol(): + assert isinstance(SemVer("0.0.1"), VersionProtocol) diff --git a/tests/test_version_schemes.py b/tests/test_version_schemes.py new file mode 100644 index 0000000000..e30c780283 --- /dev/null +++ b/tests/test_version_schemes.py @@ -0,0 +1,60 @@ +from __future__ import annotations + +import pytest +from pytest_mock import MockerFixture +import importlib_metadata as metadata + +from commitizen.config.base_config import BaseConfig +from commitizen.exceptions import VersionSchemeUnknown +from commitizen.version_schemes import Pep440, SemVer, get_version_scheme + + +def test_default_version_scheme_is_pep440(config: BaseConfig): + scheme = get_version_scheme(config) + assert scheme is Pep440 + + +def test_version_scheme_from_config(config: BaseConfig): + config.settings["version_scheme"] = "semver" + scheme = get_version_scheme(config) + assert scheme is SemVer + + +def test_version_scheme_from_name(config: BaseConfig): + config.settings["version_scheme"] = "pep440" + scheme = get_version_scheme(config, "semver") + assert scheme is SemVer + + +def test_raise_for_unknown_version_scheme(config: BaseConfig): + with pytest.raises(VersionSchemeUnknown): + get_version_scheme(config, "unknown") + + +def test_version_scheme_from_deprecated_config(config: BaseConfig): + config.settings["version_type"] = "semver" + with pytest.warns(DeprecationWarning): + scheme = get_version_scheme(config) + assert scheme is SemVer + + +def test_version_scheme_from_config_priority(config: BaseConfig): + config.settings["version_scheme"] = "pep440" + config.settings["version_type"] = "semver" + with pytest.warns(DeprecationWarning): + scheme = get_version_scheme(config) + assert scheme is Pep440 + + +def test_warn_if_version_protocol_not_implemented( + config: BaseConfig, mocker: MockerFixture +): + class NotVersionProtocol: + pass + + ep = mocker.Mock() + ep.load.return_value = NotVersionProtocol + mocker.patch.object(metadata, "entry_points", return_value=(ep,)) + + with pytest.warns(match="VersionProtocol"): + get_version_scheme(config, "any")