Skip to content

Commit

Permalink
Refactored version parsing
Browse files Browse the repository at this point in the history
  • Loading branch information
coordt committed Dec 20, 2023
1 parent be87721 commit 5ed546b
Show file tree
Hide file tree
Showing 5 changed files with 192 additions and 36 deletions.
46 changes: 19 additions & 27 deletions bumpversion/version_part.py
Original file line number Diff line number Diff line change
Expand Up @@ -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__)

Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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")
Expand Down
55 changes: 55 additions & 0 deletions bumpversion/versioning/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"""
(?P<major>0|[1-9]\d*)\.
(?P<minor>0|[1-9]\d*)\.
(?P<patch>0|[1-9]\d*)
(?:
- # dash seperator for pre-release section
(?P<pre_l>[a-zA-Z-]+) # pre-release label
(?P<pre_n>0|[1-9]\d*) # pre-release version number
)? # pre-release section is optional
(?:
\+ # plus seperator for build metadata section
(?P<buildmetadata>
[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<epoch>[0-9]+)!)? # Optional epoch
(?P<release>
(?P<major>0|[1-9]\d*)\.
(?P<minor>0|[1-9]\d*)\.
(?P<patch>0|[1-9]\d*)
)
(?P<pre> # pre-release
[-_\.]?
(?P<pre_l>(a|b|c|rc|alpha|beta|pre|preview))
[-_\.]?
(?P<pre_n>[0-9]+)?
)?
(?P<post> # post release
(?:-(?P<post_n1>[0-9]+))
|
(?:
[-_\.]?
(?P<post_l>post|rev|r)
[-_\.]?
(?P<post_n2>[0-9]+)?
)
)?
(?P<dev> # dev release
[-_\.]?
(?P<dev_l>dev)
[-_\.]?
(?P<dev_n>[0-9]+)?
)?
)
(?:\+(?P<local>[a-z0-9]+(?:[-_\.][a-z0-9]+)*))? # local version
"""


class VersionPart:
"""
Expand Down
56 changes: 56 additions & 0 deletions bumpversion/versioning/serialization.py
Original file line number Diff line number Diff line change
@@ -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
9 changes: 0 additions & 9 deletions tests/test_version_part.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
62 changes: 62 additions & 0 deletions tests/test_versioning/test_serialization.py
Original file line number Diff line number Diff line change
@@ -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<major>\d+)\.(?P<minor>\d+)\.(?P<patch>\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<major>\d+\.(?P<minor>\d+)\.(?P<patch>\d+)")

0 comments on commit 5ed546b

Please sign in to comment.