8000 Fix #284: Concise "compatibility" matching · python-semver/python-semver@37326d5 · GitHub
[go: up one dir, main page]

Skip to content

Commit 37326d5

Browse files
committed
Fix #284: Concise "compatibility" matching
Use parts of PEP 440
1 parent 3ec0131 commit 37326d5

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
)
1820

1921
from ._types import (
@@ -66,6 +68,10 @@ class Version:
6668
"""
6769

6870
__slots__ = ("_major", "_minor", "_patch", "_prerelease", "_build")
71+
#:
72+
_RE_NUMBER = r"0|[1-9]\d*"
73+
74+
6975
#: Regex for number in a prerelease
7076
_LAST_NUMBER = re.compile(r"(?:[^\d]*(\d+)[^\d]*)+")
7177
#: Regex template for a semver version
@@ -102,6 +108,14 @@ class Version:
102108
re.VERBOSE,
103109
)
104110

111+
#: The default prefix for the prerelease part.
112+
#: Used in :meth:`Version.bump_prerelease`.
113+
default_prerelease_prefix = "rc"
114+
115+
#: The default prefix for the build part
116+
#: Used in :meth:`Version.bump_build`.
117+
default_build_prefix = "build"
118+
105119
def __init__(
106120
self,
107121
major: SupportsInt,
@@ -340,22 +354,21 @@ def compare(self, other: Comparable) -> int:
340354
:return: The return value is negative if ver1 < ver2,
341355
zero if ver1 == ver2 and strictly positive if ver1 > ver2
342356
343-
>>> semver.compare("2.0.0")
357+
>>> ver = semver.Version.parse("3.4.5")
358+
>>> ver.compare("4.0.0")
344359
-1
345-
>>> semver.compare("1.0.0")
360+
>>> ver.compare("3.0.0")
346361
1
347-
>>> semver.compare("2.0.0")
348-
0
349-
>>> semver.compare(dict(major=2, minor=0, patch=0))
362+
>>> ver.compare("3.4.5")
350363
0
351364
"""
352365
cls = type(self)
353366
if isinstance(other, String.__args__): # type: ignore
354-
other = cls.parse(other)
367+
other = cls.parse(other) # type: ignore
355368
elif isinstance(other, dict):
356-
other = cls(**other)
369+
other = cls(**other) # type: ignore
357370
elif isinstance(other, (tuple, list)):
358-
other = cls(*other)
371+
other = cls(*other) # type: ignore
359372
elif not isinstance(other, cls):
360373
raise TypeError(
361374
f"Expected str, bytes, dict, tuple, list, or {cls.__name__} instance, "
@@ -518,25 +531,19 @@ def finalize_version(self) -> "Version":
518531
cls = type(self)
519532
return cls(self.major, self.minor, self.patch)
520533

521-
def match(self, match_expr: str) -> bool:
534+
def _match(self, match_expr: str) -> bool:
522535
"""
523536
Compare self to match a match expression.
524537
525538
:param match_expr: optional operator and version; valid operators are
526-
``<``` smaller than
539+
``<``` smaller than
527540
``>`` greater than
528541
``>=`` greator or equal than
529542
``<=`` smaller or equal than
530543
``==`` equal
531544
``!=`` not equal
545+
``~=`` compatible release clause
532546
:return: True if the expression matches the version, otherwise False
533-
534-
>>> semver.Version.parse("2.0.0").match(">=1.0.0")
535-
True
536-
>>> semver.Version.parse("1.0.0").match(">1.0.0")
537-
False
538-
>>> semver.Version.parse("4.0.4").match("4.0.4")
539-
True
540547
"""
541548
prefix = match_expr[:2]
542549
if prefix in (">=", "<=", "==", "!="):
@@ -551,7 +558,7 @@ def match(self, match_expr: str) -> bool:
551558
raise ValueError(
552559
"match_expr parameter should be in format <op><ver>, "
553560
"where <op> is one of "
554-
"['<', '>', '==', '<=', '>=', '!=']. "
561+
"['<', '>', '==', '<=', '>=', '!=', '~=']. "
555562
"You provided: %r" % match_expr
556563
)
557564

@@ -569,6 +576,119 @@ def match(self, match_expr: str) -> bool:
569576

570577
return cmp_res in possibilities
571578

579+
def match(self, match_expr: str) -> bool:
580+
"""Compare self to match a match expression.
581+
582+
:param match_expr: optional operator and version; valid operators are
583+
``<``` smaller than
584+
``>`` greater than
585+
``>=`` greator or equal than
586+
``<=`` smaller or equal than
587+
``==`` equal
588+
``!=`` not equal
589+
``~=`` compatible release clause
590+
:return: True if the expression matches the version, otherwise False
591+
"""
592+
# TODO: The following function should be better
593+
# integrated into a special Spec class
594+
def compare_eq(index, other) -> bool:
595+
return self[:index] == other[:index]
596+
597+
def compare_ne(index, other) -> bool:
598+
return not compare_eq(index, other)
599+
600+
def compare_lt(index, other) -> bool:
601+
return self[:index] < other[:index]
602+
603+
def compare_gt(index, other) -> bool:
604+
return not compare_lt(index, other)
605+
606+
def compare_le(index, other) -> bool:
607+
return self[:index] <= other[:index]
608+
609+
def compare_ge(index, other) -> bool:
610+
return self[:index] >= other[:index]
611+
612+
def compare_compatible(index, other) -> bool:
613+
return compare_gt(index, other) and compare_eq(index, other)
614+
615+
op_table: Dict[str, Callable[[int, Tuple], bool]] = {
616+
'==': compare_eq,
617+
'!=': compare_ne,
618+
'<': < 10670 span class=pl-s1>compare_lt,
619+
'>': compare_gt,
620+
'<=': compare_le,
621+
'>=': compare_ge,
622+
'~=': compare_compatible,
623+
}
624+
625+
regex = r"""(?P<operator>[<]|[>]|<=|>=|~=|==|!=)?
626+
(?P<version>
627+
(?P<major>0|[1-9]\d*)
628+
(?:\.(?P<minor>\*|0|[1-9]\d*)
629+
(?:\.(?P<patch>\*|0|[1-9]\d*))?
630+
)?
631+
)"""
632+
match = re.match(regex, match_expr, re.VERBOSE)
633+
if match is None:
634+
raise ValueError(
635+
"match_expr parameter should be in format <op><ver>, "
636+
"where <op> is one of %s. "
637+
"<ver> is a version string like '1.2.3' or '1.*' "
638+
"You provided: %r" % (list(op_table.keys()), match_expr)
639+
)
640+
match_version = match["version"]
641+
operator = cast(Dict, match).get('operator', '==')
642+
643+
if "*" not in match_version:
644+
# conventional compare
645+
possibilities_dict = {
646+
">": (1,),
647+
"<": (-1,),
648+
"==": (0,),
649+
"!=": (-1, 1),
650+
">=": (0, 1),
651+
"<=": (-1, 0),
652+
}
653+
654+
possibilities = possibilities_dict[operator]
655+
cmp_res = self.compare(match_version)
656+
657+
return cmp_res in possibilities
658+
659+
# Advanced compare with "*" like "<=1.2.*"
660+
# Algorithm:
661+
# TL;DR: Delegate the comparison to tuples
662+
#
663+
# 1. Create a tuple of the string with major, minor, and path
664+
# unless one of them is None
665+
# 2. Determine the position of the first "*" in the tuple from step 1
666+
# 3. Extract the matched operators
667+
# 4. Look up the function in the operator table
668+
# 5. Call the found function and pass the index (step 2) and
669+
# the tuple (step 1)
670+
# 6. Compare the both tuples up to the position of index
671+
# For example, if you have (1, 2, "*") and self is
672+
# (1, 2, 3, None, None), you compare (1, 2) <OPERATOR> (1, 2)
673+
# 7. Return the result of the comparison
674+
match_version = tuple([match[item]
675+
for item in ('major', 'minor', 'patch')
676+
if item is not None
677+
]
678+
)
679+
680+
try:
681+
index = match_version.index("*")
682+
except ValueError:
683+
index = None
684+
685+
if not index:
686+
raise ValueError("Major version cannot be set to '*'")
687+
688+
# At this point, only valid operators should be available
689+
func: Callable[[int, Tuple], bool] = op_table[operator]
690+
return func(index, match_version)
691+
572692
@classmethod
573693
def parse(
574694
cls,

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,6 +65,8 @@ 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(
@@ -66,3 +75,19 @@ def test_should_raise_value_error_for_unexpected_match_expression(left, right):
6675
def test_should_raise_value_error_for_invalid_match_expression(left, right):
6776
with pytest.raises(ValueError):
6877
match(left, right)
78+
with pytest.raises(ValueError):
79+
Version.parse(left).match(right)
80+
81+
82+
@pytest.mark.parametrize(
83+
"left,right,expected",
84+
[
85+
("2.3.7", "<2.4.*", True),
86+
("2.3.7", ">2.3.5", True),
87+
("2.3.7", "<=2.3.9", True),
88+
("2.3.7", ">=2.3.5", True),
89+
("2.3.7", "==2.3.7", True),
90+
("2.3.7", "!=2.3.7", False),
91+
],
92+
)
93+
def test_should_match_with_asterisk(left, right, expected):

0 commit comments

Comments
 (0)
0