diff --git a/README.md b/README.md index d3c77ec..dac3665 100644 --- a/README.md +++ b/README.md @@ -49,6 +49,8 @@ Here are some of the arguments that you can use for `git` provider: |-------|-------------|---------| | `version_prefix` | filter tags only starts with this prefix | `v` | | `format` | plugin will use commit hash (long) or not (short) to build a project version | `short` | +| `bump_segment` | plugin will bump the selected version's segment with the number of commits after the last tag (allowed `release`, `post`, `dev`) | `release` | + Example: @@ -72,6 +74,19 @@ Building awesome_package (0.1.0) - Built awesome_package-0.1.0-py3-none-any.whl ``` +Logic behind `bump_segment` with 10 commits: +* `release`: bumps `patch` (as in `..`) or the lowest + * `1 -> 1.0.10` + * `1.0 -> 1.0.10` + * `1.0.2 -> 1.0.12` + * `1.0.0.2 -> 1.0.0.12` +* `post`: bumps `post` + * `1.0 -> 1.0.post10` + * `1.0.post2 -> 1.0.post12` +* `dev`: bumps `dev` + * `1.0 -> 1.0.dev10` + * `1.0.dev2 -> 1.0.dev12` + ## How to develop Before getting started with development, you'll need to have poetry installed. diff --git a/poem_plugins/config/git.py b/poem_plugins/config/git.py index 2e1bca1..1ee0949 100644 --- a/poem_plugins/config/git.py +++ b/poem_plugins/config/git.py @@ -12,14 +12,23 @@ class GitVersionFormatEnum(StrEnum): SHORT = "short" +@unique +class VersionSegmentBumpEnum(StrEnum): + RELEASE = "release" + POST_RELEASE = "post" + DEV = "dev" + + @dataclass class GitProviderSettings(BaseConfig): MAPPERS = MappingProxyType( { "format": GitVersionFormatEnum, "version_prefix": str, + "bump_segment": VersionSegmentBumpEnum, }, ) format: GitVersionFormatEnum = GitVersionFormatEnum.SHORT version_prefix: str = "v" + bump_segment: VersionSegmentBumpEnum = VersionSegmentBumpEnum.RELEASE diff --git a/poem_plugins/general/version/__init__.py b/poem_plugins/general/version/__init__.py index aeba6fd..b6de326 100644 --- a/poem_plugins/general/version/__init__.py +++ b/poem_plugins/general/version/__init__.py @@ -1,13 +1,27 @@ -from typing import NamedTuple, Optional +import re +from typing import NamedTuple, Optional, Tuple + + +_REGEX_PRE = re.compile(r"^(?P(a|b|rc))(?P\d+)$") class Version(NamedTuple): - major: int - minor: int - patch: int + release: Tuple[int, ...] + epoch: Optional[int] = None + pre: Optional[str] = None + post: Optional[int] = None + dev: Optional[int] = None commit: Optional[str] = None def __str__(self) -> str: - if not self.commit: - return f"{self.major}.{self.minor}.{self.patch}" - return f"{self.major}.{self.minor}.{self.patch}+g{self.commit}" + epoch = f"{self.epoch}!" if self.epoch is not None else "" + pre = self.pre or "" + post = f".post{self.post}" if self.post is not None else "" + dev = f".dev{self.dev}" if self.dev is not None else "" + commit = f"+{self.commit}" if self.commit is not None else "" + version = ( + epoch + + ".".join(map(str, self.release)) + + "".join((pre, post, dev, commit)) + ) + return version diff --git a/poem_plugins/general/version/drivers/git.py b/poem_plugins/general/version/drivers/git.py index 8a60960..7d9a375 100644 --- a/poem_plugins/general/version/drivers/git.py +++ b/poem_plugins/general/version/drivers/git.py @@ -3,10 +3,10 @@ import warnings from dataclasses import dataclass from shutil import which -from types import MappingProxyType -from typing import Any, Callable, ClassVar, Mapping, Match, Optional -from poem_plugins.config.git import GitProviderSettings, GitVersionFormatEnum +from poem_plugins.config.git import ( + GitProviderSettings, GitVersionFormatEnum, VersionSegmentBumpEnum, +) from poem_plugins.general.version import Version from poem_plugins.general.version.drivers import IVersionDriver @@ -32,17 +32,59 @@ class GitVersionDriver(IVersionDriver): '__version__ = "{version}"\n' ) - CONVERTERS: ClassVar[ - Mapping[str, Callable[[Any], Any]] - ] = MappingProxyType( - { - "major": int, - "minor": int, - "patch": int, - }, - ) + def _get_version_pep_440(self, raw_version: str) -> Version: + if raw_version.startswith(self.settings.version_prefix): + raw_version = raw_version.removeprefix(self.settings.version_prefix) + else: + raise RuntimeError( + f"Version tag must start with '{self.settings.version_prefix}' " + f"as defined in the plugin's config (or by default).", + ) + regex = ( + r"^((?P\d+)!)?(?P\d+(\.\d+)*)" + r"(?P
(a|b|rc)\d+)?(\.post(?P\d+))?(\.dev(?P\d+))?"
+            r"-(?P\d+)-(?P\S+)$"
+        )
+        match = re.match(regex, raw_version)
+        if not match:
+            raise RuntimeError(
+                f"Failed to parse git version: '{raw_version}' must conform to "
+                f"PEP 440",
+            )
+        groups = match.groupdict()
+        epoch = groups["epoch"]
+        pre, post, dev = groups["pre"], groups["post"], groups["dev"]
+        commit_count = int(groups["commit_count"])
+        commit = groups["commit"]
+        release = list(map(int, groups["release"].split(".")))
+        if len(release) < 3:
+            release += [0] * (3 - len(release))
 
