Skip to content

Navigation Menu

Sign in
Appearance settings

Search code, repositories, users, issues, pull requests...

Provide feedback

We read every piece of feedback, and take your input very seriously.

Saved searches

Use saved searches to filter your results more quickly

Appearance settings

Commit 239f517

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

File tree

3 files changed

+174
-26
lines changed
Filter options

3 files changed

+174
-26
lines changed

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

Copy file name to clipboardExpand all lines: docs/usage/compare-versions-through-expression.rst
+8-5Lines 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

Copy file name to clipboardExpand all lines: src/semver/version.py
+138-18Lines 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

Copy file name to clipboardExpand all lines: tests/test_match.py
+28-3Lines 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 (0)
Morty Proxy This is a proxified and sanitized view of the page, visit original site.