Skip to content

Commit 6b8ce57

Browse files
noirbizarreLee-W
authored andcommitted
feat(providers): add a commitizen.provider endpoint for alternative versions providers
1 parent a2cad36 commit 6b8ce57

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

@@ -94,14 +95,14 @@ def find_increment(self, commits: List[git.GitCommit]) -> Optional[str]:
9495

9596
def __call__(self): # noqa: C901
9697
"""Steps executed to bump."""
98+
provider = get_provider(self.config)
99+
current_version: str = provider.get_version()
100+
97101
try:
98-
current_version_instance: Version = Version(self.bump_settings["version"])
102+
current_version_instance: Version = Version(current_version)
99103
except TypeError:
100104
raise NoVersionSpecifiedError()
101105

102-
# Initialize values from sources (conf)
103-
current_version: str = self.config.settings["version"]
104-
105106
tag_format: str = self.bump_settings["tag_format"]
106107
bump_commit_message: str = self.bump_settings["bump_message"]
107108
version_files: List[str] = self.bump_settings["version_files"]
@@ -280,7 +281,7 @@ def __call__(self): # noqa: C901
280281
check_consistency=self.check_consistency,
281282
)
282283

283-
self.config.set_key("version", str(new_version))
284+
provider.set_version(str(new_version))
284285

285286
if self.pre_bump_hooks:
286287
hooks.run(

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
@@ -59,6 +60,7 @@ class Settings(TypedDict, total=False):
5960
"name": "cz_conventional_commits",
6061
"version": None,
6162
"version_files": [],
63+
"version_provider": "commitizen",
6264
"tag_format": None, # example v$version
6365
"bump_message": None, # bumped v$current_version to $new_version
6466
"allow_abort": False,

commitizen/exceptions.py

+5
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,7 @@ class ExitCode(enum.IntEnum):
3131
INVALID_MANUAL_VERSION = 24
3232
INIT_FAILED = 25
3333
RUN_HOOK_FAILED = 26
34+
VERSION_PROVIDER_UNKNOWN = 27
3435

3536

3637
class CommitizenException(Exception):
@@ -173,3 +174,7 @@ class InitFailedError(CommitizenException):
173174

174175
class RunHookError(CommitizenException):
175176
exit_code = ExitCode.RUN_HOOK_FAILED
177+
178+
179+
class VersionProviderUnknown(CommitizenException):
180+
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+
PROVIDER_ENTRYPOINT = "commitizen.provider"
12+
DEFAULT_PROVIDER = "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_PROVIDER
61+
try:
62+
(ep,) = metadata.entry_points(name=provider_name, group=PROVIDER_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. |
@@ -112,6 +113,54 @@ commitizen:
112113
- fg:#858585 italic
113114
```
114115
116+
## Version providers
117+
118+
Commitizen can read and write version from different sources.
119+
By default, it use the `commitizen` one which is using the `version` field from the commitizen settings.
120+
But you can use any `commitizen.provider` entrypoint as value for `version_provider`.
121+
122+
### Custom version provider
123+
124+
You can add you own version provider by extending `VersionProvider` and exposing it on the `commitizen.provider` entrypoint.
125+
126+
Here a quick example of a `my-provider` provider reading and writing version in a `VERSION` file.
127+
128+
`my_provider.py`
129+
130+
```python
131+
from pathlib import Path
132+
from commitizen.providers import VersionProvider
133+
134+
135+
class MyProvider(VersionProvider):
136+
file = Path() / "VERSION"
137+
138+
def get_version(self) -> str:
139+
return self.file.read_text()
140+
141+
def set_version(self, version: str):
142+
self.file.write_text(version)
143+
144+
```
145+
146+
`setup.py`
147+
148+
```python
149+
from setuptools import setup
150+
151+
setup(
152+
name='my-commitizen-provider',
153+
version='0.1.0',
154+
py_modules=['my_provider'],
155+
install_requires=['commitizen'],
156+
entry_points = {
157+
'commitizen.provider': [
158+
'my-provider = my_provider:MyProvider',
159+
]
160+
}
161+
)
162+
```
163+
115164
[version_files]: bump.md#version_files
116165
[tag_format]: bump.md#tag_format
117166
[bump_message]: bump.md#bump_message

docs/exit_codes.md

+4-1
Original file line numberDiff line numberDiff line change
@@ -30,4 +30,7 @@ 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+
| InitFailedError | 25 | Failed to initialize pre-commit |
36+
| VersionProviderUnknown | 26 | `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

+20
Original file line numberDiff line numberDiff line change
@@ -833,3 +833,23 @@ def test_bump_manual_version_disallows_prerelease_offset(mocker):
833833
"--prerelease-offset cannot be combined with MANUAL_VERSION"
834834
)
835835
assert expected_error_message in str(excinfo.value)
836+
837+
838+
@pytest.mark.usefixtures("tmp_git_project")
839+
def test_bump_use_version_provider(mocker: MockFixture):
840+
mock = mocker.MagicMock(name="provider")
841+
mock.get_version.return_value = "0.0.0"
842+
get_provider = mocker.patch(
843+
"commitizen.commands.bump.get_provider", return_value=mock
844+
)
845+
846+
create_file_and_commit("fix: fake commit")
847+
testargs = ["cz", "bump", "--yes", "--changelog"]
848+
mocker.patch.object(sys, "argv", testargs)
849+
850+
cli.main()
851+
852+
assert git.tag_exist("0.0.1")
853+
get_provider.assert_called_once()
854+
mock.get_version.assert_called_once()
855+
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
@@ -44,6 +44,7 @@
4444
_settings = {
4545
"name": "cz_jira",
4646
"version": "1.0.0",
47+
"version_provider": "commitizen",
4748
"tag_format": None,
4849
"bump_message": None,
4950
"allow_abort": False,
@@ -63,6 +64,7 @@
6364
_new_settings = {
6465
"name": "cz_jira",
6566
"version": "2.0.0",
67+
"version_provider": "commitizen",
6668
"tag_format": None,
6769
"bump_message": None,
6870
"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)