Skip to content

Commit 26ab6d0

Browse files
committed
Revamp code
* Introduce new class variables VERSIONPARTS, VERSIONPARTDEFAULTS, and ALLOWED_TYPES * Simplify __init__; outsource some functionality like type checking into different functions * Use dict merging between *args and version components
1 parent fe64bfb commit 26ab6d0

File tree

3 files changed

+152
-61
lines changed

3 files changed

+152
-61
lines changed

docs/usage/compare-versions.rst

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -67,7 +67,7 @@ To compare two versions depends on your type:
6767
>>> v > "1.0"
6868
Traceback (most recent call last):
6969
...
70-
ValueError: 1.0 is not valid SemVer string
70+
ValueError: '1.0' is not valid SemVer string
7171

7272
* **A** :class:`Version <semver.version.Version>` **type and a** :func:`dict`
7373

docs/usage/create-a-version.rst

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -93,6 +93,9 @@ arguments:
9393
ValueError: You cannot pass a string and additional positional arguments
9494

9595

96+
Using Deprecated Functions to Create a Version
97+
----------------------------------------------
98+
9699
The old, deprecated module level functions are still available but
97100
using them are discoraged. They are available to convert old code
98101
to semver3.
@@ -123,4 +126,4 @@ Depending on your use case, the following methods are available:
123126
>>> semver.parse("1.2")
124127
Traceback (most recent call last):
125128
...
126-
ValueError: 1.2 is not valid SemVer string
129+
ValueError: '1.2' is not valid SemVer string

src/semver/version.py

Lines changed: 147 additions & 59 deletions
Original file line numberDiff line numberDiff line change
@@ -27,7 +27,7 @@
2727
)
2828

2929
# These types are required here because of circular imports
30-
Comparable = Union["Version", Dict[str, VersionPart], Collection[VersionPart], str]
30+
Comparable = Union["Version", Dict[str, VersionPart], Collection[VersionPart], String]
3131
Comparator = Callable[["Version", Comparable], bool]
3232

