Skip to content

Commit 0c08f75

Browse files
committed
Fix #284: Concise "compatibility" matching
Use parts of PEP 440
1 parent 39774df commit 0c08f75

File tree

3 files changed

+172
-25
lines changed

3 files changed

+172
-25
lines changed

docs/usage/compare-versions-through-expression.rst

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -20,9 +20,10 @@ That gives you the following possibilities to express your condition:
2020

2121
.. code-block:: python
2222
23-
>>> Version.parse("2.0.0").match(">=1.0.0")
23+
>>> version = Version(2, 0, 0)
24+
>>> version.match(">=1.0.0")
2425
True
25-
>>> Version.parse("1.0.0").match(">1.0.0")
26+
>>> version.match("<1.0.0")
2627
False
2728
2829
If no operator is specified, the match expression is interpreted as a
@@ -33,7 +34,8 @@ handle both cases:
3334

3435
.. code-block:: python
3536
36-
>>> Version.parse("2.0.0").match("2.0.0")
37+
>>> version = Version(2, 0, 0)
38+
>>> version.match("2.0.0")
3739
True
38-
>>> Version.parse("1.0.0").match("3.5.1")
40+
>>> version.match("3.5.1")
3941
False

src/semver/version.py

Lines changed: 138 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
"""Version handling by a semver compatible version class."""
22

3+
from ast import operator
34
import collections
45
import re
56
from functools import wraps
@@ -14,6 +15,7 @@
1415
cast,
1516
Callable,
1617
Collection,
18+
Match
1719
Type,
1820
TypeVar,
1921
)
@@ -75,6 +77,10 @@ class Version:
7577
#: The names of the different parts of a version
7678
NAMES = tuple([item[1:] for item in __slots__])
7779

80+
#:
81+
_RE_NUMBER = r"0|[1-9]\d*"
82+
83+
7884
#: Regex for number in a prerelease
7985
_LAST_NUMBER = re.compile(r"(?:[^\d]*(\d+)[^\d]*)+")
8086
#: Regex template for a semver version
@@ -110,6 +116,14 @@ class Version:
110116
re.VERBOSE,
111117
)
112118

