Skip to content

Commit 239f517

Browse files
committed
Fix #284: Concise "compatibility" matching
Use parts of PEP 440
1 parent 467ea0c commit 239f517

File tree

3 files changed

+174
-26
lines changed

3 files changed

+174
-26
lines changed

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

Lines changed: 8 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,8 @@ Comparing Versions through an Expression
22
========================================
33

44
If you need a more fine-grained approach of comparing two versions,
5-
use the :func:`semver.match` function. It expects two arguments:
5+
use the :func:`Version.match <semver.version.Version.match>` function.
6+
It expects two arguments:
67

78
1. a version string
89
2. a match expression
@@ -20,9 +21,10 @@ That gives you the following possibilities to express your condition:
2021

2122
.. code-block:: python
2223
23-
>>> semver.match("2.0.0", ">=1.0.0")
24+
>>> version = Version(2, 0, 0)
25+
>>> version.match(">=1.0.0")
2426
True
25-
>>> semver.match("1.0.0", ">1.0.0")
27+
>>> version.match("<1.0.0")
2628
False
2729
2830
If no operator is specified, the match expression is interpreted as a
@@ -33,7 +35,8 @@ handle both cases:
3335

3436
.. code-block:: python
3537
36-
>>> semver.match("2.0.0", "2.0.0")
38+
>>> version = Version(2, 0, 0)
39+
>>> version.match("2.0.0")
3740
True
38-
>>> semver.match("1.0.0", "3.5.1")
41+
>>> version.match("3.5.1")
3942
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."""
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
)
@@ -74,6 +76,10 @@ class Version:
7476
#: The names of the different parts of a version
7577
NAMES = tuple([item[1:] for item in __slots__])
7678

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

118+
#: The default prefix for the prerelease part.
119+
#: Used in :meth:`Version.bump_prerelease`.
120+
default_prerelease_prefix = "rc"
121+
122+
#: The default prefix for the build part
123+
#: Used in :meth:`Version.bump_build`.
124+
default_build_prefix = "build"
125+
112126
def __init__(
113127
self,
114128
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, "
@@ -557,25 +570,19 @@ def finalize_version(self) -> "Version":
557570
cls = type(self)
558571
return cls(self.major, self.minor, self.patch)
559572

560-
def match(self, match_expr: str) -> bool:
573+
def _match(self, match_expr: str) -> bool:
561574
"""
562575
Compare self to match a match expression.
563576
564577
:param match_expr: optional operator and version; valid operators are
565-
``<``` smaller than
578+
``<``` smaller than
566579
``>`` greater than
567580
``>=`` greator or equal than
568581
``<=`` smaller or equal than
569582
``==`` equal
570583
``!=`` not equal
584+
``~=`` compatible release clause
571585
:return: True if the expression matches the version, otherwise False
572-
573-
>>> semver.Version.parse("2.0.0").match(">=1.0.0")
574-
True
575-
>>> semver.Version.parse("1.0.0").match(">1.0.0")
576-
False
577-
>>> semver.Version.parse("4.0.4").match("4.0.4")
578-
True
579586
"""
580587
prefix = match_expr[:2]
581588
if prefix in (">=", "<=", "==", "!="):
@@ -590,7 +597,7 @@ def match(self, match_expr: str) -> bool:
590597
raise ValueError(
591598
"match_expr parameter should be in format <op><ver>, "
592599
"where <op> is one of "
593-
"['<', '>', '==', '<=', '>=', '!=']. "
600+
"['<', '>', '==', '<=', '>=', '!=', '~=']. "
594601
"You provided: %r" % match_expr
595602
)
596603

@@ -608,6 +615,119 @@ def match(self, match_expr: str) -> bool:
608615

609616
return cmp_res in possibilities
610617

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