Skip to content

Commit 6205142

Browse files
committed
Fix #284: Concise "compatibility" matching
Use parts of PEP 440
1 parent 2a3d18d commit 6205142

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+
'<': 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)
pFad - Phonifier reborn

Pfad - The Proxy pFad of © 2024 Garber Painting. All rights reserved.

Note: This service is not intended for secure transactions such as banking, social media, email, or purchasing. Use at your own risk. We assume no liability whatsoever for broken pages.


Alternative Proxies:

Alternative Proxy

pFad Proxy

pFad v3 Proxy

pFad v4 Proxy