Skip to content

Commit 16c8449

Browse files
committed
feat(providers): add scm provider reading version from the last tag matching tag_format
Fixes #641
1 parent 5a83c5d commit 16c8449

File tree

4 files changed

+122
-2
lines changed

4 files changed

+122
-2
lines changed

commitizen/providers.py

+58-1
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,18 @@
11
from __future__ import annotations
22

33
import json
4+
import re
45
from abc import ABC, abstractmethod
56
from pathlib import Path
6-
from typing import Any, ClassVar, cast
7+
from typing import Any, Callable, ClassVar, Optional, cast
78

89
import importlib_metadata as metadata
910
import tomlkit
11+
from packaging.version import VERSION_PATTERN, Version
1012

1113
from commitizen.config.base_config import BaseConfig
1214
from commitizen.exceptions import VersionProviderUnknown
15+
from commitizen.git import get_tags
1316

1417
ENTRYPOINT = "commitizen.provider"
1518
DEFAULT = "commitizen"
@@ -157,6 +160,60 @@ class ComposerProvider(JsonProvider):
157160
indent = 4
158161

159162

163+
class ScmProvider(VersionProvider):
164+
"""
165+
A provider dedicated to be coupled with `setuptools-scm`
166+
or any package manager `-scm` provider.
167+
168+
Version is fetched from git history (using `git describe`)
169+
and does not need to be set back.
170+
"""
171+
172+
TAG_FORMAT_REGEXS = {
173+
"$version": r"(?P<version>.+)",
174+
"$major": r"(?P<major>\d+)",
175+
"$minor": r"(?P<minor>\d+)",
176+
"$patch": r"(?P<patch>\d+)",
177+
"$prerelease": r"(?P<prerelease>\w+\d+)?",
178+
"$devrelease": r"(?P<devrelease>\.?dev\d+)?",
179+
}
180+
181+
def _tag_format_matcher(self) -> Callable[[str], Optional[str]]:
182+
tag_format = self.config.settings.get("tag_format") or VERSION_PATTERN
183+
for var, pattern in self.TAG_FORMAT_REGEXS.items():
184+
tag_format = tag_format.replace(var, pattern)
185+
186+
regex = re.compile(f"^{tag_format}$", re.VERBOSE)
187+
188+
def matcher(tag: str) -> Optional[str]:
189+
m = regex.match(tag)
190+
if not m:
191+
return None
192+
elif "version" in m.groupdict():
193+
return m.group("version")
194+
elif "devrelease" in m.groupdict():
195+
return "{major}.{minor}.{patch}{devrelease}".format(**m.groupdict())
196+
elif "prerelease" in m.groupdict():
197+
return "{major}.{minor}.{patch}{prerelease}".format(**m.groupdict())
198+
elif "major" in m.groupdict():
199+
return "{major}.{minor}.{patch}".format(**m.groupdict())
200+
elif tag_format == VERSION_PATTERN:
201+
return str(Version(tag))
202+
return None
203+
204+
return matcher
205+
206+
def get_version(self) -> str:
207+
matcher = self._tag_format_matcher()
208+
return next(
209+
(cast(str, matcher(t.name)) for t in get_tags() if matcher(t.name)), "0.0.0"
210+
)
211+
212+
def set_version(self, version: str):
213+
# Not necessary
214+
pass
215+
216+
160217
def get_provider(config: BaseConfig) -> VersionProvider:
161218
"""
162219
Get the version provider as defined in the configuration

docs/config.md

+4
Original file line numberDiff line numberDiff line change
@@ -123,12 +123,16 @@ Commitizen provides some version providers for some well known formats:
123123
| name | description |
124124
| ---- | ----------- |
125125
| `commitizen` | Default version provider: Fetch and set version in commitizen config. |
126+
| `scm` | Fetch the version from git and does not need to set it back |
126127
| `pep621` | Get and set version from `pyproject.toml` `project.version` field |
127128
| `poetry` | Get and set version from `pyproject.toml` `tool.poetry.version` field |
128129
| `cargo` | Get and set version from `Cargo.toml` `project.version` field |
129130
| `npm` | Get and set version from `package.json` `project.version` field |
130131
| `composer` | Get and set version from `composer.json` `project.version` field |
131132

133+
!!! note
134+
The `scm` provider is meant to be used with `setuptools-scm` or any packager `*-scm` plugin.
135+
132136
### Custom version provider
133137

134138
You can add you own version provider by extending `VersionProvider` and exposing it on the `commitizen.provider` entrypoint.

pyproject.toml

+1
Original file line numberDiff line numberDiff line change
@@ -111,6 +111,7 @@ composer = "commitizen.providers:ComposerProvider"
111111
npm = "commitizen.providers:NpmProvider"
112112
pep621 = "commitizen.providers:Pep621Provider"
113113
poetry = "commitizen.providers:PoetryProvider"
114+
scm = "commitizen.providers:ScmProvider"
114115

115116
[tool.isort]
116117
profile = "black"

tests/test_version_providers.py

+59-1
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@
33
import os
44
from pathlib import Path
55
from textwrap import dedent
6-
from typing import TYPE_CHECKING, Iterator
6+
from typing import TYPE_CHECKING, Iterator, Optional
77

88
import pytest
99

@@ -16,8 +16,10 @@
1616
NpmProvider,
1717
Pep621Provider,
1818
PoetryProvider,
19+
ScmProvider,
1920
get_provider,
2021
)
22+
from tests.utils import create_file_and_commit, create_tag
2123

2224
if TYPE_CHECKING:
2325
from pytest_mock import MockerFixture
@@ -185,3 +187,59 @@ def test_composer_provider(config: BaseConfig, chdir: Path):
185187
}
186188
"""
187189
)
190+
191+
192+
@pytest.mark.parametrize(
193+
"tag_format,tag,version",
194+
(
195+
(None, "0.1.0", "0.1.0"),
196+
(None, "v0.1.0", "0.1.0"),
197+
("v$version", "v0.1.0", "0.1.0"),
198+
("version-$version", "version-0.1.0", "0.1.0"),
199+
("v$major.$minor.$patch", "v0.1.0", "0.1.0"),
200+
("v$minor.$major.$patch", "v1.0.0", "0.1.0"),
201+
("version-$major.$minor.$patch", "version-0.1.0", "0.1.0"),
202+
("v$minor.$major.$patch$prerelease", "v1.0.0rc1", "0.1.0rc1"),
203+
("v$minor.$major.$patch$prerelease$devrelease", "v1.0.0.dev0", "0.1.0.dev0"),
204+
),
205+
)
206+
@pytest.mark.usefixtures("tmp_git_project")
207+
def test_scm_provider(
208+
config: BaseConfig, tag_format: Optional[str], tag: str, version: str
209+
):
210+
create_file_and_commit("test: fake commit")
211+
create_tag(tag)
212+
create_file_and_commit("test: fake commit")
213+
create_tag("should-not-match")
214+
215+
config.settings["version_provider"] = "scm"
216+
config.settings["tag_format"] = tag_format
217+
218+
provider = get_provider(config)
219+
assert isinstance(provider, ScmProvider)
220+
assert provider.get_version() == version
221+
222+
# Should not fail on set_version()
223+
provider.set_version("43.1")
224+
225+
226+
@pytest.mark.usefixtures("tmp_git_project")
227+
def test_scm_provider_default_without_matching_tag(config: BaseConfig):
228+
create_file_and_commit("test: fake commit")
229+
create_tag("should-not-match")
230+
create_file_and_commit("test: fake commit")
231+
232+
config.settings["version_provider"] = "scm"
233+
234+
provider = get_provider(config)
235+
assert isinstance(provider, ScmProvider)
236+
assert provider.get_version() == "0.0.0"
237+
238+
239+
@pytest.mark.usefixtures("tmp_git_project")
240+
def test_scm_provider_default_without_commits_and_tags(config: BaseConfig):
241+
config.settings["version_provider"] = "scm"
242+
243+
provider = get_provider(config)
244+
assert isinstance(provider, ScmProvider)
245+
assert provider.get_version() == "0.0.0"

0 commit comments

Comments
 (0)