Source code for tests.test_version

# Copyright Kevin Deldycke <[email protected]> and contributors.
#
# This program is Free Software; you can redistribute it and/or
# modify it under the terms of the GNU General Public License
# as published by the Free Software Foundation; either version 2
# of the License, or (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program; if not, write to the Free Software
# Foundation, Inc., 59 Temple Place - Suite 330, Boston, MA  02111-1307, USA.

from __future__ import annotations

import copy
import operator

import pytest
from boltons.strutils import strip_ansi

from meta_package_manager.version import (
    Token,
    TokenizedString,
    VersionRange,
    diff_versions,
    is_version,
    parse_version,
)


[docs] def reverse_fixtures(table): """Utility method to reverse a tuple of tuples.""" return tuple(map(tuple, map(reversed, table)))
[docs] @pytest.mark.parametrize("value", [0, 123, "0", "123", "abc", "ABC", "123abc"]) def test_token_allowed_instantiation(value): Token(value)
[docs] @pytest.mark.parametrize("value", [None, -1, "a-b-c", 1.0, True, False]) def test_token_unauthorized_instantiation(value): with pytest.raises(TypeError): Token(value)
eq_values = [ (Token("2345"), Token("2345")), (Token("2345"), Token(2345)), (Token("02345"), Token(2345)), (Token("02345"), "02345"), (Token("2345"), 2345), (Token("2345"), "2345"), (Token("0"), "0"), (Token("0"), 0), (Token("000"), 0), (Token("abc"), "abc"), (Token("abc"), Token("abc")), ] ne_values = [ (Token("2345"), Token("45")), (Token("2345"), Token(45)), (Token("2345"), Token(0)), (Token("2345"), Token("0")), (Token("2"), -2), (Token("2"), 0), (Token("abc"), "def"), (Token("Z"), "z"), (Token("acb"), 123), # Mixed int/string tokens are never equal. (Token(0), Token("a")), (Token("beta"), Token(1)), ] gt_values = [ (Token("9999"), Token("12")), (Token("9999"), Token("000000099")), (Token(9999), Token("12")), (Token(9999), Token(12)), (Token(9999), "0"), (Token(9999), 0), (Token(0), -2), (Token("0"), -2), (Token("abc"), -2), (Token("abc"), "ab"), (Token("a"), "Z"), (Token("z"), "Z"), (Token("a"), Token("Z")), # Integer tokens always sort higher than string tokens. (Token(0), Token("z")), (Token(1), Token("beta")), (Token(42), Token("rc")), ] lt_values = [ (Token("12"), Token("9999")), (Token("0000012"), Token("9999")), (Token("12"), Token(9999)), (Token(12), Token(9999)), (Token("12"), 9999), (Token("0"), 9999), (Token("ab"), "abc"), (Token("Z"), "z"), # String tokens always sort lower than integer tokens. (Token("z"), Token(0)), (Token("beta"), Token(1)), (Token("rc"), Token(42)), ]
[docs] @pytest.mark.parametrize(("token", "value"), eq_values) def test_token_eq(token, value): assert token == value
[docs] @pytest.mark.parametrize(("token", "value"), ne_values) def test_token_ne(token, value): assert token != value
[docs] @pytest.mark.parametrize(("token", "value"), gt_values) def test_token_gt(token, value): assert token > value
[docs] @pytest.mark.parametrize(("token", "value"), lt_values) def test_token_lt(token, value): assert token < value
[docs] @pytest.mark.parametrize(("token", "value"), gt_values + eq_values) def test_token_ge(token, value): assert token >= value
[docs] @pytest.mark.parametrize(("token", "value"), lt_values + eq_values) def test_token_le(token, value): assert token <= value
[docs] def test_token_hash(): assert hash(Token("9999")) == hash(Token("9999")) assert hash(Token("9999")) == hash(Token(9999)) assert hash(Token(9999)) == hash(Token(9999)) assert hash(Token("09999")) != hash(Token("9999")) assert hash(Token("09999")) != hash(Token(9999))
[docs] @pytest.mark.parametrize( ("value", "expected"), ( (0, "0"), (123, "123"), ("abc", "abc"), ("ABC", "ABC"), ("0042", "0042"), ), ) def test_token_str(value, expected): assert str(Token(value)) == expected
[docs] @pytest.mark.parametrize( ("value", "expected"), ( (0, 1), (123, 3), ("abc", 3), ("0042", 4), ("a", 1), ), ) def test_token_len(value, expected): assert len(Token(value)) == expected
[docs] @pytest.mark.parametrize( ("value", "expected"), ( (0, 0), (123, 123), ("42", 42), ("0042", 42), ), ) def test_token_int(value, expected): assert int(Token(value)) == expected # type: ignore[call-overload]
[docs] @pytest.mark.parametrize("value", ("abc", "ABC", "123abc")) def test_token_int_none_for_non_integers(value): """`Token.integer` is `None` for non-integer tokens.""" assert Token(value).integer is None
[docs] @pytest.mark.parametrize( ("value", "expected"), ( (0, True), (123, True), ("42", True), ("0042", True), ("abc", False), ("ABC", False), ("123abc", False), ), ) def test_token_isint(value, expected): assert Token(value).isint is expected
[docs] def test_token_repr(): r = repr(Token(42)) assert "Token" in r assert "42" in r
[docs] def test_token_format(): assert f"{Token('abc'):>10}" == " abc" assert f"{Token(42):>10}" == " 42"
[docs] def test_token_mixed_type_not_equal(): """Integer and string tokens are never equal, even if the string is digit-like when compared cross-type.""" assert Token(0) != Token("a") assert Token("beta") != Token(1)
[docs] @pytest.mark.parametrize( "value", (None, 0, 123, -1, "0", "1.2.3", "abc", "A-B-C", "123 a bc \n"), ) def test_tokenized_string_allowed_instantiation(value): TokenizedString(value)
[docs] @pytest.mark.parametrize( "value", (1.0, [1, 2, 3], (1, 2, 3), {1, 2, 3}, {"a": 1, "b": 2}), ) def test_tokenized_string_unauthorized_instantiation(value): with pytest.raises(TypeError): TokenizedString(value)
[docs] def test_tokenized_string_hash(): assert hash(TokenizedString("1.2.3")) == hash(TokenizedString("1.2.3")) assert hash(TokenizedString(9999)) == hash(TokenizedString(9999)) assert hash(TokenizedString("9999")) == hash(TokenizedString(9999)) assert hash(TokenizedString("09999")) != hash(TokenizedString("9999")) assert hash(TokenizedString("09999")) != hash(TokenizedString(9999)) # Different separators produce different hashes. assert hash(TokenizedString("1.2.3")) != hash(TokenizedString("1-2-3")) assert hash(TokenizedString("1.2.3")) != hash(TokenizedString("1_2_3"))
[docs] def test_tokenized_string_noop_instantiation(): assert TokenizedString(None) is None # type: ignore[arg-type]
[docs] def test_tokenized_string_idempotent_instantiation(): tok1 = TokenizedString("1.2.3") tok2 = TokenizedString(tok1) # type: ignore[arg-type] assert tok1 is tok2 assert hash(tok1) == hash(tok2) assert tok1 == tok2
[docs] def test_tokenized_string_deepcopy(): tok1 = TokenizedString("4.2.1-5666.3") tok2 = copy.deepcopy(tok1) assert tok1 is not tok2 assert hash(tok1) == hash(tok2) assert tok1 == tok2 assert tok1.separators == tok2.separators assert tok1.pretty_print() == tok2.pretty_print()
[docs] @pytest.mark.parametrize( ("value", "expected"), ( ("1.2.3", "1.2.3"), (" 1.2.3 ", "1.2.3"), (42, "42"), ("latest", "latest"), ), ) def test_tokenized_string_str(value, expected): assert str(TokenizedString(value)) == expected
[docs] @pytest.mark.parametrize( ("value", "expected"), ( ("1.2.3", 5), ("latest", 6), (42, 2), ("", 0), ), ) def test_tokenized_string_len(value, expected): assert len(TokenizedString(value)) == expected
[docs] def test_tokenized_string_repr(): r = repr(TokenizedString("1.2.3")) assert "TokenizedString" in r assert "1.2.3" in r
[docs] def test_tokenized_string_format(): assert f"{TokenizedString('1.2.3'):>10}" == " 1.2.3"
[docs] def test_tokenized_string_iter(): tokens = list(TokenizedString("1.2.3")) assert len(tokens) == 3 assert all(isinstance(t, Token) for t in tokens) assert [str(t) for t in tokens] == ["1", "2", "3"]
[docs] def test_tokenized_string_from_integer(): t = TokenizedString(42) assert t.tokens == (Token(42),) assert t.separators == () assert str(t) == "42"
[docs] @pytest.mark.parametrize("value", ("", "...", "---", "___")) def test_tokenized_string_empty_tokens(value): t = TokenizedString(value) assert t.tokens == () assert t.separators == ()
[docs] def test_tokenized_string_comparison_edge_cases(): """Comparison with `None` and unsupported types.""" v = TokenizedString("1.0") # None is handled explicitly by every operator. assert v > None assert v >= None assert not (v < None) assert not (v <= None) assert v.__eq__(None) is False assert v.__ne__(None) is True # Unsupported types: __eq__/__ne__ fall back to identity. assert v != "1.0" assert v != "1.0" assert v != 42 assert v != 42 # Ordering operators raise TypeError for unsupported types. for op in (operator.gt, operator.lt, operator.ge, operator.le): with pytest.raises(TypeError): op(v, "1.0")
[docs] @pytest.mark.parametrize( ("v_string", "expected_seps"), ( # Single token: no separators. ("latest", ()), ("42", ()), # Dot-separated. ("1.2.3", (".", ".")), ("0.0.0", (".", ".")), # Dash-separated. ("1-2-3", ("-", "-")), # Underscore-separated. ("1_2_3", ("_", "_")), # Mixed separators. ("4.2.1-5666.3", (".", ".", "-", ".")), ("1.3_1", (".", "_")), # Implicit separators at digit-letter boundaries. ("1.2beta5", (".", "", "")), ("r2917_1", ("", "_")), ("20190718-r0", ("-", "")), ("v0.9.4.3", ("", ".", ".", ".")), # Double/adjacent separators stored verbatim. ("4.2.1-5666..3", (".", ".", "-", "..")), ("4.2.1--5666.3", (".", ".", "--", ".")), # Plus separator (Gentoo/OpenWrt epochs). ("4.6.0+git+20160126-2", (".", ".", "+", "+", "-")), # Colon separator (Debian epochs). ("1:5.25-2ubuntu1", (":", ".", "-", "", "")), # Space separator. ("1 2 3", (" ", " ")), # Leading zeros preserved in pretty_print tokens. ("2020.03.24", (".", ".")), ("2.40.20161221.0239", (".", ".", ".")), # Empty string. ("", ()), # Original case preserved in pretty_print. ("4.2.1-ABC-5666.3", (".", ".", "-", "-", ".")), ("V0.9.4.3", ("", ".", ".", ".")), ), ) def test_tokenized_string_separators_and_pretty_print(v_string, expected_seps): t = TokenizedString(v_string) assert t.separators == expected_seps assert t.pretty_print() == v_string
[docs] @pytest.mark.parametrize( ("v_string", "expected_seps", "expected_pp"), ( # Leading separator is prefix in split, not stored. (".4.2.1", (".", "."), "4.2.1"), # Trailing separator is suffix in split, not stored. ("4.2.1.", (".", "."), "4.2.1"), ), ) def test_tokenized_string_separators_and_lossy_pretty_print( v_string, expected_seps, expected_pp ): t = TokenizedString(v_string) assert t.separators == expected_seps assert t.pretty_print() == expected_pp
version_list = ( ("r2917_1", ("r", 2917, 1)), (" r 29 \n 17 1 x ", ("r", 29, 17, 1, "x")), ("2020.03.24", (2020, 3, 24)), ("4.2.1-5666.3", (4, 2, 1, 5666, 3)), ("4.2.1-5666..3", (4, 2, 1, 5666, 3)), ("4.2.1-5666.3.", (4, 2, 1, 5666, 3)), (".4.2.1-5666.3", (4, 2, 1, 5666, 3)), ("4.2.1--5666.3", (4, 2, 1, 5666, 3)), ("4.2.1-#-5666.3", (4, 2, 1, 5666, 3)), ("4.2.1-ABC-5666.3", (4, 2, 1, "abc", 5666, 3)), ( "4.2.1-éèàçÇÉȲ³¼ÀÁÂÃÄ—ÅËÍÑÒÖÜÝåïš™-5666.3", (4, 2, 1, "eeaccee", 231, 4, "aaaaae", "ainooeueyastm", 5666, 3), ), ("1.3_1", (1, 3, 1)), ("2.40.20161221.0239", (2, 40, 20161221, 239)), ("latest", ("latest",)), ("1.2beta5", (1, 2, "b", 5)), ("2.8.18-x86_64", (2, 8, 18, "x", 86, 64)), ("1.8.0_112-b16", (1, 8, 0, 112, "b", 16)), ("3.6.9_build_4685", (3, 6, 9, "build", 4685)), ("1.8.6-124-g6cd4c31", (1, 8, 6, 124, "g", "6cd4c31")), ("0.9999999", (0, 9999999)), ("0.0.0", (0, 0, 0)), ("1.0+git71+c79e264-r0", (1, 0, "git", 71, "c79e264", "r", 0)), ("20190718-r0", (20190718, "r", 0)), ("0.0.0-development", (0, 0, 0, "development")), ("v0.9.4.3", ("v", 0, 9, 4, 3)), ("6.0.1-0ubuntu1", (6, 0, 1, 0, "ubuntu", 1)), ("4.6.0+git+20160126-2", (4, 6, 0, "git", 20160126, 2)), ("1:5.25-2ubuntu1", (1, 5, 25, 2, "ubuntu", 1)), ("1.18.4ubuntu1.1", (1, 18, 4, "ubuntu", 1, 1)), ("20160104ubuntu1", (20160104, "ubuntu", 1)), ("3.113+nmu3ubuntu4", (3, 113, "nmu", 3, "ubuntu", 4)), ( "13.0.2,8:d4173c853231432f001e99d882", (13, 0, 2, 8, "d4173c853231432f001e99d882"), ), ("1.01+20150706hgc3698e0+dfsg-2", (1, 1, 20150706, "hgc", 3698, "e", 0, "dfsg", 2)), # PEP 440 pre-release tags. ("1.0a1", (1, 0, "a", 1)), ("1.0rc1", (1, 0, "rc", 1)), ("1.0.post1", (1, 0, "post", 1)), ("1.0.dev456", (1, 0, "dev", 456)), # Calendar versioning. ("2024.1.15", (2024, 1, 15)), ("24.04", (24, 4)), # Long version chain. ("1.2.3.4.5.6.7.8.9.10", (1, 2, 3, 4, 5, 6, 7, 8, 9, 10)), # Single digit. ("0", (0,)), ("9", (9,)), # Java build metadata. ("21.0.2+13", (21, 0, 2, 13)), # Linux kernel style. ("6.5.0-44-generic", (6, 5, 0, 44, "generic")), # Windows four-part version. ("10.0.19045.4529", (10, 0, 19045, 4529)), # Debian tilde separator. ("1.2.3~beta1", (1, 2, 3, "b", 1)), # Git describe output. ("v2.42.0-rc2-3-gabcdef1", ("v", 2, 42, 0, "rc", 2, 3, "gabcdef", 1)), # Large single number. ("99999999999999999", (99999999999999999,)), # Node.js semver. ("18.17.1", (18, 17, 1)), # Arch Linux pkgrel. ("5.15.90.1-2", (5, 15, 90, 1, 2)), # Snap revision style. ("2.60.4+git2.e7f88a7", (2, 60, 4, "git", 2, "e7f88a7")), # PEP 440 epoch — the `!` is a plain separator, epoch semantics lost. ("1!1.0", (1, 1, 0)), # PEP 440 local version — `+` is a plain separator. ("1.0+local", (1, 0, "local")), # Semver build metadata — `+` is a plain separator. ("1.0.0+build.123", (1, 0, 0, "build", 123)), # Semver pre-release with mixed identifiers. ("1.0.0-alpha.beta", (1, 0, 0, "a", "b")), # RPM release tag. ("1.0-1.fc38", (1, 0, 1, "fc", 38)), # Go pseudo-version. ( "v0.0.0-20210101000000-abcdef123456", ("v", 0, 0, 0, 20210101000000, "abcdef", 123456), ), )
[docs] @pytest.mark.parametrize(("v_string", "v_tuple"), version_list) def test_version_tokenizer(v_string, v_tuple): assert TokenizedString(v_string) == v_tuple
[docs] @pytest.mark.parametrize( ("v_string", "expected"), ( # Strings recognized as versions. *((v_string, True) for v_string, _ in version_list), # Not recognized as versions. ("left-pad", False), ("@eslint/json", False), ("my-package-name", False), ("foo_bar_baz", False), ("some-lib", False), ), ) def test_is_version(v_string, expected): assert is_version(v_string) is expected
compared_gt = ( ("1", None), ("2.0", "1.0"), ("0.1", "0"), ("0.1", "0.0"), ("0.1", "0.0.0.0.0"), ("0.1", "0.beta2"), ("2.0", "1"), ("3.1.10", "3.1.9"), ("9.52", "9d"), ("8.2p1_1", "8.2p1"), ("3.12.0a4", "3.7.0"), ("3.12.0", "3.12.0a4"), ("3.7.7", "3.7-beta2_1"), ("2.1.1", "2.1.1-git-23hfb2-foobar"), (20200313, 20190801), ("2020.03.24", "2019.11.28"), # Java version with build metadata hash — the comma/colon structure gets # tokenized into meaningless fragments. Would need format-specific parsing. # ("14.0.1,7:664493ef4a6946b186ff29eb326336a2", # "14,36:076bab302c7b4508975440c56f6cc26a"), # Major version dominates even when minor/patch are larger. ("10.0.0", "9.99.99"), # Long version chain with trailing difference. ("1.0.0.0.0.1", "1.0.0.0.0.0"), # Single-segment integer comparison, not lexicographic. ("10", "9"), # Integer input. (20240101, 20231231), # Semver patch bump. ("1.0.1", "1.0.0"), # Calendar versioning. ("2024.1.15", "2023.12.31"), # Pre-release with higher base version. ("3.12.1", "3.12.0a4"), # Linux kernel versions. ("6.5.0", "5.15.90"), # PEP 440 post-release > release. ("1.0.post1", "1.0"), # PEP 440 pre-release chain: alpha < beta < rc < release. ("1.0b1", "1.0a1"), ("1.0rc1", "1.0b1"), ("1.0", "1.0rc1"), # PEP 440 dev within pre-release: release > dev. ("1.0a1", "1.0a1.dev1"), # Semver pre-release numeric ordering. ("1.0.0-alpha.2", "1.0.0-alpha.1"), # Semver more identifiers > fewer (longer is more specific). ("1.0.0-alpha.1", "1.0.0-alpha"), # RPM release bump. ("1.0-2.fc38", "1.0-1.fc38"), # Epochs dominate: a leading `N:` (Debian, RPM, pacman) or `N!` (PEP # 440) outranks the rest of the version, and an absent epoch counts as 0. ("1!1.0", "2.0"), ("2:1.0-1", "9.0"), ("1:1.0", "1.0"), # Higher epoch wins even over a much higher base version. ("2:1.0-1", "1:9.0-1"), # Epoch bump with an identical base. ("2:1.0-1", "1:1.0-1"), # Same epoch: fall through to a normal release comparison. ("1:1.0-2", "1:1.0-1"), # Git describe commit count. ("1.8.6-125-g6cd4c31", "1.8.6-124-g6cd4c31"), # Go pseudo-version date comparison. ( "v0.0.0-20210101000000-abcdef123456", "v0.0.0-20200101000000-abcdef123456", ), )
[docs] @pytest.mark.parametrize(("ver1", "ver2"), compared_gt) def test_version_comparison_gt(ver1, ver2): assert TokenizedString(ver1) > TokenizedString(ver2)
[docs] @pytest.mark.parametrize(("ver1", "ver2"), reverse_fixtures(compared_gt)) def test_version_comparison_lt(ver1, ver2): assert TokenizedString(ver1) < TokenizedString(ver2)
compared_eq = ( # Identical strings. ("1.2.3", "1.2.3"), ("0", "0"), ("latest", "latest"), # Leading zeros: tokens compare as integers. ("01.02.03", "1.2.3"), ("2020.03.24", "2020.3.24"), # Trailing .0 is padding. ("6.2", "6.2.0"), ("1.0", "1.0.0.0.0"), # Different separators: comparison is token-based. ("1.2.3", "1-2-3"), ("1.2.3", "1_2_3"), ("4.2.1", "4-2-1"), # PEP 440 implicit zero on pre-release tag. ("1.0a", "1.0a0"), # Cosmetic `v` prefix ignored in comparison. ("v1.0", "1.0"), ("v0.9.4.3", "0.9.4.3"), # PEP 440 alias normalization. ("1.0alpha1", "1.0a1"), ("1.0c1", "1.0rc1"), # Epoch: an explicit `0:` equals an absent epoch; padding still applies. ("0:1.0", "1.0"), ("1:1.0", "1:1.0.0"), )
[docs] @pytest.mark.parametrize(("ver1", "ver2"), compared_eq) def test_version_comparison_eq(ver1, ver2): assert TokenizedString(ver1) == TokenizedString(ver2) assert TokenizedString(ver1) == TokenizedString(ver2) assert TokenizedString(ver1) >= TokenizedString(ver2) assert TokenizedString(ver1) <= TokenizedString(ver2) assert not (TokenizedString(ver1) > TokenizedString(ver2)) assert not (TokenizedString(ver1) < TokenizedString(ver2))
[docs] @pytest.mark.parametrize( "sequence", ( [ None, "r0", "r1", "r9_0", "r9_3", "r9_30", "r2917_1", "r02918_1", "r02920_0", "r02920_1", "r02920_20", "r02920_021", ], # Semver progression. [ None, "0.1.0", "0.2.0", "0.10.0", "1.0.0", "1.0.1", "1.1.0", "1.10.0", "2.0.0", "10.0.0", ], # PEP 440 pre-release progression. [ None, "1.0a1", "1.0a2", "1.0b1", "1.0rc1", "1.0", "1.0.1", "1.1", "2.0", ], # Calendar versioning progression. [ None, "2019.11.28", "2020.03.24", "2020.12.01", "2024.1.15", ], # Epoch dominance progression: a higher epoch outranks the base version. [ None, "9.0", "1:1.0", "1:2.0", "2:0.1", ], ), ) def test_version_sorting(sequence): sorted_version = list(map(TokenizedString, sequence)) # Reverse to guarantee a different order from sorted (deterministic, no flakiness). reversed_order = list(reversed(sorted_version)) assert sorted(reversed_order) == sorted_version
[docs] @pytest.mark.parametrize( ("spec", "version", "expected"), ( # Bare version string acts as >=. ("1.0.0", "1.0.0", True), ("1.0.0", "2.0.0", True), ("1.0.0", "0.9.0", False), # Explicit >= operator. (">=1.0.0", "1.0.0", True), (">=1.0.0", "2.0.0", True), (">=1.0.0", "0.9.0", False), # Explicit > operator. (">1.0.0", "1.0.1", True), (">1.0.0", "1.0.0", False), # Explicit < operator. ("<2.0.0", "1.9.9", True), ("<2.0.0", "2.0.0", False), ("<2.0.0", "3.0.0", False), # Explicit <= operator. ("<=2.0.0", "2.0.0", True), ("<=2.0.0", "2.0.1", False), # Explicit == operator. ("==1.5.0", "1.5.0", True), ("==1.5.0", "1.5.1", False), # Explicit != operator. ("!=1.5.0", "1.5.0", False), ("!=1.5.0", "1.5.1", True), # Range with two constraints. (">=1.20.0,<2.0.0", "1.20.0", True), (">=1.20.0,<2.0.0", "1.22.19", True), (">=1.20.0,<2.0.0", "1.19.0", False), (">=1.20.0,<2.0.0", "2.0.0", False), (">=1.20.0,<2.0.0", "4.9.2", False), # Range with spaces around constraints. (">=1.0.0 , <3.0.0", "2.0.0", True), (">=1.0.0 , <3.0.0", "0.5.0", False), # Empty constraint parts are silently ignored. (",1.0.0,", "1.0.0", True), (",1.0.0,", "0.9.0", False), # Three constraints (range with exclusion). (">=1.0.0,<3.0.0,!=2.0.0", "1.5.0", True), (">=1.0.0,<3.0.0,!=2.0.0", "2.0.0", False), (">=1.0.0,<3.0.0,!=2.0.0", "0.5.0", False), # Tight pinning: lower == upper. (">=1.5.0,<=1.5.0", "1.5.0", True), (">=1.5.0,<=1.5.0", "1.5.1", False), # Single > boundary. (">0", "0.0.1", True), (">0", "0", False), ), ) def test_version_range(spec, version, expected): assert (parse_version(version) in VersionRange(spec)) is expected
[docs] def test_version_range_empty_spec(): """Empty spec has no constraints, so any version satisfies it.""" r = VersionRange("") assert parse_version("1.0.0") in r assert parse_version("0") in r
[docs] @pytest.mark.parametrize( ("spec", "expected"), ( (">=1.0.0,<2.0.0", "<VersionRange '>=1.0.0,<2.0.0'>"), ("1.0.0", "<VersionRange '1.0.0'>"), ), ) def test_version_range_repr(spec, expected): assert repr(VersionRange(spec)) == expected
[docs] def test_parse_version_is_tokenized_string(): """`parse_version` is an alias for `TokenizedString`.""" assert parse_version is TokenizedString v = parse_version("1.2.3") assert isinstance(v, TokenizedString) assert v == TokenizedString("1.2.3")
[docs] @pytest.mark.parametrize( ("old", "new", "expected_common", "expected_old", "expected_new"), ( # Token boundary: diff includes separator + full diverging token. ("2.1.1774638290", "2.1.1774896198", "2.1", ".1774638290", ".1774896198"), # Patch bump. ("1.2.3", "1.2.4", "1.2", ".3", ".4"), # Minor bump. ("1.2.3", "1.3.0", "1", ".2.3", ".3.0"), # Major bump: no separator before first token. ("1.2.3", "2.0.0", "", "1.2.3", "2.0.0"), # Pre-release tag change. ("1.0.0-alpha", "1.0.0-beta", "1.0.0", "-alpha", "-beta"), # Year-based: first token differs, no common separator. ("2013.0523", "2024.0010", "", "2013.0523", "2024.0010"), # No common prefix at all. ("abc", "def", "", "abc", "def"), # No separators in either string. ("12345", "12399", "", "12345", "12399"), # Identical versions: everything is common. ("1.2.3", "1.2.3", "1.2.3", "", ""), # One side empty. ("", "1.0", "", "", "1.0"), ("1.0", "", "", "1.0", ""), # Both empty. ("", "", "", "", ""), # Alpha bump within pre-release. ("3.12.0a4", "3.12.0a5", "3.12", ".0a4", ".0a5"), # TokenizedString inputs. (TokenizedString("1.2.3"), TokenizedString("1.2.4"), "1.2", ".3", ".4"), ), ) def test_diff_versions(old, new, expected_common, expected_old, expected_new): styled_old, styled_new = diff_versions(old, new) # Plain text must equal the full original string. assert strip_ansi(styled_old) == (str(old) if old else "") assert strip_ansi(styled_new) == (str(new) if new else "") # Verify the split point. full_old = str(old) if old else "" full_new = str(new) if new else "" assert full_old[: len(expected_common)] == expected_common assert full_old[len(expected_common) :] == expected_old assert full_new[: len(expected_common)] == expected_common assert full_new[len(expected_common) :] == expected_new
# --- Known limitations --- # # These tests document comparisons that the heuristic tokenizer gets wrong # because it does not implement format-specific semantics. Each test is # `xfail(strict=True)` so it fails loudly if the limitation is removed, # letting us claim the fix.
[docs] @pytest.mark.parametrize( ("ver1", "ver2"), ( # Debian: the revision (after the last `-`) makes a version newer, # but `-` also introduces a semver pre-release, which makes a version # older. A format-agnostic comparator cannot tell the two apart and # treats the string suffix as a pre-release. Unlike epochs, this # separator is genuinely ambiguous, so it stays unhandled. pytest.param("6.0.1-0ubuntu1", "6.0.1", id="debian-revision"), ), ) @pytest.mark.xfail(strict=True, reason="Debian revision vs semver pre-release.") def test_version_comparison_gt_known_failures(ver1, ver2): assert TokenizedString(ver1) > TokenizedString(ver2)
[docs] @pytest.mark.parametrize( ("ver1", "ver2"), ( # PEP 440: dev releases precede all pre-releases. pytest.param("1.0.dev1", "1.0a1", id="pep440-dev-lt-alpha"), # Semver 11.4.4: numeric identifiers always have lower precedence # than alphanumeric identifiers in pre-release fields. Our # int-outranks-string rule is the exact opposite. pytest.param( "1.0.0-alpha.1", "1.0.0-alpha.beta", id="semver-numeric-lt-string", ), ), ) @pytest.mark.xfail(strict=True, reason="Ordering inversion.") def test_version_comparison_lt_known_failures(ver1, ver2): assert TokenizedString(ver1) < TokenizedString(ver2)
[docs] @pytest.mark.parametrize( ("ver1", "ver2"), ( # PEP 440: local version equals base for ordering. pytest.param("1.0+local", "1.0", id="pep440-local-ignored"), # Semver: build metadata is ignored in precedence. pytest.param("1.0.0+build.123", "1.0.0", id="semver-build-metadata"), pytest.param( "1.0.0+build.123", "1.0.0+build.124", id="semver-build-metadata-diff", ), # Perl: floating-point versions treat `1.1` as `1.100`. pytest.param("1.1", "1.10", id="perl-float-equal"), # Gentoo: three-digit-group conversion of Perl versions. pytest.param("1.020030", "1.20.30", id="gentoo-3digit-group"), ), ) @pytest.mark.xfail(strict=True, reason="Format-specific normalization.") def test_version_comparison_eq_known_failures(ver1, ver2): assert TokenizedString(ver1) == TokenizedString(ver2)