Skip to content

Commit 0ac2cd8

Browse files
committed
Refactored serialization
- Moved serialization from VersionConfig to version.serialization
1 parent 384fd99 commit 0ac2cd8

File tree

3 files changed

+156
-129
lines changed

3 files changed

+156
-129
lines changed

bumpversion/version_part.py

+3-123
Original file line numberDiff line numberDiff line change
@@ -1,17 +1,13 @@
11
"""Module for managing Versions and their internal parts."""
22
import re
3-
import string
4-
from copy import copy
53
from typing import Any, Dict, List, MutableMapping, Optional, Tuple
64

75
from click import UsageError
8-
from versioning.models import VersionComponentConfig
96

10-
from bumpversion.exceptions import FormattingError, MissingValueError
117
from bumpversion.ui import get_indented_logger
128
from bumpversion.utils import labels_for_format
13-
from bumpversion.versioning.models import Version, VersionComponent, VersionSpec
14-
from bumpversion.versioning.serialization import parse_version
9+
from bumpversion.versioning.models import Version, VersionComponentConfig, VersionSpec
10+
from bumpversion.versioning.serialization import parse_version, serialize
1511

1612
logger = get_indented_logger(__name__)
1713

@@ -85,117 +81,6 @@ def parse(self, version_string: Optional[str] = None) -> Optional[Version]:
8581
version.original = version_string
8682
return version
8783

88-
# _parsed = {
89-
# key: VersionComponent(self.part_configs[key], value)
90-
# for key, value in parsed.items()
91-
# if key in self.part_configs
92-
# }
93-
# return Version(_parsed, version_string)
94-
95-
def _serialize(
96-
self, version: Version, serialize_format: str, context: MutableMapping, raise_if_incomplete: bool = False
97-
) -> str:
98-
"""
99-
Attempts to serialize a version with the given serialization format.
100-
101-
Args:
102-
version: The version to serialize
103-
serialize_format: The serialization format to use, using Python's format string syntax
104-
context: The context to use when serializing the version
105-
raise_if_incomplete: Whether to raise an error if the version is incomplete
106-
107-
Raises:
108-
FormattingError: if not serializable
109-
MissingValueError: if not all parts required in the format have values
110-
111-
Returns:
112-
The serialized version as a string
113-
"""
114-
values = copy(context)
115-
for k in version:
116-
values[k] = version[k]
117-
118-
# TODO dump complete context on debug level
119-
120-
try:
121-
# test whether all parts required in the format have values
122-
serialized = serialize_format.format(**values)
123-
124-
except KeyError as e:
125-
missing_key = getattr(e, "message", e.args[0])
126-
raise MissingValueError(
127-
f"Did not find key {missing_key!r} in {version!r} when serializing version number"
128-
) from e
129-
130-
keys_needing_representation = set()
131-
132-
keys = list(self.order)
133-
for i, k in enumerate(keys):
134-
v = values[k]
135-
136-
if not isinstance(v, VersionComponent):
137-
# values coming from environment variables don't need
138-
# representation
139-
continue
140-
141-
if not v.is_optional:
142-
keys_needing_representation = set(keys[: i + 1])
143-
144-
required_by_format = set(labels_for_format(serialize_format))
145-
146-
# try whether all parsed keys are represented
147-
if raise_if_incomplete and not keys_needing_representation <= required_by_format:
148-
missing_keys = keys_needing_representation ^ required_by_format
149-
raise FormattingError(
150-
f"""Could not represent '{"', '".join(missing_keys)}' in format '{serialize_format}'"""
151-
)
152-
153-
return serialized
154-
155-
def _choose_serialize_format(self, version: Version, context: MutableMapping) -> str:
156-
"""
157-
Choose a serialization format for the given version and context.
158-
159-
Args:
160-
version: The version to serialize
161-
context: The context to use when serializing the version
162-
163-
Returns:
164-
The serialized version as a string
165-
166-
Raises:
167-
MissingValueError: if not all parts required in the format have values
168-
"""
169-
chosen = None
170-
171-
logger.debug("Evaluating serialization formats")
172-
logger.indent()
173-
for serialize_format in self.serialize_formats:
174-
try:
175-
self._serialize(version, serialize_format, context, raise_if_incomplete=True)
176-
# Prefer shorter or first search expression.
177-
chosen_part_count = len(list(string.Formatter().parse(chosen))) if chosen else None
178-
serialize_part_count = len(list(string.Formatter().parse(serialize_format)))
179-
if not chosen or chosen_part_count > serialize_part_count:
180-
chosen = serialize_format
181-
logger.debug("Found '%s' to be a usable serialization format", chosen)
182-
else:
183-
logger.debug("Found '%s' usable serialization format, but it's longer", serialize_format)
184-
except FormattingError:
185-
# If chosen, prefer shorter
186-
if not chosen:
187-
chosen = serialize_format
188-
except MissingValueError as e:
189-
logger.info(e.message)
190-
raise e
191-
192-
if not chosen:
193-
raise KeyError("Did not find suitable serialization format")
194-
logger.dedent()
195-
logger.debug("Selected serialization format '%s'", chosen)
196-
197-
return chosen
198-
19984
def serialize(self, version: Version, context: MutableMapping) -> str:
20085
"""
20186
Serialize a version to a string.
@@ -207,9 +92,4 @@ def serialize(self, version: Version, context: MutableMapping) -> str:
20792
Returns:
20893
The serialized version as a string
20994
"""
210-
logger.debug("Serializing version '%s'", version)
211-
logger.indent()
212-
serialized = self._serialize(version, self._choose_serialize_format(version, context), context)
213-
logger.debug("Serialized to '%s'", serialized)
214-
logger.dedent()
215-
return serialized
95+
return serialize(version, list(self.serialize_formats), context)

