Skip to content
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

Feature: version providers #646

Merged
merged 6 commits into from
Feb 12, 2023
Merged
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
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
240 changes: 240 additions & 0 deletions commitizen/providers.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,240 @@
from __future__ import annotations

import json
import re
from abc import ABC, abstractmethod
from pathlib import Path
from typing import Any, Callable, ClassVar, Optional, cast

import importlib_metadata as metadata
import tomlkit
from packaging.version import VERSION_PATTERN, Version

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

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)


class FileProvider(VersionProvider):
"""
Base class for file-based version providers
"""

filename: ClassVar[str]

@property
def file(self) -> Path:
return Path() / self.filename


class TomlProvider(FileProvider):
"""
Base class for TOML-based version providers
"""

def get_version(self) -> str:
document = tomlkit.parse(self.file.read_text())
return self.get(document)

def set_version(self, version: str):
document = tomlkit.parse(self.file.read_text())
self.set(document, version)
self.file.write_text(tomlkit.dumps(document))

def get(self, document: tomlkit.TOMLDocument) -> str:
return document["project"]["version"] # type: ignore

def set(self, document: tomlkit.TOMLDocument, version: str):
document["project"]["version"] = version # type: ignore


class Pep621Provider(TomlProvider):
"""
PEP-621 version management
"""

filename = "pyproject.toml"


class PoetryProvider(TomlProvider):
"""
Poetry version management
"""

filename = "pyproject.toml"

def get(self, pyproject: tomlkit.TOMLDocument) -> str:
return pyproject["tool"]["poetry"]["version"] # type: ignore

def set(self, pyproject: tomlkit.TOMLDocument, version: str):
pyproject["tool"]["poetry"]["version"] = version # type: ignore


class CargoProvider(TomlProvider):
"""
Cargo version management
"""

filename = "Cargo.toml"

def get(self, document: tomlkit.TOMLDocument) -> str:
return document["package"]["version"] # type: ignore

def set(self, document: tomlkit.TOMLDocument, version: str):
document["package"]["version"] = version # type: ignore


class JsonProvider(FileProvider):
"""
Base class for JSON-based version providers
"""

indent: ClassVar[int] = 2

def get_version(self) -> str:
document = json.loads(self.file.read_text())
return self.get(document)

def set_version(self, version: str):
document = json.loads(self.file.read_text())
self.set(document, version)
self.file.write_text(json.dumps(document, indent=self.indent) + "\n")

def get(self, document: dict[str, Any]) -> str:
return document["version"] # type: ignore

def set(self, document: dict[str, Any], version: str):
document["version"] = version


class NpmProvider(JsonProvider):
"""
npm package.json version management
"""

filename = "package.json"


class ComposerProvider(JsonProvider):
"""
Composer version management
"""

filename = "composer.json"
indent = 4


class ScmProvider(VersionProvider):
"""
A provider fetching the current/last version from the repository history

The version is fetched using `git describe` and is never set.

It is meant for `setuptools-scm` or any package manager `*-scm` provider.
"""

TAG_FORMAT_REGEXS = {
"$version": r"(?P<version>.+)",
"$major": r"(?P<major>\d+)",
"$minor": r"(?P<minor>\d+)",
"$patch": r"(?P<patch>\d+)",
"$prerelease": r"(?P<prerelease>\w+\d+)?",
"$devrelease": r"(?P<devrelease>\.dev\d+)?",
}

def _tag_format_matcher(self) -> Callable[[str], Optional[str]]:
pattern = self.config.settings.get("tag_format") or VERSION_PATTERN
for var, tag_pattern in self.TAG_FORMAT_REGEXS.items():
pattern = pattern.replace(var, tag_pattern)

regex = re.compile(f"^{pattern}$", re.VERBOSE)

def matcher(tag: str) -> Optional[str]:
match = regex.match(tag)
if not match:
return None
groups = match.groupdict()
if "version" in groups:
return groups["version"]
elif "major" in groups:
return "".join(
(
groups["major"],
f".{groups['minor']}" if groups.get("minor") else "",
f".{groups['patch']}" if groups.get("patch") else "",
groups["prerelease"] if groups.get("prerelease") else "",
groups["devrelease"] if groups.get("devrelease") else "",
)
)
elif pattern == VERSION_PATTERN:
return str(Version(tag))
return None

return matcher

def get_version(self) -> str:
matcher = self._tag_format_matcher()
return next(
(cast(str, matcher(t.name)) for t in get_tags() if matcher(t.name)), "0.0.0"
)

def set_version(self, version: str):
# Not necessary
pass


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))
Loading