diff --git a/commitizen/bump.py b/commitizen/bump.py index 9312491044..569a972e47 100644 --- a/commitizen/bump.py +++ b/commitizen/bump.py @@ -1,8 +1,9 @@ import re from collections import OrderedDict +from datetime import datetime from itertools import zip_longest from string import Template -from typing import List, Optional, Union +from typing import Iterable, List, Optional, Tuple, Union from packaging.version import Version @@ -53,6 +54,44 @@ def find_increment( return increment +# TODO: Create new Version class to parse the version based on tag_format and encapsulate the bump logic +def _parse_release_semver( + release: Iterable[int], tag_format: Optional[str] = None +) -> Tuple[int, int, int]: + """Parse the SemVer components from the release version by handling reordering and CalVer.""" + major, minor, patch = [*release, 0, 0, 0][:3] + if tag_format: + semver_names = ["major", "minor", "patch"] + tag_format = tag_format.replace("$version", "$" + ".$".join(semver_names)) + keys = [key.split("$")[-1] for key in tag_format.split(".")] + if len(keys) == len(list(release)) and any(key in keys for key in semver_names): + tag_lookup = dict(zip(keys, release)) + major, minor, patch = [tag_lookup.get(key, 0) for key in semver_names] + + return major, minor, patch + + +def _merge_semver( + _current: str, _new_semver: str, tag_format: Optional[str] = None +) -> str: + """HACK: Merge the results of the incremented SemVer back into the release version to handle CalVer.""" + current: Iterable[int] = Version(_current).release + new_semver: Iterable[int] = Version(_new_semver).release + + release = new_semver + if tag_format: + semver_names = ["major", "minor", "patch"] + semver_lookup = dict(zip(semver_names, new_semver)) + tag_format = tag_format.replace("$version", "$" + ".$".join(semver_names)) + keys = [key.split("$")[-1] for key in tag_format.split(".")] + if len(keys) == len(list(current)): + release = [ + semver_lookup.get(key, value) for key, value in zip(keys, current) + ] + + return ".".join(map(str, release)) + + def prerelease_generator(current_version: str, prerelease: Optional[str] = None) -> str: """Generate prerelease @@ -78,9 +117,11 @@ def prerelease_generator(current_version: str, prerelease: Optional[str] = None) return pre_version -def semver_generator(current_version: str, increment: str = None) -> str: +def semver_generator( + current_version: str, increment: str = None, tag_format: Optional[str] = None +) -> str: version = Version(current_version) - prev_release = list(version.release) + prev_release = list(_parse_release_semver(version.release, tag_format)) increments = [MAJOR, MINOR, PATCH] increments_version = dict(zip_longest(increments, prev_release, fillvalue=0)) @@ -112,6 +153,7 @@ def generate_version( increment: str, prerelease: Optional[str] = None, is_local_version: bool = False, + tag_format: Optional[str] = None, ) -> Version: """Based on the given increment a proper semver will be generated. @@ -127,16 +169,21 @@ def generate_version( if is_local_version: version = Version(current_version) pre_version = prerelease_generator(str(version.local), prerelease=prerelease) - semver = semver_generator(str(version.local), increment=increment) + semver = semver_generator( + str(version.local), increment=increment, tag_format=tag_format + ) return Version(f"{version.public}+{semver}{pre_version}") else: pre_version = prerelease_generator(current_version, prerelease=prerelease) - semver = semver_generator(current_version, increment=increment) + semver = semver_generator( + current_version, increment=increment, tag_format=tag_format + ) + release = _merge_semver(current_version, semver, tag_format) # TODO: post version # TODO: dev version - return Version(f"{semver}{pre_version}") + return Version(f"{release}{pre_version}") def update_version_in_files( @@ -223,16 +270,17 @@ def create_tag(version: Union[Version, str], tag_format: Optional[str] = None): if not tag_format: return str(version) - major, minor, patch = version.release + major, minor, patch = _parse_release_semver(version.release, tag_format) prerelease = "" # version.pre is needed for mypy check if version.is_prerelease and version.pre: prerelease = f"{version.pre[0]}{version.pre[1]}" t = Template(tag_format) - return t.safe_substitute( + t_ver = t.safe_substitute( version=version, major=major, minor=minor, patch=patch, prerelease=prerelease ) + return datetime.utcnow().strftime(t_ver) def create_commit_message( diff --git a/commitizen/commands/bump.py b/commitizen/commands/bump.py index 4f2a0d3981..5c44623d9f 100644 --- a/commitizen/commands/bump.py +++ b/commitizen/commands/bump.py @@ -124,7 +124,7 @@ def __call__(self): # noqa: C901 if increment is None: increment = self.find_increment(commits) - # It may happen that there are commits, but they are not elegible + # 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 @@ -147,6 +147,7 @@ def __call__(self): # noqa: C901 increment, prerelease=prerelease, is_local_version=is_local_version, + tag_format=tag_format, ) new_tag_version = bump.create_tag(new_version, tag_format=tag_format) diff --git a/docs/bump.md b/docs/bump.md index bf3b207cd6..ac2a47db13 100644 --- a/docs/bump.md +++ b/docs/bump.md @@ -183,26 +183,28 @@ cz bump --changelog --changelog-to-stdout > body.md ### `tag_format` -It is used to read the format from the git tags, and also to generate the tags. +The `tag_format` is used to parse the format from the git tags and to generate new tags. -Commitizen supports 2 types of formats, a simple and a more complex. +Commitizen defaults to the standard Semver format (`$version`) or can be customized using a predefined set of variables. ```bash cz bump --tag-format="v$version" ``` ```bash -cz bump --tag-format="v$minor.$major.$patch$prerelease" +cz bump --tag-format="v$major.$minor.$patch$prerelease" ``` In your `pyproject.toml` or `.cz.toml` ```toml [tool.commitizen] -tag_format = "v$minor.$major.$patch$prerelease" +tag_format = "v$major.$minor.$patch$prerelease" ``` -The variables must be preceded by a `$` sign. +#### Standard SemVer Variables + +The SemVer variables must be preceded by a `$` sign. Supported variables: @@ -214,6 +216,34 @@ Supported variables: | `$patch` | PATCH increment | | `$prerelease` | Prerelase (alpha, beta, release candidate) | +#### CalVer Variables + +Commitizen also supports [CalVer](https://calver.org/) in any combination with or without SemVer; however, using standard CalVer formats is recommended for interoperability with other tools. Common CalVer `tag_format`'s include `%Y.%m` or `%y.%m.%d`. + +If a unique build number is desired, this could either be implemented by incrementing the string in the configuration file with whatever build tool is used (i.e. `%Y.%m.123` to `%Y.%m.124`) or by mapping all commit types to one SemVer component through the `bump_map` configuration and a `tag_format` of `"%Y.%m.$major`. + +```bash +cz bump --tag-format="%y.%-m.%-d$prerelease" +cz bump --tag-format="%Y.%m.$major.$prerelease" +cz bump --tag-format="%Y.%m.$version" +``` + +```toml +[tool.commitizen] +tag_format = "%Y.%m.%d$prerelease" +``` + +The full `strftime` formatting options are supported. Below are the most common for CalVer. For a full list, see: [https://strftime.org/](https://strftime.org/) + +| Code | Description | Example | +| ----- | ---------------------------------------------------------- | ------------- | +| `%y` | Year without century as a zero-padded decimal number. | 08 (for 2008) | +| `%Y` | Year with century as a decimal number. | 2008 | +| `$m` | Month as a zero-padded decimal number. | 01 (for Jan) | +| `$-m` | Month as a decimal number. (Platform specific) | 1 (for Jan) | +| `$d` | Day of the month as a zero-padded decimal number. | 05 | +| `$-d` | Day of the month as a decimal number. (Platform specific) | 5 | + --- ### `version_files` \* diff --git a/docs/customization.md b/docs/customization.md index b96c459e41..a8527001e7 100644 --- a/docs/customization.md +++ b/docs/customization.md @@ -170,7 +170,7 @@ commitizen: When the [`use_shortcuts`](https://commitizen-tools.github.io/commitizen/config/#settings) config option is enabled, commitizen can show and use keyboard shortcuts to select items from lists directly. For example, when using the `cz_conventional_commits` commitizen template, shortcut keys are shown when selecting the commit type. Unless otherwise defined, keyboard shortcuts will be numbered automatically. To specify keyboard shortcuts for your custom choices, provide the shortcut using the `key` parameter in dictionary form for each choice you would like to customize. - + ## 2. Customize through customizing a class The basic steps are: diff --git a/pyproject.toml b/pyproject.toml index c72e717fdd..3a76eaeafd 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -74,6 +74,7 @@ pytest-regressions = "^2.2.0" pytest-freezegun = "^0.4.2" types-PyYAML = "^5.4.3" types-termcolor = "^0.1.1" +types-freezegun = "^1.1.0" [tool.poetry.scripts] cz = "commitizen.cli:main" diff --git a/tests/test_bump_create_tag.py b/tests/test_bump_create_tag.py index 0d6ee60d69..6c4c546b81 100644 --- a/tests/test_bump_create_tag.py +++ b/tests/test_bump_create_tag.py @@ -1,4 +1,5 @@ import pytest +from freezegun import freeze_time from packaging.version import Version from commitizen import bump @@ -13,9 +14,16 @@ (("1.2.3+1.0.0", "v$version"), "v1.2.3+1.0.0"), (("1.2.3+1.0.0", "v$version-local"), "v1.2.3+1.0.0-local"), (("1.2.3+1.0.0", "ver$major.$minor.$patch"), "ver1.2.3"), + (("1.2.3a1", "%y.%m.%d$prerelease"), "21.05.06a1"), + (("1.2.3a1", "%Y.%-m.%-d$prerelease"), "2021.5.6a1"), + (("1.2.3a1", "%y.%-m.$major.$minor"), "21.5.1.2"), + (("2021.01.31a1", "%Y.%-m.%-d$prerelease"), "2021.5.6a1"), + (("21.04.02", "%y.%m.%d"), "21.05.06"), + (("21.4.2.1.2", "%y.%-m.%-d.$major.$minor"), "21.5.6.1.2"), ] +@freeze_time("2021-5-6") @pytest.mark.parametrize("test_input,expected", conversion) def test_create_tag(test_input, expected): version, format = test_input diff --git a/tests/test_bump_find_version.py b/tests/test_bump_find_version.py index 1436d9bd1e..6333077306 100644 --- a/tests/test_bump_find_version.py +++ b/tests/test_bump_find_version.py @@ -95,3 +95,23 @@ def test_generate_version_local(test_input, expected): prerelease=prerelease, is_local_version=is_local_version, ) == Version(expected) + + +@pytest.mark.parametrize( + "current_version,increment,tag_format,expected,", + ( + ("0.1.1", "MINOR", "v$version", "0.2.0"), + ("21.05.06", "PATCH", "%y.%m.%d", "21.05.06"), + ("21.5.6.1.2", "MAJOR", "%y.%-m.%-d.$major.$minor", "21.5.6.2.0"), + ), +) +def test_generate_version_with_formats( + current_version, increment, tag_format, expected +): + assert generate_version( + current_version, + increment=increment, + prerelease=None, + is_local_version=False, + tag_format=tag_format, + ) == Version(expected)