Skip to content

Commit dfa354b

Browse files
committed
feat(providers): add a commitizen.provider endpoint for alternative versions providers
1 parent dbcc173 commit dfa354b

12 files changed

+226
-8
lines changed

commitizen/commands/bump.py

+6-5
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@
2121
NotAllowed,
2222
NoVersionSpecifiedError,
2323
)
24+
from commitizen.providers import get_provider
2425

2526
logger = getLogger("commitizen")
2627

@@ -91,14 +92,14 @@ def find_increment(self, commits: List[git.GitCommit]) -> Optional[str]:
9192

9293
def __call__(self): # noqa: C901
9394
"""Steps executed to bump."""
95+
provider = get_provider(self.config)
96+
current_version: str = provider.get_version()
97+
9498
try:
95-
current_version_instance: Version = Version(self.bump_settings["version"])
99+
current_version_instance: Version = Version(current_version)
96100
except TypeError:
97101
raise NoVersionSpecifiedError()
98102

99-
# Initialize values from sources (conf)
100-
current_version: str = self.config.settings["version"]
101-
102103
tag_format: str = self.bump_settings["tag_format"]
103104
bump_commit_message: str = self.bump_settings["bump_message"]
104105
version_files: List[str] = self.bump_settings["version_files"]
@@ -270,7 +271,7 @@ def __call__(self): # noqa: C901
270271
check_consistency=self.check_consistency,
271272
)
272273

273-
self.config.set_key("version", str(new_version))
274+
provider.set_version(str(new_version))
274275

275276
if is_files_only:
276277
raise ExpectedExit()

commitizen/commands/version.py

+3-2
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44
from commitizen import out
55
from commitizen.__version__ import __version__
66
from commitizen.config import BaseConfig
7+
from commitizen.providers import get_provider
78

89

910
class Version:
@@ -21,14 +22,14 @@ def __call__(self):
2122
out.write(f"Python Version: {self.python_version}")
2223
out.write(f"Operating System: {self.operating_system}")
2324
elif self.parameter.get("project"):
24-
version = self.config.settings["version"]
25+
version = get_provider(self.config).get_version()
2526
if version:
2627
out.write(f"{version}")
2728
else:
2829
out.error("No project information in this project.")
2930
elif self.parameter.get("verbose"):
3031
out.write(f"Installed Commitizen Version: {__version__}")
31-
version = self.config.settings["version"]
32+
version = get_provider(self.config).get_version()
3233
if version:
3334
out.write(f"Project Version: {version}")
3435
else:

commitizen/defaults.py

+2
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@ class Settings(TypedDict, total=False):
2929
name: str
3030
version: Optional[str]
3131
version_files: List[str]
32+
version_provider: Optional[str]
3233
tag_format: Optional[str]
3334
bump_message: Optional[str]
3435
allow_abort: bool
@@ -56,6 +57,7 @@ class Settings(TypedDict, total=False):
5657
"name": "cz_conventional_commits",
5758
"version": None,
5859
"version_files": [],
60+
"version_provider": "commitizen",
5961
"tag_format": None, # example v$version
6062
"bump_message": None, # bumped v$current_version to $new_version
6163
"allow_abort": False,

commitizen/exceptions.py

+5
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@ class ExitCode(enum.IntEnum):
2929
UNRECOGNIZED_CHARACTERSET_ENCODING = 22
3030
GIT_COMMAND_ERROR = 23
3131
INVALID_MANUAL_VERSION = 24
32+
VERSION_PROVIDER_UNKNOWN = 25
3233

3334

3435
class CommitizenException(Exception):
@@ -163,3 +164,7 @@ class GitCommandError(CommitizenException):
163164

164165
class InvalidManualVersion(CommitizenException):
165166
exit_code = ExitCode.INVALID_MANUAL_VERSION
167+
168+
169+
class VersionProviderUnknown(CommitizenException):
170+
exit_code = ExitCode.VERSION_PROVIDER_UNKNOWN

commitizen/providers.py

