Skip to content

Commit ff470fa

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 71c69f4 commit ff470fa

File tree

3 files changed

+156
-64
lines changed

3 files changed

+156
-64
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: 151 additions & 62 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

@@ -63,7 +63,7 @@ class Version:
6363
6464
* a maximum length of 5 items that comprehend the major,
6565
minor, patch, prerelease, or build parts.
66-
* a str or bytes string that contains a valid semver
66+
* a str or bytes string at first position that contains a valid semver
6767
version string.
6868
:param major: version when you make incompatible API changes.
6969
:param minor: version when you add functionality in
@@ -83,6 +83,21 @@ class Version:
8383
Version(major=2, minor=3, patch=4, prerelease=None, build="build.2")
8484
"""
8585

86+
#: The name of the version parts
87+
VERSIONPARTS: Tuple[str, str, str, str, str] = (
88+
"major", "minor", "patch", "prerelease", "build"
89+
)
90+
#: The default values for each part (position match with ``VERSIONPARTS``):
91+
VERSIONPARTDEFAULTS: VersionTuple = (0, 0, 0, None, None)
92+
#: The allowed types for each part (position match with ``VERSIONPARTS``):
93+
ALLOWED_TYPES = (
94+
(int, str, bytes), # major
95+
(int, str, bytes), # minor
96+
(int, str, bytes), # patch
97+
(int, str, bytes, type(None)), # prerelease
98+
(int, str, bytes, type(None)), # build
99+
)
100+
86101
__slots__ = ("_major", "_minor", "_patch", "_prerelease", "_build")
87102
#: Regex for number in a prerelease
88103
_LAST_NUMBER = re.compile(r"(?:[^\d]*(\d+)[^\d]*)+")
@@ -108,6 +123,45 @@ class Version:
108123
re.VERBOSE,
109124
)
110125

126+
def _check_types(self, *args: Tuple) -> List[bool]:
127+
"""
128+
Check if the given arguments conform to the types in ``ALLOWED_TYPES``.
129+
130+
:return: bool for each position
131+
"""
132+
cls = self.__class__
133+
return [
134+
isinstance(item, expected_type)
135+
for item, expected_type in zip(args, cls.ALLOWED_TYPES)
136+
]
137+
138+
def _raise_if_args_are_invalid(self, *args):
139+
"""
140+
Checks conditions for positional arguments. For example:
141+
142+
* No more than 5 arguments.
143+
* If first argument is a string, contains a dot, and there
144+
are more arguments.
145+
* Arguments have invalid types.
146+
147+
:raises ValueError: if more arguments than 5 or if first argument
148+
is a string, contains a dot, and there are more arguments.
149+
:raises TypeError: if there are invalid types.
150+
"""
151+
if args and len(args) > 5:
152+
raise ValueError("You cannot pass more than 5 arguments to Version")
153+
elif len(args) > 1 and "." in str(args[0]):
154+
raise ValueError(
155+
"You cannot pass a string and additional positional arguments"
156+
)
157+
types_in_args = self._check_types(*args)
158+
if not all(types_in_args):
159+
pos = types_in_args.index(False)
160+
raise TypeError(
161+
"not expecting type in argument position "
162+
f"{pos} (type: {type(args[pos])})"
163+
)
164+
111165
def __init__(
112166
self,
113167
*args: Tuple[
@@ -117,76 +171,82 @@ def __init__(
117171
Optional[StringOrInt], # prerelease
118172
Optional[StringOrInt], # build
119173
],
120-
major: SupportsInt = 0,
121-
minor: SupportsInt = 0,
122-
patch: SupportsInt = 0,
174+
# *,
175+
major: SupportsInt = None,
176+
minor: SupportsInt = None,
177+
patch: SupportsInt = None,
123178
prerelease: StringOrInt = None,
124179
build: StringOrInt = None,
125180
):
126-
def _check_types(*args):
127-
if args and len(args) > 5:
128-
raise ValueError("You cannot pass more than 5 arguments to Version")
129-
elif len(args) > 1 and "." in str(args[0]):
130-
raise ValueError(
131-
"You cannot pass a string and additional positional arguments"
132-
)
133-
allowed_types_in_args = (
134-
(int, str, bytes), # major
135-
(int, str, bytes), # minor
136-
(int, str, bytes), # patch
137-
(int, str, bytes, type(None)), # prerelease
138-
(int, str, bytes, type(None)), # build
139-
)
140-
return [
141-
isinstance(item, expected_type)
142-
for item, expected_type in zip(args, allowed_types_in_args)
143-
]
181+
#
182+
# The algorithm to support different Version calls is this:
183+
#
184+
# 1. Check first, if there are invalid calls. For example
185+
# more than 5 items in args or a unsupported combination
186+
# of args and version part arguments (major, minor, etc.)
187+
# If yes, raise an exception.
188+
#
189+
# 2. Create a dictargs dict:
190+
# a. If the first argument is a version string which contains
191+
# a dot it's likely it's a semver string. Try to convert
192+
# them into a dict and save it to dictargs.
193+
# b. If the first argument is not a version string, try to
194+
# create the dictargs from the args argument.
195+
#
196+
# 3. Create a versiondict from the version part arguments.
197+
# This contains only items if the argument is not None.
198+
#
199+
# 4. Merge the two dicts, versiondict overwrites dictargs.
200+
# In other words, if the user specifies Version(1, major=2)
201+
# the major=2 has precedence over the 1.
202+
#
203+
# 5. Set all version components from versiondict. If the key
204+
# doesn't exist, set a default value.
144205

145206
cls = self.__class__
146-
verlist: List[Optional[StringOrInt]] = [None, None, None, None, None]
207+
# (1) check combinations and types
208+
self._raise_if_args_are_invalid(*args)
147209

148-
types_in_args = _check_types(*args)
149-
if not all(types_in_args):
150-
pos = types_in_args.index(False)
151-
raise TypeError(
152-
"not expecting type in argument position "
153-
f"{pos} (type: {type(args[pos])})"
154-
)
155-
elif args and "." in str(args[0]):
156-
# we have a version string as first argument
157-
v = cls._parse(args[0]) # type: ignore
158-
for idx, key in enumerate(
159-
("major", "minor", "patch", "prerelease", "build")
160-
):
161-
verlist[idx] = v[key]
210+
# (2) First argument was a string
211+
if args and args[0] and "." in cls._enforce_str(args[0]): # type: ignore
212+
dictargs = cls._parse(cast(String, args[0]))
162213
else:
163-
for index, item in enumerate(args):
164-
verlist[index] = args[index] # type: ignore
214+
dictargs = dict(zip(cls.VERSIONPARTS, args))
165215

166-
# Build a dictionary of the arguments except prerelease and build
167-
try:
168-
version_parts = {
169-
# Prefer major, minor, and patch arguments over args
170-
"major": int(major or verlist[0] or 0),
171-
"minor": int(minor or verlist[1] or 0),
172-
"patch": int(patch or verlist[2] or 0),
173-
}
174-
except ValueError:
175-
raise ValueError(
176-
"Expected integer or integer string for major, minor, or patch"
216+
# (3) Only include part in versiondict if value is not None
217+
versiondict = {
218+
part: value
219+
for part, value in zip(
220+
cls.VERSIONPARTS, (major, minor, patch, prerelease, build)
177221
)
222+
if value is not None
223+
}
178224

179-
for name, value in version_parts.items():
180-
if value < 0:
181-
raise ValueError(
182-
"{!r} is negative. A version can only be positive.".format(name)
183-
)
225+
# (4) Order here is important: versiondict overwrites dictargs
226+
versiondict = {**dictargs, **versiondict} # type: ignore
184227

185-
self._major = version_parts["major"]
186-
self._minor = version_parts["minor"]
187-
self._patch = version_parts["patch"]
188-
self._prerelease = cls._enforce_str(prerelease or verlist[3])
189-
self._build = cls._enforce_str(build or verlist[4])
228+
# (5) Set all version components:
229+
self._major = cls._ensure_int(
230+
cast(StringOrInt, versiondict.get("major", cls.VERSIONPARTDEFAULTS[0]))
231+
)
232+
self._minor = cls._ensure_int(
233+
cast(StringOrInt, versiondict.get("minor", cls.VERSIONPARTDEFAULTS[1]))
234+
)
235+
self._patch = cls._ensure_int(
236+
cast(StringOrInt, versiondict.get("patch", cls.VERSIONPARTDEFAULTS[2]))
237+
)
238+
self._prerelease = cls._enforce_str(
239+
cast(
240+
Optional[StringOrInt],
241+
versiondict.get("prerelease", cls.VERSIONPARTDEFAULTS[3]),
242+
)
243+
)
244+
self._build = cls._enforce_str(
245+
cast(
246+
Optional[StringOrInt],
247+
versiondict.get("build", cls.VERSIONPARTDEFAULTS[4]),
248+
)
249+
)
190250

191251
@classmethod
192252
def _nat_cmp(cls, a, b): # TODO: type hints
@@ -211,6 +271,31 @@ def cmp_prerelease_tag(a, b):
211271
else:
212272
return _cmp(len(a), len(b))
213273

274+
@classmethod
275+
def _ensure_int(cls, value: StringOrInt) -> int:
276+
"""
277+
Ensures integer value type regardless if argument type is str or bytes.
278+
Otherwise raise ValueError.
279+
280+
:param value:
281+
:raises ValueError: Two conditions:
282+
* If value is not an integer or cannot be converted.
283+
* If value is negative.
284+
:return: the converted value as integer
285+
"""
286+
try:
287+
value = int(value)
288+
except ValueError:
289+
raise ValueError(
290+
"Expected integer or integer string for major, minor, or patch"
291+
)
292+
293+
if value < 0:
294+
raise ValueError(
295+
f"Argument {value} is negative. A version can only be positive."
296+
)
297+
return value
298+
214299
@classmethod
215300
def _enforce_str(cls, s: Optional[StringOrInt]) -> Optional[str]:
216301
"""
@@ -462,8 +547,12 @@ def compare(self, other: Comparable) -> int:
462547
0
463548
"""
464549
cls = type(self)
550+
551+
# See https://github.com/python/mypy/issues/4019
465552
if isinstance(other, String.__args__): # type: ignore
466-
other = cls.parse(other)
553+
if "." not in cast(str, cls._ensure_str(other)):
554+
raise ValueError("Expected semver version string.")
555+
other = cls(other)
467556
elif isinstance(other, dict):
468557
other = cls(**other)
469558
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