Skip to content

Feature: version providers #646

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

Merged
merged 6 commits into from
Feb 12, 2023
Merged
Show file tree
Hide file tree
Changes from 1 commit
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
Next Next commit
feat(providers): add a commitizen.provider endpoint for alternative…
… versions providers
  • Loading branch information
noirbizarre committed Feb 11, 2023
commit 2de2ea4b4f4ef892fe773fdc5e3343aba31e7921
11 changes: 6 additions & 5 deletions commitizen/commands/bump.py
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@
NotAllowed,
NoVersionSpecifiedError,
)
from commitizen.providers import get_provider

logger = getLogger("commitizen")

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

def __call__(self): # noqa: C901
"""Steps executed to bump."""
provider = get_provider(self.config)
current_version: str = provider.get_version()

try:
current_version_instance: Version = Version(self.bump_settings["version"])
current_version_instance: Version = Version(current_version)
except TypeError:
raise NoVersionSpecifiedError()

# Initialize values from sources (conf)
current_version: str = self.config.settings["version"]

tag_format: str = self.bump_settings["tag_format"]
bump_commit_message: str = self.bump_settings["bump_message"]
version_files: List[str] = self.bump_settings["version_files"]
Expand Down Expand Up @@ -280,7 +281,7 @@ def __call__(self): # noqa: C901
check_consistency=self.check_consistency,
)

self.config.set_key("version", str(new_version))
provider.set_version(str(new_version))

if self.pre_bump_hooks:
hooks.run(
Expand Down
5 changes: 3 additions & 2 deletions commitizen/commands/version.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
from commitizen import out
from commitizen.__version__ import __version__
from commitizen.config import BaseConfig
from commitizen.providers import get_provider


class Version:
Expand All @@ -21,14 +22,14 @@ def __call__(self):
out.write(f"Python Version: {self.python_version}")
out.write(f"Operating System: {self.operating_system}")
elif self.parameter.get("project"):
version = self.config.settings["version"]
version = get_provider(self.config).get_version()
if version:
out.write(f"{version}")
else:
out.error("No project information in this project.")
elif self.parameter.get("verbose"):
out.write(f"Installed Commitizen Version: {__version__}")
version = self.config.settings["version"]
version = get_provider(self.config).get_version()
if version:
out.write(f"Project Version: {version}")
else:
Expand Down
2 changes: 2 additions & 0 deletions commitizen/defaults.py
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ class Settings(TypedDict, total=False):
name: str
version: Optional[str]
version_files: List[str]
version_provider: Optional[str]
tag_format: Optional[str]
bump_message: Optional[str]
allow_abort: bool
Expand Down Expand Up @@ -59,6 +60,7 @@ class Settings(TypedDict, total=False):
"name": "cz_conventional_commits",
"version": None,
"version_files": [],
"version_provider": "commitizen",
"tag_format": None, # example v$version
"bump_message": None, # bumped v$current_version to $new_version
"allow_abort": False,
Expand Down
5 changes: 5 additions & 0 deletions commitizen/exceptions.py
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ class ExitCode(enum.IntEnum):
INVALID_MANUAL_VERSION = 24
INIT_FAILED = 25
RUN_HOOK_FAILED = 26
VERSION_PROVIDER_UNKNOWN = 27


class CommitizenException(Exception):
Expand Down Expand Up @@ -173,3 +174,7 @@ class InitFailedError(CommitizenException):

class RunHookError(CommitizenException):
exit_code = ExitCode.RUN_HOOK_FAILED


class VersionProviderUnknown(CommitizenException):
exit_code = ExitCode.VERSION_PROVIDER_UNKNOWN
66 changes: 66 additions & 0 deletions commitizen/providers.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
from __future__ import annotations

from abc import ABC, abstractmethod
from typing import cast

import importlib_metadata as metadata

from commitizen.config.base_config import BaseConfig
from commitizen.exceptions import VersionProviderUnknown

PROVIDER_ENTRYPOINT = "commitizen.provider"
DEFAULT_PROVIDER = "commitizen"


class VersionProvider(ABC):
Copy link
Member

Choose a reason for hiding this comment

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

What do you think of using protocols?

Copy link
Member Author

Choose a reason for hiding this comment

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

In this precise case, I prefer ABC over Protocol because:

  • I expect both a constructor and methods (I could move the config in the method signatures, but this makes more verbose code without benefits in my opinion)
  • it is intended for user/community to extend this, using ABC force to implement methods and raise an explicit error if not, which I think is easier to understand (so more discoverable for them, less support for you)

But if you prefer Protocol over ABC I can switch 👍🏼

Copy link
Member

Choose a reason for hiding this comment

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

@woile What do you think?

Copy link
Member

Choose a reason for hiding this comment

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

I prefer Protocol 🤣 but using ABC is fine as well. Point 2 is the key, with Protocols people would have to use mypy to catch the problems, I like the explicit error.

"""
Abstract base class for version providers.

Each version provider should inherit and implement this class.
"""

config: BaseConfig

def __init__(self, config: BaseConfig):
self.config = config

@abstractmethod
def get_version(self) -> str:
"""
Get the current version
"""
...

@abstractmethod
def set_version(self, version: str):
"""
Set the new current version
"""
...


class CommitizenProvider(VersionProvider):
"""
Default version provider: Fetch and set version in commitizen config.
"""

def get_version(self) -> str:
return self.config.settings["version"] # type: ignore

def set_version(self, version: str):
self.config.set_key("version", version)


def get_provider(config: BaseConfig) -> VersionProvider:
"""
Get the version provider as defined in the configuration

:raises VersionProviderUnknown: if the provider named by `version_provider` is not found.
"""
provider_name = config.settings["version_provider"] or DEFAULT_PROVIDER
try:
(ep,) = metadata.entry_points(name=provider_name, group=PROVIDER_ENTRYPOINT)
except ValueError:
raise VersionProviderUnknown(f'Version Provider "{provider_name}" unknown.')
provider_cls = ep.load()
return cast(VersionProvider, provider_cls(config))
49 changes: 49 additions & 0 deletions docs/config.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
| `name` | `str` | `"cz_conventional_commits"` | Name of the committing rules to use |
| `version` | `str` | `None` | Current version. Example: "0.1.2" |
| `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] |
| `version_provider` | `str` | `commitizen` | Version provider used to read and write version [See more](#version-providers) |
| `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] |
| `update_changelog_on_bump` | `bool` | `false` | Create changelog when running `cz bump` |
| `gpg_sign` | `bool` | `false` | Use gpg signed tags instead of lightweight tags. |
Expand Down Expand Up @@ -112,6 +113,54 @@ commitizen:
- fg:#858585 italic
```

## Version providers

Commitizen can read and write version from different sources.
By default, it use the `commitizen` one which is using the `version` field from the commitizen settings.
But you can use any `commitizen.provider` entrypoint as value for `version_provider`.

### Custom version provider

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

Here a quick example of a `my-provider` provider reading and writing version in a `VERSION` file.

`my_provider.py`

```python
from pathlib import Path
from commitizen.providers import VersionProvider


