From 16c8449465b59eb5b5da1a91899f7dbd78905b8f Mon Sep 17 00:00:00 2001 From: Axel H Date: Sun, 1 Jan 2023 19:54:15 +0100 Subject: [PATCH] feat(providers): add scm provider reading version from the last tag matching tag_format Fixes #641 --- commitizen/providers.py | 59 +++++++++++++++++++++++++++++++- docs/config.md | 4 +++ pyproject.toml | 1 + tests/test_version_providers.py | 60 ++++++++++++++++++++++++++++++++- 4 files changed, 122 insertions(+), 2 deletions(-) diff --git a/commitizen/providers.py b/commitizen/providers.py index 03db22b44a..dff69746b8 100644 --- a/commitizen/providers.py +++ b/commitizen/providers.py @@ -1,15 +1,18 @@ from __future__ import annotations import json +import re from abc import ABC, abstractmethod from pathlib import Path -from typing import Any, ClassVar, cast +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 ENTRYPOINT = "commitizen.provider" DEFAULT = "commitizen" @@ -157,6 +160,60 @@ class ComposerProvider(JsonProvider): indent = 4 +class ScmProvider(VersionProvider): + """ + A provider dedicated to be coupled with `setuptools-scm` + or any package manager `-scm` provider. + + Version is fetched from git history (using `git describe`) + and does not need to be set back. + """ + + TAG_FORMAT_REGEXS = { + "$version": r"(?P.+)", + "$major": r"(?P\d+)", + "$minor": r"(?P\d+)", + "$patch": r"(?P\d+)", + "$prerelease": r"(?P\w+\d+)?", + "$devrelease": r"(?P\.?dev\d+)?", + } + + def _tag_format_matcher(self) -> Callable[[str], Optional[str]]: + tag_format = self.config.settings.get("tag_format") or VERSION_PATTERN + for var, pattern in self.TAG_FORMAT_REGEXS.items(): + tag_format = tag_format.replace(var, pattern) + + regex = re.compile(f"^{tag_format}$", re.VERBOSE) + + def matcher(tag: str) -> Optional[str]: + m = regex.match(tag) + if not m: + return None + elif "version" in m.groupdict(): + return m.group("version") + elif "devrelease" in m.groupdict(): + return "{major}.{minor}.{patch}{devrelease}".format(**m.groupdict()) + elif "prerelease" in m.groupdict(): + return "{major}.{minor}.{patch}{prerelease}".format(**m.groupdict()) + elif "major" in m.groupdict(): + return "{major}.{minor}.{patch}".format(**m.groupdict()) + elif tag_format == 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 diff --git a/docs/config.md b/docs/config.md index bf2490ce13..5f596167a8 100644 --- a/docs/config.md +++ b/docs/config.md @@ -123,12 +123,16 @@ Commitizen provides some version providers for some well known formats: | name | description | | ---- | ----------- | | `commitizen` | Default version provider: Fetch and set version in commitizen config. | +| `scm` | Fetch the version from git and does not need to set it back | | `pep621` | Get and set version from `pyproject.toml` `project.version` field | | `poetry` | Get and set version from `pyproject.toml` `tool.poetry.version` field | | `cargo` | Get and set version from `Cargo.toml` `project.version` field | | `npm` | Get and set version from `package.json` `project.version` field | | `composer` | Get and set version from `composer.json` `project.version` field | +!!! note + The `scm` provider is meant to be used with `setuptools-scm` or any packager `*-scm` plugin. + ### Custom version provider You can add you own version provider by extending `VersionProvider` and exposing it on the `commitizen.provider` entrypoint. diff --git a/pyproject.toml b/pyproject.toml index be73fc0334..87aa2ac74b 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -111,6 +111,7 @@ composer = "commitizen.providers:ComposerProvider" npm = "commitizen.providers:NpmProvider" pep621 = "commitizen.providers:Pep621Provider" poetry = "commitizen.providers:PoetryProvider" +scm = "commitizen.providers:ScmProvider" [tool.isort] profile = "black" diff --git a/tests/test_version_providers.py b/tests/test_version_providers.py index d5aa15bc2d..3296108b26 100644 --- a/tests/test_version_providers.py +++ b/tests/test_version_providers.py @@ -3,7 +3,7 @@ import os from pathlib import Path from textwrap import dedent -from typing import TYPE_CHECKING, Iterator +from typing import TYPE_CHECKING, Iterator, Optional import pytest @@ -16,8 +16,10 @@ NpmProvider, Pep621Provider, PoetryProvider, + ScmProvider, get_provider, ) +from tests.utils import create_file_and_commit, create_tag if TYPE_CHECKING: from pytest_mock import MockerFixture @@ -185,3 +187,59 @@ def test_composer_provider(config: BaseConfig, chdir: Path): } """ ) + + +@pytest.mark.parametrize( + "tag_format,tag,version", + ( + (None, "0.1.0", "0.1.0"), + (None, "v0.1.0", "0.1.0"), + ("v$version", "v0.1.0", "0.1.0"), + ("version-$version", "version-0.1.0", "0.1.0"), + ("v$major.$minor.$patch", "v0.1.0", "0.1.0"), + ("v$minor.$major.$patch", "v1.0.0", "0.1.0"), + ("version-$major.$minor.$patch", "version-0.1.0", "0.1.0"), + ("v$minor.$major.$patch$prerelease", "v1.0.0rc1", "0.1.0rc1"), + ("v$minor.$major.$patch$prerelease$devrelease", "v1.0.0.dev0", "0.1.0.dev0"), + ), +) +@pytest.mark.usefixtures("tmp_git_project") +def test_scm_provider( + config: BaseConfig, tag_format: Optional[str], tag: str, version: str +): + create_file_and_commit("test: fake commit") + create_tag(tag) + create_file_and_commit("test: fake commit") + create_tag("should-not-match") + + config.settings["version_provider"] = "scm" + config.settings["tag_format"] = tag_format + + provider = get_provider(config) + assert isinstance(provider, ScmProvider) + assert provider.get_version() == version + + # Should not fail on set_version() + provider.set_version("43.1") + + +@pytest.mark.usefixtures("tmp_git_project") +def test_scm_provider_default_without_matching_tag(config: BaseConfig): + create_file_and_commit("test: fake commit") + create_tag("should-not-match") + create_file_and_commit("test: fake commit") + + config.settings["version_provider"] = "scm" + + provider = get_provider(config) + assert isinstance(provider, ScmProvider) + assert provider.get_version() == "0.0.0" + + +@pytest.mark.usefixtures("tmp_git_project") +def test_scm_provider_default_without_commits_and_tags(config: BaseConfig): + config.settings["version_provider"] = "scm" + + provider = get_provider(config) + assert isinstance(provider, ScmProvider) + assert provider.get_version() == "0.0.0"