3333
T = TypeVar("T", bound="Version")
@@ -65,7 +65,7 @@ class Version:
6565
6666
* a maximum length of 5 items that comprehend the major,
6767
minor, patch, prerelease, or build parts.
68-
* a str or bytes string that contains a valid semver
68+
* a str or bytes string at first position that contains a valid semver
6969
version string.
7070
:param major: version when you make incompatible API changes.
7171
:param minor: version when you add functionality in
@@ -85,6 +85,21 @@ class Version:
8585
Version(major=2, minor=3, patch=4, prerelease=None, build="build.2")
8686
"""
8787

88+
#: The name of the version parts
89+
VERSIONPARTS: Tuple[str, str, str, str, str] = (
90+
"major", "minor", "patch", "prerelease", "build"
91+
)
92+
#: The default values for each part (position match with ``VERSIONPARTS``):
93+
VERSIONPARTDEFAULTS: VersionTuple = (0, 0, 0, None, None)
94+
#: The allowed types for each part (position match with ``VERSIONPARTS``):
95+
ALLOWED_TYPES = (
96+
(int, str, bytes), # major
97+
(int, str, bytes), # minor
98+
(int, str, bytes), # patch
99+
(int, str, bytes, type(None)), # prerelease
100+
(int, str, bytes, type(None)), # build
101+
)
102+
88103
__slots__ = ("_major", "_minor", "_patch", "_prerelease", "_build")
89104

90105
#: The names of the different parts of a version
@@ -125,6 +140,45 @@ class Version:
125140
re.VERBOSE,
126141
)
127142

143+
def _check_types(self, *args: Tuple) -> List[bool]:
144+
"""
145+
Check if the given arguments conform to the types in ``ALLOWED_TYPES``.
146+
147+
:return: bool for each position
148+
"""
149+
cls = self.__class__
150+
return [
151+
isinstance(item, expected_type)
152+
for item, expected_type in zip(args, cls.ALLOWED_TYPES)
153+
]
154+
155+
def _raise_if_args_are_invalid(self, *args):
156+
"""
157+
Checks conditions for positional arguments. For example:
158+
159+
* No more than 5 arguments.
160+
* If first argument is a string, contains a dot, and there
161+
are more arguments.
162+
* Arguments have invalid types.
163+
164+
:raises ValueError: if more arguments than 5 or if first argument
165+
is a string, contains a dot, and there are more arguments.
166+
:raises TypeError: if there are invalid types.
167+
"""
168+
if args and len(args) > 5:
169+
raise ValueError("You cannot pass more than 5 arguments to Version")
170+
elif len(args) > 1 and "." in str(args[0]):
171+
raise ValueError(
172+
"You cannot pass a string and additional positional arguments"
173+
)
174+
types_in_args = self._check_types(*args)
175+
if not all(types_in_args):
176+
pos = types_in_args.index(False)
177+
raise TypeError(
178+
"not expecting type in argument position "
179+
f"{pos} (type: {type(args[pos])})"
180+
)
181+
128182
def __init__(
129183
self,
130184
*args: Tuple[
@@ -140,70 +194,75 @@ def __init__(
140194
prerelease: Optional[Union[String, int]] = None,
141195
build: Optional[Union[String, int]] = None,
142196
):
143-
def _check_types(*args):
144-
if args and len(args) > 5:
145-
raise ValueError("You cannot pass more than 5 arguments to Version")
146-
elif len(args) > 1 and "." in str(args[0]):
147-
raise ValueError(
148-
"You cannot pass a string and additional positional arguments"
149-
)
150-
allowed_types_in_args = (
151-
(int, str, bytes), # major
152-
(int, str, bytes), # minor
153-
(int, str, bytes), # patch
154-
(int, str, bytes, type(None)), # prerelease
155-
(int, str, bytes, type(None)), # build
156-
)
157-
return [
158-
isinstance(item, expected_type)
159-
for item, expected_type in zip(args, allowed_types_in_args)
160-
]
197+
#
198+
# The algorithm to support different Version calls is this:
199+
#
200+
# 1. Check first, if there are invalid calls. For example
201+
# more than 5 items in args or a unsupported combination
202+
# of args and version part arguments (major, minor, etc.)
203+
# If yes, raise an exception.
204+
#
205+
# 2. Create a dictargs dict:
206+
# a. If the first argument is a version string which contains
207+
# a dot it's likely it's a semver string. Try to convert
208+
# them into a dict and save it to dictargs.
209+
# b. If the first argument is not a version string, try to
210+
# create the dictargs from the args argument.
211+
#
212+
# 3. Create a versiondict from the version part arguments.
213+
# This contains only items if the argument is not None.
214+
#
215+
# 4. Merge the two dicts, versiondict overwrites dictargs.
216+
# In other words, if the user specifies Version(1, major=2)
217+
# the major=2 has precedence over the 1.
218+
#
219+
# 5. Set all version components from versiondict. If the key
220+
# doesn't exist, set a default value.
161221

162222
cls = self.__class__
163-
verlist: List[Optional[StringOrInt]] = [None, None, None, None, None]
223+
# (1) check combinations and types
224+
self._raise_if_args_are_invalid(*args)
164225

165-
types_in_args = _check_types(*args)
166-
if not all(types_in_args):
167-
pos = types_in_args.index(False)
168-
raise TypeError(
169-
"not expecting type in argument position "
170-
f"{pos} (type: {type(args[pos])})"
171-
)
172-
elif args and "." in str(args[0]):
173-
# we have a version string as first argument
174-
v = cls._parse(args[0]) # type: ignore
175-
for idx, key in enumerate(
176-
("major", "minor", "patch", "prerelease", "build")
177-
):
178-
verlist[idx] = v[key]
226+
# (2) First argument was a string
227+
if args and args[0] and "." in cls._enforce_str(args[0]): # type: ignore
228+
dictargs = cls._parse(cast(String, args[0]))
179229
else:
180-
for index, item in enumerate(args):
181-
verlist[index] = args[index] # type: ignore
230+
dictargs = dict(zip(cls.VERSIONPARTS, args))
182231

183-
# Build a dictionary of the arguments except prerelease and build
184-
try:
185-
version_parts = {
186-
# Prefer major, minor, and patch arguments over args
187-
"major": int(major or verlist[0] or 0),
188-
"minor": int(minor or verlist[1] or 0),
189-
"patch": int(patch or verlist[2] or 0),
190-
}
191-
except ValueError:
192-
raise ValueError(
193-
"Expected integer or integer string for major, minor, or patch"
232+
# (3) Only include part in versiondict if value is not None
233+
versiondict = {
234+
part: value
235+
for part, value in zip(
236+
cls.VERSIONPARTS, (major, minor, patch, prerelease, build)
194237
)
238+
if value is not None
239+
}
195240

196-
for name, value in version_parts.items():
197-
if value < 0:
198-
raise ValueError(
199-
"{!r} is negative. A version can only be positive.".format(name)
200-
)
241+
# (4) Order here is important: versiondict overwrites dictargs
242+
versiondict = {**dictargs, **versiondict} # type: ignore
201243

202-
self._major = version_parts["major"]
203-
self._minor = version_parts["minor"]
204-
self._patch = version_parts["patch"]
205-
self._prerelease = cls._enforce_str(prerelease or verlist[3])
206-
self._build = cls._enforce_str(build or verlist[4])
244+
# (5) Set all version components:
245+
self._major = cls._ensure_int(
246+
cast(StringOrInt, versiondict.get("major", cls.VERSIONPARTDEFAULTS[0]))
247+
)
248+
self._minor = cls._ensure_int(
249+
cast(StringOrInt, versiondict.get("minor", cls.VERSIONPARTDEFAULTS[1]))
250+
)
251+
self._patch = cls._ensure_int(
252+
cast(StringOrInt, versiondict.get("patch", cls.VERSIONPARTDEFAULTS[2]))
253+
)
254+
self._prerelease = cls._enforce_str(
255+
cast(
256+
Optional[StringOrInt],
257+
versiondict.get("prerelease", cls.VERSIONPARTDEFAULTS[3]),
258+
)
259+
)
260+
self._build = cls._enforce_str(
261+
cast(
262+
Optional[StringOrInt],
263+
versiondict.get("build", cls.VERSIONPARTDEFAULTS[4]),
264+
)
265+
)
207266

208267
@classmethod
209268
def _nat_cmp(cls, a, b): # TODO: type hints
@@ -228,6 +287,31 @@ def cmp_prerelease_tag(a, b):
228287
else:
229288
return _cmp(len(a), len(b))
230289

290+
@classmethod
291+
def _ensure_int(cls, value: StringOrInt) -> int:
292+
"""
293+
Ensures integer value type regardless if argument type is str or bytes.
294+
Otherwise raise ValueError.
295+
296+
:param value:
297+
:raises ValueError: Two conditions:
298+
* If value is not an integer or cannot be converted.
299+
* If value is negative.
300+
:return: the converted value as integer
301+
"""
302+
try:
303+
value = int(value)
304+
except ValueError:
305+
raise ValueError(
306+
"Expected integer or integer string for major, minor, or patch"
307+
)
308+
309+
if value < 0:
310+
raise ValueError(
311+
f"Argument {value} is negative. A version can only be positive."
312+
)
313+
return value
314+
231315
@classmethod
232316
def _enforce_str(cls, s: Optional[StringOrInt]) -> Optional[str]:
233317
"""
@@ -486,8 +570,12 @@ def compare(self, other: Comparable) -> int:
486570
0
487571
"""
488572
cls = type(self)
573+
574+
# See https://github.com/python/mypy/issues/4019
489575
if isinstance(other, String.__args__): # type: ignore
490-
other = cls.parse(other)
576+
if "." not in cast(str, cls._ensure_str(other)):
577+
raise ValueError("Expected semver version string.")
578+
other = cls(other)
491579
elif isinstance(other, dict):
492580
other = cls(**other)
493581
elif isinstance(other, (tuple, list)):

0 commit comments

Comments
 (0)
pFad - Phonifier reborn

Pfad - The Proxy pFad of © 2024 Garber Painting. All rights reserved.

Note: This service is not intended for secure transactions such as banking, social media, email, or purchasing. Use at your own risk. We assume no liability whatsoever for broken pages.


Alternative Proxies:

Alternative Proxy

pFad Proxy

pFad v3 Proxy

pFad v4 Proxy