8000 Revamp code · python-semver/python-semver@ff470fa · GitHub
[go: up one dir, main page]

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
< 10000 td data-grid-cell-id="diff-d6e8c013b9e7be774edf21bed5411e65f2a2bf6b43110b67e6c4dccdcab23791-143-202-2" data-line-anchor="diff-d6e8c013b9e7be774edf21bed5411e65f2a2bf6b43110b67e6c4dccdcab23791R202" data-selected="false" role="gridcell" style="background-color:var(--diffBlob-additionLine-bgColor, var(--diffBlob-addition-bgColor-line));padding-right:24px" tabindex="-1" valign="top" class="focusable-grid-cell diff-text-cell right-side-diff-cell left-side">+
#
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 10000 ], 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 10000 = 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)
0