bumpversion/versioning/serialization.py

+84-3
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,13 @@
11
"""Functions for serializing and deserializing version objects."""
22
import re
3-
from typing import Dict
3+
from copy import copy
4+
from operator import itemgetter
5+
from typing import Dict, List, MutableMapping
46

5-
from bumpversion.exceptions import BumpVersionError
7+
from bumpversion.exceptions import BumpVersionError, FormattingError
68
from bumpversion.ui import get_indented_logger
7-
from bumpversion.utils import key_val_string
9+
from bumpversion.utils import key_val_string, labels_for_format
10+
from bumpversion.versioning.models import Version
811

912
logger = get_indented_logger(__name__)
1013

@@ -54,3 +57,81 @@ def parse_version(version_string: str, parse_pattern: str) -> Dict[str, str]:
5457
logger.dedent()
5558

5659
return parsed
60+
61+
62+
def multisort(xs: list, specs: tuple) -> list:
63+
"""
64+
Sort a list of dictionaries by multiple keys.
65+
66+
From https://docs.python.org/3/howto/sorting.html#sort-stability-and-complex-sorts
67+
68+
Args:
69+
xs: The list of dictionaries to sort
70+
specs: A tuple of (key, reverse) pairs
71+
72+
Returns:
73+
The sorted list
74+
"""
75+
for key, reverse in reversed(specs):
76+
xs.sort(key=itemgetter(key), reverse=reverse)
77+
return xs
78+
79+
80+
def serialize(version: Version, serialize_patterns: List[str], context: MutableMapping) -> str:
81+
"""
82+
Attempts to serialize a version with the given serialization format.
83+
84+
- valid serialization patterns are those that are renderable with the given context
85+
- formats that contain all required components are preferred
86+
- the shortest valid serialization pattern is used
87+
- if two patterns are equally short, the first one is used
88+
- if no valid serialization pattern is found, an error is raised
89+
90+
Args:
91+
version: The version to serialize
92+
serialize_patterns: The serialization format to use, using Python's format string syntax
93+
context: The context to use when serializing the version
94+
95+
Raises:
96+
FormattingError: if a serialization pattern
97+
98+
Returns:
99+
The serialized version as a string
100+
"""
101+
logger.debug("Serializing version '%s'", version)
102+
logger.indent()
103+
104+
local_context = copy(context)
105+
local_context.update(version.values())
106+
local_context_keys = set(local_context.keys())
107+
required_component_labels = set(version.required_components())
108+
109+
patterns = []
110+
for index, pattern in enumerate(serialize_patterns):
111+
labels = set(labels_for_format(pattern))
112+
patterns.append(
113+
{
114+
"pattern": pattern,
115+
"labels": labels,
116+
"order": index,
117+
"num_labels": len(labels),
118+
"renderable": local_context_keys >= labels,
119+
"has_required_components": required_component_labels <= labels,
120+
}
121+
)
122+
123+
valid_patterns = filter(itemgetter("renderable"), patterns)
124+
sorted_patterns = multisort(
125+
list(valid_patterns), (("has_required_components", True), ("num_labels", False), ("order", False))
126+
)
127+
128+
if not sorted_patterns:
129+
raise FormattingError(f"Could not find a valid serialization format in {serialize_patterns!r} for {version!r}")
130+
131+
chosen_pattern = sorted_patterns[0]["pattern"]
132+
logger.debug("Using serialization format '%s'", chosen_pattern)
133+
serialized = chosen_pattern.format(**local_context)
134+
logger.debug("Serialized to '%s'", serialized)
135+
logger.dedent()
136+
137+
return serialized

