Skip to content

feat(#173): support CalVer tag formatting #385

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Closed
wants to merge 3 commits into from
Closed
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
64 changes: 56 additions & 8 deletions commitizen/bump.py
Original file line number Diff line number Diff line change
@@ -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

Expand Down Expand Up @@ -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

Expand All @@ -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))

Expand Down Expand Up @@ -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.

Expand All @@ -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(
Expand Down Expand Up @@ -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(
Expand Down
3 changes: 2 additions & 1 deletion commitizen/commands/bump.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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)
Expand Down
40 changes: 35 additions & 5 deletions docs/bump.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:

Expand All @@ -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` \*
Expand Down
2 changes: 1 addition & 1 deletion docs/customization.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
1 change: 1 addition & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
8 changes: 8 additions & 0 deletions tests/test_bump_create_tag.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import pytest
from freezegun import freeze_time
from packaging.version import Version

from commitizen import bump
Expand All @@ -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"),
Copy link
Member

Choose a reason for hiding this comment

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

What happens when going from calver to calver?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

This currently fails because create_tag expects version.release to be three integers (in Version, splits on '.' and maps to int) so a CalVer of 21.5.1.2 would fail here:

major, minor, patch = version.release

This will definitely need to be fixed. I'll check how custom formats (dashes, etc.) are handled by commitizen and give this more thought

(("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
Expand Down
20 changes: 20 additions & 0 deletions tests/test_bump_find_version.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)