+66
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,66 @@
1+
from __future__ import annotations
2+
3+
from abc import ABC, abstractmethod
4+
from typing import cast
5+
6+
import importlib_metadata as metadata
7+
8+
from commitizen.config.base_config import BaseConfig
9+
from commitizen.exceptions import VersionProviderUnknown
10+
11+
ENTRYPOINT = "commitizen.provider"
12+
DEFAULT = "commitizen"
13+
14+
15+
class VersionProvider(ABC):
16+
"""
17+
Abstract base class for version providers.
18+
19+
Each version provider should inherit and implement this class.
20+
"""
21+
22+
config: BaseConfig
23+
24+
def __init__(self, config: BaseConfig):
25+
self.config = config
26+
27+
@abstractmethod
28+
def get_version(self) -> str:
29+
"""
30+
Get the current version
31+
"""
32+
...
33+
34+
@abstractmethod
35+
def set_version(self, version: str):
36+
"""
37+
Set the new current version
38+
"""
39+
...
40+
41+
42+
class CommitizenProvider(VersionProvider):
43+
"""
44+
Default version provider: Fetch and set version in commitizen config.
45+
"""
46+
47+
def get_version(self) -> str:
48+
return self.config.settings["version"] # type: ignore
49+
50+
def set_version(self, version: str):
51+
self.config.set_key("version", version)
52+
53+
54+
def get_provider(config: BaseConfig) -> VersionProvider:
55+
"""
56+
Get the version provider as defined in the configuration
57+
58+
:raises VersionProviderUnknown: if the provider named by `version_provider` is not found.
59+
"""
60+
provider_name = config.settings["version_provider"] or DEFAULT
61+
try:
62+
(ep,) = metadata.entry_points(name=provider_name, group=ENTRYPOINT)
63+
except ValueError:
64+
raise VersionProviderUnknown(f'Version Provider "{provider_name}" unknown.')
65+
provider_cls = ep.load()
66+
return cast(VersionProvider, provider_cls(config))

docs/config.md