-    def get_version(self) -> Version:
+        segments = {
+            "epoch": int(epoch) if epoch is not None else None,
+            "pre": pre,
+            "post": int(post) if post is not None else None,
+            "dev": int(dev) if dev is not None else None,
+            "commit": commit,
+        }
+
+        if commit_count:
+            bump_segment = self.settings.bump_segment
+            if bump_segment == VersionSegmentBumpEnum.RELEASE:
+                print(release)
+                release[-1] += commit_count
+            else:
+                segments[bump_segment.value] = segments[bump_segment.value] or 0
+                segments[bump_segment.value] += commit_count  # type: ignore
+
+        segments["release"] = tuple(release)
+
+        if self.settings.format == GitVersionFormatEnum.SHORT:
+            segments["commit"] = None
+
+        return Version(**segments)  # type: ignore
+
+    def _git_describe(self) -> str:
         if GIT_BIN is None:
             raise RuntimeError(WARNING_TEXT)
 
@@ -53,27 +95,12 @@ def get_version(self) -> Version:
         )
 
         if result.returncode != 0:
-            raise RuntimeError("Cannot found git version")
-        raw_version = result.stdout.strip()
-        regex = (
-            r"(?P\d+)\.(?P\d+)-(?P\d+)-(?P\S+)"
-        )
-        match: Optional[Match[str]] = re.match(
-            self.settings.version_prefix + regex,
-            raw_version,
-        )
+            raise RuntimeError("Failed to find git version tag")
+        return result.stdout.strip()
 
