From 5ed546bd20831136270cfa0264441faa042d4cd9 Mon Sep 17 00:00:00 2001 From: Corey Oordt Date: Wed, 20 Dec 2023 08:06:08 -0600 Subject: [PATCH] Refactored version parsing --- bumpversion/version_part.py | 46 +++++++-------- bumpversion/versioning/models.py | 55 ++++++++++++++++++ bumpversion/versioning/serialization.py | 56 +++++++++++++++++++ tests/test_version_part.py | 9 --- tests/test_versioning/test_serialization.py | 62 +++++++++++++++++++++ 5 files changed, 192 insertions(+), 36 deletions(-) create mode 100644 bumpversion/versioning/serialization.py create mode 100644 tests/test_versioning/test_serialization.py diff --git a/bumpversion/version_part.py b/bumpversion/version_part.py index 50a2f611..880f1687 100644 --- a/bumpversion/version_part.py +++ b/bumpversion/version_part.py @@ -9,8 +9,9 @@ from bumpversion.config.models import VersionPartConfig from bumpversion.exceptions import FormattingError, MissingValueError from bumpversion.ui import get_indented_logger -from bumpversion.utils import key_val_string, labels_for_format +from bumpversion.utils import labels_for_format from bumpversion.versioning.models import Version, VersionPart +from bumpversion.versioning.serialization import parse_version logger = get_indented_logger(__name__) @@ -74,39 +75,17 @@ def parse(self, version_string: Optional[str] = None) -> Optional[Version]: Returns: A Version object representing the string. """ - if not version_string: - return None - - regexp_one_line = "".join([line.split("#")[0].strip() for line in self.parse_regex.pattern.splitlines()]) - - logger.info( - "Parsing version '%s' using regexp '%s'", - version_string, - regexp_one_line, - ) - logger.indent() - - match = self.parse_regex.search(version_string) + parsed = parse_version(version_string, self.parse_regex.pattern) - if not match: - logger.warning( - "Evaluating 'parse' option: '%s' does not parse current version '%s'", - self.parse_regex.pattern, - version_string, - ) + if not parsed: return None _parsed = { key: VersionPart(self.part_configs[key], value) - for key, value in match.groupdict().items() + for key, value in parsed.items() if key in self.part_configs } - v = Version(_parsed, version_string) - - logger.info("Parsed the following values: %s", key_val_string(v.values)) - logger.dedent() - - return v + return Version(_parsed, version_string) def _serialize( self, version: Version, serialize_format: str, context: MutableMapping, raise_if_incomplete: bool = False @@ -169,6 +148,19 @@ def _serialize( return serialized def _choose_serialize_format(self, version: Version, context: MutableMapping) -> str: + """ + Choose a serialization format for the given version and context. + + Args: + version: The version to serialize + context: The context to use when serializing the version + + Returns: + The serialized version as a string + + Raises: + MissingValueError: if not all parts required in the format have values + """ chosen = None logger.debug("Evaluating serialization formats") diff --git a/bumpversion/versioning/models.py b/bumpversion/versioning/models.py index 8de38d48..aa754a29 100644 --- a/bumpversion/versioning/models.py +++ b/bumpversion/versioning/models.py @@ -6,6 +6,61 @@ from bumpversion.utils import key_val_string from bumpversion.versioning.functions import NumericFunction, PartFunction, ValuesFunction +# Adapted from https://regex101.com/r/Ly7O1x/3/ +SEMVER_PATTERN = r""" + (?P0|[1-9]\d*)\. + (?P0|[1-9]\d*)\. + (?P0|[1-9]\d*) + (?: + - # dash seperator for pre-release section + (?P[a-zA-Z-]+) # pre-release label + (?P0|[1-9]\d*) # pre-release version number + )? # pre-release section is optional + (?: + \+ # plus seperator for build metadata section + (?P + [0-9a-zA-Z-]+ + (?:\.[0-9a-zA-Z-]+)* + ) + )? # build metadata section is optional +""" + +# Adapted from https://packaging.python.org/en/latest/specifications/version-specifiers/ +PEP440_PATTERN = r""" + v? + (?: + (?:(?P[0-9]+)!)? # Optional epoch + (?P + (?P0|[1-9]\d*)\. + (?P0|[1-9]\d*)\. + (?P0|[1-9]\d*) + ) + (?P
                                          # pre-release
+            [-_\.]?
+            (?P(a|b|c|rc|alpha|beta|pre|preview))
+            [-_\.]?
+            (?P[0-9]+)?
+        )?
+        (?P                                         # post release
+            (?:-(?P[0-9]+))
+            |
+            (?:
+                [-_\.]?
+                (?Ppost|rev|r)
+                [-_\.]?
+                (?P[0-9]+)?
+            )
+        )?
+        (?P                                          # dev release
+            [-_\.]?
+            (?Pdev)
+            [-_\.]?
+            (?P[0-9]+)?
+        )?
+    )
+    (?:\+(?P[a-z0-9]+(?:[-_\.][a-z0-9]+)*))?       # local version
+"""
+
 
 class VersionPart:
     """