class MyProvider(VersionProvider):
file = Path() / "VERSION"

def get_version(self) -> str:
return self.file.read_text()

def set_version(self, version: str):
self.file.write_text(version)

```

`setup.py`

```python
from setuptools import setup

setup(
name='my-commitizen-provider',
version='0.1.0',
py_modules=['my_provider'],
install_requires=['commitizen'],
entry_points = {
'commitizen.provider': [
'my-provider = my_provider:MyProvider',
]
}
)
```

[version_files]: bump.md#version_files
[tag_format]: bump.md#tag_format
[bump_message]: bump.md#bump_message
Expand Down
5 changes: 4 additions & 1 deletion docs/exit_codes.md
Original file line number Diff line number Diff line change
Expand Up @@ -30,4 +30,7 @@ These exit codes can be found in `commitizen/exceptions.py::ExitCode`.
| NotAllowed | 20 | `--incremental` cannot be combined with a `rev_range` |
| NoneIncrementExit | 21 | The commits found are not eligible to be bumped |
| CharacterSetDecodeError | 22 | The character encoding of the command output could not be determined |
| GitCommandError | 23 | Unexpected failure while calling a git command |
| GitCommandError | 23 | Unexpected failure while calling a git command |
| InvalidManualVersion | 24 | Manually provided version is invalid |
| InitFailedError | 25 | Failed to initialize pre-commit |
| VersionProviderUnknown | 26 | `version_provider` setting is set to an unknown version provider indentifier |
3 changes: 3 additions & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -104,6 +104,9 @@ cz_conventional_commits = "commitizen.cz.conventional_commits:ConventionalCommit
cz_jira = "commitizen.cz.jira:JiraSmartCz"
cz_customize = "commitizen.cz.customize:CustomizeCommitsCz"

[tool.poetry.plugins."commitizen.provider"]
commitizen = "commitizen.providers:CommitizenProvider"

[tool.isort]
profile = "black"
known_first_party = ["commitizen", "tests"]
Expand Down
20 changes: 20 additions & 0 deletions tests/commands/test_bump_command.py
Original file line number Diff line number Diff line change
Expand Up @@ -833,3 +833,23 @@ def test_bump_manual_version_disallows_prerelease_offset(mocker):
"--prerelease-offset cannot be combined with MANUAL_VERSION"
)
assert expected_error_message in str(excinfo.value)


@pytest.mark.usefixtures("tmp_git_project")
def test_bump_use_version_provider(mocker: MockFixture):
mock = mocker.MagicMock(name="provider")
mock.get_version.return_value = "0.0.0"
get_provider = mocker.patch(
"commitizen.commands.bump.get_provider", return_value=mock
)

create_file_and_commit("fix: fake commit")
testargs = ["cz", "bump", "--yes", "--changelog"]
mocker.patch.object(sys, "argv", testargs)

cli.main()

assert git.tag_exist("0.0.1")
get_provider.assert_called_once()
mock.get_version.assert_called_once()
mock.set_version.assert_called_once_with("0.0.1")
31 changes: 31 additions & 0 deletions tests/commands/test_version_command.py
Original file line number Diff line number Diff line change
@@ -1,8 +1,12 @@
import platform
import sys

import pytest
from pytest_mock import MockerFixture

from commitizen import commands
from commitizen.__version__ import __version__
from commitizen.config.base_config import BaseConfig


def test_version_for_showing_project_version(config, capsys):
Expand Down Expand Up @@ -70,3 +74,30 @@ def test_version_for_showing_commitizen_system_info(config, capsys):
assert f"Commitizen Version: {__version__}" in captured.out
assert f"Python Version: {sys.version}" in captured.out
assert f"Operating System: {platform.system()}" in captured.out


@pytest.mark.parametrize("project", (True, False))
@pytest.mark.usefixtures("tmp_git_project")
def test_version_use_version_provider(
mocker: MockerFixture,
config: BaseConfig,
capsys: pytest.CaptureFixture,
project: bool,
):
version = "0.0.0"
mock = mocker.MagicMock(name="provider")
mock.get_version.return_value = version
get_provider = mocker.patch(
"commitizen.commands.version.get_provider", return_value=mock
)

commands.Version(
config,
{"report": False, "project": project, "commitizen": False, "verbose": True},
)()
captured = capsys.readouterr()

assert version in captured.out
get_provider.assert_called_once()
mock.get_version.assert_called_once()
mock.set_version.assert_not_called()
2 changes: 2 additions & 0 deletions tests/test_conf.py
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,7 @@
_settings = {
"name": "cz_jira",
"version": "1.0.0",
"version_provider": "commitizen",
"tag_format": None,
"bump_message": None,
"allow_abort": False,
Expand All @@ -63,6 +64,7 @@
_new_settings = {
"name": "cz_jira",
"version": "2.0.0",
"version_provider": "commitizen",
"tag_format": None,
"bump_message": None,
"allow_abort": False,
Expand Down
35 changes: 35 additions & 0 deletions tests/test_version_providers.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
from __future__ import annotations

from typing import TYPE_CHECKING

import pytest

from commitizen.config.base_config import BaseConfig
from commitizen.exceptions import VersionProviderUnknown
from commitizen.providers import CommitizenProvider, get_provider

if TYPE_CHECKING:
from pytest_mock import MockerFixture


def test_default_version_provider_is_commitizen_config(config: BaseConfig):
provider = get_provider(config)

assert isinstance(provider, CommitizenProvider)


def test_raise_for_unknown_provider(config: BaseConfig):
config.settings["version_provider"] = "unknown"
with pytest.raises(VersionProviderUnknown):
get_provider(config)


def test_commitizen_provider(config: BaseConfig, mocker: MockerFixture):
config.settings["version"] = "42"
mock = mocker.patch.object(config, "set_key")

provider = CommitizenProvider(config)
assert provider.get_version() == "42"

provider.set_version("43.1")
mock.assert_called_once_with("version", "43.1")