-        if match is None:
-            raise RuntimeError("Cannot parse git version")
-        raw_kwargs = dict(match.groupdict())
-        if self.settings.format == GitVersionFormatEnum.SHORT:
-            raw_kwargs.pop("commit", None)
-        kwargs = {
-            k: self.CONVERTERS.get(k, lambda x: x)(v)
-            for k, v in raw_kwargs.items()
-        }
-
-        return Version(**kwargs)
+    def get_version(self) -> Version:
+        raw_version = self._git_describe()
+        return self._get_version_pep_440(raw_version)
 
     def render_version_file(
         self,
@@ -81,8 +108,8 @@ def render_version_file(
     ) -> str:
         return self.VERSION_TEMPLATE.format(
             whoami='poem-plugins "git" plugin',
-            major=version.major,
-            minor=version.minor,
-            patch=version.patch,
+            major=version.release[0],
+            minor=version.release[1],
+            patch=version.release[2],
             version=str(version),
         )
diff --git a/tests/conftest.py b/tests/conftest.py
index 0c1ee88..a3d95c3 100644
--- a/tests/conftest.py
+++ b/tests/conftest.py
@@ -38,8 +38,9 @@ def simple_project(tmpdir) -> Iterator[Path]:
 
 @pytest.fixture
 def expected_long_version() -> Version:
-    return Version(1, 2, 0, "g3c3e199")
+    return Version(release=(1, 2, 0), commit="g3c3e199")
+
 
 @pytest.fixture
 def expected_version() -> Version:
-    return Version(1, 2, 0)
+    return Version(release=(1, 2, 0))
diff --git a/tests/general/versions/drivers/conftest.py b/tests/general/versions/drivers/conftest.py
index cbcff6b..b404d9c 100644
--- a/tests/general/versions/drivers/conftest.py
+++ b/tests/general/versions/drivers/conftest.py
@@ -8,6 +8,26 @@
 def git_settings() -> GitProviderSettings:
     return GitProviderSettings()
 
+
 @pytest.fixture
 def git_version_driver(git_settings) -> GitVersionDriver:
     return GitVersionDriver(git_settings)
+
+
+class MockedGitVersionDriver(GitVersionDriver):
+
+    _describe: str
+
+    def __init__(self, *args, describe: str, **kwargs):
+        super().__init__(*args, **kwargs)
+        self._describe = describe
+
+    def _git_describe(self) -> str:
+        return self._describe
+
+
+@pytest.fixture
+def get_mocked_git_version_driver(git_settings):
+    return lambda describe, git_settings=git_settings: (
+        MockedGitVersionDriver(git_settings, describe=describe)
+    )
diff --git a/tests/general/versions/drivers/git/test_bump_segment.py b/tests/general/versions/drivers/git/test_bump_segment.py
new file mode 100644
index 0000000..a0ed60a
--- /dev/null
+++ b/tests/general/versions/drivers/git/test_bump_segment.py
@@ -0,0 +1,84 @@
+import pytest
+
+from poem_plugins.config.git import (
+    GitProviderSettings, GitVersionFormatEnum, VersionSegmentBumpEnum,
+)
+from poem_plugins.general.version import Version
+
+
+@pytest.fixture
+def git_settings() -> GitProviderSettings:
+    return GitProviderSettings(format=GitVersionFormatEnum.SHORT)
+
+
+@pytest.mark.parametrize(
+    "describe,segment,expected", (
+        (
+            "1",
+            VersionSegmentBumpEnum.RELEASE,
+            Version(release=(1, 0, 10)),
+        ),
+        (
+            "1.2",
+            VersionSegmentBumpEnum.RELEASE,
+            Version(release=(1, 2, 10)),
+        ),
+        (
+            "1.2.3",
+            VersionSegmentBumpEnum.RELEASE,
+            Version(release=(1, 2, 13)),
+        ),
+        (
+            "1.2.3.4",
+            VersionSegmentBumpEnum.RELEASE,
+            Version(release=(1, 2, 3, 14)),
+        ),
+        (
+            "1.2.post3",
+            VersionSegmentBumpEnum.POST_RELEASE,
+            Version(release=(1, 2, 0), post=13),
+        ),
+        (
+            "1.2.dev3",
+            VersionSegmentBumpEnum.DEV,
+            Version(release=(1, 2, 0), dev=13),
+        ),
+    ),
+)
+def test_pep_440(
+    get_mocked_git_version_driver, describe, segment, expected,
+) -> None:
+    describe = "v" + describe + "-10-g3c3e199"
+
+    git_settings = GitProviderSettings(bump_segment=segment)
+    driver = get_mocked_git_version_driver(describe=describe, git_settings=git_settings)
+    assert driver.get_version() == expected
+
+
+@pytest.mark.parametrize(
+    "describe,segment,expected", (
+        (
+            "1.2.3.4",
+            VersionSegmentBumpEnum.RELEASE,
+            Version(release=(1, 2, 3, 4)),
+        ),
+        (
+            "1.2",
+            VersionSegmentBumpEnum.POST_RELEASE,
+            Version(release=(1, 2, 0)),
+        ),
+        (
+            "1.2",
+            VersionSegmentBumpEnum.DEV,
+            Version(release=(1, 2, 0)),
+        ),
+    ),
+)
+def test_pep_440_zero_commits(
+    get_mocked_git_version_driver, describe, segment, expected,
+) -> None:
+    describe = "v" + describe + "-0-g3c3e199"
+
+    git_settings = GitProviderSettings(bump_segment=segment)
+    driver = get_mocked_git_version_driver(describe=describe, git_settings=git_settings)
+    assert driver.get_version() == expected
diff --git a/tests/general/versions/drivers/git/test_long.py b/tests/general/versions/drivers/git/test_long.py
index 807f136..d459482 100644
--- a/tests/general/versions/drivers/git/test_long.py
+++ b/tests/general/versions/drivers/git/test_long.py
@@ -17,6 +17,65 @@ def test_get_version(
     assert git_version_driver.get_version() == expected_long_version
 
 
+@pytest.mark.parametrize(
+    "describe,expected", (
+        (
+            "1",
+            Version(release=(1, 0, 10), commit="g3c3e199"),
+        ),
+        (
+            "1.2",
+            Version(release=(1, 2, 10), commit="g3c3e199"),
+        ),
+        (
+            "1.2.3",
+            Version(release=(1, 2, 13), commit="g3c3e199"),
+        ),
+        (
+            "1.2.3.0",
+            Version(release=(1, 2, 3, 10), commit="g3c3e199"),
+        ),
+        (
+            "1.2a1",
+            Version(release=(1, 2, 10), pre="a1", commit="g3c3e199"),
+        ),
+        (
+            "1.2b1",
+            Version(release=(1, 2, 10), pre="b1", commit="g3c3e199"),
+        ),
+        (
+            "1.2rc1",
+            Version(release=(1, 2, 10), pre="rc1", commit="g3c3e199"),
+        ),
+        (
+            "1.2.post1",
+            Version(release=(1, 2, 10), post=1, commit="g3c3e199"),
+        ),
+        (
+            "1.2.dev1",
+            Version(release=(1, 2, 10), dev=1, commit="g3c3e199"),
+        ),
+        (
+            "1!1.2",
+            Version(epoch=1, release=(1, 2, 10), commit="g3c3e199"),
+        ),
+        (
+            "1!1.2.3a1.post1.dev1",
+            Version(
+                epoch=1, release=(1, 2, 13), pre="a1", post=1, dev=1, commit="g3c3e199",
+            ),
+        ),
+    ),
+)
+def test_pep_440(
+    get_mocked_git_version_driver, describe, expected,
+) -> None:
+    describe = "v" + describe + "-10-g3c3e199"
+
+    driver = get_mocked_git_version_driver(describe=describe)
+    assert driver.get_version() == expected
+
+
 def test_render_version(
     git_version_driver: IVersionDriver,
     expected_long_version: Version,
@@ -29,7 +88,31 @@ def test_render_version(
             "# NEVER EDIT THIS FILE MANUALLY",
             "",
             "version_info = (1, 2, 0)",
-            '__version__ = "1.2.0+gg3c3e199"',
+            '__version__ = "1.2.0+g3c3e199"',
+            "",
+        ),
+    )
+    assert content == expected
+
+
+def test_render_version_pep440(git_version_driver: IVersionDriver) -> None:
+    version = Version(
+        release=(1, 2, 3, 4),
+        epoch=1,
+        pre="a2",
+        post=3,
+        dev=4,
+        commit="g3c3e199",
+    )
+    content = git_version_driver.render_version_file(version)
+    expected = "\n".join(
+        (
+            "# THIS FILE WAS GENERATED AUTOMATICALLY",
+            '# BY: poem-plugins "git" plugin',
+            "# NEVER EDIT THIS FILE MANUALLY",
+            "",
+            "version_info = (1, 2, 3)",
+            '__version__ = "1!1.2.3.4a2.post3.dev4+g3c3e199"',
             "",
         ),
     )
diff --git a/tests/general/versions/drivers/git/test_short.py b/tests/general/versions/drivers/git/test_short.py
index ac7f877..47d5283 100644
--- a/tests/general/versions/drivers/git/test_short.py
+++ b/tests/general/versions/drivers/git/test_short.py
@@ -17,6 +17,74 @@ def test_get_version(
     assert git_version_driver.get_version() == expected_version
 
 
+@pytest.mark.parametrize(
+    "describe,expected", (
+        (
+            "1",
+            Version(release=(1, 0, 10)),
+        ),
+        (
+            "1.2",
+            Version(release=(1, 2, 10)),
+        ),
+        (
+            "1.2.3",
+            Version(release=(1, 2, 13)),
+        ),
+        (
+            "1.2.3.0",
+            Version(release=(1, 2, 3, 10)),
+        ),
+        (
+            "1.2a1",
+            Version(release=(1, 2, 10), pre="a1"),
+        ),
+        (
+            "1.2b1",
+            Version(release=(1, 2, 10), pre="b1"),
+        ),
+        (
+            "1.2rc1",
+            Version(release=(1, 2, 10), pre="rc1"),
+        ),
+        (
+            "1.2.post1",
+            Version(release=(1, 2, 10), post=1),
+        ),
+        (
+            "1.2.dev1",
+            Version(release=(1, 2, 10), dev=1),
+        ),
+        (
+            "1!1.2",
+            Version(epoch=1, release=(1, 2, 10)),
+        ),
+        (
+            "1!1.2.3a1.post1.dev1",
+            Version(
+                epoch=1, release=(1, 2, 13), pre="a1", post=1, dev=1,
+            ),
+        ),
+    ),
+)
+def test_get_version_pep_440(
+    get_mocked_git_version_driver, describe, expected,
+) -> None:
+    describe = "v" + describe + "-10-g3c3e199"
+    driver = get_mocked_git_version_driver(describe=describe)
+    assert driver.get_version() == expected
+
+
+def test_get_version_malformed(get_mocked_git_version_driver) -> None:
+    describe = "v1post123"
+    driver = get_mocked_git_version_driver(describe=describe)
+    with pytest.raises(
+        RuntimeError,
+        match="Failed to parse git version: '1post123' must conform to PEP 440",
+    ):
+        driver.get_version()
+
+
 def test_render_version(
     git_version_driver: IVersionDriver,
     expected_version: Version,
@@ -34,3 +102,27 @@ def test_render_version(
         ),
     )
     assert content == expected
+
+
+def test_render_version_pep440(git_version_driver: IVersionDriver) -> None:
+    version = Version(
+        release=(1, 2, 3, 4),
+        epoch=1,
+        pre="a2",
+        post=3,
+        dev=4,
+        commit=None,
+    )
+    content = git_version_driver.render_version_file(version)
+    expected = "\n".join(
+        (
+            "# THIS FILE WAS GENERATED AUTOMATICALLY",
+            '# BY: poem-plugins "git" plugin',
+            "# NEVER EDIT THIS FILE MANUALLY",
+            "",
+            "version_info = (1, 2, 3)",
+            '__version__ = "1!1.2.3.4a2.post3.dev4"',
+            "",
+        ),
+    )
+    assert content == expected
diff --git a/tests/general/versions/drivers/git/test_version_prefix.py b/tests/general/versions/drivers/git/test_version_prefix.py
new file mode 100644
index 0000000..bc8f1c4
--- /dev/null
+++ b/tests/general/versions/drivers/git/test_version_prefix.py
@@ -0,0 +1,31 @@
+import re
+
+import pytest
+
+from poem_plugins.config.git import GitProviderSettings, GitVersionFormatEnum
+from poem_plugins.general.version import Version
+
+
+@pytest.fixture
+def git_settings() -> GitProviderSettings:
+    return GitProviderSettings(format=GitVersionFormatEnum.SHORT)
+
+
+def test_version_prefix_ok(get_mocked_git_version_driver, git_settings) -> None:
+    describe = "v1.2.3-10-g3c3e199"
+    driver = get_mocked_git_version_driver(describe=describe, git_settings=git_settings)
+    assert driver.get_version() == Version(release=(1, 2, 13))
+
+
+def test_version_prefix_fail(get_mocked_git_version_driver) -> None:
+    describe = "1.2.3-10-g3c3e199"
+    git_settings = GitProviderSettings(version_prefix="v")
+    driver = get_mocked_git_version_driver(describe=describe, git_settings=git_settings)
+    with pytest.raises(
+        RuntimeError,
+        match=re.escape(
+            "Version tag must start with 'v' as defined in the plugin's config "
+            "(or by default).",
+        ),
+    ):
+        driver.get_version()
diff --git a/tests/plugins/versions/test_git.py b/tests/plugins/versions/test_git.py
index 95c7151..55d447e 100644
--- a/tests/plugins/versions/test_git.py
+++ b/tests/plugins/versions/test_git.py
@@ -51,11 +51,7 @@ def test_file_version(
     version_module = module_from_spec(spec)
     spec.loader.exec_module(version_module)
     assert version_module.__version__ == str(expected_version)
-    assert version_module.version_info == (
-        expected_version.major,
-        expected_version.minor,
-        expected_version.patch,
-    )
+    assert version_module.version_info == expected_version.release[:3]
 
 
 def test_pyproject_version(