diff --git a/bumpversion/versioning/serialization.py b/bumpversion/versioning/serialization.py
new file mode 100644
index 00000000..91bd76ab
--- /dev/null
+++ b/bumpversion/versioning/serialization.py
@@ -0,0 +1,56 @@
+"""Functions for serializing and deserializing version objects."""
+import re
+from typing import Dict
+
+from bumpversion.exceptions import BumpVersionError
+from bumpversion.ui import get_indented_logger
+from bumpversion.utils import key_val_string
+
+logger = get_indented_logger(__name__)
+
+
+def parse_version(version_string: str, parse_pattern: str) -> Dict[str, str]:
+    """
+    Parse a version string into a dictionary of the parts and values using a regular expression.
+
+    Args:
+        version_string: Version string to parse
+        parse_pattern: The regular expression pattern to use for parsing
+
+    Returns:
+        A dictionary of version part labels and their values, or an empty dictionary
+        if the version string doesn't match.
+
+    Raises:
+        BumpVersionError: If the parse_pattern is not a valid regular expression
+    """
+    if not version_string:
+        logger.debug("Version string is empty, returning empty dict")
+        return {}
+    elif not parse_pattern:
+        logger.debug("Parse pattern is empty, returning empty dict")
+        return {}
+
+    logger.debug("Parsing version '%s' using regexp '%s'", version_string, parse_pattern)
+    logger.indent()
+
+    try:
+        pattern = re.compile(parse_pattern, re.VERBOSE)
+    except re.error as e:
+        raise BumpVersionError(f"'{parse_pattern}' is not a valid regular expression.") from e
+
+    match = re.search(pattern, version_string)
+
+    if not match:
+        logger.debug(
+            "'%s' does not parse current version '%s'",
+            parse_pattern,
+            version_string,
+        )
+        return {}
+
+    parsed = match.groupdict(default="")
+    logger.debug("Parsed the following values: %s", key_val_string(parsed))
+    logger.dedent()
+
+    return parsed
diff --git a/tests/test_version_part.py b/tests/test_version_part.py
index fa9b60cd..e21766d2 100644
--- a/tests/test_version_part.py
+++ b/tests/test_version_part.py
@@ -235,15 +235,6 @@ def test_version_part_invalid_regex_exit(tmp_path: Path) -> None:
             get_config_data(overrides)
 
 
-def test_parse_doesnt_parse_current_version(tmp_path: Path, caplog: LogCaptureFixture) -> None:
-    """A warning should be output when the parse regex doesn't parse the version."""
-    overrides = {"current_version": "12", "parse": "xxx"}
-    with inside_dir(tmp_path):
-        get_config_data(overrides)
-
-    assert "    Evaluating 'parse' option: 'xxx' does not parse current version '12'" in caplog.messages
-
-
 def test_part_does_not_revert_to_zero_if_optional(tmp_path: Path) -> None:
     """A non-numeric part with the optional value should not revert to zero."""
     # From https://github.com/c4urself/bump2version/issues/248
diff --git a/tests/test_versioning/test_serialization.py b/tests/test_versioning/test_serialization.py
new file mode 100644
index 00000000..213a8ba7
--- /dev/null
+++ b/tests/test_versioning/test_serialization.py
@@ -0,0 +1,62 @@
+"""Tests for the serialization of versioned objects."""
+from bumpversion.versioning.serialization import parse_version
+from bumpversion.versioning.models import SEMVER_PATTERN
+from bumpversion.exceptions import BumpVersionError
+
+import pytest
+from pytest import param
+
+
+class TestParseVersion:
+    """Test the parse_version function."""
+
+    def test_empty_version_returns_empty_dict(self):
+        assert parse_version("", "") == {}
+        assert parse_version(None, "") == {}
+
+    def test_empty_parse_pattern_returns_empty_dict(self):
+        assert parse_version("1.2.3", "") == {}
+        assert parse_version("1.2.3", None) == {}
+
+    @pytest.mark.parametrize(
+        ["version_string", "parse_pattern", "expected"],
+        [
+            param(
+                "1.2.3",
+                SEMVER_PATTERN,
+                {"buildmetadata": "", "major": "1", "minor": "2", "patch": "3", "pre_l": "", "pre_n": ""},
+                id="parse-version",
+            ),
+            param(
+                "1.2.3-alpha1",
+                SEMVER_PATTERN,
+                {"buildmetadata": "", "major": "1", "minor": "2", "patch": "3", "pre_l": "alpha", "pre_n": "1"},
+                id="parse-prerelease",
+            ),
+            param(
+                "1.2.3+build.1",
+                SEMVER_PATTERN,
+                {"buildmetadata": "build.1", "major": "1", "minor": "2", "patch": "3", "pre_l": "", "pre_n": ""},
+                id="parse-buildmetadata",
+            ),
+            param(
+                "1.2.3-alpha1+build.1",
+                SEMVER_PATTERN,
+                {"buildmetadata": "build.1", "major": "1", "minor": "2", "patch": "3", "pre_l": "alpha", "pre_n": "1"},
+                id="parse-prerelease-buildmetadata",
+            ),
+        ],
+    )
+    def test_parses_version_and_returns_full_dict(self, version_string: str, parse_pattern: str, expected: dict):
+        """The version string should be parsed into a dictionary of the parts and values, including missing parts."""
+        results = parse_version(version_string, parse_pattern)
+        assert results == expected
+
+    def test_unmatched_pattern_returns_empty_dict(self):
+        """If the version string doesn't match the parse pattern, an empty dictionary should be returned."""
+        assert parse_version("1.2.3", r"v(?P\d+)\.(?P\d+)\.(?P\d+)") == {}
+
+    def test_invalid_parse_pattern_raises_error(self):
+        """If the parse pattern is not a valid regular expression, a ValueError should be raised."""
+        with pytest.raises(BumpVersionError):
+            parse_version("1.2.3", r"v(?P\d+\.(?P\d+)\.(?P\d+)")