tests/test_versioning/test_serialization.py

+69-3
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,8 @@
11
"""Tests for the serialization of versioned objects."""
2-
from bumpversion.versioning.serialization import parse_version
3-
from bumpversion.versioning.models import SEMVER_PATTERN
4-
from bumpversion.exceptions import BumpVersionError
2+
from bumpversion.versioning.serialization import parse_version, serialize
3+
from bumpversion.versioning.conventions import semver_spec, SEMVER_PATTERN
4+
from bumpversion.versioning.models import Version
5+
from bumpversion.exceptions import BumpVersionError, FormattingError, MissingValueError
56

67
import pytest
78
from pytest import param
@@ -60,3 +61,68 @@ def test_invalid_parse_pattern_raises_error(self):
6061
"""If the parse pattern is not a valid regular expression, a ValueError should be raised."""
6162
with pytest.raises(BumpVersionError):
6263
parse_version("1.2.3", r"v(?P<major>\d+\.(?P<minor>\d+)\.(?P<patch>\d+)")
64+
65+
def test_parse_pattern_with_newlines(self):
66+
"""A parse pattern with newlines should be parsed correctly."""
67+
pattern = r"MAJOR=(?P<major>\d+)\nMINOR=(?P<minor>\d+)\nPATCH=(?P<patch>\d+)\n"
68+
assert parse_version("MAJOR=31\nMINOR=0\nPATCH=3\n", pattern) == {"major": "31", "minor": "0", "patch": "3"}
69+
70+
71+
class TestSerialize:
72+
"""Test the serialize function."""
73+
74+
@pytest.mark.parametrize(
75+
["version", "expected"],
76+
[
77+
param(
78+
semver_spec().create_version({"major": "1", "minor": "2", "patch": "3"}),
79+
"1.2.3",
80+
id="major-minor-patch",
81+
),
82+
param(
83+
semver_spec().create_version({"major": "1", "minor": "2", "patch": "0"}),
84+
"1.2",
85+
id="major-minor-patch-zero",
86+
),
87+
param(
88+
semver_spec().create_version({"major": "1", "minor": "0", "patch": "0"}),
89+
"1",
90+
id="major-minor-zero-patch-zero",
91+
),
92+
],
93+
)
94+
def test_picks_string_with_least_labels(self, version: Version, expected: str):
95+
patterns = ["{major}.{minor}.{patch}", "{major}.{minor}", "{major}"]
96+
assert serialize(version, serialize_patterns=patterns, context=version.values()) == expected
97+
98+
def test_renders_a_format_with_newlines(self):
99+
"""A serialization format with newlines should be rendered correctly."""
100+
version = semver_spec().create_version({"major": "31", "minor": "0", "patch": "3"})
101+
assert (
102+
serialize(
103+
version, serialize_patterns=["MAJOR={major}\nMINOR={minor}\nPATCH={patch}\n"], context=version.values()
104+
)
105+
== "MAJOR=31\nMINOR=0\nPATCH=3\n"
106+
)
107+
108+
def test_renders_a_format_with_additional_context(self):
109+
"""A serialization format with additional context should be rendered correctly."""
110+
version = semver_spec().create_version({"major": "1", "minor": "2", "patch": "3"})
111+
assert (
112+
serialize(
113+
version,
114+
serialize_patterns=["{major}.{minor}.{patch}+{$BUILDMETADATA}"],
115+
context={"$BUILDMETADATA": "build.1", "major": "1", "minor": "2", "patch": "3"},
116+
)
117+
== "1.2.3+build.1"
118+
)
119+
120+
def test_raises_error_if_context_is_missing_values(self):
121+
"""An error is raised if not all parts required in the format have values."""
122+
version = semver_spec().create_version({"major": "1", "minor": "2"})
123+
with pytest.raises(FormattingError):
124+
serialize(
125+
version,
126+
serialize_patterns=["{major}.{minor}.{patch}+{$BUILDMETADATA}"],
127+
context={"major": "1", "minor": "2", "patch": "0"},
128+
)

0 commit comments

Comments
 (0)