119+
#: The default prefix for the prerelease part.
120+
#: Used in :meth:`Version.bump_prerelease`.
121+
default_prerelease_prefix = "rc"
122+
123+
#: The default prefix for the build part
124+
#: Used in :meth:`Version.bump_build`.
125+
default_build_prefix = "build"
126+
113127
def __init__(
114128
self,
115129
major: SupportsInt,
@@ -382,22 +396,21 @@ def compare(self, other: Comparable) -> int:
382396
:return: The return value is negative if ver1 < ver2,
383397
zero if ver1 == ver2 and strictly positive if ver1 > ver2
384398
385-
>>> semver.compare("2.0.0")
399+
>>> ver = semver.Version.parse("3.4.5")
400+
>>> ver.compare("4.0.0")
386401
-1
387-
>>> semver.compare("1.0.0")
402+
>>> ver.compare("3.0.0")
388403
1
389-
>>> semver.compare("2.0.0")
390-
0
391-
>>> semver.compare(dict(major=2, minor=0, patch=0))
404+
>>> ver.compare("3.4.5")
392405
0
393406
"""
394407
cls = type(self)
395408
if isinstance(other, String.__args__): # type: ignore
396-
other = cls.parse(other)
409+
other = cls.parse(other) # type: ignore
397410
elif isinstance(other, dict):
398-
other = cls(**other)
411+
other = cls(**other) # type: ignore
399412
elif isinstance(other, (tuple, list)):
400-
other = cls(*other)
413+
other = cls(*other) # type: ignore
401414
elif not isinstance(other, cls):
402415
raise TypeError(
403416
f"Expected str, bytes, dict, tuple, list, or {cls.__name__} instance, "
@@ -555,25 +568,19 @@ def finalize_version(self) -> "Version":
555568
cls = type(self)
556569
return cls(self.major, self.minor, self.patch)
557570

558-
def match(self, match_expr: str) -> bool:
571+
def _match(self, match_expr: str) -> bool:
559572
"""
560573
Compare self to match a match expression.
561574
562575
:param match_expr: optional operator and version; valid operators are
563-
``<`` smaller than
576+
``<``` smaller than
564577
``>`` greater than
565578
``>=`` greator or equal than
566579
``<=`` smaller or equal than
567580
``==`` equal
568581
``!=`` not equal
582+
``~=`` compatible release clause
569583
:return: True if the expression matches the version, otherwise False
570-
571-
>>> semver.Version.parse("2.0.0").match(">=1.0.0")
572-
True
573-
>>> semver.Version.parse("1.0.0").match(">1.0.0")
574-
False
575-
>>> semver.Version.parse("4.0.4").match("4.0.4")
576-
True
577584
"""
578585
prefix = match_expr[:2]
579586
if prefix in (">=", "<=", "==", "!="):
@@ -588,7 +595,7 @@ def match(self, match_expr: str) -> bool:
588595
raise ValueError(
589596
"match_expr parameter should be in format <op><ver>, "
590597
"where <op> is one of "
591-
"['<', '>', '==', '<=', '>=', '!=']. "
598+
"['<', '>', '==', '<=', '>=', '!=', '~=']. "
592599
"You provided: %r" % match_expr
593600
)
594601

@@ -606,6 +613,119 @@ def match(self, match_expr: str) -> bool:
606613

607614
return cmp_res in possibilities
608615

616+
def match(self, match_expr: str) -> bool:
617+
"""Compare self to match a match expression.
618+
619+
:param match_expr: optional operator and version; valid operators are
620+
``<``` smaller than
621+
``>`` greater than
622+
``>=`` greator or equal than
623+
``<=`` smaller or equal than
624+
``==`` equal
625+
``!=`` not equal
626+
``~=`` compatible release clause
627+
:return: True if the expression matches the version, otherwise False
628+
"""
629+
# TODO: The following function should be better
630+
# integrated into a special Spec class
631+
def compare_eq(index, other) -> bool:
632+
return self[:index] == other[:index]
633+
634+
def compare_ne(index, other) -> bool:
635+
return not compare_eq(index, other)
636+
637+
def compare_lt(index, other) -> bool:
638+
return self[:index] < other[:index]
639+
640+
def compare_gt(index, other) -> bool:
641+
return not compare_lt(index, other)
642+
643+
def compare_le(index, other) -> bool:
644+
return self[:index] <= other[:index]
645+
646+
def compare_ge(index, other) -> bool:
647+
return self[:index] >= other[:index]
648+
649+
def compare_compatible(index, other) -> bool:
650+
return compare_gt(index, other) and compare_eq(index, other)
651+
652+
op_table: Dict[str, Callable[[int, Tuple], bool]] = {
653+
'==': compare_eq,
654+
'!=': compare_ne,
655+
'<': compare_lt,
656+
'>': compare_gt,
657+
'<=': compare_le,
658+
'>=': compare_ge,
659+
'~=': compare_compatible,
660+
}
661+
662+
regex = r"""(?P<operator>[<]|[>]|<=|>=|~=|==|!=)?
663+
(?P<version>
664+
(?P<major>0|[1-9]\d*)
665+
(?:\.(?P<minor>\*|0|[1-9]\d*)
666+
(?:\.(?P<patch>\*|0|[1-9]\d*))?
667+
)?
668+
)"""
669+
match = re.match(regex, match_expr, re.VERBOSE)
670+
if match is None:
671+
raise ValueError(
672+
"match_expr parameter should be in format <op><ver>, "
673+
"where <op> is one of %s. "
674+
"<ver> is a version string like '1.2.3' or '1.*' "
675+
"You provided: %r" % (list(op_table.keys()), match_expr)
676+
)
677+
match_version = match["version"]
678+
operator = cast(Dict, match).get('operator', '==')
679+
680+
if "*" not in match_version:
681+
# conventional compare
682+
possibilities_dict = {
683+
">": (1,),
684+
"<": (-1,),
685+
"==": (0,),
686+
"!=": (-1, 1),
687+
">=": (0, 1),
688+
"<=": (-1, 0),
689+
}
690+
691+
possibilities = possibilities_dict[operator]
692+
cmp_res = self.compare(match_version)
693+
694+
return cmp_res in possibilities
695+
696+
# Advanced compare with "*" like "<=1.2.*"
697+
# Algorithm:
698+
# TL;DR: Delegate the comparison to tuples
699+
#
700+
# 1. Create a tuple of the string with major, minor, and path
701+
# unless one of them is None
702+
# 2. Determine the position of the first "*" in the tuple from step 1
703+
# 3. Extract the matched operators
704+
# 4. Look up the function in the operator table
705+
# 5. Call the found function and pass the index (step 2) and
706+
# the tuple (step 1)
707+
# 6. Compare the both tuples up to the position of index
708+
# For example, if you have (1, 2, "*") and self is
709+
# (1, 2, 3, None, None), you compare (1, 2) <OPERATOR> (1, 2)
710+
# 7. Return the result of the comparison
711+
match_version = tuple([match[item]
712+
for item in ('major', 'minor', 'patch')
713+
if item is not None
714+
]
715+
)
716+
717+
try:
718+
index = match_version.index("*")
719+
except ValueError:
720+
index = None
721+
722+
if not index:
723+
raise ValueError("Major version cannot be set to '*'")
724+
725+
# At this point, only valid operators should be available
726+
func: Callable[[int, Tuple], bool] = op_table[operator]
727+
return func(index, match_version)
728+
609729
@classmethod
610730
def parse(
611731
cls: Type[T], version: String, optional_minor_and_patch: bool = False

tests/test_match.py

Lines changed: 28 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,18 @@
11
import pytest
22

3-
from semver import match
3+
from semver import match, Version
44

55

66
def test_should_match_simple():
7-
assert match("2.3.7", ">=2.3.6") is True
7+
left, right = ("2.3.7", ">=2.3.6")
8+
assert match(left, right) is True
9+
assert Version.parse(left).match(right) is True
810

911

1012
def test_should_no_match_simple():
11-
assert match("2.3.7", ">=2.3.8") is False
13+
left, right = ("2.3.7", ">=2.3.8")
14+
assert match(left, right) is False
15+
assert Version.parse(left).match(right) is False
1216

1317

1418
@pytest.mark.parametrize(
@@ -21,6 +25,7 @@ def test_should_no_match_simple():
2125
)
2226
def test_should_match_not_equal(left, right, expected):
2327
assert match(left, right) is expected
28+
assert Version.parse(left).match(right) is expected
2429

2530

2631
@pytest.mark.parametrize(
@@ -33,6 +38,7 @@ def test_should_match_not_equal(left, right, expected):
3338
)
3439
def test_should_match_equal_by_default(left, right, expected):
3540
assert match(left, right) is expected
41+
assert Version.parse(left).match(right) is expected
3642

3743

3844
@pytest.mark.parametrize(
@@ -50,6 +56,7 @@ def test_should_not_raise_value_error_for_expected_match_expression(
5056
left, right, expected
5157
):
5258
assert match(left, right) is expected
59+
assert Version.parse(left).match(right) is expected
5360

5461

5562
@pytest.mark.parametrize(
@@ -58,9 +65,27 @@ def test_should_not_raise_value_error_for_expected_match_expression(
5865
def test_should_raise_value_error_for_unexpected_match_expression(left, right):
5966
with pytest.raises(ValueError):
6067
match(left, right)
68+
with pytest.raises(ValueError):
69+
Version.parse(left).match(right)
6170

6271

6372
@pytest.mark.parametrize("left,right", [("1.0.0", ""), ("1.0.0", "!")])
6473
def test_should_raise_value_error_for_invalid_match_expression(left, right):
6574
with pytest.raises(ValueError):
6675
match(left, right)
76+
with pytest.raises(ValueError):
77+
Version.parse(left).match(right)
78+
79+
80+
@pytest.mark.parametrize(
81+
"left,right,expected",
82+
[
83+
("2.3.7", "<2.4.*", True),
84+
("2.3.7", ">2.3.5", True),
85+
("2.3.7", "<=2.3.9", True),
86+
("2.3.7", ">=2.3.5", True),
87+
("2.3.7", "==2.3.7", True),
88+
("2.3.7", "!=2.3.7", False),
89+
],
90+
)
91+
def test_should_match_with_asterisk(left, right, expected):

0 commit comments

Comments
 (0)