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 json
import operator
import re

import pytest
from click_extra import strip_ansi

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

from .conftest import PROJECT_ROOT


[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 # noqa: PLC2801 assert v.__ne__(None) is True # noqa: PLC2801 # 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, 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
[docs] @pytest.mark.parametrize( ("v_string", "expected"), ( ("1.2.3", False), ("14ubuntu6", False), ("2.0.0rc1", False), # Hex characters with a single digit/letter transition: not a hash. ("eeaccee231", False), ("1.8.6-124-g6cd4c31", True), ("1.0+git71+c79e264-r0", True), ), ) def test_has_hex_hash(v_string, expected): assert TokenizedString(v_string).has_hex_hash 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"), # Gentoo and Alpine post-release suffixes, verified against # `apk version -t` on Alpine 3.24.1 running apk-tools 3.0.8. ("1.0_p1", "1.0"), ("1.0_git20240101", "1.0"), ("1.0_p1", "1.0_alpha1"), # Their pre-release half keeps the generic string-loses-to-release rule. ("1.0", "1.0_alpha1"), ("1.0", "1.0_rc1"), # The same words behind another separator stay pre-release markers. ("1.0", "1.0-p1"), # 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", ), ) # Known limitations: comparisons the heuristic tokenizer gets wrong, because it # implements no format-specific semantics. Each row is `xfail(strict=True)`, so # it fails loudly once the limitation is gone, which lets us claim the fix. GT_KNOWN_FAILURES = ( # 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", marks=pytest.mark.xfail( strict=True, reason="Debian revision vs semver pre-release." ), ), ) LT_KNOWN_FAILURES = ( # PEP 440: dev releases precede all pre-releases. pytest.param( "1.0.dev1", "1.0a1", id="pep440-dev-lt-alpha", marks=pytest.mark.xfail(strict=True, reason="Ordering inversion."), ), # 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", marks=pytest.mark.xfail(strict=True, reason="Ordering inversion."), ), )
[docs] @pytest.mark.parametrize(("ver1", "ver2"), (*compared_gt, *GT_KNOWN_FAILURES)) def test_version_comparison_gt(ver1, ver2): assert TokenizedString(ver1) > TokenizedString(ver2)
[docs] @pytest.mark.parametrize( ("ver1", "ver2"), (*reverse_fixtures(compared_gt), *LT_KNOWN_FAILURES) ) def test_version_comparison_lt(ver1, ver2): assert TokenizedString(ver1) < TokenizedString(ver2)
[docs] @pytest.mark.parametrize("tag", sorted(UNDERSCORE_POST_RELEASE_TAGS)) def test_underscore_post_release_tag_outranks_its_base(tag): """Every tag of the set sorts above the release it suffixes. Enumerating the set rather than sampling it is what keeps a tag added later from silently keeping the pre-release reading of the generic rule. """ assert TokenizedString(f"1.0_{tag}1") > TokenizedString("1.0")
[docs] @pytest.mark.parametrize("tag", sorted(UNDERSCORE_POST_RELEASE_TAGS)) @pytest.mark.parametrize("separator", (".", "-", "+", "")) def test_post_release_tag_needs_its_underscore(tag, separator): """The same tag behind any other separator keeps the pre-release reading. Gating on the underscore is what confines the Gentoo and Alpine reading to the two schemes spelling it that way, leaving a build identifier like `2.1.1-git-23hfb2` ordered below its base. """ assert TokenizedString(f"1.0{separator}{tag}1") < TokenizedString("1.0")
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"), ) EQ_KNOWN_FAILURES = tuple( pytest.param( ver1, ver2, id=param_id, marks=pytest.mark.xfail(strict=True, reason="Format-specific normalization."), ) for ver1, ver2, param_id in ( # PEP 440: local version equals base for ordering. ("1.0+local", "1.0", "pep440-local-ignored"), # Semver: build metadata is ignored in precedence. ("1.0.0+build.123", "1.0.0", "semver-build-metadata"), ("1.0.0+build.123", "1.0.0+build.124", "semver-build-metadata-diff"), # Perl: floating-point versions treat `1.1` as `1.100`. ("1.1", "1.10", "perl-float-equal"), # Gentoo: three-digit-group conversion of Perl versions. ("1.020030", "1.20.30", "gentoo-3digit-group"), ) )
[docs] @pytest.mark.parametrize(("ver1", "ver2"), (*compared_eq, *EQ_KNOWN_FAILURES)) def test_version_comparison_eq(ver1, 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] def styled_split(styled: str) -> tuple[str, str]: """Recover the (prefix, suffix) boundary from a `diff_versions()` result. The split has to be read back out of the colors, which is the only place the function records it. Slicing the *inputs* by an expected length instead compares the expectation against itself and passes whatever the function did, which is how this file spent its first year asserting nothing about where `diff_versions()` actually cut. """ runs = re.findall(r"\x1b\[[0-9;]*m(.*?)\x1b\[0m", styled, re.DOTALL) # A common prefix is emitted as its own run ahead of the suffix. With none, # the whole string is the suffix. return (runs[0], runs[1]) if len(runs) > 1 else ("", runs[0] if runs else "")
[docs] def test_diff_versions(): """Split every pair of the shared corpus where both frontends agree to. `tests/version-diff-cases.json` is the contract, read here and by `testDiffVersions()` in `tests/gnome/run-tests.js`: the GNOME Shell extension carries a second implementation of this function, for a frontend Python never runs in, and nothing but one shared case list keeps the two honest. They had drifted on 17 of 21 pairs, the JS one stopping short of the separator introducing the diverging token and leaving it in the dimmed prefix. A case added to the file is asserted against both implementations on the next run. It is read here rather than parametrized from, so no worker stats the filesystem while collecting. """ corpus = json.loads( PROJECT_ROOT.joinpath("tests", "version-diff-cases.json").read_text( encoding="UTF-8", ), ) # An unreadable or empty corpus would otherwise report a clean run over # the nothing it managed to load. assert corpus mismatches = [] for case in corpus: installed, latest = case["installed"], case["latest"] styled_old, styled_new = diff_versions(installed, latest) # Plain text must equal the full original string. if strip_ansi(styled_old) != installed: mismatches.append(f"{case['id']}: {styled_old!r} is not {installed!r}") if strip_ansi(styled_new) != latest: mismatches.append(f"{case['id']}: {styled_new!r} is not {latest!r}") # Verify the split point, as the function actually drew it. for styled, suffix in ( (styled_old, case["oldSuffix"]), (styled_new, case["newSuffix"]), ): drawn = styled_split(styled) if drawn != (case["prefix"], suffix): mismatches.append( f"{case['id']}: {installed} \u2192 {latest} split as {drawn}, " f"where the corpus asks for {(case['prefix'], suffix)}", ) assert not mismatches, "\n".join(mismatches)
[docs] def test_diff_versions_accepts_tokenized_strings(): """A `TokenizedString` is stringified before the split, like a plain str.""" tokenized = diff_versions(TokenizedString("1.2.3"), TokenizedString("1.2.4")) assert tokenized == diff_versions("1.2.3", "1.2.4")