+49
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77
| `name` | `str` | `"cz_conventional_commits"` | Name of the committing rules to use |
88
| `version` | `str` | `None` | Current version. Example: "0.1.2" |
99
| `version_files` | `list` | `[ ]` | Files were the version will be updated. A pattern to match a line, can also be specified, separated by `:` [See more][version_files] |
10+
| `version_provider` | `str` | `commitizen` | Version provider used to read and write version [See more](#version-providers) |
1011
| `tag_format` | `str` | `None` | Format for the git tag, useful for old projects, that use a convention like `"v1.2.1"`. [See more][tag_format] |
1112
| `update_changelog_on_bump` | `bool` | `false` | Create changelog when running `cz bump` |
1213
| `gpg_sign` | `bool` | `false` | Use gpg signed tags instead of lightweight tags. |
@@ -111,6 +112,54 @@ commitizen:
111112
- fg:#858585 italic
112113
```
113114
115+
## Version providers
116+
117+
Commitizen can read and write version from different sources.
118+
By default, it use the `commitizen` one which is using the `version` field from the commitizen settings.
119+
But you can use any `commitizen.provider` entrypoint as value for `version_provider`.
120+
121+
### Custom version provider
122+
123+
You can add you own version provider by extending `VersionProvider` and exposing it on the `commitizen.provider` entrypoint.
124+
125+
Here a quick example of a `my-provider` provider reading and writing version in a `VERSION` file.
126+
127+
`my_provider.py`
128+
129+
```python
130+
from pathlib import Path
131+
from commitizen.providers import VersionProvider
132+
133+
134+
class MyProvider(VersionProvider):
135+
file = Path() / "VERSION"
136+
137+
def get_version(self) -> str:
138+
return self.file.read_text()
139+
140+
def set_version(self, version: str):
141+
self.file.write_text(version)
142+
143+
```
144+
145+
`setup.py`
146+
147+
```python
148+
from setuptools import setup
149+
150+
setup(
151+
name='my-commitizen-provider',
152+
version='0.1.0',
153+
py_modules=['my_provider'],
154+
install_requires=['commitizen'],
155+
entry_points = {
156+
'commitizen.provider': [
157+
'my-provider = my_provider:MyProvider',
158+
]
159+
}
160+
)
161+
```
162+
114163
[version_files]: bump.md#version_files
115164
[tag_format]: bump.md#tag_format
116165
[bump_message]: bump.md#bump_message

docs/exit_codes.md

+3-1
Original file line numberDiff line numberDiff line change
@@ -30,4 +30,6 @@ These exit codes can be found in `commitizen/exceptions.py::ExitCode`.
3030
| NotAllowed | 20 | `--incremental` cannot be combined with a `rev_range` |
3131
| NoneIncrementExit | 21 | The commits found are not eligible to be bumped |
3232
| CharacterSetDecodeError | 22 | The character encoding of the command output could not be determined |
33-
| GitCommandError | 23 | Unexpected failure while calling a git command |
33+
| GitCommandError | 23 | Unexpected failure while calling a git command |
34+
| InvalidManualVersion | 24 | Manually provided version is invalid |
35+
| VersionProviderUnknown | 25 | `version_provider` setting is set to an unknown version provider indentifier |

pyproject.toml

+3
Original file line numberDiff line numberDiff line change
@@ -104,6 +104,9 @@ cz_conventional_commits = "commitizen.cz.conventional_commits:ConventionalCommit
104104
cz_jira = "commitizen.cz.jira:JiraSmartCz"
105105
cz_customize = "commitizen.cz.customize:CustomizeCommitsCz"
106106

107+
[tool.poetry.plugins."commitizen.provider"]
108+
commitizen = "commitizen.providers:CommitizenProvider"
109+
107110
[tool.isort]
108111
profile = "black"
109112
known_first_party = ["commitizen", "tests"]

tests/commands/test_bump_command.py

+21
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44
from unittest.mock import MagicMock
55

66
import pytest
7+
from pytest_mock import MockerFixture
78

89
import commitizen.commands.bump as bump
910
from commitizen import cli, cmd, git
@@ -754,3 +755,23 @@ def test_bump_manual_version_disallows_major_version_zero(mocker):
754755
"--major-version-zero cannot be combined with MANUAL_VERSION"
755756
)
756757
assert expected_error_message in str(excinfo.value)
758+
759+
760+
@pytest.mark.usefixtures("tmp_git_project")
761+
def test_bump_use_version_provider(mocker: MockerFixture):
762+
mock = mocker.MagicMock(name="provider")
763+
mock.get_version.return_value = "0.0.0"
764+
get_provider = mocker.patch(
765+
"commitizen.commands.bump.get_provider", return_value=mock
766+
)
767+
768+
create_file_and_commit("fix: fake commit")
769+
testargs = ["cz", "bump", "--yes", "--changelog"]
770+
mocker.patch.object(sys, "argv", testargs)
771+
772+
cli.main()
773+
774+
assert git.tag_exist("0.0.1")
775+
get_provider.assert_called_once()
776+
mock.get_version.assert_called_once()
777+
mock.set_version.assert_called_once_with("0.0.1")

tests/commands/test_version_command.py

+31
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,12 @@
11
import platform
22
import sys
33

4+
import pytest
5+
from pytest_mock import MockerFixture
6+
47
from commitizen import commands
58
from commitizen.__version__ import __version__
9+
from commitizen.config.base_config import BaseConfig
610

711

812
def test_version_for_showing_project_version(config, capsys):
@@ -70,3 +74,30 @@ def test_version_for_showing_commitizen_system_info(config, capsys):
7074
assert f"Commitizen Version: {__version__}" in captured.out
7175
assert f"Python Version: {sys.version}" in captured.out
7276
assert f"Operating System: {platform.system()}" in captured.out
77+
78+
79+
@pytest.mark.parametrize("project", (True, False))
80+
@pytest.mark.usefixtures("tmp_git_project")
81+
def test_version_use_version_provider(
82+
mocker: MockerFixture,
83+
config: BaseConfig,
84+
capsys: pytest.CaptureFixture,
85+
project: bool,
86+
):
87+
version = "0.0.0"
88+
mock = mocker.MagicMock(name="provider")
89+
mock.get_version.return_value = version
90+
get_provider = mocker.patch(
91+
"commitizen.commands.version.get_provider", return_value=mock
92+
)
93+
94+
commands.Version(
95+
config,
96+
{"report": False, "project": project, "commitizen": False, "verbose": True},
97+
)()
98+
captured = capsys.readouterr()
99+
100+
assert version in captured.out
101+
get_provider.assert_called_once()
102+
mock.get_version.assert_called_once()
103+
mock.set_version.assert_not_called()

tests/test_conf.py

+2
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,7 @@
3838
_settings = {
3939
"name": "cz_jira",
4040
"version": "1.0.0",
41+
"version_provider": "commitizen",
4142
"tag_format": None,
4243
"bump_message": None,
4344
"allow_abort": False,
@@ -54,6 +55,7 @@
5455
_new_settings = {
5556
"name": "cz_jira",
5657
"version": "2.0.0",
58+
"version_provider": "commitizen",
5759
"tag_format": None,
5860
"bump_message": None,
5961
"allow_abort": False,

tests/test_version_providers.py

+35
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
from __future__ import annotations
2+
3+
from typing import TYPE_CHECKING
4+
5+
import pytest
6+
7+
from commitizen.config.base_config import BaseConfig
8+
from commitizen.exceptions import VersionProviderUnknown
9+
from commitizen.providers import CommitizenProvider, get_provider
10+
11+
if TYPE_CHECKING:
12+
from pytest_mock import MockerFixture
13+
14+
15+
def test_default_version_provider_is_commitizen_config(config: BaseConfig):
16+
provider = get_provider(config)
17+
18+
assert isinstance(provider, CommitizenProvider)
19+
20+
21+
def test_raise_for_unknown_provider(config: BaseConfig):
22+
config.settings["version_provider"] = "unknown"
23+
with pytest.raises(VersionProviderUnknown):
24+
get_provider(config)
25+
26+
27+
def test_commitizen_provider(config: BaseConfig, mocker: MockerFixture):
28+
config.settings["version"] = "42"
29+
mock = mocker.patch.object(config, "set_key")
30+
31+
provider = CommitizenProvider(config)
32+
assert provider.get_version() == "42"
33+
34+
provider.set_version("43.1")
35+
mock.assert_called_once_with("version", "43.1")

0 commit comments

Comments
 (0)