diff --git a/docs/source/api.rst b/docs/source/api.rst index ad4c76fca..c58f7b36a 100644 --- a/docs/source/api.rst +++ b/docs/source/api.rst @@ -43,5 +43,5 @@ Python API rez.system rez.util rez.utils - rez.vendor.version + rez.version rez.wrapper diff --git a/docs/source/conf.py b/docs/source/conf.py index 4702e6547..ed9fad1cd 100644 --- a/docs/source/conf.py +++ b/docs/source/conf.py @@ -48,7 +48,9 @@ # TODO: Remove once we unvendor enum. ("py:class", "rez.solver._Common"), ("py:class", "_thread._local"), - ("py:class", "rez.utils.platform_._UnixPlatform") + ("py:class", "rez.utils.platform_._UnixPlatform"), + ("py:class", "rez.version._util._Common"), + ("py:class", "rez.version._version._Comparable"), ] nitpick_ignore_regex = [ diff --git a/docs/source/package_commands.rst b/docs/source/package_commands.rst index 24b357d19..7e54f2b89 100644 --- a/docs/source/package_commands.rst +++ b/docs/source/package_commands.rst @@ -477,7 +477,7 @@ Following is a list of the objects and functions available. if "foo.cli" in ephemerals: info("Foo cli option is being specified!") -.. py:function:: ephemerals.get_range(name: str, range_: str) -> ~rez.vendor.version.version.VersionRange +.. py:function:: ephemerals.get_range(name: str, range_: str) -> ~rez.version.VersionRange Use ``get_range`` to test with the :func:`intersects` function. Here, we enable ``foo``'s commandline tools by default, unless explicitly disabled via @@ -539,7 +539,7 @@ Following is a list of the objects and functions available. info("floob version is %s" % resolve.floob.version) -.. py:function:: intersects(range1: str | ~rez.vendor.version.version.VersionRange | ~rez.rex_bindings.VariantBinding | ~rez.rex_bindings.VersionBinding, range2: str) -> bool +.. py:function:: intersects(range1: str | ~rez.version.VersionRange | ~rez.rex_bindings.VariantBinding | ~rez.rex_bindings.VersionBinding, range2: str) -> bool A boolean function that returns True if the version or version range of the given object, intersects with the given version range. Valid objects to query include: diff --git a/src/build_utils/license/apply_copyright b/src/build_utils/license/apply_copyright index 6d5d1edbd..880cfa583 100755 --- a/src/build_utils/license/apply_copyright +++ b/src/build_utils/license/apply_copyright @@ -15,7 +15,6 @@ find ./src/rez/bind -name '*.py' > .lic.tmp find ./src/rez/cli -name '*.py' >> .lic.tmp find ./src/rez/tests -name '*.py' >> .lic.tmp find ./src/rez/utils -name '*.py' >> .lic.tmp -find ./src/rez/vendor/version -name '*.py' >> .lic.tmp find ./src/rez/bind -name '*.py' >> .lic.tmp find ./src/rezgui -name '*.py' >> .lic.tmp find ./src/rezplugins -name '*.py' >> .lic.tmp diff --git a/src/rez/bind/_utils.py b/src/rez/bind/_utils.py index 54e21be14..8630a7631 100644 --- a/src/rez/bind/_utils.py +++ b/src/rez/bind/_utils.py @@ -6,7 +6,7 @@ Utility functions for bind modules. """ from __future__ import absolute_import -from rez.vendor.version.version import Version +from rez.version import Version from rez.exceptions import RezBindError from rez.config import config from rez.util import which diff --git a/src/rez/bind/arch.py b/src/rez/bind/arch.py index 7bb55c706..146317694 100644 --- a/src/rez/bind/arch.py +++ b/src/rez/bind/arch.py @@ -7,7 +7,7 @@ """ from __future__ import absolute_import from rez.package_maker import make_package -from rez.vendor.version.version import Version +from rez.version import Version from rez.bind._utils import check_version from rez.system import system diff --git a/src/rez/bind/hello_world.py b/src/rez/bind/hello_world.py index 773e295be..170bf67f5 100644 --- a/src/rez/bind/hello_world.py +++ b/src/rez/bind/hello_world.py @@ -12,7 +12,7 @@ from __future__ import absolute_import, print_function from rez.package_maker import make_package -from rez.vendor.version.version import Version +from rez.version import Version from rez.utils.lint_helper import env from rez.utils.execution import create_executable_script, ExecutableScriptMode from rez.bind._utils import make_dirs, check_version diff --git a/src/rez/bind/os.py b/src/rez/bind/os.py index a6a88c280..28fe9d2e0 100644 --- a/src/rez/bind/os.py +++ b/src/rez/bind/os.py @@ -7,7 +7,7 @@ """ from __future__ import absolute_import from rez.package_maker import make_package -from rez.vendor.version.version import Version +from rez.version import Version from rez.bind._utils import check_version from rez.system import system diff --git a/src/rez/bind/platform.py b/src/rez/bind/platform.py index f0e2e1c1e..5aac37c7c 100644 --- a/src/rez/bind/platform.py +++ b/src/rez/bind/platform.py @@ -7,7 +7,7 @@ """ from __future__ import absolute_import from rez.package_maker import make_package -from rez.vendor.version.version import Version +from rez.version import Version from rez.bind._utils import check_version from rez.system import system diff --git a/src/rez/bind/rezgui.py b/src/rez/bind/rezgui.py index 1fcbb07d4..844c32ecc 100644 --- a/src/rez/bind/rezgui.py +++ b/src/rez/bind/rezgui.py @@ -10,7 +10,7 @@ from rez.package_maker import make_package from rez.bind._utils import check_version, make_dirs from rez.system import system -from rez.vendor.version.version import Version +from rez.version import Version from rez.utils.lint_helper import env from rez.utils.execution import create_executable_script import shutil diff --git a/src/rez/cli/mv.py b/src/rez/cli/mv.py index 3fe4287d2..e02688296 100644 --- a/src/rez/cli/mv.py +++ b/src/rez/cli/mv.py @@ -54,7 +54,7 @@ def list_repos_containing_pkg(pkg_name, pkg_version): def command(opts, parser, extra_arg_groups=None): - from rez.vendor.version.requirement import VersionedObject + from rez.version import VersionedObject from rez.packages import get_package_from_repository from rez.package_move import move_package import sys diff --git a/src/rez/cli/pkg-ignore.py b/src/rez/cli/pkg-ignore.py index 66a1dceab..557b1ca67 100644 --- a/src/rez/cli/pkg-ignore.py +++ b/src/rez/cli/pkg-ignore.py @@ -63,7 +63,7 @@ def list_repos_containing_pkg(pkg_name, pkg_version): def command(opts, parser, extra_arg_groups=None): from rez.package_repository import package_repository_manager - from rez.vendor.version.requirement import VersionedObject + from rez.version import VersionedObject import sys obj = VersionedObject(opts.PKG) diff --git a/src/rez/cli/rm.py b/src/rez/cli/rm.py index 31512e2d2..664152437 100644 --- a/src/rez/cli/rm.py +++ b/src/rez/cli/rm.py @@ -36,7 +36,7 @@ def setup_parser(parser, completions=False): def remove_package(opts, parser): - from rez.vendor.version.requirement import VersionedObject + from rez.version import VersionedObject from rez.package_remove import remove_package if opts.dry_run: @@ -55,7 +55,7 @@ def remove_package(opts, parser): def remove_package_family(opts, parser, force=False): - from rez.vendor.version.requirement import VersionedObject + from rez.version import VersionedObject from rez.package_remove import remove_package_family from rez.exceptions import PackageRepositoryError diff --git a/src/rez/cli/selftest.py b/src/rez/cli/selftest.py index ef9e8a5de..a4b6fcf52 100644 --- a/src/rez/cli/selftest.py +++ b/src/rez/cli/selftest.py @@ -59,6 +59,9 @@ def __call__(self, parser, namespace, values, option_string=None): # create argparse entry for each module's unit test for name, module in sorted(tests): + if not module.__doc__: + raise RuntimeError("Module {0!r} doesn't have a docstring. Please add one.".format(module.__file__)) + parser.add_argument( "--%s" % name, action=AddTestModuleAction, nargs=0, dest="module_tests", default=[], diff --git a/src/rez/package_filter.py b/src/rez/package_filter.py index d76139b2e..1e29066c9 100644 --- a/src/rez/package_filter.py +++ b/src/rez/package_filter.py @@ -7,7 +7,7 @@ from rez.config import config from rez.utils.data_utils import cached_property, cached_class_property from rez.vendor.six import six -from rez.vendor.version.requirement import VersionedObject, Requirement +from rez.version import VersionedObject, Requirement from hashlib import sha1 import fnmatch import re diff --git a/src/rez/package_maker.py b/src/rez/package_maker.py index 83dac81a1..bb14c0da8 100644 --- a/src/rez/package_maker.py +++ b/src/rez/package_maker.py @@ -16,7 +16,7 @@ from rez.package_py_utils import expand_requirement from rez.vendor.schema.schema import Schema, Optional, Or, Use, And from rez.vendor.six import six -from rez.vendor.version.version import Version +from rez.version import Version from contextlib import contextmanager import os diff --git a/src/rez/package_order.py b/src/rez/package_order.py index 9d3aaac65..e00839fea 100644 --- a/src/rez/package_order.py +++ b/src/rez/package_order.py @@ -7,7 +7,7 @@ from rez.config import config from rez.utils.data_utils import cached_class_property -from rez.vendor.version.version import Version +from rez.version import Version class PackageOrder(object): diff --git a/src/rez/package_py_utils.py b/src/rez/package_py_utils.py index be493e0fb..23117bcdb 100644 --- a/src/rez/package_py_utils.py +++ b/src/rez/package_py_utils.py @@ -56,8 +56,8 @@ def expand_requirement(request, paths=None): if '*' not in request: return request - from rez.vendor.version.version import VersionRange - from rez.vendor.version.requirement import Requirement + from rez.version import VersionRange + from rez.version import Requirement from rez.packages import get_latest_package from uuid import uuid4 diff --git a/src/rez/package_remove.py b/src/rez/package_remove.py index 8ed6e3950..51192f36f 100644 --- a/src/rez/package_remove.py +++ b/src/rez/package_remove.py @@ -3,7 +3,7 @@ from rez.package_repository import package_repository_manager -from rez.vendor.version.version import Version +from rez.version import Version from rez.utils.logging_ import print_info from rez.vendor.six import six from rez.config import config diff --git a/src/rez/package_resources.py b/src/rez/package_resources.py index f4b497249..53c566b83 100644 --- a/src/rez/package_resources.py +++ b/src/rez/package_resources.py @@ -12,7 +12,7 @@ from rez.utils.formatting import PackageRequest from rez.exceptions import PackageMetadataError, ResourceError from rez.config import config, Config, create_config -from rez.vendor.version.version import Version +from rez.version import Version from rez.vendor.schema.schema import Schema, SchemaError, Optional, Or, And, Use from rez.vendor.six import six diff --git a/src/rez/package_search.py b/src/rez/package_search.py index 6b5f09c4f..26b7f60dc 100644 --- a/src/rez/package_search.py +++ b/src/rez/package_search.py @@ -23,7 +23,7 @@ from rez.config import config -from rez.vendor.version.requirement import Requirement +from rez.version import Requirement def get_reverse_dependency_tree(package_name, depth=None, paths=None, diff --git a/src/rez/package_serialise.py b/src/rez/package_serialise.py index 04f6d6de6..8d1f702c0 100644 --- a/src/rez/package_serialise.py +++ b/src/rez/package_serialise.py @@ -7,7 +7,7 @@ from rez.serialise import FileFormat from rez.package_resources import help_schema, late_bound from rez.vendor.schema.schema import Schema, Optional, And, Or, Use -from rez.vendor.version.version import Version +from rez.version import Version from rez.utils.schema import extensible_schema_dict from rez.utils.sourcecode import SourceCode from rez.utils.formatting import PackageRequest, indent, \ diff --git a/src/rez/package_test.py b/src/rez/package_test.py index ddd475e8b..89b0c977e 100644 --- a/src/rez/package_test.py +++ b/src/rez/package_test.py @@ -10,7 +10,7 @@ from rez.utils.colorize import heading, Printer from rez.utils.logging_ import print_info, print_warning, print_error from rez.vendor.six import six -from rez.vendor.version.requirement import Requirement, RequirementList +from rez.version import Requirement, RequirementList from rez.utils.py23 import quote import time import sys diff --git a/src/rez/packages.py b/src/rez/packages.py index fab8b292e..b9078688b 100644 --- a/src/rez/packages.py +++ b/src/rez/packages.py @@ -14,8 +14,8 @@ from rez.utils.schema import schema_keys from rez.utils.resources import ResourceHandle, ResourceWrapper from rez.exceptions import PackageFamilyNotFoundError, ResourceError -from rez.vendor.version.version import Version, VersionRange -from rez.vendor.version.requirement import VersionedObject +from rez.version import Version, VersionRange +from rez.version import VersionedObject from rez.vendor.six import six from rez.serialise import FileFormat from rez.config import config diff --git a/src/rez/pip.py b/src/rez/pip.py index 5d74ab2e3..ece540535 100644 --- a/src/rez/pip.py +++ b/src/rez/pip.py @@ -5,7 +5,7 @@ from __future__ import print_function, absolute_import from rez.packages import get_latest_package -from rez.vendor.version.version import Version +from rez.version import Version from rez.vendor.distlib.database import DistributionPath from rez.vendor.enum.enum import Enum from rez.vendor.packaging.version import Version as PackagingVersion diff --git a/src/rez/resolved_context.py b/src/rez/resolved_context.py index 4e41ca817..2c4a4fe49 100644 --- a/src/rez/resolved_context.py +++ b/src/rez/resolved_context.py @@ -36,8 +36,8 @@ read_graph_from_string from rez.utils.resolve_graph import failure_detail_from_graph from rez.vendor.six import six -from rez.vendor.version.version import VersionRange -from rez.vendor.version.requirement import Requirement +from rez.version import VersionRange +from rez.version import Requirement from rez.vendor.enum import Enum from rez.vendor import yaml from rez.utils import json diff --git a/src/rez/resolver.py b/src/rez/resolver.py index 4acc624de..5fb999525 100644 --- a/src/rez/resolver.py +++ b/src/rez/resolver.py @@ -10,7 +10,7 @@ from rez.utils.logging_ import log_duration from rez.config import config from rez.vendor.enum import Enum -from rez.vendor.version.requirement import Requirement +from rez.version import Requirement from contextlib import contextmanager from hashlib import sha1 diff --git a/src/rez/rex_bindings.py b/src/rez/rex_bindings.py index 9deca06f8..cbfe53c61 100644 --- a/src/rez/rex_bindings.py +++ b/src/rez/rex_bindings.py @@ -11,8 +11,8 @@ unnecessary data from Rex, and provide APIs that will not change. """ from rez.vendor.six import six -from rez.vendor.version.version import VersionRange -from rez.vendor.version.requirement import Requirement +from rez.version import VersionRange +from rez.version import Requirement basestring = six.string_types[0] diff --git a/src/rez/solver.py b/src/rez/solver.py index c90b87328..9990e6457 100644 --- a/src/rez/solver.py +++ b/src/rez/solver.py @@ -23,9 +23,8 @@ from rez.vendor.pygraph.algorithms.accessibility import accessibility from rez.exceptions import PackageNotFoundError, ResolveError, \ PackageFamilyNotFoundError, RezSystemError -from rez.vendor.version.version import VersionRange -from rez.vendor.version.requirement import VersionedObject, Requirement, \ - RequirementList +from rez.version import VersionRange +from rez.version import VersionedObject, Requirement, RequirementList from rez.vendor.enum import Enum from contextlib import contextmanager from itertools import product, chain diff --git a/src/rez/tests/test_commands.py b/src/rez/tests/test_commands.py index 86cf85f2e..df545bc9a 100644 --- a/src/rez/tests/test_commands.py +++ b/src/rez/tests/test_commands.py @@ -5,7 +5,7 @@ """ test package commands """ -from rez.vendor.version.requirement import VersionedObject +from rez.version import VersionedObject from rez.rex import Comment, EnvAction, Shebang, Setenv, Alias, Appendenv from rez.resolved_context import ResolvedContext from rez.utils.filesystem import canonical_path diff --git a/src/rez/tests/test_copy_package.py b/src/rez/tests/test_copy_package.py index 95315a772..591c9de38 100644 --- a/src/rez/tests/test_copy_package.py +++ b/src/rez/tests/test_copy_package.py @@ -16,7 +16,7 @@ from rez.resolved_context import ResolvedContext from rez.packages import get_latest_package from rez.package_copy import copy_package -from rez.vendor.version.version import VersionRange +from rez.version import VersionRange from rez.tests.util import TestBase, TempdirMixin diff --git a/src/rez/tests/test_package_filter.py b/src/rez/tests/test_package_filter.py index 296eb891b..66d7481b4 100644 --- a/src/rez/tests/test_package_filter.py +++ b/src/rez/tests/test_package_filter.py @@ -7,7 +7,7 @@ """ from rez.tests.util import TestBase from rez.packages import iter_packages -from rez.vendor.version.requirement import Requirement +from rez.version import Requirement from rez.package_filter import PackageFilter, PackageFilterList, GlobRule, \ RegexRule, RangeRule, TimestampRule diff --git a/src/rez/tests/test_packages.py b/src/rez/tests/test_packages.py index 11dc60c1f..55f5254c6 100644 --- a/src/rez/tests/test_packages.py +++ b/src/rez/tests/test_packages.py @@ -20,8 +20,8 @@ from rez.utils.formatting import PackageRequest from rez.utils.sourcecode import SourceCode import unittest -from rez.vendor.version.version import Version -from rez.vendor.version.util import VersionError +from rez.version import Version +from rez.version import VersionError from rez.utils.filesystem import canonical_path import shutil import os.path diff --git a/src/rez/tests/test_packages_order.py b/src/rez/tests/test_packages_order.py index f90181eef..702963f5f 100644 --- a/src/rez/tests/test_packages_order.py +++ b/src/rez/tests/test_packages_order.py @@ -12,7 +12,7 @@ TimestampPackageOrder, SortedOrder, PackageOrderList, from_pod from rez.packages import iter_packages from rez.tests.util import TestBase, TempdirMixin -from rez.vendor.version.version import Version +from rez.version import Version class _BaseTestPackagesOrder(TestBase, TempdirMixin): diff --git a/src/rez/tests/test_pip_utils.py b/src/rez/tests/test_pip_utils.py index c5911fe06..c42bf6766 100644 --- a/src/rez/tests/test_pip_utils.py +++ b/src/rez/tests/test_pip_utils.py @@ -9,8 +9,8 @@ import rez.vendor.packaging.version import rez.vendor.distlib.database -from rez.vendor.version.version import VersionRange -from rez.vendor.version.requirement import Requirement +from rez.version import VersionRange +from rez.version import Requirement from rez.vendor.packaging.requirements import Requirement as packaging_Requirement from rez.vendor.packaging.specifiers import SpecifierSet from rez.exceptions import PackageRequestError diff --git a/src/rez/tests/test_rex.py b/src/rez/tests/test_rex.py index 6ae2601a7..32a37d495 100644 --- a/src/rez/tests/test_rex.py +++ b/src/rez/tests/test_rex.py @@ -13,8 +13,8 @@ from rez.exceptions import RexError, RexUndefinedVariableError from rez.config import config import unittest -from rez.vendor.version.version import Version -from rez.vendor.version.requirement import Requirement +from rez.version import Version +from rez.version import Requirement from rez.tests.util import TestBase from rez.utils.backcompat import convert_old_commands from rez.package_repository import package_repository_manager diff --git a/src/rez/tests/test_solver.py b/src/rez/tests/test_solver.py index 82ee932d1..1244dede5 100644 --- a/src/rez/tests/test_solver.py +++ b/src/rez/tests/test_solver.py @@ -8,7 +8,7 @@ from __future__ import print_function import rez.exceptions -from rez.vendor.version.requirement import Requirement +from rez.version import Requirement from rez.solver import Solver, Cycle, SolverStatus from rez.config import config import unittest diff --git a/src/rez/tests/test_version.py b/src/rez/tests/test_version.py index ebffd3013..72c26b612 100644 --- a/src/rez/tests/test_version.py +++ b/src/rez/tests/test_version.py @@ -3,15 +3,507 @@ """ -unit tests for 'version' module +unit tests for 'rez.version' module """ +import random +import textwrap import unittest -from rez.vendor.version.test import TestVersionSchema +from rez.version import Version, AlphanumericVersionToken, \ + VersionRange, reverse_sort_key +from rez.version._version import _ReversedComparable +from rez.version import Requirement, RequirementList +from rez.version import VersionError -class TestVersions(TestVersionSchema): + +def _print(txt=''): + # uncomment for verbose output + #print txt pass +class TestVersionSchema(unittest.TestCase): + make_token = AlphanumericVersionToken + + def __init__(self, fn): + unittest.TestCase.__init__(self, fn) + + def _test_strict_weak_ordering(self, a, b): + self.assertTrue(a == a) + self.assertTrue(b == b) + + e = (a == b) + ne = (a != b) + lt = (a < b) + lte = (a <= b) + gt = (a > b) + gte = (a >= b) + + _print('\n' + textwrap.dedent( + """ + '%s' '%s' + ==: %s + !=: %s + <: %s + <=: %s + >: %s + >=: %s + """).strip() % (a, b, e, ne, lt, lte, gt, gte)) + + self.assertTrue(e != ne) + if e: + self.assertTrue(not lt) + self.assertTrue(not gt) + self.assertTrue(lte) + self.assertTrue(gte) + else: + self.assertTrue(lt != gt) + self.assertTrue(lte != gte) + self.assertTrue(lt == lte) + self.assertTrue(gt == gte) + + if not isinstance(a, _ReversedComparable): + self._test_strict_weak_ordering(reverse_sort_key(a), + reverse_sort_key(b)) + + def _test_ordered(self, items): + def _test(fn, items_, op_str): + for i, a in enumerate(items_): + for b in items_[i + 1:]: + _print("'%s' %s '%s'" % (a, op_str, b)) + self.assertTrue(fn(a, b)) + + _test(lambda a, b: a < b, items, '<') + _test(lambda a, b: a <= b, items, '<=') + _test(lambda a, b: a != b, items, '!=') + _test(lambda a, b: a > b, list(reversed(items)), '>') + _test(lambda a, b: a >= b, list(reversed(items)), '>=') + _test(lambda a, b: a != b, list(reversed(items)), '!=') + + def _create_random_token(self): + s = self.make_token.create_random_token_string() + return self.make_token(s) + + def _create_random_version(self): + ver_str = '.'.join(self.make_token.create_random_token_string() + for i in range(random.randint(0, 6))) + return Version(ver_str, make_token=self.make_token) + + def test_misc(self): + self.assertEqual(Version("1.2.12").as_tuple(), ("1", "2", "12")) + + def test_token_strict_weak_ordering(self): + # test equal tokens + tok = self._create_random_token() + self._test_strict_weak_ordering(tok, tok) + + # test random tokens + for i in range(100): + tok1 = self._create_random_token() + tok2 = self._create_random_token() + self._test_strict_weak_ordering(tok1, tok2) + + def test_version_strict_weak_ordering(self): + # test equal versions + ver = self._create_random_version() + self._test_strict_weak_ordering(ver, ver) + + # test random versions + for i in range(100): + ver1 = self._create_random_version() + ver2 = self._create_random_version() + self._test_strict_weak_ordering(ver1, ver2) + + def test_token_comparisons(self): + def _lt(a, b): + _print("'%s' < '%s'" % (a, b)) + self.assertTrue(self.make_token(a) < self.make_token(b)) + self.assertTrue(Version(a) < Version(b)) + + _print() + _lt("3", "4") + _lt("01", "1") + _lt("beta", "1") + _lt("alpha3", "alpha4") + _lt("alpha", "alpha3") + _lt("gamma33", "33gamma") + + def test_version_comparisons(self): + def _eq(a, b): + _print("'%s' == '%s'" % (a, b)) + self.assertTrue(Version(a) == Version(b)) + + _print() + _eq("", "") + _eq("1", "1") + _eq("1.2", "1-2") + _eq("1.2-3", "1-2.3") + + ascending = ["", + "0.0.0", + "1", + "2", + "2.alpha1", + "2.alpha2", + "2.beta", + "2.0", + "2.0.8.8", + "2.1", + "2.1.0"] + self._test_ordered([Version(x) for x in ascending]) + + def _eq2(a, b): + _print("'%s' == '%s'" % (a, b)) + self.assertTrue(a == b) + + # test behaviour in sets + a = Version("1.0") + b = Version("1.0") + c = Version("1.0alpha") + d = Version("2.0.0") + + _eq2(set([a]) - set([a]), set()) + _eq2(set([a]) - set([b]), set()) + _eq2(set([a, a]) - set([a]), set()) + _eq2(set([b, c, d]) - set([a]), set([c, d])) + _eq2(set([b, c]) | set([c, d]), set([b, c, d])) + _eq2(set([b, c]) & set([c, d]), set([c])) + + def test_version_range(self): + def _eq(a, b): + _print("'%s' == '%s'" % (a, b)) + a_range = VersionRange(a) + b_range = VersionRange(b) + + self.assertTrue(a_range == b_range) + self.assertTrue(a_range.issuperset(a_range)) + self.assertTrue(a_range.issuperset(b_range)) + self.assertTrue(VersionRange(str(a_range)) == a_range) + self.assertTrue(VersionRange(str(b_range)) == a_range) + self.assertTrue(hash(a_range) == hash(b_range)) + + a_ = a.replace('.', '-') + a_ = a_.replace("--", "..") + a_range_ = VersionRange(a_) + self.assertTrue(a_range_ == a_range) + self.assertTrue(hash(a_range_) == hash(a_range)) + + range_strs = a.split('|') + ranges = [VersionRange(x) for x in range_strs] + ranges_ = ranges[0].union(ranges[1:]) + self.assertTrue(ranges_ == a_range) + + self.assertTrue(a_range | b_range == a_range) + self.assertTrue(a_range - b_range is None) + self.assertTrue(b_range - a_range is None) + self.assertTrue(VersionRange() & a_range == a_range) + self.assertTrue(b_range.span() & a_range == a_range) + + a_inv = a_range.inverse() + self.assertTrue(a_inv == ~b_range) + + if a_inv: + self.assertTrue(~a_inv == a_range) + self.assertTrue(a_range | a_inv == VersionRange()) + self.assertTrue(a_range & a_inv is None) + + a_ranges = a_range.split() + a_range_ = a_ranges[0].union(a_ranges[1:]) + self.assertTrue(a_range_ == b_range) + + def _and(a, b, c): + _print("'%s' & '%s' == '%s'" % (a, b, c)) + a_range = VersionRange(a) + b_range = VersionRange(b) + c_range = None if c is None else VersionRange(c) + self.assertTrue(a_range & b_range == c_range) + self.assertTrue(b_range & a_range == c_range) + + a_or_b = a_range | b_range + a_and_b = a_range & b_range + a_sub_b = a_range - b_range + b_sub_a = b_range - a_range + ranges = [a_and_b, a_sub_b, b_sub_a] + ranges = [x for x in ranges if x] + self.assertTrue(ranges[0].union(ranges[1:]) == a_or_b) + + def _inv(a, b): + a_range = VersionRange(a) + b_range = VersionRange(b) + self.assertTrue(~a_range == b_range) + self.assertTrue(~b_range == a_range) + self.assertTrue(a_range | b_range == VersionRange()) + self.assertTrue(a_range & b_range is None) + + # simple cases + _print() + _eq("", "") + _eq("1", "1") + _eq("1.0.0", "1.0.0") + _eq("3+<3_", "3") + _eq("_+<__", "_") + _eq("1.2+<=2.0", "1.2..2.0") + _eq("10+,<20", "10+<20") + _eq("1+<1.0", "1+<1.0") + _eq(">=2", "2+") + _eq(">=1.21.1,<1.23", ">=1.21.1<1.23") + _eq(">1.21.1,<1.23", ">1.21.1<1.23") + _eq(">1.21.1<1.23", ">1.21.1<1.23") + _eq(">1.21.1,<=1.23", ">1.21.1<=1.23") + + # Reverse order which is a syntax pip packages use more often now. + # Only allowed when separated by a comma. + _eq("<1.23,>=1.21.1", ">=1.21.1<1.23") + _eq("<1.23,>1.21.1", ">1.21.1<1.23") + + # optimised cases + _eq("3|3", "3") + _eq("3|1", "1|3") + _eq("5|3|1", "1|3|5") + _eq("1|1_", "1+<1__") + _eq("1|1_|1__", "1+,<1___") + _eq("|", "") + _eq("||", "||||||||") + _eq("1|1_+", "1+") + _eq("<1|1", "<1_") + _eq("1+<3|3+<5", "1+<5") + _eq(">4<6|1+<3", "1+<3|>4,<6") + _eq("4+<6|1+<3|", "") + _eq("4|2+", "2+") + _eq("3|<5", "<5") + _eq("<3|>3", ">3|<3") + _eq("3+|<3", "") + _eq("3+|<4", "") + _eq("2+<=6|3+<5", "2..6") + _eq("3+,<5|2+<=6", "2+<=6") + _eq("2|2+", "2+") + _eq("2|2.1+", "2+") + _eq("2|<2.1", "<2_") + _eq("3..3", "==3") + _eq(">=3,<=3", "==3") + + # AND'ing + _and("3", "3", "3") + _and("1", "==1", "==1") + _and("", "==1", "==1") + _and("3", "4", None) + _and("<3", "5+", None) + _and("4+<6", "6+<8", None) + _and("2+", "<=4", "2..4") + _and("1", "1.0", "1.0") + _and("4..6", "6+<8", "==6") + + # inverse + _inv("3+", "<3") + _inv("<=3", ">3") + _inv("3.5", "<3.5|3.5_+") + self.assertTrue(~VersionRange() is None) + + # odd (but valid) cases + _eq(">", ">") # greater than the empty version + _eq("+", "") # greater or equal to empty version (is all vers) + _eq(">=", "") # equivalent to above + _eq("<=", "==") # less or equal to empty version (is only empty) + _eq("..", "==") # from empty version to empty version + _eq("+<=", "==") # equivalent to above + + invalid_range = [ + "4+<2", # lower bound greater than upper + ">3<3", # both greater and less than same version + ">3<=3", # greater and less or equal to same version + "3+<3" # greater and equal to, and less than, same version + ] + + for s in invalid_range: + self.assertRaises(VersionError, VersionRange, s) + + invalid_syntax = [ + "<", # less than the empty version + "><", # both greater and less than empty version + ">3>4", # both are lower bounds + "<3<4", # both are upper bounds + "<4>3", # upper bound before lower without comma + ",<4", # leading comma + "4+,", # trailing comma + "1>=", # pre-lower-op in post + "+1", # post-lower-op in pre + "4<", # pre-upper-op in post + "1+<2<3" # more than two bounds + ] + + for s in invalid_syntax: + self.assertRaises(VersionError, VersionRange, s) + + # test simple logic + self.assertTrue(VersionRange("").is_any()) + self.assertTrue(VersionRange("2+<4").bounded()) + self.assertTrue(VersionRange("2+").lower_bounded()) + self.assertTrue(not VersionRange("2+").upper_bounded()) + self.assertTrue(not VersionRange("2+").bounded()) + self.assertTrue(VersionRange("<2").upper_bounded()) + self.assertTrue(not VersionRange("<2").lower_bounded()) + self.assertTrue(not VersionRange("<2").bounded()) + + # test range from version(s) + v = Version("3") + self.assertTrue(VersionRange.from_version(v, "eq") == VersionRange("==3")) + self.assertTrue(VersionRange.from_version(v, "gt") == VersionRange(">3")) + self.assertTrue(VersionRange.from_version(v, "gte") == VersionRange("3+")) + self.assertTrue(VersionRange.from_version(v, "lt") == VersionRange("<3")) + self.assertTrue(VersionRange.from_version(v, "lte") == VersionRange("<=3")) + + range1 = VersionRange.from_version(Version("2"), "gte") + range2 = VersionRange.from_version(Version("4"), "lte") + _eq(str(range1 & range2), "2..4") + + v2 = Version("6.0") + v3 = Version("4") + self.assertTrue(VersionRange.from_versions([v, v2, v3]) + == VersionRange("==3|==4|==6.0")) + + # test behaviour in sets + def _eq2(a, b): + _print("'%s' == '%s'" % (a, b)) + self.assertTrue(a == b) + + a = VersionRange("1+<=2.5") + b = VersionRange("1..2.5") + c = VersionRange(">=5") + d = VersionRange(">6.1.0") + e = VersionRange("3.2") + + _eq2(set([a]) - set([a]), set()) + _eq2(set([a]) - set([b]), set()) + _eq2(set([a, a]) - set([a]), set()) + _eq2(set([b, c, d, e]) - set([a]), set([c, d, e])) + _eq2(set([b, c, e]) | set([c, d]), set([b, c, d, e])) + _eq2(set([b, c]) & set([c, d]), set([c])) + + def test_containment(self): + # basic containment + self.assertTrue(Version("3") in VersionRange("3+")) + self.assertTrue(Version("5") in VersionRange("3..5")) + self.assertTrue(Version("5_") not in VersionRange("3..5")) + self.assertTrue(Version("3.0.0") in VersionRange("3+")) + self.assertTrue(Version("3.0.0") not in VersionRange("3.1+")) + self.assertTrue(Version("3") in VersionRange("<1|5|6|8|7|3|60+")) + self.assertTrue(Version("3") in VersionRange("<1|5|6|8|7|==3|60+")) + self.assertTrue(VersionRange("2.1+<4") in VersionRange("<4")) + self.assertTrue(VersionRange("2.1..4") not in VersionRange("<4")) + self.assertTrue(VersionRange("3") in VersionRange("3")) + self.assertTrue(VersionRange("==3") in VersionRange("3")) + self.assertTrue(VersionRange("3.5+<3_") in VersionRange("3")) + self.assertTrue(VersionRange("3") not in VersionRange("4+<6")) + self.assertTrue(VersionRange("3+<10") not in VersionRange("4+<6")) + + # iterating over sorted version list + numbers = [2, 3, 5, 10, 11, 13, 14] + versions = [Version(str(x)) for x in numbers] + rev_versions = list(reversed(versions)) + composite_range = VersionRange.from_versions(versions) + + entries = [(VersionRange(""), 7), + (VersionRange("0+"), 7), + (VersionRange("5+"), 5), + (VersionRange("6+"), 4), + (VersionRange("50+"), 0), + (VersionRange(">5"), 4), + (VersionRange("5"), 1), + (VersionRange("6"), 0), + (VersionRange("<5"), 2), + (VersionRange("<6"), 3), + (VersionRange("<50"), 7), + (VersionRange("<=5"), 3), + (VersionRange("<1"), 0), + (VersionRange("2|9+"), 5), + (VersionRange("3+<6|12+<13.5"), 3), + (VersionRange("<1|20+"), 0), + (VersionRange(">0<20"), 7)] + + for range_, count in entries: + # brute-force containment tests + matches = set(x for x in versions if x in range_) + self.assertEqual(len(matches), count) + + # more optimal containment tests + def _test_it(it): + matches_ = set(version for contains, version in it if contains) + self.assertEqual(matches_, matches) + + _test_it(range_.iter_intersect_test(versions)) + _test_it(range_.iter_intersect_test(rev_versions, descending=True)) + + # throw in an intersection test + self.assertEqual(composite_range.intersects(range_), (count != 0)) + int_range = composite_range & range_ + versions_ = [] if int_range is None else int_range.to_versions() + self.assertEqual(set(versions_), matches) + + # throw in a superset test as well + self.assertEqual(range_.issuperset(composite_range), (count == 7)) + if count: + self.assertTrue(composite_range.issuperset(int_range)) + + def test_requirement_list(self): + def _eq(reqs, expected_reqs): + _print("requirements(%s) == requirements(%s)" + % (' '.join(reqs), ' '.join(expected_reqs))) + reqs_ = [Requirement(x) for x in reqs] + reqlist = RequirementList(reqs_) + _print("result: %s" % str(reqlist)) + + exp_reqs_ = [Requirement(x) for x in expected_reqs] + self.assertTrue(reqlist.requirements == exp_reqs_) + + exp_names = set(x.name for x in exp_reqs_ if not x.conflict) + self.assertTrue(reqlist.names == exp_names) + + exp_confl_names = set(x.name for x in exp_reqs_ if x.conflict) + self.assertTrue(reqlist.conflict_names == exp_confl_names) + + def _confl(reqs, a, b): + _print("requirements(%s) == %s <--!--> %s" % (' '.join(reqs), a, b)) + reqs_ = [Requirement(x) for x in reqs] + reqlist = RequirementList(reqs_) + _print("result: %s" % str(reqlist)) + + a_req = Requirement(a) + b_req = Requirement(b) + self.assertTrue(reqlist.conflict == (a_req, b_req)) + + _print() + _eq(["foo"], + ["foo"]) + _eq(["foo", "bah"], + ["foo", "bah"]) + _eq(["bah", "foo"], + ["bah", "foo"]) + _eq(["foo-4+", "foo-4.5"], + ["foo-4.5"]) + _eq(["bah-2.4", "foo", "bah-2.4.1+"], + ["bah-2.4.1+<2.4_", "foo"]) + _eq(["foo-2+", "!foo-4+"], + ["foo-2+<4"]) + _eq(["!bah-1", "!bah-3"], + ["!bah-1|3"]) + _eq(["!bah-5", "foo-2.3", "!bah-5.6+"], + ["!bah-5+", "foo-2.3"]) + _eq(["~bah-4", "foo", "bah<4.2"], + ["bah-4+<4.2", "foo"]) + _eq(["~bah", "!foo", "bah<4.2"], + ["bah<4.2", "!foo"]) + _eq(["~bah-3+", "~bah-5"], + ["~bah-5"]) + + _confl(["foo-1", "foo-2"], + "foo-1", "foo-2") + _confl(["foo-2", "foo-1"], + "foo-2", "foo-1") + _confl(["foo", "~bah-5+", "bah-2"], + "~bah-5+", "bah-2") + _confl(["foo", "~bah-5+", "bah-7..12", "bah-2"], + "bah-7..12", "bah-2") + + if __name__ == '__main__': unittest.main() diff --git a/src/rez/utils/formatting.py b/src/rez/utils/formatting.py index 3b7db24d2..e39708e4f 100644 --- a/src/rez/utils/formatting.py +++ b/src/rez/utils/formatting.py @@ -9,7 +9,7 @@ from string import Formatter from rez.vendor.enum import Enum -from rez.vendor.version.requirement import Requirement +from rez.version import Requirement from rez.exceptions import PackageRequestError from rez.vendor.six import six from pprint import pformat diff --git a/src/rez/utils/patching.py b/src/rez/utils/patching.py index 6986b1c23..1252867bb 100644 --- a/src/rez/utils/patching.py +++ b/src/rez/utils/patching.py @@ -2,7 +2,7 @@ # Copyright Contributors to the Rez Project -from rez.vendor.version.requirement import Requirement +from rez.version import Requirement def get_patched_request(requires, patchlist): diff --git a/src/rez/utils/pip.py b/src/rez/utils/pip.py index 2df33c137..a4c70e08c 100644 --- a/src/rez/utils/pip.py +++ b/src/rez/utils/pip.py @@ -16,8 +16,8 @@ InvalidVersion as packaging_InvalidVersion ) from rez.vendor.packaging.requirements import Requirement as packaging_Requirement -from rez.vendor.version.requirement import Requirement -from rez.vendor.version.version import Version, VersionRange +from rez.version import Requirement +from rez.version import Version, VersionRange from rez.utils.logging_ import print_warning from rez.exceptions import PackageRequestError diff --git a/src/rez/utils/yaml.py b/src/rez/utils/yaml.py index f1e95c6f8..5cef870f9 100644 --- a/src/rez/utils/yaml.py +++ b/src/rez/utils/yaml.py @@ -5,8 +5,8 @@ from rez.utils.sourcecode import SourceCode from rez.vendor import yaml from rez.vendor.yaml.dumper import SafeDumper -from rez.vendor.version.version import Version -from rez.vendor.version.requirement import Requirement +from rez.version import Version +from rez.version import Requirement from types import FunctionType, BuiltinFunctionType from inspect import getsourcelines from textwrap import dedent diff --git a/src/rez/vendor/version/__init__.py b/src/rez/vendor/version/__init__.py index ac31011e1..ec87df48e 100644 --- a/src/rez/vendor/version/__init__.py +++ b/src/rez/vendor/version/__init__.py @@ -1,2 +1,11 @@ # SPDX-License-Identifier: Apache-2.0 # Copyright Contributors to the Rez Project + + +import rez.deprecations + +rez.deprecations.warn( + "module 'rez.vendor.version' is deprecated and will be removed in 3.0.0. Use 'rez.version' instead.", + rez.deprecations.RezDeprecationWarning, + stacklevel=2 +) diff --git a/src/rez/vendor/version/requirement.py b/src/rez/vendor/version/requirement.py index 7c4cc6a9e..04f53ae5f 100644 --- a/src/rez/vendor/version/requirement.py +++ b/src/rez/vendor/version/requirement.py @@ -2,416 +2,4 @@ # Copyright Contributors to the Rez Project -from rez.vendor.version.version import Version, VersionRange -from rez.vendor.version.util import _Common -import re - - -class VersionedObject(_Common): - """Definition of a versioned object, eg "foo-1.0". - - "foo" is also a valid object definiton - when there is no version part, we - are defining an unversioned object. - - Note that '-', '@' or '#' can be used as the seperator between object name - and version, however this is purely cosmetic - "foo-1" is the same as "foo@1". - """ - sep_regex_str = r'[-@#]' - sep_regex = re.compile(sep_regex_str) - - def __init__(self, s): - self.name_ = None - self.version_ = None - self.sep_ = '-' - if s is None: - return - - m = self.sep_regex.search(s) - if m: - i = m.start() - self.name_ = s[:i] - self.sep_ = s[i] - ver_str = s[i + 1:] - self.version_ = Version(ver_str) - else: - self.name_ = s - self.version_ = Version() - - @classmethod - def construct(cls, name, version=None): - """Create a VersionedObject directly from an object name and version. - - Args: - name: Object name string. - version: Version object. - """ - other = VersionedObject(None) - other.name_ = name - other.version_ = Version() if version is None else version - return other - - @property - def name(self): - """Name of the object.""" - return self.name_ - - @property - def version(self): - """Version of the object.""" - return self.version_ - - def as_exact_requirement(self): - """Get the versioned object, as an exact requirement string. - - Returns: - Equivalent requirement string, eg "maya==2016.1" - """ - sep_str = '' - ver_str = '' - if self.version_: - sep_str = "==" - ver_str = str(self.version_) - return self.name_ + sep_str + ver_str - - def __eq__(self, other): - return (isinstance(other, VersionedObject) - and (self.name_ == other.name_) - and (self.version_ == other.version_)) - - def __hash__(self): - return hash((self.name_, self.version_)) - - def __str__(self): - sep_str = '' - ver_str = '' - if self.version_: - sep_str = self.sep_ - ver_str = str(self.version_) - return self.name_ + sep_str + ver_str - - -class Requirement(_Common): - """Requirement for a versioned object. - - Examples of valid requirement strings: - - foo-1.0 - foo@1.0 - foo#1.0 - foo-1+ - foo-1+<4.3 - foo<3 - foo==1.0.1 - - Defines a requirement for an object. For example, "foo-5+" means that you - require any version of "foo", version 5 or greater. An unversioned - requirement can also be used ("foo"), this means you require any version of - foo. You can drop the hyphen between object name and version range if the - version range starts with a non-alphanumeric character - eg "foo<2". - - There are two different prefixes that can be applied to a requirement: - - - "!": The conflict requirement. This means that you require this version - range of an object NOT to be present. To conflict with all versions of an - object, use "!foo". - - - "~": This is known as a "weak reference", and means, "I do not require this - object, but if present, it must be within this range." It is equivalent to - the *conflict of the inverse* of the given version range. - - There is one subtle case to be aware of. "~foo" is a requirement that has no - effect - ie, it means "I do not require foo, but if foo is present, it can - be any version." This statement is still valid, but will produce a - Requirement object with a None range. - """ - sep_regex = re.compile(r'[-@#=<>]') - - def __init__(self, s, invalid_bound_error=True): - self.name_ = None - self.range_ = None - self.negate_ = False - self.conflict_ = False - self._str = None - self.sep_ = '-' - if s is None: - return - - self.conflict_ = s.startswith('!') - if self.conflict_: - s = s[1:] - elif s.startswith('~'): - s = s[1:] - self.negate_ = True - self.conflict_ = True - - m = self.sep_regex.search(s) - if m: - i = m.start() - self.name_ = s[:i] - req_str = s[i:] - if req_str[0] in ('-', '@', '#'): - self.sep_ = req_str[0] - req_str = req_str[1:] - - self.range_ = VersionRange( - req_str, invalid_bound_error=invalid_bound_error) - if self.negate_: - self.range_ = ~self.range_ - elif self.negate_: - self.name_ = s - # rare case - '~foo' equates to no effect - self.range_ = None - else: - self.name_ = s - self.range_ = VersionRange() - - @classmethod - def construct(cls, name, range=None): - """Create a requirement directly from an object name and VersionRange. - - Args: - name: Object name string. - range: VersionRange object. If None, an unversioned requirement is - created. - """ - other = Requirement(None) - other.name_ = name - other.range_ = VersionRange() if range is None else range - return other - - @property - def name(self): - """Name of the required object.""" - return self.name_ - - @property - def range(self): - """VersionRange of the requirement.""" - return self.range_ - - @property - def conflict(self): - """True if the requirement is a conflict requirement, eg "!foo", "~foo-1". - """ - return self.conflict_ - - @property - def weak(self): - """True if the requirement is weak, eg "~foo". - - Note that weak requirements are also conflict requirements, but not - necessarily the other way around. - """ - return self.negate_ - - def safe_str(self): - """Return a string representation that is safe for the current filesystem, - and guarantees that no two different Requirement objects will encode to - the same value.""" - return str(self) - - def conflicts_with(self, other): - """Returns True if this requirement conflicts with another `Requirement` - or `VersionedObject`.""" - if isinstance(other, Requirement): - if (self.name_ != other.name_) or (self.range is None) \ - or (other.range is None): - return False - elif self.conflict: - return False if other.conflict \ - else self.range_.issuperset(other.range_) - elif other.conflict: - return other.range_.issuperset(self.range_) - else: - return not self.range_.intersects(other.range_) - else: # VersionedObject - if (self.name_ != other.name_) or (self.range is None): - return False - if self.conflict: - return (other.version_ in self.range_) - else: - return (other.version_ not in self.range_) - - def merged(self, other): - """Returns the merged result of two requirements. - - Two requirements can be in conflict and if so, this function returns - None. For example, requests for "foo-4" and "foo-6" are in conflict, - since both cannot be satisfied with a single version of foo. - - Some example successful requirements merges are: - - "foo-3+" and "!foo-5+" == "foo-3+<5" - - "foo-1" and "foo-1.5" == "foo-1.5" - - "!foo-2" and "!foo-5" == "!foo-2|5" - """ - if self.name_ != other.name_: - return None # cannot merge across object names - - def _r(r_): - r = Requirement(None) - r.name_ = r_.name_ - r.negate_ = r_.negate_ - r.conflict_ = r_.conflict_ - r.sep_ = r_.sep_ - return r - - if self.range is None: - return other - elif other.range is None: - return self - elif self.conflict: - if other.conflict: - r = _r(self) - r.range_ = self.range_ | other.range_ - r.negate_ = (self.negate_ and other.negate_ - and not r.range_.is_any()) - return r - else: - range_ = other.range - self.range - if range_ is None: - return None - else: - r = _r(other) - r.range_ = range_ - return r - elif other.conflict: - range_ = self.range_ - other.range_ - if range_ is None: - return None - else: - r = _r(self) - r.range_ = range_ - return r - else: - range_ = self.range_ & other.range_ - if range_ is None: - return None - else: - r = _r(self) - r.range_ = range_ - return r - - def __eq__(self, other): - return (isinstance(other, Requirement) - and (self.name_ == other.name_) - and (self.range_ == other.range_) - and (self.conflict_ == other.conflict_)) - - def __hash__(self): - return hash(str(self)) - - def __str__(self): - if self._str is None: - pre_str = '~' if self.negate_ else ('!' if self.conflict_ else '') - range_str = '' - sep_str = '' - - range_ = self.range_ - if self.negate_: - range_ = ~range_ if range_ else VersionRange() - - if not range_.is_any(): - range_str = str(range_) - if range_str[0] not in ('=', '<', '>'): - sep_str = self.sep_ - - self._str = pre_str + self.name_ + sep_str + range_str - return self._str - - -class RequirementList(_Common): - """A list of requirements. - - This class takes a Requirement list and reduces it to the equivalent - optimal form, merging any requirements for common objects. Order of objects - is retained. - """ - def __init__(self, requirements): - """Create a RequirementList. - - Args: - requirements: List of Requirement objects. - """ - self.requirements_ = [] - self.conflict_ = None - self.requirements_dict = {} - self.names_ = set() - self.conflict_names_ = set() - - for req in requirements: - existing_req = self.requirements_dict.get(req.name) - - if existing_req is None: - self.requirements_dict[req.name] = req - else: - merged_req = existing_req.merged(req) - if merged_req is None: - self.conflict_ = (existing_req, req) - return - else: - self.requirements_dict[req.name] = merged_req - - seen = set() - - # build optimised list, this intends to match original request order - # as closely as possible - for req in requirements: - if req.name not in seen: - seen.add(req.name) - req_ = self.requirements_dict[req.name] - self.requirements_.append(req_) - - if req_.conflict: - self.conflict_names_.add(req.name) - else: - self.names_.add(req.name) - - @property - def requirements(self): - """Returns optimised list of requirements, or None if there are - conflicts. - """ - return self.requirements_ - - @property - def conflict(self): - """Get the requirement conflict, if any. - - Returns: - None if there is no conflict, otherwise a 2-tuple containing the - conflicting Requirement objects. - """ - return self.conflict_ - - @property - def names(self): - """Set of names of requirements, not including conflict requirements. - """ - return self.names_ - - @property - def conflict_names(self): - """Set of conflict requirement names.""" - return self.conflict_names_ - - def __iter__(self): - for requirement in (self.requirements_ or []): - yield requirement - - def get(self, name): - """Returns the Requirement for the given object, or None. - """ - return self.requirements_dict.get(name) - - def __eq__(self, other): - return (isinstance(other, RequirementList) - and (self.requirements_ == other.requirements_) - and (self.conflict_ == other.conflict_)) - - def __str__(self): - if self.conflict_: - s1 = str(self.conflict_[0]) - s2 = str(self.conflict_[1]) - return "%s <--!--> %s" % (s1, s2) - else: - return ' '.join(str(x) for x in self.requirements_) +from rez.version._requirement import * diff --git a/src/rez/vendor/version/test.py b/src/rez/vendor/version/test.py deleted file mode 100644 index 8f8919fa5..000000000 --- a/src/rez/vendor/version/test.py +++ /dev/null @@ -1,505 +0,0 @@ -# SPDX-License-Identifier: Apache-2.0 -# Copyright Contributors to the Rez Project - - -from rez.vendor.version.version import Version, AlphanumericVersionToken, \ - VersionRange, reverse_sort_key, _ReversedComparable -from rez.vendor.version.requirement import Requirement, RequirementList -from rez.vendor.version.util import VersionError -import random -import textwrap -import unittest - - - -def _print(txt=''): - # uncomment for verbose output - #print txt - pass - - -class TestVersionSchema(unittest.TestCase): - make_token = AlphanumericVersionToken - - def __init__(self, fn): - unittest.TestCase.__init__(self, fn) - - def _test_strict_weak_ordering(self, a, b): - self.assertTrue(a == a) - self.assertTrue(b == b) - - e = (a == b) - ne = (a != b) - lt = (a < b) - lte = (a <= b) - gt = (a > b) - gte = (a >= b) - - _print('\n' + textwrap.dedent( - """ - '%s' '%s' - ==: %s - !=: %s - <: %s - <=: %s - >: %s - >=: %s - """).strip() % (a, b, e, ne, lt, lte, gt, gte)) - - self.assertTrue(e != ne) - if e: - self.assertTrue(not lt) - self.assertTrue(not gt) - self.assertTrue(lte) - self.assertTrue(gte) - else: - self.assertTrue(lt != gt) - self.assertTrue(lte != gte) - self.assertTrue(lt == lte) - self.assertTrue(gt == gte) - - if not isinstance(a, _ReversedComparable): - self._test_strict_weak_ordering(reverse_sort_key(a), - reverse_sort_key(b)) - - def _test_ordered(self, items): - def _test(fn, items_, op_str): - for i, a in enumerate(items_): - for b in items_[i+1:]: - _print("'%s' %s '%s'" % (a, op_str, b)) - self.assertTrue(fn(a, b)) - - _test(lambda a, b: a < b, items, '<') - _test(lambda a, b: a <= b, items, '<=') - _test(lambda a, b: a != b, items, '!=') - _test(lambda a, b: a > b, list(reversed(items)), '>') - _test(lambda a, b: a >= b, list(reversed(items)), '>=') - _test(lambda a, b: a != b, list(reversed(items)), '!=') - - def _create_random_token(self): - s = self.make_token.create_random_token_string() - return self.make_token(s) - - def _create_random_version(self): - ver_str = '.'.join(self.make_token.create_random_token_string() - for i in range(random.randint(0, 6))) - return Version(ver_str, make_token=self.make_token) - - def test_misc(self): - self.assertEqual(Version("1.2.12").as_tuple(), ("1", "2", "12")) - - def test_token_strict_weak_ordering(self): - # test equal tokens - tok = self._create_random_token() - self._test_strict_weak_ordering(tok, tok) - - # test random tokens - for i in range(100): - tok1 = self._create_random_token() - tok2 = self._create_random_token() - self._test_strict_weak_ordering(tok1, tok2) - - def test_version_strict_weak_ordering(self): - # test equal versions - ver = self._create_random_version() - self._test_strict_weak_ordering(ver, ver) - - # test random versions - for i in range(100): - ver1 = self._create_random_version() - ver2 = self._create_random_version() - self._test_strict_weak_ordering(ver1, ver2) - - def test_token_comparisons(self): - def _lt(a, b): - _print("'%s' < '%s'" % (a, b)) - self.assertTrue(self.make_token(a) < self.make_token(b)) - self.assertTrue(Version(a) < Version(b)) - - _print() - _lt("3", "4") - _lt("01", "1") - _lt("beta", "1") - _lt("alpha3", "alpha4") - _lt("alpha", "alpha3") - _lt("gamma33", "33gamma") - - def test_version_comparisons(self): - def _eq(a, b): - _print("'%s' == '%s'" % (a, b)) - self.assertTrue(Version(a) == Version(b)) - - _print() - _eq("", "") - _eq("1", "1") - _eq("1.2", "1-2") - _eq("1.2-3", "1-2.3") - - ascending = ["", - "0.0.0", - "1", - "2", - "2.alpha1", - "2.alpha2", - "2.beta", - "2.0", - "2.0.8.8", - "2.1", - "2.1.0"] - self._test_ordered([Version(x) for x in ascending]) - - def _eq2(a, b): - _print("'%s' == '%s'" % (a, b)) - self.assertTrue(a == b) - - # test behaviour in sets - a = Version("1.0") - b = Version("1.0") - c = Version("1.0alpha") - d = Version("2.0.0") - - _eq2(set([a]) - set([a]), set()) - _eq2(set([a]) - set([b]), set()) - _eq2(set([a, a]) - set([a]), set()) - _eq2(set([b, c, d]) - set([a]), set([c, d])) - _eq2(set([b, c]) | set([c, d]), set([b, c, d])) - _eq2(set([b, c]) & set([c, d]), set([c])) - - def test_version_range(self): - def _eq(a, b): - _print("'%s' == '%s'" % (a, b)) - a_range = VersionRange(a) - b_range = VersionRange(b) - - self.assertTrue(a_range == b_range) - self.assertTrue(a_range.issuperset(a_range)) - self.assertTrue(a_range.issuperset(b_range)) - self.assertTrue(VersionRange(str(a_range)) == a_range) - self.assertTrue(VersionRange(str(b_range)) == a_range) - self.assertTrue(hash(a_range) == hash(b_range)) - - a_ = a.replace('.', '-') - a_ = a_.replace("--", "..") - a_range_ = VersionRange(a_) - self.assertTrue(a_range_ == a_range) - self.assertTrue(hash(a_range_) == hash(a_range)) - - range_strs = a.split('|') - ranges = [VersionRange(x) for x in range_strs] - ranges_ = ranges[0].union(ranges[1:]) - self.assertTrue(ranges_ == a_range) - - self.assertTrue(a_range | b_range == a_range) - self.assertTrue(a_range - b_range is None) - self.assertTrue(b_range - a_range is None) - self.assertTrue(VersionRange() & a_range == a_range) - self.assertTrue(b_range.span() & a_range == a_range) - - a_inv = a_range.inverse() - self.assertTrue(a_inv == ~b_range) - - if a_inv: - self.assertTrue(~a_inv == a_range) - self.assertTrue(a_range | a_inv == VersionRange()) - self.assertTrue(a_range & a_inv is None) - - a_ranges = a_range.split() - a_range_ = a_ranges[0].union(a_ranges[1:]) - self.assertTrue(a_range_ == b_range) - - def _and(a, b, c): - _print("'%s' & '%s' == '%s'" % (a, b, c)) - a_range = VersionRange(a) - b_range = VersionRange(b) - c_range = None if c is None else VersionRange(c) - self.assertTrue(a_range & b_range == c_range) - self.assertTrue(b_range & a_range == c_range) - - a_or_b = a_range | b_range - a_and_b = a_range & b_range - a_sub_b = a_range - b_range - b_sub_a = b_range - a_range - ranges = [a_and_b, a_sub_b, b_sub_a] - ranges = [x for x in ranges if x] - self.assertTrue(ranges[0].union(ranges[1:]) == a_or_b) - - def _inv(a, b): - a_range = VersionRange(a) - b_range = VersionRange(b) - self.assertTrue(~a_range == b_range) - self.assertTrue(~b_range == a_range) - self.assertTrue(a_range | b_range == VersionRange()) - self.assertTrue(a_range & b_range is None) - - # simple cases - _print() - _eq("", "") - _eq("1", "1") - _eq("1.0.0", "1.0.0") - _eq("3+<3_", "3") - _eq("_+<__", "_") - _eq("1.2+<=2.0", "1.2..2.0") - _eq("10+,<20", "10+<20") - _eq("1+<1.0", "1+<1.0") - _eq(">=2", "2+") - _eq(">=1.21.1,<1.23", ">=1.21.1<1.23") - _eq(">1.21.1,<1.23", ">1.21.1<1.23") - _eq(">1.21.1<1.23", ">1.21.1<1.23") - _eq(">1.21.1,<=1.23", ">1.21.1<=1.23") - - # Reverse order which is a syntax pip packages use more often now. - # Only allowed when separated by a comma. - _eq("<1.23,>=1.21.1", ">=1.21.1<1.23") - _eq("<1.23,>1.21.1", ">1.21.1<1.23") - - # optimised cases - _eq("3|3", "3") - _eq("3|1", "1|3") - _eq("5|3|1", "1|3|5") - _eq("1|1_", "1+<1__") - _eq("1|1_|1__", "1+,<1___") - _eq("|", "") - _eq("||", "||||||||") - _eq("1|1_+", "1+") - _eq("<1|1", "<1_") - _eq("1+<3|3+<5", "1+<5") - _eq(">4<6|1+<3", "1+<3|>4,<6") - _eq("4+<6|1+<3|", "") - _eq("4|2+", "2+") - _eq("3|<5", "<5") - _eq("<3|>3", ">3|<3") - _eq("3+|<3", "") - _eq("3+|<4", "") - _eq("2+<=6|3+<5", "2..6") - _eq("3+,<5|2+<=6", "2+<=6") - _eq("2|2+", "2+") - _eq("2|2.1+", "2+") - _eq("2|<2.1", "<2_") - _eq("3..3", "==3") - _eq(">=3,<=3", "==3") - - # AND'ing - _and("3", "3", "3") - _and("1", "==1", "==1") - _and("", "==1", "==1") - _and("3", "4", None) - _and("<3", "5+", None) - _and("4+<6", "6+<8", None) - _and("2+", "<=4", "2..4") - _and("1", "1.0", "1.0") - _and("4..6", "6+<8", "==6") - - # inverse - _inv("3+", "<3") - _inv("<=3", ">3") - _inv("3.5", "<3.5|3.5_+") - self.assertTrue(~VersionRange() is None) - - # odd (but valid) cases - _eq(">", ">") # greater than the empty version - _eq("+", "") # greater or equal to empty version (is all vers) - _eq(">=", "") # equivalent to above - _eq("<=", "==") # less or equal to empty version (is only empty) - _eq("..", "==") # from empty version to empty version - _eq("+<=", "==") # equivalent to above - - invalid_range = [ - "4+<2", # lower bound greater than upper - ">3<3", # both greater and less than same version - ">3<=3", # greater and less or equal to same version - "3+<3" # greater and equal to, and less than, same version - ] - - for s in invalid_range: - self.assertRaises(VersionError, VersionRange, s) - - invalid_syntax = [ - "<", # less than the empty version - "><", # both greater and less than empty version - ">3>4", # both are lower bounds - "<3<4", # both are upper bounds - "<4>3", # upper bound before lower without comma - ",<4", # leading comma - "4+,", # trailing comma - "1>=", # pre-lower-op in post - "+1", # post-lower-op in pre - "4<", # pre-upper-op in post - "1+<2<3" # more than two bounds - ] - - for s in invalid_syntax: - self.assertRaises(VersionError, VersionRange, s) - - # test simple logic - self.assertTrue(VersionRange("").is_any()) - self.assertTrue(VersionRange("2+<4").bounded()) - self.assertTrue(VersionRange("2+").lower_bounded()) - self.assertTrue(not VersionRange("2+").upper_bounded()) - self.assertTrue(not VersionRange("2+").bounded()) - self.assertTrue(VersionRange("<2").upper_bounded()) - self.assertTrue(not VersionRange("<2").lower_bounded()) - self.assertTrue(not VersionRange("<2").bounded()) - - # test range from version(s) - v = Version("3") - self.assertTrue(VersionRange.from_version(v, "eq") == VersionRange("==3")) - self.assertTrue(VersionRange.from_version(v, "gt") == VersionRange(">3")) - self.assertTrue(VersionRange.from_version(v, "gte") == VersionRange("3+")) - self.assertTrue(VersionRange.from_version(v, "lt") == VersionRange("<3")) - self.assertTrue(VersionRange.from_version(v, "lte") == VersionRange("<=3")) - - range1 = VersionRange.from_version(Version("2"), "gte") - range2 = VersionRange.from_version(Version("4"), "lte") - _eq(str(range1 & range2), "2..4") - - v2 = Version("6.0") - v3 = Version("4") - self.assertTrue(VersionRange.from_versions([v, v2, v3]) - == VersionRange("==3|==4|==6.0")) - - # test behaviour in sets - def _eq2(a, b): - _print("'%s' == '%s'" % (a, b)) - self.assertTrue(a == b) - - a = VersionRange("1+<=2.5") - b = VersionRange("1..2.5") - c = VersionRange(">=5") - d = VersionRange(">6.1.0") - e = VersionRange("3.2") - - _eq2(set([a]) - set([a]), set()) - _eq2(set([a]) - set([b]), set()) - _eq2(set([a, a]) - set([a]), set()) - _eq2(set([b, c, d, e]) - set([a]), set([c, d, e])) - _eq2(set([b, c, e]) | set([c, d]), set([b, c, d, e])) - _eq2(set([b, c]) & set([c, d]), set([c])) - - def test_containment(self): - # basic containment - self.assertTrue(Version("3") in VersionRange("3+")) - self.assertTrue(Version("5") in VersionRange("3..5")) - self.assertTrue(Version("5_") not in VersionRange("3..5")) - self.assertTrue(Version("3.0.0") in VersionRange("3+")) - self.assertTrue(Version("3.0.0") not in VersionRange("3.1+")) - self.assertTrue(Version("3") in VersionRange("<1|5|6|8|7|3|60+")) - self.assertTrue(Version("3") in VersionRange("<1|5|6|8|7|==3|60+")) - self.assertTrue(VersionRange("2.1+<4") in VersionRange("<4")) - self.assertTrue(VersionRange("2.1..4") not in VersionRange("<4")) - self.assertTrue(VersionRange("3") in VersionRange("3")) - self.assertTrue(VersionRange("==3") in VersionRange("3")) - self.assertTrue(VersionRange("3.5+<3_") in VersionRange("3")) - self.assertTrue(VersionRange("3") not in VersionRange("4+<6")) - self.assertTrue(VersionRange("3+<10") not in VersionRange("4+<6")) - - # iterating over sorted version list - numbers = [2, 3, 5, 10, 11, 13, 14] - versions = [Version(str(x)) for x in numbers] - rev_versions = list(reversed(versions)) - composite_range = VersionRange.from_versions(versions) - - entries = [(VersionRange(""), 7), - (VersionRange("0+"), 7), - (VersionRange("5+"), 5), - (VersionRange("6+"), 4), - (VersionRange("50+"), 0), - (VersionRange(">5"), 4), - (VersionRange("5"), 1), - (VersionRange("6"), 0), - (VersionRange("<5"), 2), - (VersionRange("<6"), 3), - (VersionRange("<50"), 7), - (VersionRange("<=5"), 3), - (VersionRange("<1"), 0), - (VersionRange("2|9+"), 5), - (VersionRange("3+<6|12+<13.5"), 3), - (VersionRange("<1|20+"), 0), - (VersionRange(">0<20"), 7)] - - for range_, count in entries: - # brute-force containment tests - matches = set(x for x in versions if x in range_) - self.assertEqual(len(matches), count) - - # more optimal containment tests - def _test_it(it): - matches_ = set(version for contains, version in it if contains) - self.assertEqual(matches_, matches) - - _test_it(range_.iter_intersect_test(versions)) - _test_it(range_.iter_intersect_test(rev_versions, descending=True)) - - # throw in an intersection test - self.assertEqual(composite_range.intersects(range_), (count != 0)) - int_range = composite_range & range_ - versions_ = [] if int_range is None else int_range.to_versions() - self.assertEqual(set(versions_), matches) - - # throw in a superset test as well - self.assertEqual(range_.issuperset(composite_range), (count == 7)) - if count: - self.assertTrue(composite_range.issuperset(int_range)) - - def test_requirement_list(self): - def _eq(reqs, expected_reqs): - _print("requirements(%s) == requirements(%s)" - % (' '.join(reqs), ' '.join(expected_reqs))) - reqs_ = [Requirement(x) for x in reqs] - reqlist = RequirementList(reqs_) - _print("result: %s" % str(reqlist)) - - exp_reqs_ = [Requirement(x) for x in expected_reqs] - self.assertTrue(reqlist.requirements == exp_reqs_) - - exp_names = set(x.name for x in exp_reqs_ if not x.conflict) - self.assertTrue(reqlist.names == exp_names) - - exp_confl_names = set(x.name for x in exp_reqs_ if x.conflict) - self.assertTrue(reqlist.conflict_names == exp_confl_names) - - def _confl(reqs, a, b): - _print("requirements(%s) == %s <--!--> %s" % (' '.join(reqs), a, b)) - reqs_ = [Requirement(x) for x in reqs] - reqlist = RequirementList(reqs_) - _print("result: %s" % str(reqlist)) - - a_req = Requirement(a) - b_req = Requirement(b) - self.assertTrue(reqlist.conflict == (a_req, b_req)) - - _print() - _eq(["foo"], - ["foo"]) - _eq(["foo", "bah"], - ["foo", "bah"]) - _eq(["bah", "foo"], - ["bah", "foo"]) - _eq(["foo-4+", "foo-4.5"], - ["foo-4.5"]) - _eq(["bah-2.4", "foo", "bah-2.4.1+"], - ["bah-2.4.1+<2.4_", "foo"]) - _eq(["foo-2+", "!foo-4+"], - ["foo-2+<4"]) - _eq(["!bah-1", "!bah-3"], - ["!bah-1|3"]) - _eq(["!bah-5", "foo-2.3", "!bah-5.6+"], - ["!bah-5+", "foo-2.3"]) - _eq(["~bah-4", "foo", "bah<4.2"], - ["bah-4+<4.2", "foo"]) - _eq(["~bah", "!foo", "bah<4.2"], - ["bah<4.2", "!foo"]) - _eq(["~bah-3+", "~bah-5"], - ["~bah-5"]) - - _confl(["foo-1", "foo-2"], - "foo-1", "foo-2") - _confl(["foo-2", "foo-1"], - "foo-2", "foo-1") - _confl(["foo", "~bah-5+", "bah-2"], - "~bah-5+", "bah-2") - _confl(["foo", "~bah-5+", "bah-7..12", "bah-2"], - "bah-7..12", "bah-2") - - -if __name__ == '__main__': - unittest.main() diff --git a/src/rez/vendor/version/util.py b/src/rez/vendor/version/util.py index 47da793b6..49dddd77c 100644 --- a/src/rez/vendor/version/util.py +++ b/src/rez/vendor/version/util.py @@ -2,29 +2,4 @@ # Copyright Contributors to the Rez Project -from itertools import groupby - - -class VersionError(Exception): - pass - - -class ParseException(Exception): - pass - - -class _Common(object): - def __str__(self): - raise NotImplementedError - - def __ne__(self, other): - return not (self == other) - - def __repr__(self): - return "%s(%r)" % (self.__class__.__name__, str(self)) - - -def dedup(iterable): - """Removes duplicates from a sorted sequence.""" - for e in groupby(iterable): - yield e[0] +from rez.version._util import * diff --git a/src/rez/vendor/version/version.py b/src/rez/vendor/version/version.py index fb7a85c09..f3b7c54d7 100644 --- a/src/rez/vendor/version/version.py +++ b/src/rez/vendor/version/version.py @@ -2,1491 +2,4 @@ # Copyright Contributors to the Rez Project -""" -Implements a well defined versioning schema. - -There are three class types - VersionToken, Version and VersionRange. A Version -is a set of zero or more VersionTokens, separate by '.'s or '-'s (eg "1.2-3"). -A VersionToken is a string containing alphanumerics, and default implemenations -'NumericToken' and 'AlphanumericVersionToken' are supplied. You can implement -your own if you want stricter tokens or different sorting behaviour. - -A VersionRange is a set of one or more contiguous version ranges - for example, -"3+<5" contains any version >=3 but less than 5. Version ranges can be used to -define dependency requirements between objects. They can be OR'd together, AND'd -and inverted. - -The empty version '', and empty version range '', are also handled. The empty -version is used to denote unversioned objects. The empty version range, also -known as the 'any' range, is used to refer to any version of an object. -""" -from __future__ import print_function -from .util import VersionError, ParseException, _Common, \ - dedup -import rez.vendor.pyparsing.pyparsing as pp -from bisect import bisect_left -import copy -import string -import re - - -re_token = re.compile(r"[a-zA-Z0-9_]+") - - -class _Comparable(_Common): - def __gt__(self, other): - return not (self < other or self == other) - - def __le__(self, other): - return self < other or self == other - - def __ge__(self, other): - return not self < other - - -class _ReversedComparable(_Common): - def __init__(self, value): - self.value = value - - def __lt__(self, other): - return not (self.value < other.value) - - def __gt__(self, other): - return not (self < other or self == other) - - def __le__(self, other): - return self < other or self == other - - def __ge__(self, other): - return not self < other - - def __str__(self): - return "reverse(%s)" % str(self.value) - - def __repr__(self): - return "reverse(%r)" % self.value - - -class VersionToken(_Comparable): - """Token within a version number. - - A version token is that part of a version number that appears between a - delimiter, typically '.' or '-'. For example, the version number '2.3.07b' - contains the tokens '2', '3' and '07b' respectively. - - Version tokens are only allowed to contain alphanumerics (any case) and - underscores. - """ - def __init__(self, token): - """Create a VersionToken. - - Args: - token: Token string, eg "rc02" - """ - raise NotImplementedError - - @classmethod - def create_random_token_string(cls): - """Create a random token string. For testing purposes only.""" - raise NotImplementedError - - def less_than(self, other): - """Compare to another VersionToken. - - Args: - other: The VersionToken object to compare against. - - Returns: - True if this token is less than other, False otherwise. - """ - raise NotImplementedError - - def next(self): - """Returns the next largest token.""" - raise NotImplementedError - - def __str__(self): - raise NotImplementedError - - def __lt__(self, other): - return self.less_than(other) - - def __eq__(self, other): - return (not self < other) and (not other < self) - - -class NumericToken(VersionToken): - """Numeric version token. - - Version token supporting numbers only. Padding is ignored. - """ - def __init__(self, token): - if not token.isdigit(): - raise VersionError("Invalid version token: '%s'" % token) - else: - self.n = int(token) - - @classmethod - def create_random_token_string(cls): - import random - chars = string.digits - return ''.join([chars[random.randint(0, len(chars) - 1)] - for _ in range(8)]) - - def __str__(self): - return str(self.n) - - def __eq__(self, other): - return (self.n == other.n) - - def less_than(self, other): - return (self.n < other.n) - - def __next__(self): - other = copy.copy(self) - other.n = self.n = 1 - return other - - def next(self): - return self.__next__() - - -class _SubToken(_Comparable): - """Used internally by AlphanumericVersionToken.""" - def __init__(self, s): - self.s = s - self.n = int(s) if s.isdigit() else None - - def __lt__(self, other): - if self.n is None: - return (self.s < other.s) if other.n is None else True - else: - return False if other.n is None \ - else ((self.n, self.s) < (other.n, other.s)) - - def __eq__(self, other): - return (self.s == other.s) and (self.n == other.n) - - def __str__(self): - return self.s - - -class AlphanumericVersionToken(VersionToken): - """Alphanumeric version token. - - These tokens compare as follows: - - each token is split into alpha and numeric groups (subtokens); - - the resulting subtoken list is compared. - - alpha comparison is case-sensitive, numeric comparison is padding-sensitive. - - Subtokens compare as follows: - - alphas come before numbers; - - alphas are compared alphabetically (_, then A-Z, then a-z); - - numbers are compared numerically. If numbers are equivalent but zero- - padded differently, they are then compared alphabetically. Thus "01" < "1". - - Some example comparisons that equate to true: - - "3" < "4" - - "01" < "1" - - "beta" < "1" - - "alpha3" < "alpha4" - - "alpha" < "alpha3" - - "gamma33" < "33gamma" - """ - numeric_regex = re.compile("[0-9]+") - regex = re.compile(r"[a-zA-Z0-9_]+\Z") - - def __init__(self, token): - if token is None: - self.subtokens = None - elif not self.regex.match(token): - raise VersionError("Invalid version token: '%s'" % token) - else: - self.subtokens = self._parse(token) - - @classmethod - def create_random_token_string(cls): - import random - chars = string.digits + string.ascii_letters - return ''.join([chars[random.randint(0, len(chars) - 1)] - for _ in range(8)]) - - def __str__(self): - return ''.join(map(str, self.subtokens)) - - def __eq__(self, other): - return (self.subtokens == other.subtokens) - - def less_than(self, other): - return (self.subtokens < other.subtokens) - - def __next__(self): - other = AlphanumericVersionToken(None) - other.subtokens = self.subtokens[:] - subtok = other.subtokens[-1] - if subtok.n is None: - other.subtokens[-1] = _SubToken(subtok.s + '_') - else: - other.subtokens.append(_SubToken('_')) - return other - - def next(self): - return self.__next__() - - @classmethod - def _parse(cls, s): - subtokens = [] - alphas = cls.numeric_regex.split(s) - numerics = cls.numeric_regex.findall(s) - b = True - - while alphas or numerics: - if b: - alpha = alphas[0] - alphas = alphas[1:] - if alpha: - subtokens.append(_SubToken(alpha)) - else: - numeric = numerics[0] - numerics = numerics[1:] - subtokens.append(_SubToken(numeric)) - b = not b - - return subtokens - - -def reverse_sort_key(comparable): - """Key that gives reverse sort order on versions and version ranges. - - Example: - - >>> Version("1.0") < Version("2.0") - True - >>> reverse_sort_key(Version("1.0")) < reverse_sort_key(Version("2.0")) - False - - Args: - comparable (`Version` or `VesionRange`): Object to wrap. - - Returns: - `_ReversedComparable`: Wrapper object that reverses comparisons. - """ - return _ReversedComparable(comparable) - - -class Version(_Comparable): - """Version object. - - A Version is a sequence of zero or more version tokens, separated by either - a dot '.' or hyphen '-' delimiters. Note that separators only affect Version - objects cosmetically - in other words, the version '1.0.0' is equivalent to - '1-0-0'. - - The empty version '' is the smallest possible version, and can be used to - represent an unversioned resource. - """ - inf = None - - def __init__(self, ver_str='', make_token=AlphanumericVersionToken): - """Create a Version object. - - Args: - ver_str: Version string. - make_token: Callable that creates a VersionToken subclass from a - string. - """ - self.tokens = [] - self.seps = [] - self._str = None - self._hash = None - - if ver_str: - toks = re_token.findall(ver_str) - if not toks: - raise VersionError(ver_str) - - seps = re_token.split(ver_str) - if seps[0] or seps[-1] or max(len(x) for x in seps) > 1: - raise VersionError("Invalid version syntax: '%s'" % ver_str) - - for tok in toks: - try: - self.tokens.append(make_token(tok)) - except VersionError as e: - raise VersionError("Invalid version '%s': %s" - % (ver_str, str(e))) - - self.seps = seps[1:-1] - - def copy(self): - """Returns a copy of the version.""" - other = Version(None) - other.tokens = self.tokens[:] - other.seps = self.seps[:] - return other - - def trim(self, len_): - """Return a copy of the version, possibly with less tokens. - - Args: - len_ (int): New version length. If >= current length, an - unchanged copy of the version is returned. - """ - other = Version(None) - other.tokens = self.tokens[:len_] - other.seps = self.seps[:len_ - 1] - return other - - def __next__(self): - """Return 'next' version. Eg, next(1.2) is 1.2_""" - if self.tokens: - other = self.copy() - tok = other.tokens.pop() - other.tokens.append(tok.next()) - return other - else: - return Version.inf - - def next(self): - return self.__next__() - - @property - def major(self): - """Semantic versioning major version.""" - return self[0] - - @property - def minor(self): - """Semantic versioning minor version.""" - return self[1] - - @property - def patch(self): - """Semantic versioning patch version.""" - return self[2] - - def as_tuple(self): - """Convert to a tuple of strings. - - Example: - - >>> print Version("1.2.12").as_tuple() - ('1', '2', '12') - """ - return tuple(map(str, self.tokens)) - - def __len__(self): - return len(self.tokens or []) - - def __getitem__(self, index): - try: - return (self.tokens or [])[index] - except IndexError: - raise IndexError("version token index out of range") - - def __nonzero__(self): - """The empty version equates to False.""" - return bool(self.tokens) - - __bool__ = __nonzero__ # py3 compat - - def __eq__(self, other): - return isinstance(other, Version) and self.tokens == other.tokens - - def __lt__(self, other): - if self.tokens is None: - return False - elif other.tokens is None: - return True - else: - return (self.tokens < other.tokens) - - def __hash__(self): - if self._hash is None: - self._hash = hash(None) if self.tokens is None \ - else hash(tuple(map(str, self.tokens))) - return self._hash - - def __str__(self): - if self._str is None: - self._str = "[INF]" if self.tokens is None \ - else ''.join(str(x) + y for x, y in zip(self.tokens, self.seps + [''])) - return self._str - - -# internal use only -Version.inf = Version() -Version.inf.tokens = None - - -class _LowerBound(_Comparable): - min = None - - def __init__(self, version, inclusive): - self.version = version - self.inclusive = inclusive - - def __str__(self): - if self.version: - s = "%s+" if self.inclusive else ">%s" - return s % self.version - else: - return '' if self.inclusive else ">" - - def __eq__(self, other): - return (self.version == other.version) \ - and (self.inclusive == other.inclusive) - - def __lt__(self, other): - return (self.version < other.version) \ - or ((self.version == other.version) - and (self.inclusive and not other.inclusive)) - - def __hash__(self): - return hash((self.version, self.inclusive)) - - def contains_version(self, version): - return (version > self.version) \ - or (self.inclusive and (version == self.version)) - -_LowerBound.min = _LowerBound(Version(), True) - - -class _UpperBound(_Comparable): - inf = None - - def __init__(self, version, inclusive): - self.version = version - self.inclusive = inclusive - if not version and not inclusive: - raise VersionError("Invalid upper bound: '%s'" % str(self)) - - def __str__(self): - s = "<=%s" if self.inclusive else "<%s" - return s % self.version - - def __eq__(self, other): - return (self.version == other.version) \ - and (self.inclusive == other.inclusive) - - def __lt__(self, other): - return (self.version < other.version) \ - or ((self.version == other.version) - and (not self.inclusive and other.inclusive)) - - def __hash__(self): - return hash((self.version, self.inclusive)) - - def contains_version(self, version): - return (version < self.version) \ - or (self.inclusive and (version == self.version)) - -_UpperBound.inf = _UpperBound(Version.inf, True) - - -class _Bound(_Comparable): - any = None - - def __init__(self, lower=None, upper=None, invalid_bound_error=True): - self.lower = lower or _LowerBound.min - self.upper = upper or _UpperBound.inf - - if (invalid_bound_error and - (self.lower.version > self.upper.version - or ((self.lower.version == self.upper.version) - and not (self.lower.inclusive and self.upper.inclusive)))): - raise VersionError("Invalid bound") - - def __str__(self): - if self.upper.version == Version.inf: - return str(self.lower) - elif self.lower.version == self.upper.version: - return "==%s" % str(self.lower.version) - elif self.lower.inclusive and self.upper.inclusive: - if self.lower.version: - return "%s..%s" % (self.lower.version, self.upper.version) - else: - return "<=%s" % self.upper.version - elif (self.lower.inclusive and not self.upper.inclusive) \ - and (self.lower.version.next() == self.upper.version): - return str(self.lower.version) - else: - return "%s%s" % (self.lower, self.upper) - - def __eq__(self, other): - return (self.lower == other.lower) and (self.upper == other.upper) - - def __lt__(self, other): - return (self.lower, self.upper) < (other.lower, other.upper) - - def __hash__(self): - return hash((self.lower, self.upper)) - - def lower_bounded(self): - return (self.lower != _LowerBound.min) - - def upper_bounded(self): - return (self.upper != _UpperBound.inf) - - def contains_version(self, version): - return (self.version_containment(version) == 0) - - def version_containment(self, version): - if not self.lower.contains_version(version): - return -1 - if not self.upper.contains_version(version): - return 1 - return 0 - - def contains_bound(self, bound): - return (self.lower <= bound.lower) and (self.upper >= bound.upper) - - def intersects(self, other): - lower = max(self.lower, other.lower) - upper = min(self.upper, other.upper) - - return (lower.version < upper.version) or \ - ((lower.version == upper.version) and - (lower.inclusive and upper.inclusive)) - - def intersection(self, other): - lower = max(self.lower, other.lower) - upper = min(self.upper, other.upper) - - if (lower.version < upper.version) or \ - ((lower.version == upper.version) and - (lower.inclusive and upper.inclusive)): - return _Bound(lower, upper) - else: - return None - -_Bound.any = _Bound() - - -class _VersionRangeParser(object): - debug = False # set to True to enable parser debugging - - re_flags = (re.VERBOSE | re.DEBUG) if debug else re.VERBOSE - - # The regular expression for a version - one or more version tokens - # followed by a non-capturing group of version separator followed by - # one or more version tokens. - # - # Note that this assumes AlphanumericVersionToken-based versions! - # - # TODO - Would be better to have `VersionRange` keep a static dict of - # parser instances, per token class type. We would add a 'regex' static - # string to each token class, and that could be used to construct - # `version_group` as below. We need to keep a dict of these parser instances, - # to avoid recompiling the large regex every time a version range is - # instantiated. In the cpp port this would be simpler - VersionRange could - # just have a static parser that is instantiated when the version range - # template class is instantiated. - # - version_group = r"([0-9a-zA-Z_]+(?:[.-][0-9a-zA-Z_]+)*)" - - version_range_regex = ( - # Match a version number (e.g. 1.0.0) - r" ^(?P{version_group})$" - "|" - # Or match an exact version number (e.g. ==1.0.0) - " ^(?P" - " ==" # Required == operator - " (?P{version_group})?" - " )$" - "|" - # Or match an inclusive bound (e.g. 1.0.0..2.0.0) - " ^(?P" - " (?P{version_group})?" - " \.\." # Required .. operator - " (?P{version_group})?" - " )$" - "|" - # Or match a lower bound (e.g. 1.0.0+) - " ^(?P" - " (?P>|>=)?" # Bound is exclusive? - " (?P{version_group})?" - " (?(lower_bound_prefix)|\+)" # + only if bound is not exclusive - " )$" - "|" - # Or match an upper bound (e.g. <=1.0.0) - " ^(?P" - " (?P<(?={version_group})|<=)?" # Bound is exclusive? - " (?P{version_group})?" - " )$" - "|" - # Or match a range in ascending order (e.g. 1.0.0+<2.0.0) - " ^(?P" - " (?P" - " (?P>|>=)?" # Lower bound is exclusive? - " (?P{version_group})?" - " (?(range_lower_asc_prefix)|\+)?" # + only if lower bound is not exclusive - " )(?P" - " (?(range_lower_asc_version),?|)" # , only if lower bound is found - " (?P<(?={version_group})|<=)" # <= only if followed by a version group - " (?P{version_group})?" - " )" - " )$" - "|" - # Or match a range in descending order (e.g. <=2.0.0,1.0.0+) - " ^(?P" - " (?P" - " (?P<|<=)?" # Upper bound is exclusive? - " (?P{version_group})?" - " (?(range_upper_desc_prefix)|\+)?" # + only if upper bound is not exclusive - " )(?P" - " (?(range_upper_desc_version),|)" # Comma is not optional because we don't want to recognize something like "<4>3" - " (?P<(?={version_group})|>=?)" # >= or > only if followed by a version group - " (?P{version_group})?" - " )" - " )$" - ).format(version_group=version_group) - - regex = re.compile(version_range_regex, re_flags) - - def __init__(self, input_string, make_token, invalid_bound_error=True): - self.make_token = make_token - self._groups = {} - self._input_string = input_string - self.bounds = [] - self.invalid_bound_error = invalid_bound_error - - is_any = False - - for part in input_string.split("|"): - if part == '': - # OR'ing anthing with the 'any' version range ('') will also - # give the any range. Note that we can't early out here, as we - # need to validate that the rest of the string is syntactically - # correct - # - is_any = True - self.bounds = [] - continue - - match = re.search(self.regex, part) - if not match: - raise ParseException("Syntax error in version range '%s'" % part) - - if is_any: - # we're already the 'any' range regardless, so avoid more work - continue - - self._groups = match.groupdict() - - # Note: the following is ordered by approx likelihood of use - - if self._groups['range_asc']: - self._act_lower_and_upper_bound_asc() - - elif self._groups['version']: - self._act_version() - - elif self._groups['lower_bound']: - self._act_lower_bound() - - elif self._groups['exact_version']: - self._act_exact_version() - - elif self._groups['range_desc']: - self._act_lower_and_upper_bound_desc() - - elif self._groups['inclusive_bound']: - self._act_bound() - - elif self._groups['upper_bound']: - self._act_upper_bound() - - def _is_lower_bound_exclusive(self, token): - return (token == ">") - - def _is_upper_bound_exclusive(self, token): - return (token == "<") - - def _create_version_from_token(self, token): - return Version(token, make_token=self.make_token) - - def action(fn): - def fn_(self): - result = fn(self) - if self.debug: - label = fn.__name__.replace("_act_", "") - print("%-21s: %s" % (label, self._input_string)) - for key, value in self._groups.items(): - print(" %-17s= %s" % (key, value)) - print(" %-17s= %s" % ("bounds", self.bounds)) - return result - return fn_ - - @action - def _act_version(self): - version = self._create_version_from_token(self._groups['version']) - lower_bound = _LowerBound(version, True) - upper_bound = _UpperBound(version.next(), False) if version else None - - self.bounds.append(_Bound(lower_bound, upper_bound)) - - @action - def _act_exact_version(self): - version = self._create_version_from_token(self._groups['exact_version_group']) - lower_bound = _LowerBound(version, True) - upper_bound = _UpperBound(version, True) - - self.bounds.append(_Bound(lower_bound, upper_bound)) - - @action - def _act_bound(self): - lower_version = self._create_version_from_token(self._groups['inclusive_lower_version']) - lower_bound = _LowerBound(lower_version, True) - - upper_version = self._create_version_from_token(self._groups['inclusive_upper_version']) - upper_bound = _UpperBound(upper_version, True) - - self.bounds.append(_Bound(lower_bound, upper_bound, self.invalid_bound_error)) - - @action - def _act_lower_bound(self): - version = self._create_version_from_token(self._groups['lower_version']) - exclusive = self._is_lower_bound_exclusive(self._groups['lower_bound_prefix']) - lower_bound = _LowerBound(version, not exclusive) - - self.bounds.append(_Bound(lower_bound, None)) - - @action - def _act_upper_bound(self): - version = self._create_version_from_token(self._groups['upper_version']) - exclusive = self._is_upper_bound_exclusive(self._groups['upper_bound_prefix']) - upper_bound = _UpperBound(version, not exclusive) - - self.bounds.append(_Bound(None, upper_bound)) - - @action - def _act_lower_and_upper_bound_asc(self): - lower_bound = None - upper_bound = None - - if self._groups['range_lower_asc']: - version = self._create_version_from_token(self._groups['range_lower_asc_version']) - exclusive = self._is_lower_bound_exclusive(self._groups['range_lower_asc_prefix']) - lower_bound = _LowerBound(version, not exclusive) - - if self._groups['range_upper_asc']: - version = self._create_version_from_token(self._groups['range_upper_asc_version']) - exclusive = self._is_upper_bound_exclusive(self._groups['range_upper_asc_prefix']) - upper_bound = _UpperBound(version, not exclusive) - - self.bounds.append(_Bound(lower_bound, upper_bound, self.invalid_bound_error)) - - @action - def _act_lower_and_upper_bound_desc(self): - lower_bound = None - upper_bound = None - - if self._groups['range_lower_desc']: - version = self._create_version_from_token(self._groups['range_lower_desc_version']) - exclusive = self._is_lower_bound_exclusive(self._groups['range_lower_desc_prefix']) - lower_bound = _LowerBound(version, not exclusive) - - if self._groups['range_upper_desc']: - version = self._create_version_from_token(self._groups['range_upper_desc_version']) - exclusive = self._is_upper_bound_exclusive(self._groups['range_upper_desc_prefix']) - upper_bound = _UpperBound(version, not exclusive) - - self.bounds.append(_Bound(lower_bound, upper_bound, self.invalid_bound_error)) - - -class VersionRange(_Comparable): - """Version range. - - A version range is a set of one or more contiguous ranges of versions. For - example, "3.0 or greater, but less than 4" is a contiguous range that contains - versions such as "3.0", "3.1.0", "3.99" etc. Version ranges behave something - like sets - they can be intersected, added and subtracted, but can also be - inverted. You can test to see if a Version is contained within a VersionRange. - - A VersionRange "3" (for example) is the superset of any version "3[.X.X...]". - The version "3" itself is also within this range, and is smaller than "3.0" - - any version with common leading tokens, but with a larger token count, is - the larger version of the two. - - VersionRange objects have a flexible syntax that let you describe any - combination of contiguous ranges, including inclusive and exclusive upper - and lower bounds. This is best explained by example (those listed on the - same line are equivalent): - - "3": 'superset' syntax, contains "3", "3.0", "3.1.4" etc; - "2+", ">=2": inclusive lower bound syntax, contains "2", "2.1", "5.0.0" etc; - ">2": exclusive lower bound; - "<5": exclusive upper bound; - "<=5": inclusive upper bound; - "==2": a range that contains only the exact single version "2". - - "1+<5", ">=1<5": inclusive lower, exclusive upper. The most common form of - a 'bounded' version range (ie, one with a lower and upper bound); - ">1<5": exclusive lower, exclusive upper; - ">1<=5": exclusive lower, inclusive upper; - "1+<=5", "1..5": inclusive lower, inclusive upper; - - "<=4,>2", "<4,>2", "<4,>=2": Reverse pip syntax (note comma) - - To help with readability, bounded ranges can also have their bounds separated - with a comma, eg ">=2,<=6". The comma is purely cosmetic and is dropped in - the string representation. - - To describe more than one contiguous range, seperate ranges with the or '|' - symbol. For example, the version range "4|6+" contains versions such as "4", - "4.0", "4.3.1", "6", "6.1", "10.0.0", but does not contain any version - "5[.X.X...X]". If you provide multiple ranges that overlap, they will be - automatically optimised - for example, the version range "3+<6|4+<8" - becomes "3+<8". - - Note that the empty string version range represents the superset of all - possible versions - this is called the "any" range. The empty version can - also be used as an upper or lower bound, leading to some odd but perfectly - valid version range syntax. For example, ">" is a valid range - read like - ">''", it means "any version greater than the empty version". - """ - def __init__(self, range_str='', make_token=AlphanumericVersionToken, - invalid_bound_error=True): - """Create a VersionRange object. - - Args: - range_str: Range string, such as "3", "3+<4.5", "2|6+". The range - will be optimised, so the string representation of this instance - may not match range_str. For example, "3+<6|4+<8" == "3+<8". - make_token: Version token class to use. - invalid_bound_error (bool): If True, raise an exception if an - impossible range is given, such as '3+<2'. - """ - self._str = None - self.bounds = [] # note: kept in ascending order - if range_str is None: - return - - try: - parser = _VersionRangeParser(range_str, make_token, - invalid_bound_error=invalid_bound_error) - bounds = parser.bounds - except ParseException as e: - raise VersionError("Syntax error in version range '%s': %s" - % (range_str, str(e))) - except VersionError as e: - raise VersionError("Invalid version range '%s': %s" - % (range_str, str(e))) - - if bounds: - self.bounds = self._union(bounds) - else: - self.bounds.append(_Bound.any) - - def is_any(self): - """Returns True if this is the "any" range, ie the empty string range - that contains all versions.""" - return (len(self.bounds) == 1) and (self.bounds[0] == _Bound.any) - - def lower_bounded(self): - """Returns True if the range has a lower bound (that is not the empty - version).""" - return self.bounds[0].lower_bounded() - - def upper_bounded(self): - """Returns True if the range has an upper bound.""" - return self.bounds[-1].upper_bounded() - - def bounded(self): - """Returns True if the range has a lower and upper bound.""" - return (self.lower_bounded() and self.upper_bounded()) - - def issuperset(self, range): - """Returns True if the VersionRange is contained within this range. - """ - return self._issuperset(self.bounds, range.bounds) - - def issubset(self, range): - """Returns True if we are contained within the version range. - """ - return range.issuperset(self) - - def union(self, other): - """OR together version ranges. - - Calculates the union of this range with one or more other ranges. - - Args: - other: VersionRange object (or list of) to OR with. - - Returns: - New VersionRange object representing the union. - """ - if not hasattr(other, "__iter__"): - other = [other] - bounds = self.bounds[:] - for range in other: - bounds += range.bounds - - bounds = self._union(bounds) - range = VersionRange(None) - range.bounds = bounds - return range - - def intersection(self, other): - """AND together version ranges. - - Calculates the intersection of this range with one or more other ranges. - - Args: - other: VersionRange object (or list of) to AND with. - - Returns: - New VersionRange object representing the intersection, or None if - no ranges intersect. - """ - if not hasattr(other, "__iter__"): - other = [other] - - bounds = self.bounds - for range in other: - bounds = self._intersection(bounds, range.bounds) - if not bounds: - return None - - range = VersionRange(None) - range.bounds = bounds - return range - - def inverse(self): - """Calculate the inverse of the range. - - Returns: - New VersionRange object representing the inverse of this range, or - None if there is no inverse (ie, this range is the any range). - """ - if self.is_any(): - return None - else: - bounds = self._inverse(self.bounds) - range = VersionRange(None) - range.bounds = bounds - return range - - def intersects(self, other): - """Determine if we intersect with another range. - - Args: - other: VersionRange object. - - Returns: - True if the ranges intersect, False otherwise. - """ - return self._intersects(self.bounds, other.bounds) - - def split(self): - """Split into separate contiguous ranges. - - Returns: - A list of VersionRange objects. For example, the range "3|5+" will - be split into ["3", "5+"]. - """ - ranges = [] - for bound in self.bounds: - range = VersionRange(None) - range.bounds = [bound] - ranges.append(range) - return ranges - - @classmethod - def as_span(cls, lower_version=None, upper_version=None, - lower_inclusive=True, upper_inclusive=True): - """Create a range from lower_version..upper_version. - - Args: - lower_version: Version object representing lower bound of the range. - upper_version: Version object representing upper bound of the range. - - Returns: - `VersionRange` object. - """ - lower = (None if lower_version is None - else _LowerBound(lower_version, lower_inclusive)) - upper = (None if upper_version is None - else _UpperBound(upper_version, upper_inclusive)) - bound = _Bound(lower, upper) - - range = cls(None) - range.bounds = [bound] - return range - - @classmethod - def from_version(cls, version, op=None): - """Create a range from a version. - - Args: - version: Version object. This is used as the upper/lower bound of - the range. - op: Operation as a string. One of 'gt'/'>', 'gte'/'>=', lt'/'<', - 'lte'/'<=', 'eq'/'=='. If None, a bounded range will be created - that contains the version superset. - - Returns: - `VersionRange` object. - """ - lower = None - upper = None - - if op is None: - lower = _LowerBound(version, True) - upper = _UpperBound(version.next(), False) - elif op in ("eq", "=="): - lower = _LowerBound(version, True) - upper = _UpperBound(version, True) - elif op in ("gt", ">"): - lower = _LowerBound(version, False) - elif op in ("gte", ">="): - lower = _LowerBound(version, True) - elif op in ("lt", "<"): - upper = _UpperBound(version, False) - elif op in ("lte", "<="): - upper = _UpperBound(version, True) - else: - raise VersionError("Unknown bound operation '%s'" % op) - - bound = _Bound(lower, upper) - range = cls(None) - range.bounds = [bound] - return range - - @classmethod - def from_versions(cls, versions): - """Create a range from a list of versions. - - This method creates a range that contains only the given versions and - no other. Typically the range looks like (for eg) "==3|==4|==5.1". - - Args: - versions: List of Version objects. - - Returns: - `VersionRange` object. - """ - range = cls(None) - range.bounds = [] - for version in dedup(sorted(versions)): - lower = _LowerBound(version, True) - upper = _UpperBound(version, True) - bound = _Bound(lower, upper) - range.bounds.append(bound) - return range - - def to_versions(self): - """Returns exact version ranges as Version objects, or None if there - are no exact version ranges present. - """ - versions = [] - for bound in self.bounds: - if bound.lower.inclusive and bound.upper.inclusive \ - and (bound.lower.version == bound.upper.version): - versions.append(bound.lower.version) - - return versions or None - - def contains_version(self, version): - """Returns True if version is contained in this range.""" - if len(self.bounds) < 5: - # not worth overhead of binary search - for bound in self.bounds: - i = bound.version_containment(version) - if i == 0: - return True - if i == -1: - return False - else: - _, contains = self._contains_version(version) - return contains - - return False - - def iter_intersect_test(self, iterable, key=None, descending=False): - """Performs containment tests on a sorted list of versions. - - This is more optimal than performing separate containment tests on a - list of sorted versions. - - Args: - iterable: An ordered sequence of versioned objects. If the list - is not sorted by version, behaviour is undefined. - key (callable): Function that returns a `Version` given an object - from `iterable`. If None, the identity function is used. - descending (bool): Set to True if `iterable` is in descending - version order. - - Returns: - An iterator that returns (bool, object) tuples, where 'object' is - the original object in `iterable`, and the bool indicates whether - that version is contained in this range. - """ - return _ContainsVersionIterator(self, iterable, key, descending) - - def iter_intersecting(self, iterable, key=None, descending=False): - """Like `iter_intersect_test`, but returns intersections only. - - Returns: - An iterator that returns items from `iterable` that intersect. - """ - return _ContainsVersionIterator(self, iterable, key, descending, - mode=_ContainsVersionIterator.MODE_INTERSECTING) - - def iter_non_intersecting(self, iterable, key=None, descending=False): - """Like `iter_intersect_test`, but returns non-intersections only. - - Returns: - An iterator that returns items from `iterable` that don't intersect. - """ - return _ContainsVersionIterator(self, iterable, key, descending, - mode=_ContainsVersionIterator.MODE_NON_INTERSECTING) - - def span(self): - """Return a contiguous range that is a superset of this range. - - Returns: - A VersionRange object representing the span of this range. For - example, the span of "2+<4|6+<8" would be "2+<8". - """ - other = VersionRange(None) - bound = _Bound(self.bounds[0].lower, self.bounds[-1].upper) - other.bounds = [bound] - return other - - # TODO have this return a new VersionRange instead - this currently breaks - # VersionRange immutability, and could invalidate __str__. - def visit_versions(self, func): - """Visit each version in the range, and apply a function to each. - - This is for advanced usage only. - - If `func` returns a `Version`, this call will change the versions in - place. - - It is possible to change versions in a way that is nonsensical - for - example setting an upper bound to a smaller version than the lower bound. - Use at your own risk. - - Args: - func (callable): Takes a `Version` instance arg, and is applied to - every version in the range. If `func` returns a `Version`, it - will replace the existing version, updating this `VersionRange` - instance in place. - """ - for bound in self.bounds: - if bound.lower is not _LowerBound.min: - result = func(bound.lower.version) - if isinstance(result, Version): - bound.lower.version = result - - if bound.upper is not _UpperBound.inf: - result = func(bound.upper.version) - if isinstance(result, Version): - bound.upper.version = result - - def __contains__(self, version_or_range): - if isinstance(version_or_range, Version): - return self.contains_version(version_or_range) - else: - return self.issuperset(version_or_range) - - def __len__(self): - return len(self.bounds) - - def __invert__(self): - return self.inverse() - - def __and__(self, other): - return self.intersection(other) - - def __or__(self, other): - return self.union(other) - - def __add__(self, other): - return self.union(other) - - def __sub__(self, other): - inv = other.inverse() - return None if inv is None else self.intersection(inv) - - def __str__(self): - if self._str is None: - self._str = '|'.join(map(str, self.bounds)) - return self._str - - def __eq__(self, other): - return isinstance(other, VersionRange) and self.bounds == other.bounds - - def __lt__(self, other): - return (self.bounds < other.bounds) - - def __hash__(self): - return hash(tuple(self.bounds)) - - def _contains_version(self, version): - vbound = _Bound(_LowerBound(version, True)) - i = bisect_left(self.bounds, vbound) - if i and self.bounds[i - 1].contains_version(version): - return i - 1, True - if (i < len(self.bounds)) and self.bounds[i].contains_version(version): - return i, True - return i, False - - @classmethod - def _union(cls, bounds): - if len(bounds) < 2: - return bounds - - bounds_ = list(sorted(bounds)) - new_bounds = [] - prev_bound = None - upper = None - start = 0 - - for i, bound in enumerate(bounds_): - if i and ((bound.lower.version > upper.version) - or ((bound.lower.version == upper.version) - and (not bound.lower.inclusive) - and (not prev_bound.upper.inclusive))): - new_bound = _Bound(bounds_[start].lower, upper) - new_bounds.append(new_bound) - start = i - - prev_bound = bound - upper = bound.upper if upper is None else max(upper, bound.upper) - - new_bound = _Bound(bounds_[start].lower, upper) - new_bounds.append(new_bound) - return new_bounds - - @classmethod - def _intersection(cls, bounds1, bounds2): - new_bounds = [] - for bound1 in bounds1: - for bound2 in bounds2: - b = bound1.intersection(bound2) - if b: - new_bounds.append(b) - return new_bounds - - @classmethod - def _inverse(cls, bounds): - lbounds = [None] - ubounds = [] - - for bound in bounds: - if not bound.lower.version and bound.lower.inclusive: - ubounds.append(None) - else: - b = _UpperBound(bound.lower.version, not bound.lower.inclusive) - ubounds.append(b) - - if bound.upper.version == Version.inf: - lbounds.append(None) - else: - b = _LowerBound(bound.upper.version, not bound.upper.inclusive) - lbounds.append(b) - - ubounds.append(None) - new_bounds = [] - - for lower, upper in zip(lbounds, ubounds): - if not (lower is None and upper is None): - new_bounds.append(_Bound(lower, upper)) - - return new_bounds - - @classmethod - def _issuperset(cls, bounds1, bounds2): - lo = 0 - for bound2 in bounds2: - i = bisect_left(bounds1, bound2, lo=lo) - if i and bounds1[i - 1].contains_bound(bound2): - lo = i - 1 - continue - if (i < len(bounds1)) and bounds1[i].contains_bound(bound2): - lo = i - continue - return False - - return True - - @classmethod - def _intersects(cls, bounds1, bounds2): - # sort so bounds1 is the shorter list - bounds1, bounds2 = sorted((bounds1, bounds2), key=lambda x: len(x)) - - if len(bounds2) < 5: - # not worth overhead of binary search - for bound1 in bounds1: - for bound2 in bounds2: - if bound1.intersects(bound2): - return True - return False - - lo = 0 - for bound1 in bounds1: - i = bisect_left(bounds2, bound1, lo=lo) - if i and bounds2[i - 1].intersects(bound1): - return True - if (i < len(bounds2)) and bounds2[i].intersects(bound1): - return True - lo = max(i - 1, 0) - - return False - - -class _ContainsVersionIterator(object): - MODE_INTERSECTING = 0 - MODE_NON_INTERSECTING = 2 - MODE_ALL = 3 - - def __init__(self, range_, iterable, key=None, descending=False, mode=MODE_ALL): - self.mode = mode - self.range_ = range_ - self.index = None - self.nbounds = len(self.range_.bounds) - self._constant = True if range_.is_any() else None - self.fn = self._descending if descending else self._ascending - self.it = iter(iterable) - if key is None: - key = lambda x: x - self.keyfunc = key - - if mode == self.MODE_ALL: - self.next_fn = self._next - elif mode == self.MODE_INTERSECTING: - self.next_fn = self._next_intersecting - else: - self.next_fn = self._next_non_intersecting - - def __iter__(self): - return self - - def __next__(self): - return self.next_fn() - - def next(self): - return self.next_fn() - - def _next(self): - value = next(self.it) - if self._constant is not None: - return self._constant, value - - version = self.keyfunc(value) - intersects = self.fn(version) - return intersects, value - - def _next_intersecting(self): - while True: - value = next(self.it) - - if self._constant: - return value - elif self._constant is not None: - raise StopIteration - - version = self.keyfunc(value) - intersects = self.fn(version) - if intersects: - return value - - def _next_non_intersecting(self): - while True: - value = next(self.it) - - if self._constant: - raise StopIteration - elif self._constant is not None: - return value - - version = self.keyfunc(value) - intersects = self.fn(version) - if not intersects: - return value - - @property - def _bound(self): - if self.index < self.nbounds: - return self.range_.bounds[self.index] - else: - return None - - def _ascending(self, version): - if self.index is None: - self.index, contains = self.range_._contains_version(version) - bound = self._bound - if contains: - if not bound.upper_bounded(): - self._constant = True - return True - elif bound is None: # past end of last bound - self._constant = False - return False - else: - return False # there are more bound(s) ahead - else: - bound = self._bound - j = bound.version_containment(version) - if j == 0: - return True - elif j == -1: - return False - else: - while True: - self.index += 1 - bound = self._bound - if bound is None: # past end of last bound - self._constant = False - return False - else: - j = bound.version_containment(version) - if j == 0: - if not bound.upper_bounded(): - self._constant = True - return True - elif j == -1: - return False - - def _descending(self, version): - if self.index is None: - self.index, contains = self.range_._contains_version(version) - bound = self._bound - if contains: - if not bound.lower_bounded(): - self._constant = True - return True - elif bound is None: # past end of last bound - self.index = self.nbounds - 1 - return False - elif self.index == 0: # before start of first bound - self._constant = False - return False - else: - self.index -= 1 - return False - else: - bound = self._bound - j = bound.version_containment(version) - if j == 0: - return True - elif j == 1: - return False - else: - while self.index: - self.index -= 1 - bound = self._bound - j = bound.version_containment(version) - if j == 0: - if not bound.lower_bounded(): - self._constant = True - return True - elif j == 1: - return False - - self._constant = False # before start of first bound - return False +from rez.version._version import * diff --git a/src/rez/version/__init__.py b/src/rez/version/__init__.py new file mode 100644 index 000000000..af11036e9 --- /dev/null +++ b/src/rez/version/__init__.py @@ -0,0 +1,50 @@ +# SPDX-License-Identifier: Apache-2.0 +# Copyright Contributors to the Rez Project + + +""" +Implements everything needed to manipulate versions and requirements. + +There are three class types: :class:`VersionToken`, :class:`Version` and :class:`VersionRange`. +A :class:`Version` is a set of zero or more :class:`VersionToken`\\s, separate by ``.``\\s or ``-``\\s (eg ``1.2-3``). +A :class:`VersionToken` is a string containing alphanumerics, and default implemenations +:class:`NumericToken` and :class:`AlphanumericVersionToken` are supplied. You can implement +your own if you want stricter tokens or different sorting behaviour. + +A :class:`VersionRange` is a set of one or more contiguous version ranges. For example, +``3+<5`` contains any version >=3 but less than 5. Version ranges can be used to +define dependency requirements between objects. They can be OR'd together, AND'd +and inverted. + +The empty version ``''``, and empty version range ``''``, are also handled. The empty +version is used to denote unversioned objects. The empty version range, also +known as the 'any' range, is used to refer to any version of an object. + +Requirements and list of requirements are represented by :class:`Requirement` and +:class:`RequirementList` respectively. +""" + +from rez.version._requirement import Requirement, RequirementList, VersionedObject +from rez.version._util import ParseException, VersionError +from rez.version._version import ( + AlphanumericVersionToken, + NumericToken, + Version, + VersionRange, + VersionToken, + reverse_sort_key, +) + +__all__ = ( + "Version", + "VersionRange", + "Requirement", + "RequirementList", + "VersionedObject", + "VersionToken", + "NumericToken", + "AlphanumericVersionToken", + "reverse_sort_key", + "ParseException", + "VersionError", +) diff --git a/src/rez/version/_requirement.py b/src/rez/version/_requirement.py new file mode 100644 index 000000000..9e72a1133 --- /dev/null +++ b/src/rez/version/_requirement.py @@ -0,0 +1,476 @@ +# SPDX-License-Identifier: Apache-2.0 +# Copyright Contributors to the Rez Project + + +from rez.version._version import Version, VersionRange +from rez.version._util import _Common +import re + + +class VersionedObject(_Common): + """Definition of a versioned object, eg ``foo-1.0``. + + ``foo`` is also a valid object definiton. When there is no version part, we + are defining an unversioned object. + + .. note:: + Note that ``-``, ``@`` or ``#`` can be used as the seperator between object name + and version, however this is purely cosmetic. ``foo-1`` is the same as ``foo@1``. + """ + sep_regex_str = r'[-@#]' + sep_regex = re.compile(sep_regex_str) + + def __init__(self, s): + """ + Args: + s (str): + """ + self.name_ = None + self.version_ = None + self.sep_ = '-' + if s is None: + return + + m = self.sep_regex.search(s) + if m: + i = m.start() + self.name_ = s[:i] + self.sep_ = s[i] + ver_str = s[i + 1:] + self.version_ = Version(ver_str) + else: + self.name_ = s + self.version_ = Version() + + @classmethod + def construct(cls, name, version=None): + """Create a VersionedObject directly from an object name and version. + + Args: + name (str): Object name string. + version (typing.Optional[Version]): Version object. + """ + other = VersionedObject(None) + other.name_ = name + other.version_ = Version() if version is None else version + return other + + @property + def name(self): + """Name of the object. + + Returns: + str: + """ + return self.name_ + + @property + def version(self): + """Version of the object. + + Returns: + Version: + """ + return self.version_ + + def as_exact_requirement(self): + """Get the versioned object, as an exact requirement string. + + Returns: + str: Equivalent requirement string, eg ``maya==2016.1`` + """ + sep_str = '' + ver_str = '' + if self.version_: + sep_str = "==" + ver_str = str(self.version_) + return self.name_ + sep_str + ver_str + + def __eq__(self, other): + return (isinstance(other, VersionedObject) + and (self.name_ == other.name_) + and (self.version_ == other.version_)) + + def __hash__(self): + return hash((self.name_, self.version_)) + + def __str__(self): + sep_str = '' + ver_str = '' + if self.version_: + sep_str = self.sep_ + ver_str = str(self.version_) + return self.name_ + sep_str + ver_str + + +class Requirement(_Common): + """ + Defines a requirement for an object. For example, ``foo-5+`` means that you + require any version of ``foo``, version 5 or greater. An unversioned + requirement can also be used (``foo``), this means you require any version of + foo. You can drop the hyphen between object name and version range if the + version range starts with a non-alphanumeric character - eg ``foo<2``. + + There are two different prefixes that can be applied to a requirement: + + - ``!``: The conflict requirement. This means that you require this version + range of an object NOT to be present. To conflict with all versions of an + object, use "!foo". + - ``~``: This is known as a "weak reference", and means, "I do not require this + object, but if present, it must be within this range." It is equivalent to + the *conflict of the inverse* of the given version range. + + There is one subtle case to be aware of. ``~foo`` is a requirement that has no + effect. It means "I do not require foo, but if foo is present, it can + be any version." This statement is still valid, but will produce a + Requirement object with a None range. + + Examples of valid requirement strings: + + - ``foo-1.0`` + - ``foo@1.0`` + - ``foo#1.0`` + - ``foo-1+`` + - ``foo-1+<4.3`` + - ``foo<3`` + - ``foo==1.0.1`` + """ + sep_regex = re.compile(r'[-@#=<>]') + + def __init__(self, s, invalid_bound_error=True): + """ + Args: + s (str): Requirement string + invalid_bound_error (bool): If True, raise :exc:`VersionError` if an + impossible range is given, such as ``3+<2``. + """ + self.name_ = None + self.range_ = None + self.negate_ = False + self.conflict_ = False + self._str = None + self.sep_ = '-' + if s is None: + return + + self.conflict_ = s.startswith('!') + if self.conflict_: + s = s[1:] + elif s.startswith('~'): + s = s[1:] + self.negate_ = True + self.conflict_ = True + + m = self.sep_regex.search(s) + if m: + i = m.start() + self.name_ = s[:i] + req_str = s[i:] + if req_str[0] in ('-', '@', '#'): + self.sep_ = req_str[0] + req_str = req_str[1:] + + self.range_ = VersionRange( + req_str, invalid_bound_error=invalid_bound_error) + if self.negate_: + self.range_ = ~self.range_ + elif self.negate_: + self.name_ = s + # rare case - '~foo' equates to no effect + self.range_ = None + else: + self.name_ = s + self.range_ = VersionRange() + + @classmethod + def construct(cls, name, range=None): + """Create a requirement directly from an object name and VersionRange. + + Args: + name (str): Object name string. + range (typing.Optional[VersionRange]): If None, an unversioned requirement is + created. + """ + other = Requirement(None) + other.name_ = name + other.range_ = VersionRange() if range is None else range + return other + + @property + def name(self): + """Name of the required object. + + Returns: + str: + """ + return self.name_ + + @property + def range(self): + """Version range of the requirement. + + Returns: + VersionRange: + """ + return self.range_ + + @property + def conflict(self): + """True if the requirement is a conflict requirement, eg "!foo", "~foo-1". + + Returns: + bool: + """ + return self.conflict_ + + @property + def weak(self): + """True if the requirement is weak, eg "~foo". + + .. note:: + Note that weak requirements are also conflict requirements, but not + necessarily the other way around. + + Returns: + bool: + """ + return self.negate_ + + def safe_str(self): + """Return a string representation that is safe for the current filesystem, + and guarantees that no two different Requirement objects will encode to + the same value. + + Returns: + str: + """ + return str(self) + + def conflicts_with(self, other): + """Returns True if this requirement conflicts with another :class:`Requirement` + or :class:`VersionedObject`. + + Returns: + bool: + """ + if isinstance(other, Requirement): + if (self.name_ != other.name_) or (self.range is None) \ + or (other.range is None): + return False + elif self.conflict: + return False if other.conflict \ + else self.range_.issuperset(other.range_) + elif other.conflict: + return other.range_.issuperset(self.range_) + else: + return not self.range_.intersects(other.range_) + else: # VersionedObject + if (self.name_ != other.name_) or (self.range is None): + return False + if self.conflict: + return (other.version_ in self.range_) + else: + return (other.version_ not in self.range_) + + def merged(self, other): + """Merge two requirements. + + Two requirements can be in conflict and if so, this function returns + None. For example, requests for ``foo-4`` and ``foo-6`` are in conflict, + since both cannot be satisfied with a single version of foo. + + Some example successful requirements merges are: + + - ``foo-3+`` and ``!foo-5+`` == ``foo-3+<5`` + - ``foo-1`` and ``foo-1.5`` == ``foo-1.5`` + - ``!foo-2`` and ``!foo-5`` == ``!foo-2|5`` + + Returns: + Requirement: the merged result of two requirements. + """ + if self.name_ != other.name_: + return None # cannot merge across object names + + def _r(r_): + r = Requirement(None) + r.name_ = r_.name_ + r.negate_ = r_.negate_ + r.conflict_ = r_.conflict_ + r.sep_ = r_.sep_ + return r + + if self.range is None: + return other + elif other.range is None: + return self + elif self.conflict: + if other.conflict: + r = _r(self) + r.range_ = self.range_ | other.range_ + r.negate_ = (self.negate_ and other.negate_ + and not r.range_.is_any()) + return r + else: + range_ = other.range - self.range + if range_ is None: + return None + else: + r = _r(other) + r.range_ = range_ + return r + elif other.conflict: + range_ = self.range_ - other.range_ + if range_ is None: + return None + else: + r = _r(self) + r.range_ = range_ + return r + else: + range_ = self.range_ & other.range_ + if range_ is None: + return None + else: + r = _r(self) + r.range_ = range_ + return r + + def __eq__(self, other): + return (isinstance(other, Requirement) + and (self.name_ == other.name_) + and (self.range_ == other.range_) + and (self.conflict_ == other.conflict_)) + + def __hash__(self): + return hash(str(self)) + + def __str__(self): + if self._str is None: + pre_str = '~' if self.negate_ else ('!' if self.conflict_ else '') + range_str = '' + sep_str = '' + + range_ = self.range_ + if self.negate_: + range_ = ~range_ if range_ else VersionRange() + + if not range_.is_any(): + range_str = str(range_) + if range_str[0] not in ('=', '<', '>'): + sep_str = self.sep_ + + self._str = pre_str + self.name_ + sep_str + range_str + return self._str + + +class RequirementList(_Common): + """A list of requirements. + + This class takes a Requirement list and reduces it to the equivalent + optimal form, merging any requirements for common objects. Order of objects + is retained. + """ + def __init__(self, requirements): + """ + Args: + requirements (list[Requirement]): List of requirements. + """ + self.requirements_ = [] + self.conflict_ = None + self.requirements_dict = {} + self.names_ = set() + self.conflict_names_ = set() + + for req in requirements: + existing_req = self.requirements_dict.get(req.name) + + if existing_req is None: + self.requirements_dict[req.name] = req + else: + merged_req = existing_req.merged(req) + if merged_req is None: + self.conflict_ = (existing_req, req) + return + else: + self.requirements_dict[req.name] = merged_req + + seen = set() + + # build optimised list, this intends to match original request order + # as closely as possible + for req in requirements: + if req.name not in seen: + seen.add(req.name) + req_ = self.requirements_dict[req.name] + self.requirements_.append(req_) + + if req_.conflict: + self.conflict_names_.add(req.name) + else: + self.names_.add(req.name) + + @property + def requirements(self): + """Returns optimised list of requirements, or None if there are + conflicts. + + Returns: + list[Requirement]: + """ + return self.requirements_ + + @property + def conflict(self): + """Get the requirement conflict, if any. + + Returns: + typing.Optional[tuple[Requirement]]: None if there is no conflict, otherwise a + 2-tuple containing the conflicting requirement objects. + """ + return self.conflict_ + + @property + def names(self): + """Set of names of requirements, not including conflict requirements. + + Returns: + set[str]: + """ + return self.names_ + + @property + def conflict_names(self): + """Set of conflict requirement names. + + Returns: + set[str]: + """ + return self.conflict_names_ + + def __iter__(self): + for requirement in (self.requirements_ or []): + yield requirement + + def get(self, name): + """Returns the requirement for the given object, or None. + + Args: + name (str): requirement to get. + + Returns: + Requirement: + """ + return self.requirements_dict.get(name) + + def __eq__(self, other): + return (isinstance(other, RequirementList) + and (self.requirements_ == other.requirements_) + and (self.conflict_ == other.conflict_)) + + def __str__(self): + if self.conflict_: + s1 = str(self.conflict_[0]) + s2 = str(self.conflict_[1]) + return "%s <--!--> %s" % (s1, s2) + else: + return ' '.join(str(x) for x in self.requirements_) diff --git a/src/rez/version/_util.py b/src/rez/version/_util.py new file mode 100644 index 000000000..47da793b6 --- /dev/null +++ b/src/rez/version/_util.py @@ -0,0 +1,30 @@ +# SPDX-License-Identifier: Apache-2.0 +# Copyright Contributors to the Rez Project + + +from itertools import groupby + + +class VersionError(Exception): + pass + + +class ParseException(Exception): + pass + + +class _Common(object): + def __str__(self): + raise NotImplementedError + + def __ne__(self, other): + return not (self == other) + + def __repr__(self): + return "%s(%r)" % (self.__class__.__name__, str(self)) + + +def dedup(iterable): + """Removes duplicates from a sorted sequence.""" + for e in groupby(iterable): + yield e[0] diff --git a/src/rez/version/_version.py b/src/rez/version/_version.py new file mode 100644 index 000000000..8658980a2 --- /dev/null +++ b/src/rez/version/_version.py @@ -0,0 +1,1541 @@ +# SPDX-License-Identifier: Apache-2.0 +# Copyright Contributors to the Rez Project + + +from __future__ import print_function +from rez.version._util import VersionError, ParseException, _Common, \ + dedup +from bisect import bisect_left +import copy +import string +import re + + +re_token = re.compile(r"[a-zA-Z0-9_]+") + + +class _Comparable(_Common): + def __gt__(self, other): + return not (self < other or self == other) + + def __le__(self, other): + return self < other or self == other + + def __ge__(self, other): + return not self < other + + +class _ReversedComparable(_Common): + def __init__(self, value): + self.value = value + + def __lt__(self, other): + return not (self.value < other.value) + + def __gt__(self, other): + return not (self < other or self == other) + + def __le__(self, other): + return self < other or self == other + + def __ge__(self, other): + return not self < other + + def __str__(self): + return "reverse(%s)" % str(self.value) + + def __repr__(self): + return "reverse(%r)" % self.value + + +class VersionToken(_Comparable): + """Token within a version number. + + A version token is that part of a version number that appears between a + delimiter, typically ``.`` or ``-``. For example, the version number ``2.3.07b`` + contains the tokens ``2``, ``3`` and ``07b`` respectively. + + Version tokens are only allowed to contain alphanumerics (any case) and + underscores. + """ + def __init__(self, token): + """ + Args: + token (str): Token string, eg "rc02" + """ + raise NotImplementedError + + @classmethod + def create_random_token_string(cls): + """Create a random token string. For testing purposes only. + + :meta private: + """ + raise NotImplementedError + + def less_than(self, other): + """Compare to another :class:`VersionToken`. + + Args: + other (VersionToken): The VersionToken object to compare against. + + Returns: + bool: True if this token is less than other, False otherwise. + """ + raise NotImplementedError + + def next(self): + """Returns the next largest token.""" + raise NotImplementedError + + def __str__(self): + raise NotImplementedError + + def __lt__(self, other): + return self.less_than(other) + + def __eq__(self, other): + return (not self < other) and (not other < self) + + +class NumericToken(VersionToken): + """Numeric version token. + + Version token supporting numbers only. Padding is ignored. + """ + def __init__(self, token): + if not token.isdigit(): + raise VersionError("Invalid version token: '%s'" % token) + else: + self.n = int(token) + + @classmethod + def create_random_token_string(cls): + import random + chars = string.digits + return ''.join([chars[random.randint(0, len(chars) - 1)] + for _ in range(8)]) + + def __str__(self): + return str(self.n) + + def __eq__(self, other): + return (self.n == other.n) + + def less_than(self, other): + return (self.n < other.n) + + def __next__(self): + other = copy.copy(self) + other.n = self.n = 1 + return other + + def next(self): + return self.__next__() + + +class _SubToken(_Comparable): + """Used internally by AlphanumericVersionToken.""" + def __init__(self, s): + self.s = s + self.n = int(s) if s.isdigit() else None + + def __lt__(self, other): + if self.n is None: + return (self.s < other.s) if other.n is None else True + else: + return False if other.n is None \ + else ((self.n, self.s) < (other.n, other.s)) + + def __eq__(self, other): + return (self.s == other.s) and (self.n == other.n) + + def __str__(self): + return self.s + + +class AlphanumericVersionToken(VersionToken): + """Alphanumeric version token. + + These tokens compare as follows: + + - each token is split into alpha and numeric groups (subtokens); + - the resulting subtoken list is compared. + - alpha comparison is case-sensitive, numeric comparison is padding-sensitive. + + Subtokens compare as follows: + + - alphas come before numbers; + - alphas are compared alphabetically (``_``, then A-Z, then a-z); + - numbers are compared numerically. If numbers are equivalent but zero-padded + differently, they are then compared alphabetically. Thus ``01`` < ``1``. + + Some example comparisons that equate to true: + + - ``3`` < ``4`` + - ``01`` < ``1`` + - ``beta`` < ``1`` + - ``alpha3`` < ``alpha4`` + - ``alpha`` < ``alpha3`` + - ``gamma33`` < ``33gamma`` + """ + numeric_regex = re.compile("[0-9]+") + regex = re.compile(r"[a-zA-Z0-9_]+\Z") + + def __init__(self, token): + if token is None: + self.subtokens = None + elif not self.regex.match(token): + raise VersionError("Invalid version token: '%s'" % token) + else: + self.subtokens = self._parse(token) + + @classmethod + def create_random_token_string(cls): + import random + chars = string.digits + string.ascii_letters + return ''.join([chars[random.randint(0, len(chars) - 1)] + for _ in range(8)]) + + def __str__(self): + return ''.join(map(str, self.subtokens)) + + def __eq__(self, other): + return (self.subtokens == other.subtokens) + + def less_than(self, other): + return (self.subtokens < other.subtokens) + + def __next__(self): + other = AlphanumericVersionToken(None) + other.subtokens = self.subtokens[:] + subtok = other.subtokens[-1] + if subtok.n is None: + other.subtokens[-1] = _SubToken(subtok.s + '_') + else: + other.subtokens.append(_SubToken('_')) + return other + + def next(self): + return self.__next__() + + @classmethod + def _parse(cls, s): + subtokens = [] + alphas = cls.numeric_regex.split(s) + numerics = cls.numeric_regex.findall(s) + b = True + + while alphas or numerics: + if b: + alpha = alphas[0] + alphas = alphas[1:] + if alpha: + subtokens.append(_SubToken(alpha)) + else: + numeric = numerics[0] + numerics = numerics[1:] + subtokens.append(_SubToken(numeric)) + b = not b + + return subtokens + + +def reverse_sort_key(comparable): + """Key that gives reverse sort order on versions and version ranges. + + Example: + + >>> Version("1.0") < Version("2.0") + True + >>> reverse_sort_key(Version("1.0")) < reverse_sort_key(Version("2.0")) + False + + Args: + comparable (Version or VersionRange): Object to wrap. + + Returns: + _ReversedComparable: Wrapper object that reverses comparisons. + """ + return _ReversedComparable(comparable) + + +class Version(_Comparable): + """ + A Version is a sequence of zero or more version tokens, separated by either + a dot ``.`` or hyphen ``-`` delimiters. Note that separators only affect Version + objects cosmetically. In other words, the version ``1.0.0`` is equivalent to + ``1-0-0``. + + The empty version ``''`` is the smallest possible version, and can be used to + represent an unversioned resource. + """ + inf = None + + def __init__(self, ver_str='', make_token=AlphanumericVersionToken): + """ + Args: + ver_str (str): Version string. + make_token (typing.Callable[[str], None]): Callable that creates a VersionToken subclass from a + string. + """ + self.tokens = [] + self.seps = [] + self._str = None + self._hash = None + + if ver_str: + toks = re_token.findall(ver_str) + if not toks: + raise VersionError(ver_str) + + seps = re_token.split(ver_str) + if seps[0] or seps[-1] or max(len(x) for x in seps) > 1: + raise VersionError("Invalid version syntax: '%s'" % ver_str) + + for tok in toks: + try: + self.tokens.append(make_token(tok)) + except VersionError as e: + raise VersionError("Invalid version '%s': %s" + % (ver_str, str(e))) + + self.seps = seps[1:-1] + + def copy(self): + """ + Returns a copy of the version. + + Returns: + Version: + """ + other = Version(None) + other.tokens = self.tokens[:] + other.seps = self.seps[:] + return other + + def trim(self, len_): + """Return a copy of the version, possibly with less tokens. + + Args: + len_ (int): New version length. If >= current length, an + unchanged copy of the version is returned. + + Returns: + Version: + """ + other = Version(None) + other.tokens = self.tokens[:len_] + other.seps = self.seps[:len_ - 1] + return other + + def __next__(self): + """Return :meth:`next` version. Eg, ``next(1.2)`` is ``1.2_``""" + if self.tokens: + other = self.copy() + tok = other.tokens.pop() + other.tokens.append(tok.next()) + return other + else: + return Version.inf + + def next(self): + return self.__next__() + + @property + def major(self): + """Semantic versioning major version. + + Returns: + VersionToken: A VersionToken or a subclass of a VersionToken. + """ + return self[0] + + @property + def minor(self): + """Semantic versioning minor version. + + Returns: + VersionToken: A VersionToken or a subclass of a VersionToken. + """ + return self[1] + + @property + def patch(self): + """Semantic versioning patch version. + + Returns: + VersionToken: A VersionToken or a subclass of a VersionToken. + """ + return self[2] + + def as_tuple(self): + """Convert to a tuple of strings. + + Example: + + >>> print Version("1.2.12").as_tuple() + ('1', '2', '12') + + Returns: + tuple[str]: + """ + return tuple(map(str, self.tokens)) + + def __len__(self): + return len(self.tokens or []) + + def __getitem__(self, index): + try: + return (self.tokens or [])[index] + except IndexError: + raise IndexError("version token index out of range") + + def __nonzero__(self): + """The empty version equates to False.""" + return bool(self.tokens) + + __bool__ = __nonzero__ # py3 compat + + def __eq__(self, other): + return isinstance(other, Version) and self.tokens == other.tokens + + def __lt__(self, other): + if self.tokens is None: + return False + elif other.tokens is None: + return True + else: + return (self.tokens < other.tokens) + + def __hash__(self): + if self._hash is None: + self._hash = hash(None) if self.tokens is None \ + else hash(tuple(map(str, self.tokens))) + return self._hash + + def __str__(self): + if self._str is None: + self._str = "[INF]" if self.tokens is None \ + else ''.join(str(x) + y for x, y in zip(self.tokens, self.seps + [''])) + return self._str + + +# internal use only +Version.inf = Version() +Version.inf.tokens = None + + +class _LowerBound(_Comparable): + min = None + + def __init__(self, version, inclusive): + self.version = version + self.inclusive = inclusive + + def __str__(self): + if self.version: + s = "%s+" if self.inclusive else ">%s" + return s % self.version + else: + return '' if self.inclusive else ">" + + def __eq__(self, other): + return (self.version == other.version) \ + and (self.inclusive == other.inclusive) + + def __lt__(self, other): + return (self.version < other.version) \ + or ((self.version == other.version) + and (self.inclusive and not other.inclusive)) + + def __hash__(self): + return hash((self.version, self.inclusive)) + + def contains_version(self, version): + return (version > self.version) \ + or (self.inclusive and (version == self.version)) + + +_LowerBound.min = _LowerBound(Version(), True) + + +class _UpperBound(_Comparable): + inf = None + + def __init__(self, version, inclusive): + self.version = version + self.inclusive = inclusive + if not version and not inclusive: + raise VersionError("Invalid upper bound: '%s'" % str(self)) + + def __str__(self): + s = "<=%s" if self.inclusive else "<%s" + return s % self.version + + def __eq__(self, other): + return (self.version == other.version) \ + and (self.inclusive == other.inclusive) + + def __lt__(self, other): + return (self.version < other.version) \ + or ((self.version == other.version) + and (not self.inclusive and other.inclusive)) + + def __hash__(self): + return hash((self.version, self.inclusive)) + + def contains_version(self, version): + return (version < self.version) \ + or (self.inclusive and (version == self.version)) + + +_UpperBound.inf = _UpperBound(Version.inf, True) + + +class _Bound(_Comparable): + any = None + + def __init__(self, lower=None, upper=None, invalid_bound_error=True): + self.lower = lower or _LowerBound.min + self.upper = upper or _UpperBound.inf + + if invalid_bound_error and ( + self.lower.version > self.upper.version + or ( + (self.lower.version == self.upper.version) + and not (self.lower.inclusive and self.upper.inclusive) + ) + ): + raise VersionError("Invalid bound") + + def __str__(self): + if self.upper.version == Version.inf: + return str(self.lower) + elif self.lower.version == self.upper.version: + return "==%s" % str(self.lower.version) + elif self.lower.inclusive and self.upper.inclusive: + if self.lower.version: + return "%s..%s" % (self.lower.version, self.upper.version) + else: + return "<=%s" % self.upper.version + elif (self.lower.inclusive and not self.upper.inclusive) \ + and (self.lower.version.next() == self.upper.version): + return str(self.lower.version) + else: + return "%s%s" % (self.lower, self.upper) + + def __eq__(self, other): + return (self.lower == other.lower) and (self.upper == other.upper) + + def __lt__(self, other): + return (self.lower, self.upper) < (other.lower, other.upper) + + def __hash__(self): + return hash((self.lower, self.upper)) + + def lower_bounded(self): + return (self.lower != _LowerBound.min) + + def upper_bounded(self): + return (self.upper != _UpperBound.inf) + + def contains_version(self, version): + return (self.version_containment(version) == 0) + + def version_containment(self, version): + if not self.lower.contains_version(version): + return -1 + if not self.upper.contains_version(version): + return 1 + return 0 + + def contains_bound(self, bound): + return (self.lower <= bound.lower) and (self.upper >= bound.upper) + + def intersects(self, other): + lower = max(self.lower, other.lower) + upper = min(self.upper, other.upper) + + return (lower.version < upper.version) or ( + (lower.version == upper.version) and (lower.inclusive and upper.inclusive) + ) + + def intersection(self, other): + lower = max(self.lower, other.lower) + upper = min(self.upper, other.upper) + + if (lower.version < upper.version) or ( + (lower.version == upper.version) and (lower.inclusive and upper.inclusive) + ): + return _Bound(lower, upper) + else: + return None + + +_Bound.any = _Bound() + + +class _VersionRangeParser(object): + debug = False # set to True to enable parser debugging + + re_flags = (re.VERBOSE | re.DEBUG) if debug else re.VERBOSE + + # The regular expression for a version - one or more version tokens + # followed by a non-capturing group of version separator followed by + # one or more version tokens. + # + # Note that this assumes AlphanumericVersionToken-based versions! + # + # TODO - Would be better to have `VersionRange` keep a static dict of + # parser instances, per token class type. We would add a 'regex' static + # string to each token class, and that could be used to construct + # `version_group` as below. We need to keep a dict of these parser instances, + # to avoid recompiling the large regex every time a version range is + # instantiated. In the cpp port this would be simpler - VersionRange could + # just have a static parser that is instantiated when the version range + # template class is instantiated. + # + version_group = r"([0-9a-zA-Z_]+(?:[.-][0-9a-zA-Z_]+)*)" + + version_range_regex = ( + # Match a version number (e.g. 1.0.0) + r" ^(?P{version_group})$" + "|" + # Or match an exact version number (e.g. ==1.0.0) + " ^(?P" + " ==" # Required == operator + " (?P{version_group})?" + " )$" + "|" + # Or match an inclusive bound (e.g. 1.0.0..2.0.0) + " ^(?P" + " (?P{version_group})?" + r" \.\." # Required .. operator + " (?P{version_group})?" + " )$" + "|" + # Or match a lower bound (e.g. 1.0.0+) + " ^(?P" + " (?P>|>=)?" # Bound is exclusive? + " (?P{version_group})?" + r" (?(lower_bound_prefix)|\+)" # + only if bound is not exclusive + " )$" + "|" + # Or match an upper bound (e.g. <=1.0.0) + " ^(?P" + " (?P<(?={version_group})|<=)?" # Bound is exclusive? + " (?P{version_group})?" + " )$" + "|" + # Or match a range in ascending order (e.g. 1.0.0+<2.0.0) + " ^(?P" + " (?P" + " (?P>|>=)?" # Lower bound is exclusive? + " (?P{version_group})?" + r" (?(range_lower_asc_prefix)|\+)?" # + only if lower bound is not exclusive + " )(?P" + " (?(range_lower_asc_version),?|)" # , only if lower bound is found + " (?P<(?={version_group})|<=)" # <= only if followed by a version group + " (?P{version_group})?" + " )" + " )$" + "|" + # Or match a range in descending order (e.g. <=2.0.0,1.0.0+) + " ^(?P" + " (?P" + " (?P<|<=)?" # Upper bound is exclusive? + " (?P{version_group})?" + r" (?(range_upper_desc_prefix)|\+)?" # + only if upper bound is not exclusive + " )(?P" + " (?(range_upper_desc_version),|)" # Comma is not optional because we don't want + # to recognize something like "<4>3" + " (?P<(?={version_group})|>=?)" # >= or > only if followed + # by a version group + " (?P{version_group})?" + " )" + " )$" + ).format(version_group=version_group) + + regex = re.compile(version_range_regex, re_flags) + + def __init__(self, input_string, make_token, invalid_bound_error=True): + self.make_token = make_token + self._groups = {} + self._input_string = input_string + self.bounds = [] + self.invalid_bound_error = invalid_bound_error + + is_any = False + + for part in input_string.split("|"): + if part == '': + # OR'ing anthing with the 'any' version range ('') will also + # give the any range. Note that we can't early out here, as we + # need to validate that the rest of the string is syntactically + # correct + # + is_any = True + self.bounds = [] + continue + + match = re.search(self.regex, part) + if not match: + raise ParseException("Syntax error in version range '%s'" % part) + + if is_any: + # we're already the 'any' range regardless, so avoid more work + continue + + self._groups = match.groupdict() + + # Note: the following is ordered by approx likelihood of use + + if self._groups['range_asc']: + self._act_lower_and_upper_bound_asc() + + elif self._groups['version']: + self._act_version() + + elif self._groups['lower_bound']: + self._act_lower_bound() + + elif self._groups['exact_version']: + self._act_exact_version() + + elif self._groups['range_desc']: + self._act_lower_and_upper_bound_desc() + + elif self._groups['inclusive_bound']: + self._act_bound() + + elif self._groups['upper_bound']: + self._act_upper_bound() + + def _is_lower_bound_exclusive(self, token): + return (token == ">") + + def _is_upper_bound_exclusive(self, token): + return (token == "<") + + def _create_version_from_token(self, token): + return Version(token, make_token=self.make_token) + + def action(fn): + def fn_(self): + result = fn(self) + if self.debug: + label = fn.__name__.replace("_act_", "") + print("%-21s: %s" % (label, self._input_string)) + for key, value in self._groups.items(): + print(" %-17s= %s" % (key, value)) + print(" %-17s= %s" % ("bounds", self.bounds)) + return result + return fn_ + + @action + def _act_version(self): + version = self._create_version_from_token(self._groups['version']) + lower_bound = _LowerBound(version, True) + upper_bound = _UpperBound(version.next(), False) if version else None + + self.bounds.append(_Bound(lower_bound, upper_bound)) + + @action + def _act_exact_version(self): + version = self._create_version_from_token(self._groups['exact_version_group']) + lower_bound = _LowerBound(version, True) + upper_bound = _UpperBound(version, True) + + self.bounds.append(_Bound(lower_bound, upper_bound)) + + @action + def _act_bound(self): + lower_version = self._create_version_from_token(self._groups['inclusive_lower_version']) + lower_bound = _LowerBound(lower_version, True) + + upper_version = self._create_version_from_token(self._groups['inclusive_upper_version']) + upper_bound = _UpperBound(upper_version, True) + + self.bounds.append(_Bound(lower_bound, upper_bound, self.invalid_bound_error)) + + @action + def _act_lower_bound(self): + version = self._create_version_from_token(self._groups['lower_version']) + exclusive = self._is_lower_bound_exclusive(self._groups['lower_bound_prefix']) + lower_bound = _LowerBound(version, not exclusive) + + self.bounds.append(_Bound(lower_bound, None)) + + @action + def _act_upper_bound(self): + version = self._create_version_from_token(self._groups['upper_version']) + exclusive = self._is_upper_bound_exclusive(self._groups['upper_bound_prefix']) + upper_bound = _UpperBound(version, not exclusive) + + self.bounds.append(_Bound(None, upper_bound)) + + @action + def _act_lower_and_upper_bound_asc(self): + lower_bound = None + upper_bound = None + + if self._groups['range_lower_asc']: + version = self._create_version_from_token(self._groups['range_lower_asc_version']) + exclusive = self._is_lower_bound_exclusive(self._groups['range_lower_asc_prefix']) + lower_bound = _LowerBound(version, not exclusive) + + if self._groups['range_upper_asc']: + version = self._create_version_from_token(self._groups['range_upper_asc_version']) + exclusive = self._is_upper_bound_exclusive(self._groups['range_upper_asc_prefix']) + upper_bound = _UpperBound(version, not exclusive) + + self.bounds.append(_Bound(lower_bound, upper_bound, self.invalid_bound_error)) + + @action + def _act_lower_and_upper_bound_desc(self): + lower_bound = None + upper_bound = None + + if self._groups['range_lower_desc']: + version = self._create_version_from_token(self._groups['range_lower_desc_version']) + exclusive = self._is_lower_bound_exclusive(self._groups['range_lower_desc_prefix']) + lower_bound = _LowerBound(version, not exclusive) + + if self._groups['range_upper_desc']: + version = self._create_version_from_token(self._groups['range_upper_desc_version']) + exclusive = self._is_upper_bound_exclusive(self._groups['range_upper_desc_prefix']) + upper_bound = _UpperBound(version, not exclusive) + + self.bounds.append(_Bound(lower_bound, upper_bound, self.invalid_bound_error)) + + +class VersionRange(_Comparable): + """ + A version range is a set of one or more contiguous ranges of versions. For + example, "3.0 or greater, but less than 4" is a contiguous range that contains + versions such as ``3.0``, ``3.1.0``, ``3.99`` etc. Version ranges behave something + like sets. They can be intersected, added and subtracted, but can also be + inverted. You can test to see if a :class:`Version` is contained within a :class:`VersionRange`. + + A VersionRange ``3`` (for example) is the superset of any version ``3[.X.X...]``. + The version ``3`` itself is also within this range, and is smaller than ``3.0``. + Any version with common leading tokens, but with a larger token count, is + the larger version of the two. + + VersionRange objects have a flexible syntax that let you describe any + combination of contiguous ranges, including inclusive and exclusive upper + and lower bounds. This is best explained by example (those listed on the + same line are equivalent): + + - ``3``: 'superset' syntax, contains ``3``, ``3.0``, ``3.1.4`` etc; + - ``2+``, ``>=2``: inclusive lower bound syntax, contains ``2``, ``2.1``, ``5.0.0`` etc; + - ``>2``: exclusive lower bound; + - ``<5``: exclusive upper bound; + - ``<=5``: inclusive upper bound; + - ``==2``: a range that contains only the exact single version ``2``. + + .. + + - ``1+<5``, ``>=1<5``: inclusive lower, exclusive upper. The most common form of + a 'bounded' version range (ie, one with a lower and upper bound); + + .. + + - ``>1<5``: exclusive lower, exclusive upper; + - ``>1<=5``: exclusive lower, inclusive upper; + - ``1+<=5``, ``1..5``: inclusive lower, inclusive upper; + + .. + + - ``<=4,>2``, ``<4,>2``, ``<4,>=2``: Reverse pip syntax (note comma) + + To help with readability, bounded ranges can also have their bounds separated + with a comma, eg ``>=2,<=6``. The comma is purely cosmetic and is dropped in + the string representation. + + To describe more than one contiguous range, seperate ranges with the or ``|`` + symbol. For example, the version range ``4|6+`` contains versions such as ``4``, + ``4.0``, ``4.3.1``, ``6``, ``6.1``, ``10.0.0``, but does not contain any version + ``5[.X.X...X]``. If you provide multiple ranges that overlap, they will be + automatically optimised. For example, the version range ``3+<6|4+<8`` + becomes ``3+<8``. + + Note that the empty string version range represents the superset of all + possible versions. This is called the "any" range. The empty version can + also be used as an upper or lower bound, leading to some odd but perfectly + valid version range syntax. For example, ``>`` is a valid range - read like + ``>''``, it means ``any version greater than the empty version``. + """ + def __init__(self, range_str='', make_token=AlphanumericVersionToken, + invalid_bound_error=True): + """ + Args: + range_str (str): Range string, such as "3", "3+<4.5", "2|6+". The range + will be optimised, so the string representation of this instance + may not match range_str. For example, "3+<6|4+<8" == "3+<8". + make_token (typing.Type[VersionToken]): Version token class to use. + invalid_bound_error (bool): If True, raise an exception if an + impossible range is given, such as '3+<2'. + """ + self._str = None + self.bounds = [] # note: kept in ascending order + if range_str is None: + return + + try: + parser = _VersionRangeParser(range_str, make_token, + invalid_bound_error=invalid_bound_error) + bounds = parser.bounds + except ParseException as e: + raise VersionError("Syntax error in version range '%s': %s" + % (range_str, str(e))) + except VersionError as e: + raise VersionError("Invalid version range '%s': %s" + % (range_str, str(e))) + + if bounds: + self.bounds = self._union(bounds) + else: + self.bounds.append(_Bound.any) + + def is_any(self): + """ + Returns: + bool: True if this is the "any" range, ie the empty string range + that contains all versions. + """ + return (len(self.bounds) == 1) and (self.bounds[0] == _Bound.any) + + def lower_bounded(self): + """ + Returns: + bool: True if the range has a lower bound (that is not the empty + version). + """ + return self.bounds[0].lower_bounded() + + def upper_bounded(self): + """ + Returns: + bool: True if the range has an upper bound. + """ + return self.bounds[-1].upper_bounded() + + def bounded(self): + """ + Returns: + bool: True if the range has a lower and upper bound. + """ + return (self.lower_bounded() and self.upper_bounded()) + + def issuperset(self, range): + """ + Returns: + bool: True if the VersionRange is contained within this range. + """ + return self._issuperset(self.bounds, range.bounds) + + def issubset(self, range): + """ + Returns: + bool: True if we are contained within the version range. + """ + return range.issuperset(self) + + def union(self, other): + """OR together version ranges. + + Calculates the union of this range with one or more other ranges. + + Args: + other (VersionRange or list[VersionRange]): Version range object(s) to OR with. + + Returns: + VersionRange: Range object representing the union. + """ + if not hasattr(other, "__iter__"): + other = [other] + bounds = self.bounds[:] + for range in other: + bounds += range.bounds + + bounds = self._union(bounds) + range = VersionRange(None) + range.bounds = bounds + return range + + def intersection(self, other): + """AND together version ranges. + + Calculates the intersection of this range with one or more other ranges. + + Args: + other (VersionRange or list[VersionRange]): Version range object(s) to AND with. + + Returns: + typing.Optional[VersionRange]: New VersionRange object representing the intersection, or None if + no ranges intersect. + """ + if not hasattr(other, "__iter__"): + other = [other] + + bounds = self.bounds + for range in other: + bounds = self._intersection(bounds, range.bounds) + if not bounds: + return None + + range = VersionRange(None) + range.bounds = bounds + return range + + def inverse(self): + """Calculate the inverse of the range. + + Returns: + typing.Optional[VersionRange]: New VersionRange object representing the inverse of this range, or + None if there is no inverse (ie, this range is the any range). + """ + if self.is_any(): + return None + else: + bounds = self._inverse(self.bounds) + range = VersionRange(None) + range.bounds = bounds + return range + + def intersects(self, other): + """Determine if we intersect with another range. + + Args: + other (VersionRange): Version range object. + + Returns: + bool: True if the ranges intersect, False otherwise. + """ + return self._intersects(self.bounds, other.bounds) + + def split(self): + """Split into separate contiguous ranges. + + Returns: + list[VersionRange]: A list of VersionRange objects. For example, the range ``3|5+`` will + be split into ``["3", "5+"]``. + """ + ranges = [] + for bound in self.bounds: + range = VersionRange(None) + range.bounds = [bound] + ranges.append(range) + return ranges + + @classmethod + def as_span(cls, lower_version=None, upper_version=None, + lower_inclusive=True, upper_inclusive=True): + """Create a range from lower_version..upper_version. + + Args: + lower_version (Version): Version object representing lower bound of the range. + upper_version (Version): Version object representing upper bound of the range. + lower_inclusive (bool): Include lower_version into the span. + upper_inclusive (bool): Include upper_inclusive into the span. + Returns: + VersionRange: + """ + lower = (None if lower_version is None + else _LowerBound(lower_version, lower_inclusive)) + upper = (None if upper_version is None + else _UpperBound(upper_version, upper_inclusive)) + bound = _Bound(lower, upper) + + range = cls(None) + range.bounds = [bound] + return range + + @classmethod + def from_version(cls, version, op=None): + """Create a range from a version. + + Args: + version (Version): This is used as the upper/lower bound of + the range. + op (typing.Optional[str]): Operation as a string. One of: gt, >, gte, >=, lt, <, + lte, <=, eq, ==. If None, a bounded range will be created + that contains the version superset. + + Returns: + VersionRange: + """ + lower = None + upper = None + + if op is None: + lower = _LowerBound(version, True) + upper = _UpperBound(version.next(), False) + elif op in ("eq", "=="): + lower = _LowerBound(version, True) + upper = _UpperBound(version, True) + elif op in ("gt", ">"): + lower = _LowerBound(version, False) + elif op in ("gte", ">="): + lower = _LowerBound(version, True) + elif op in ("lt", "<"): + upper = _UpperBound(version, False) + elif op in ("lte", "<="): + upper = _UpperBound(version, True) + else: + raise VersionError("Unknown bound operation '%s'" % op) + + bound = _Bound(lower, upper) + range = cls(None) + range.bounds = [bound] + return range + + @classmethod + def from_versions(cls, versions): + """Create a range from a list of versions. + + This method creates a range that contains only the given versions and + no other. Typically the range looks like (for eg) ``==3|==4|==5.1``. + + Args: + versions (list[Version]): List of Version objects. + + Returns: + VersionRange: + """ + range = cls(None) + range.bounds = [] + for version in dedup(sorted(versions)): + lower = _LowerBound(version, True) + upper = _UpperBound(version, True) + bound = _Bound(lower, upper) + range.bounds.append(bound) + return range + + def to_versions(self): + """Returns exact version ranges as Version objects, or None if there + are no exact version ranges present. + + Returns: + typing.Optional[list[Version]]: + """ + versions = [] + for bound in self.bounds: + if bound.lower.inclusive and bound.upper.inclusive \ + and (bound.lower.version == bound.upper.version): + versions.append(bound.lower.version) + + return versions or None + + def contains_version(self, version): + """Returns True if version is contained in this range. + + Returns: + bool: + """ + if len(self.bounds) < 5: + # not worth overhead of binary search + for bound in self.bounds: + i = bound.version_containment(version) + if i == 0: + return True + if i == -1: + return False + else: + _, contains = self._contains_version(version) + return contains + + return False + + def iter_intersect_test(self, iterable, key=None, descending=False): + """Performs containment tests on a sorted list of versions. + + This is more optimal than performing separate containment tests on a + list of sorted versions. + + Args: + iterable: An ordered sequence of versioned objects. If the list + is not sorted by version, behaviour is undefined. + key (typing.Callable[typing.Any]): Function that returns a :class:`Version` given an object + from ``iterable``. If None, the identity function is used. + descending (bool): Set to True if ``iterable`` is in descending + version order. + + Returns: + ~collections.abc.Iterator[tuple[bool, typing.Any]]: An iterator that returns (bool, object) tuples, + where 'object' is the original object in ``iterable``, and the bool indicates whether + that version is contained in this range. + """ + return _ContainsVersionIterator(self, iterable, key, descending) + + def iter_intersecting(self, iterable, key=None, descending=False): + """Like :meth:iter_intersect_test`, but returns intersections only. + + Returns: + An iterator that returns items from `iterable` that intersect. + """ + return _ContainsVersionIterator( + self, iterable, key, descending, mode=_ContainsVersionIterator.MODE_INTERSECTING + ) + + def iter_non_intersecting(self, iterable, key=None, descending=False): + """Like :meth:`iter_intersect_test`, but returns non-intersections only. + + Returns: + An iterator that returns items from `iterable` that don't intersect. + """ + return _ContainsVersionIterator( + self, iterable, key, descending, mode=_ContainsVersionIterator.MODE_NON_INTERSECTING + ) + + def span(self): + """Return a contiguous range that is a superset of this range. + + Returns: + VersionRange: A range object representing the span of this range. For + example, the span of ``2+<4|6+<8`` would be ``2+<8``. + """ + other = VersionRange(None) + bound = _Bound(self.bounds[0].lower, self.bounds[-1].upper) + other.bounds = [bound] + return other + + # TODO have this return a new VersionRange instead - this currently breaks + # VersionRange immutability, and could invalidate __str__. + def visit_versions(self, func): + """Visit each version in the range, and apply a function to each. + + This is for advanced usage only. + + If ``func`` returns a :class:`Version`, this call will change the versions in + place. + + It is possible to change versions in a way that is nonsensical - for + example setting an upper bound to a smaller version than the lower bound. + Use at your own risk. + + Args: + func (typing.Callable[[Version], typing.Optional[Version]]): Takes a + version, and is applied to every version in the range. + If ``func`` returns a :class:`Version`, it will replace the existing version, + updating this :class:`VersionRange` instance in place. + + Returns: + None: + """ + for bound in self.bounds: + if bound.lower is not _LowerBound.min: + result = func(bound.lower.version) + if isinstance(result, Version): + bound.lower.version = result + + if bound.upper is not _UpperBound.inf: + result = func(bound.upper.version) + if isinstance(result, Version): + bound.upper.version = result + + def __contains__(self, version_or_range): + if isinstance(version_or_range, Version): + return self.contains_version(version_or_range) + else: + return self.issuperset(version_or_range) + + def __len__(self): + return len(self.bounds) + + def __invert__(self): + return self.inverse() + + def __and__(self, other): + return self.intersection(other) + + def __or__(self, other): + return self.union(other) + + def __add__(self, other): + return self.union(other) + + def __sub__(self, other): + inv = other.inverse() + return None if inv is None else self.intersection(inv) + + def __str__(self): + if self._str is None: + self._str = '|'.join(map(str, self.bounds)) + return self._str + + def __eq__(self, other): + return isinstance(other, VersionRange) and self.bounds == other.bounds + + def __lt__(self, other): + return (self.bounds < other.bounds) + + def __hash__(self): + return hash(tuple(self.bounds)) + + def _contains_version(self, version): + vbound = _Bound(_LowerBound(version, True)) + i = bisect_left(self.bounds, vbound) + if i and self.bounds[i - 1].contains_version(version): + return i - 1, True + if (i < len(self.bounds)) and self.bounds[i].contains_version(version): + return i, True + return i, False + + @classmethod + def _union(cls, bounds): + if len(bounds) < 2: + return bounds + + bounds_ = list(sorted(bounds)) + new_bounds = [] + prev_bound = None + upper = None + start = 0 + + for i, bound in enumerate(bounds_): + if i and ((bound.lower.version > upper.version) + or ((bound.lower.version == upper.version) + and (not bound.lower.inclusive) + and (not prev_bound.upper.inclusive))): + new_bound = _Bound(bounds_[start].lower, upper) + new_bounds.append(new_bound) + start = i + + prev_bound = bound + upper = bound.upper if upper is None else max(upper, bound.upper) + + new_bound = _Bound(bounds_[start].lower, upper) + new_bounds.append(new_bound) + return new_bounds + + @classmethod + def _intersection(cls, bounds1, bounds2): + new_bounds = [] + for bound1 in bounds1: + for bound2 in bounds2: + b = bound1.intersection(bound2) + if b: + new_bounds.append(b) + return new_bounds + + @classmethod + def _inverse(cls, bounds): + lbounds = [None] + ubounds = [] + + for bound in bounds: + if not bound.lower.version and bound.lower.inclusive: + ubounds.append(None) + else: + b = _UpperBound(bound.lower.version, not bound.lower.inclusive) + ubounds.append(b) + + if bound.upper.version == Version.inf: + lbounds.append(None) + else: + b = _LowerBound(bound.upper.version, not bound.upper.inclusive) + lbounds.append(b) + + ubounds.append(None) + new_bounds = [] + + for lower, upper in zip(lbounds, ubounds): + if not (lower is None and upper is None): + new_bounds.append(_Bound(lower, upper)) + + return new_bounds + + @classmethod + def _issuperset(cls, bounds1, bounds2): + lo = 0 + for bound2 in bounds2: + i = bisect_left(bounds1, bound2, lo=lo) + if i and bounds1[i - 1].contains_bound(bound2): + lo = i - 1 + continue + if (i < len(bounds1)) and bounds1[i].contains_bound(bound2): + lo = i + continue + return False + + return True + + @classmethod + def _intersects(cls, bounds1, bounds2): + # sort so bounds1 is the shorter list + bounds1, bounds2 = sorted((bounds1, bounds2), key=lambda x: len(x)) + + if len(bounds2) < 5: + # not worth overhead of binary search + for bound1 in bounds1: + for bound2 in bounds2: + if bound1.intersects(bound2): + return True + return False + + lo = 0 + for bound1 in bounds1: + i = bisect_left(bounds2, bound1, lo=lo) + if i and bounds2[i - 1].intersects(bound1): + return True + if (i < len(bounds2)) and bounds2[i].intersects(bound1): + return True + lo = max(i - 1, 0) + + return False + + +class _ContainsVersionIterator(object): + MODE_INTERSECTING = 0 + MODE_NON_INTERSECTING = 2 + MODE_ALL = 3 + + def __init__(self, range_, iterable, key=None, descending=False, mode=MODE_ALL): + self.mode = mode + self.range_ = range_ + self.index = None + self.nbounds = len(self.range_.bounds) + self._constant = True if range_.is_any() else None + self.fn = self._descending if descending else self._ascending + self.it = iter(iterable) + if key is None: + key = lambda x: x # noqa: E731 + self.keyfunc = key + + if mode == self.MODE_ALL: + self.next_fn = self._next + elif mode == self.MODE_INTERSECTING: + self.next_fn = self._next_intersecting + else: + self.next_fn = self._next_non_intersecting + + def __iter__(self): + return self + + def __next__(self): + return self.next_fn() + + def next(self): + return self.next_fn() + + def _next(self): + value = next(self.it) + if self._constant is not None: + return self._constant, value + + version = self.keyfunc(value) + intersects = self.fn(version) + return intersects, value + + def _next_intersecting(self): + while True: + value = next(self.it) + + if self._constant: + return value + elif self._constant is not None: + raise StopIteration + + version = self.keyfunc(value) + intersects = self.fn(version) + if intersects: + return value + + def _next_non_intersecting(self): + while True: + value = next(self.it) + + if self._constant: + raise StopIteration + elif self._constant is not None: + return value + + version = self.keyfunc(value) + intersects = self.fn(version) + if not intersects: + return value + + @property + def _bound(self): + if self.index < self.nbounds: + return self.range_.bounds[self.index] + else: + return None + + def _ascending(self, version): + if self.index is None: + self.index, contains = self.range_._contains_version(version) + bound = self._bound + if contains: + if not bound.upper_bounded(): + self._constant = True + return True + elif bound is None: # past end of last bound + self._constant = False + return False + else: + return False # there are more bound(s) ahead + else: + bound = self._bound + j = bound.version_containment(version) + if j == 0: + return True + elif j == -1: + return False + else: + while True: + self.index += 1 + bound = self._bound + if bound is None: # past end of last bound + self._constant = False + return False + else: + j = bound.version_containment(version) + if j == 0: + if not bound.upper_bounded(): + self._constant = True + return True + elif j == -1: + return False + + def _descending(self, version): + if self.index is None: + self.index, contains = self.range_._contains_version(version) + bound = self._bound + if contains: + if not bound.lower_bounded(): + self._constant = True + return True + elif bound is None: # past end of last bound + self.index = self.nbounds - 1 + return False + elif self.index == 0: # before start of first bound + self._constant = False + return False + else: + self.index -= 1 + return False + else: + bound = self._bound + j = bound.version_containment(version) + if j == 0: + return True + elif j == 1: + return False + else: + while self.index: + self.index -= 1 + bound = self._bound + j = bound.version_containment(version) + if j == 0: + if not bound.lower_bounded(): + self._constant = True + return True + elif j == 1: + return False + + self._constant = False # before start of first bound + return False diff --git a/src/rezgui/dialogs/AboutDialog.py b/src/rezgui/dialogs/AboutDialog.py index e94cb311b..2e90c9d72 100644 --- a/src/rezgui/dialogs/AboutDialog.py +++ b/src/rezgui/dialogs/AboutDialog.py @@ -5,7 +5,7 @@ from Qt import QtCore, QtWidgets from rezgui.util import create_pane, get_icon from rez import __version__ -from rez.vendor.version.version import Version +from rez.version import Version class AboutDialog(QtWidgets.QDialog): diff --git a/src/rezgui/dialogs/ResolveDialog.py b/src/rezgui/dialogs/ResolveDialog.py index 9ca54e82d..ad0f66dd2 100644 --- a/src/rezgui/dialogs/ResolveDialog.py +++ b/src/rezgui/dialogs/ResolveDialog.py @@ -11,7 +11,7 @@ from rezgui.objects.ResolveThread import ResolveThread from rezgui.objects.App import app from rez.vendor.six.six import StringIO -from rez.vendor.version.requirement import Requirement +from rez.version import Requirement from rez.config import config diff --git a/src/rezgui/widgets/BrowsePackageWidget.py b/src/rezgui/widgets/BrowsePackageWidget.py index 334d1c263..39eff4dfe 100644 --- a/src/rezgui/widgets/BrowsePackageWidget.py +++ b/src/rezgui/widgets/BrowsePackageWidget.py @@ -9,7 +9,7 @@ from rezgui.widgets.PackageTabWidget import PackageTabWidget from rezgui.mixins.ContextViewMixin import ContextViewMixin from rezgui.objects.App import app -from rez.vendor.version.requirement import Requirement +from rez.version import Requirement class BrowsePackageWidget(QtWidgets.QWidget, ContextViewMixin): diff --git a/src/rezgui/widgets/ContextTableWidget.py b/src/rezgui/widgets/ContextTableWidget.py index 3b96b748e..c786af516 100644 --- a/src/rezgui/widgets/ContextTableWidget.py +++ b/src/rezgui/widgets/ContextTableWidget.py @@ -12,8 +12,8 @@ from rezgui.models.ContextModel import ContextModel from rezgui.objects.App import app from rez.packages import iter_packages -from rez.vendor.version.requirement import Requirement -from rez.vendor.version.version import VersionRange +from rez.version import Requirement +from rez.version import VersionRange from functools import partial import os.path diff --git a/src/rezgui/widgets/PackageLineEdit.py b/src/rezgui/widgets/PackageLineEdit.py index 9fc692c2a..82cf34841 100644 --- a/src/rezgui/widgets/PackageLineEdit.py +++ b/src/rezgui/widgets/PackageLineEdit.py @@ -6,7 +6,7 @@ from rezgui.models.ContextModel import ContextModel from rezgui.mixins.ContextViewMixin import ContextViewMixin from rez.packages import get_completions, iter_packages -from rez.vendor.version.requirement import Requirement +from rez.version import Requirement class PackageLineEdit(QtWidgets.QLineEdit, ContextViewMixin): diff --git a/src/rezgui/widgets/VariantCellWidget.py b/src/rezgui/widgets/VariantCellWidget.py index c2a86c6f1..3447c1ea0 100644 --- a/src/rezgui/widgets/VariantCellWidget.py +++ b/src/rezgui/widgets/VariantCellWidget.py @@ -9,8 +9,8 @@ from rez.packages import PackageSearchPath from rez.package_filter import PackageFilterList from rez.resolved_context import PatchLock, get_lock_request -from rez.vendor.version.requirement import RequirementList -from rez.vendor.version.version import VersionRange +from rez.version import RequirementList +from rez.version import VersionRange from functools import partial diff --git a/src/rezgui/widgets/VariantHelpWidget.py b/src/rezgui/widgets/VariantHelpWidget.py index 1bd63be00..baa803cc9 100644 --- a/src/rezgui/widgets/VariantHelpWidget.py +++ b/src/rezgui/widgets/VariantHelpWidget.py @@ -6,7 +6,7 @@ from rezgui.util import create_pane, get_icon_widget from rezgui.mixins.ContextViewMixin import ContextViewMixin from rezgui.widgets.PackageLoadingWidget import PackageLoadingWidget -from rez.vendor.version.version import VersionRange +from rez.version import VersionRange from rez.package_help import PackageHelp from functools import partial diff --git a/src/rezgui/widgets/VariantVersionsTable.py b/src/rezgui/widgets/VariantVersionsTable.py index 1b5fb00a3..2f323b7a5 100644 --- a/src/rezgui/widgets/VariantVersionsTable.py +++ b/src/rezgui/widgets/VariantVersionsTable.py @@ -7,7 +7,7 @@ from rez.package_filter import PackageFilterList from rezgui.util import get_timestamp_str, update_font, get_icon_widget, create_pane from rez.packages import iter_packages -from rez.vendor.version.version import VersionRange +from rez.version import VersionRange class VariantVersionsTable(QtWidgets.QTableWidget, ContextViewMixin): diff --git a/src/rezgui/widgets/VariantVersionsWidget.py b/src/rezgui/widgets/VariantVersionsWidget.py index d8c4b8287..2557f6f7b 100644 --- a/src/rezgui/widgets/VariantVersionsWidget.py +++ b/src/rezgui/widgets/VariantVersionsWidget.py @@ -9,7 +9,7 @@ from rezgui.widgets.ChangelogEdit import ChangelogEdit from rezgui.mixins.ContextViewMixin import ContextViewMixin from rez.utils.formatting import positional_number_string -from rez.vendor.version.version import VersionRange +from rez.version import VersionRange class VariantVersionsWidget(PackageLoadingWidget, ContextViewMixin): diff --git a/src/rezplugins/package_repository/filesystem.py b/src/rezplugins/package_repository/filesystem.py index 31f9d9e64..f506ad038 100644 --- a/src/rezplugins/package_repository/filesystem.py +++ b/src/rezplugins/package_repository/filesystem.py @@ -35,7 +35,7 @@ from rez.backport.lru_cache import lru_cache from rez.vendor.schema.schema import Schema, Optional, And, Use, Or from rez.vendor.six import six -from rez.vendor.version.version import Version, VersionRange +from rez.version import Version, VersionRange basestring = six.string_types[0] diff --git a/src/rezplugins/package_repository/memory.py b/src/rezplugins/package_repository/memory.py index e6b018173..74d50731e 100644 --- a/src/rezplugins/package_repository/memory.py +++ b/src/rezplugins/package_repository/memory.py @@ -10,7 +10,7 @@ PackageResourceHelper, package_pod_schema from rez.utils.formatting import is_valid_package_name from rez.utils.resources import ResourcePool, cached_property -from rez.vendor.version.requirement import VersionedObject +from rez.version import VersionedObject # This repository type is used when loading 'developer' packages (a package.yaml