diff --git a/AUTHORS.rst b/AUTHORS.rst index 432e1376..af8357f9 100644 --- a/AUTHORS.rst +++ b/AUTHORS.rst @@ -1,6 +1,6 @@ The following organizations or individuals have contributed to this repo: -- Shivam Sandbhor -- nexB Inc. -- Philippe Ombredanne +- Shivam Sandbhor @sbs2001 +- Philippe Ombredanne @pombredanne +- Hritik Vijay @Hritik14 diff --git a/README.rst b/README.rst index 55b752ab..c9d968c4 100644 --- a/README.rst +++ b/README.rst @@ -5,60 +5,104 @@ univers: mostly universal version and version ranges comparison and conversion .. |Build Status| image:: https://api.travis-ci.com/sbs2001/univers.svg?branch=main&status=passed .. |License| image:: https://img.shields.io/badge/License-Apache%202.0-blue.svg - :target: https://opensource.org/licenses/Apache-2.0 + :target: https://scancode-licensedb.aboutcode.org/apache-2.0.html .. |Python 3.6+| image:: https://img.shields.io/badge/python-3.6+-blue.svg :target: https://www.python.org/downloads/release/python-380/ - -univers was born out of the need for a mostly univeral way to perform software -package version comparisons in VulnerableCode. +**univers** was born out of the need for a mostly univeral way to store version +ranges and to compare two software package versions in VulnerableCode. Package version ranges and version constraints are useful and essential: +- When relating a known vulnerability or bug to a range of affected package + versions. For instance a statement such as "vulnerability 123 affects + package bar, version 3.1 and version 4.2 but not version 5" defines a + range of bar versions affected by a vulnerability. + - When resolving the dependencies of a package to express which subset of the versions are supported. For instance a dependency requirement statement such as "I require package foo, version 2.0 and later versions" defines a range of acceptable foo versions. -- When relating a known vulnerability or bug to a range of affected package - versions. For instance a statement such as "vulnerability 123 affects - package bar, version 3.1 and version 4.2 but not version 5" also defines a - range of affected bar versions. +Version syntaxes and range notations are quite different across ecosystems, +making it is difficult to process versions and version ranges across ecosystems +in a consistent way. -Existing tools support typically a single algorithm to parse and compare -versions and this is not accurate across different ecosystems, since each -follow different versioning rules. For example there's no concept of 'epoch' in -semver versioning as used in package types and ecosystem such as npm or -rubygems, but epochs do exist in debian versions. A tool designed for semver or -dpkg versions processing would not be able to handle correctly the other version -scheme. +Existing tools and libraries typically support a single algorithms to parse and +compare versions with a single version range notation for a single package +ecosystem. -univers is different and considers the ecosystem-specific version scheme used. +**univers** is different: -How does univers work ? -========================= +- It tracks each ecosystem versionning scheme and how two versions are compared. -univers wraps, embeds or implements multiple version comparision libraries, each -focused on specific ecosystem version scheme. +- It support a growing number of package ecosystems versioning in a single + library. -It also implements an experimental unified syntax for version ranges specifier -and can parse and convert existing version range strings to this unified syntax. +- It can parse version range strings using their native notation (such as an npm + range) into the common "vers" notation and internal object model and can + return back a native version range string rebuilt from a "vers" range. +- It is designed to work with `Package URLs (purl) `_. -The supported package ecosystems versioning schemes and underlying libraries are: -- semver: npm, golang, PHP composer, rubygems and others that follow the semver - spec, using `semantic_version `_ library. -- debian: handled by the - `debian-inspector `_ - library. +How does **univers** work ? +============================ + +**univers** wraps, embeds and implements multiple version comparison libraries, +each focused on a specific ecosystem versionning scheme. + +For each scheme, **univers** provides an implementation for: + +- the version comparison procedure e.g, how to compare two versions, +- parsing and converting from a native version range notation to the + **univers** normalized and unified internal model, +- converting a range back to its scheme-native range syntax and to the + ``vers`` syntax. + +**univers** implements ``vers``, an experimental unified and mostly universal +version range syntax. It can parse and convert an existing native version range +strings to this unified syntax. For example, this means: + +- converting ">1.2.3" as used in a Python package into ``vers:pypi/>1.2.3``, + +- or converting "^1.0.2" as used in an npm package dependency declartion into + ``vers:npm/>=1.0.2,<2.0.0`` + +The supported package ecosystems versioning schemes and underlying libraries +include: + +- npm that use the "node-semver" ranges notation and the semver versions syntax + This is supported in part by the `semantic_version `_ library. + - pypi: handled by Python's packaging library and the standard ``packaging.version`` module. -- maven: handled by the embedded `rpm_vercmp `_ library. -- ebuild/gentoo: handled by the embedded `gentoo_vercmp `_ module. -As we grow, new schemes will be implemented accordingly. +- Rubygems which use a semver-like but not-quite-semver scheme and there can be + commonly more than three version segments. + Gems also use a slightly different range notation from node-semver with + different operators and slightly different semantics: for instance it uses "~>" + as a pessimistic operator and supports exclusion with != and does not support + "OR" between constraints (that it call requirements). + +- debian: handled by the `debian-inspector `_ + library. + +- maven: handled by the embedded `pymaven `_ library. + +- rpm: handled by the embedded `rpm_vercmp `_ library. + +- golang (using semver) + +- PHP composer + +- ebuild/gentoo: handled by the embedded `gentoo_vercmp `_ module. + +- arch linux : handled by the embedded `arch utility borrowed from msys2 `_ module. + +The level of support for each ecosystem may not be even for now and new schemes +and support for more package types are implemented on a continuous basis. Alternative @@ -66,9 +110,11 @@ Alternative Rather than using ecosystem-specific version schemes and code, another approach is to use a single procedure for all the versions as implemented in `libversion -`_. This works in the most common case -but may not work correctly for specific tasks that demand accurate version -comparison such as for dependency resolution and vulnerabilities checks. +`_. ``libversion`` works in the most +common case but may not work correctly when a task that demand precise version +comparisons such as for dependency resolution and vulnerability lookup where +a "good enough" comparison accuracy is not acceptable. ``libversion`` does not +handle version range notations. Installation @@ -80,35 +126,40 @@ Installation Examples ======== -Compare two versions using the Python comparison operators: +Compare two native Python versions: + +.. code:: python + + from univers.version import PypiVersion + assert PypiVersion("1.2.3") < PypiVersion("1.2.4") + + +Normalize a version range from an npm: .. code:: python - from univers.version import PYPIVersion - v1 = PYPIVersion("1.2.3") - v2 = PYPIVersion("1.2.4") - assert v1 < v2 == True + from univers.version_range import NpmVersionRange + range = NpmVersionRange.from_native("^1.0.2") + assert str(range) == "vers:npm/>=1.0.2,<2.0.0" -Test if a version is within or outside of a version range: +Test if a version is within or outside a version range: .. code:: python - from univers.version import PYPIVersion - from univers.version_specifier import VersionSpecifier + from univers.version import PypiVersion + from univers.version_range import VersionRange - vs = VersionSpecifier.from_scheme_version_spec_string("pypi", ">=1.2.4") - v1 = PYPIVersion("1.2.4") - v2 = PYPIVersion("1.2.3") + range = VersionRange.from_string("vers:pypi/>=1.2.4") - assert (v1 in vs ) == True - assert (v2 in vs ) == False + assert PypiVersion("1.2.4") in range + assert PypiVersion("1.2.3") not in range Development ============ -Starting from a git clone of https://github.com/nexB/univers run these:: +Run these commands, starting from a git clone of https://github.com/nexB/univers :: $ configure --dev $ source venv/bin/active @@ -121,5 +172,6 @@ Visit https://github.com/nexB/univers and https://gitter.im/aboutcode-org/vulnerablecode and https://gitter.im/aboutcode-org/aboutcode for support and chat. + Primary license: Apache-2.0 SPDX-License-Identifier: Apache-2.0 AND BSD-3-Clause AND MIT diff --git a/conftest.py b/conftest.py index 346c2698..57496c7a 100644 --- a/conftest.py +++ b/conftest.py @@ -2,18 +2,6 @@ # Copyright (c) nexB Inc. and others. # SPDX-License-Identifier: Apache-2.0 # -# Visit https://aboutcode.org and https://github.com/nexB/ for support and download. -# -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. +# Visit https://aboutcode.org and https://github.com/nexB/univers for support and download. collect_ignore = ["setup.py"] diff --git a/src/univers/arch.py b/src/univers/arch.py index dee2aa69..898987d2 100644 --- a/src/univers/arch.py +++ b/src/univers/arch.py @@ -1,7 +1,10 @@ # -# Copyright 2016-2020 Christoph Reiter +# Copyright (c) Christoph Reiter # SPDX-License-Identifier: MIT -# Version comparision utility extracted from msys2 and further stripped down. +# Version utility extracted from msys2 https://github.com/msys2/msys2-web/ +# and further stripped down. +# +# Visit https://aboutcode.org and https://github.com/nexB/univers for support and download. import re from itertools import zip_longest diff --git a/src/univers/arch.py.ABOUT b/src/univers/arch.py.ABOUT index de4f978f..fbad7f04 100644 --- a/src/univers/arch.py.ABOUT +++ b/src/univers/arch.py.ABOUT @@ -7,6 +7,6 @@ license_expression: MIT homepage_url: https://github.com/msys2/msys2-web/ notes: | - The version comparision utility is extracted from msys2 and further stripped down. + The version comparison utility is extracted from msys2 and further stripped down. notice_file: arch.py.NOTICE \ No newline at end of file diff --git a/src/univers/debian.py b/src/univers/debian.py index c1ae1b8f..4e243cae 100644 --- a/src/univers/debian.py +++ b/src/univers/debian.py @@ -1,10 +1,12 @@ # # Copyright (c) nexB Inc. and others. -# Exatrcted from http://nexb.com and https://github.com/nexB/debian_inspector/ +# Extracted from http://nexb.com and https://github.com/nexB/debian_inspector/ # Copyright (c) Peter Odding # Author: Peter Odding # URL: https://github.com/xolox/python-deb-pkg-tools # SPDX-License-Identifier: MIT +# +# Visit https://aboutcode.org and https://github.com/nexB/univers for support and download. import logging import operator as operator_module diff --git a/src/univers/gentoo.py b/src/univers/gentoo.py index ca6dae47..892a7ceb 100644 --- a/src/univers/gentoo.py +++ b/src/univers/gentoo.py @@ -1,12 +1,18 @@ # # Copyright (c) 2006-2019, pkgcore contributors # SPDX-License-Identifier: BSD-3-Clause -# Version comparision utility extracted from pkgcore and further stripped down. +# Version comparison utility extracted from pkgcore and further stripped down. +# +# Visit https://aboutcode.org and https://github.com/nexB/univers for support and download. import re from univers.utils import cmp +from univers.utils import remove_spaces +_is_gentoo_version = re.compile( + r"^(?:\d+)(?:\.\d+)*[a-zA-Z]?(?:_(p(?:re)?|beta|alpha|rc)\d*)*$" +).match suffix_regexp = re.compile("^(alpha|beta|rc|pre|p)(\\d*)$") @@ -19,6 +25,11 @@ """ +def is_valid(string): + version, _ = parse_version_and_revision(remove_spaces(string)) + return _is_gentoo_version(version) + + def parse_version_and_revision(version_string): """ Return a tuple of (version string, revision int) given a ``version_string``. diff --git a/src/univers/gentoo.py.ABOUT b/src/univers/gentoo.py.ABOUT index 7dccf0b2..3e76a2c6 100644 --- a/src/univers/gentoo.py.ABOUT +++ b/src/univers/gentoo.py.ABOUT @@ -6,6 +6,6 @@ copyright: | license_expression: BSD-3-Clause homepage_url: https://github.com/pkgcore/pkgcore/blob/master/src/pkgcore/ebuild/cpv.py -notes: The version comparision utility is extracted from pkgcore and further stripped down. +notes: The version comparison utility is extracted from pkgcore and further stripped down. notice_file: gentoo.py.NOTICE \ No newline at end of file diff --git a/src/univers/maven.py b/src/univers/maven.py index 6abb8d39..072058d4 100644 --- a/src/univers/maven.py +++ b/src/univers/maven.py @@ -1,8 +1,10 @@ # # Copyright (c) SAS Institute Inc. # SPDX-License-Identifier: Apache-2.0 -# Version comparision utility extracted from pymaven and further stripped down +# Version comparison utility extracted from pymaven and further stripped down # and significantly modified from the original at pymaven +# +# Visit https://aboutcode.org and https://github.com/nexB/univers for support and download. import functools from itertools import zip_longest diff --git a/src/univers/rpm.py b/src/univers/rpm.py index 61f9fea9..7589f591 100644 --- a/src/univers/rpm.py +++ b/src/univers/rpm.py @@ -1,13 +1,15 @@ # # Copyright (c) SAS Institute Inc. # SPDX-License-Identifier: Apache-2.0 -# Version comparision utility extracted from python-rpm-vercmp and further +# Version comparison utility extracted from python-rpm-vercmp and further # stripped down and significantly modified from the original at python-rpm-vercmp +# +# Visit https://aboutcode.org and https://github.com/nexB/univers for support and download. import re -class Vercmp(object): +class Vercmp: R_NONALNUMTILDE = re.compile(br"^([^a-zA-Z0-9~]*)(.*)$") R_NUM = re.compile(br"^([\d]+)(.*)$") R_ALPHA = re.compile(br"^([a-zA-Z]+)(.*)$") diff --git a/src/univers/rpm.py.README b/src/univers/rpm.py.README index 82b3f611..5f7a323c 100644 --- a/src/univers/rpm.py.README +++ b/src/univers/rpm.py.README @@ -1,6 +1,6 @@ Pure Python implementation of rpmvercmp. -The RPM Package Manager (http://rpm.org) has a version comparision algorithm, +The RPM Package Manager (http://rpm.org) has a version comparison algorithm, implemented in its C library, which performs the comparison in a certain way. In certain circumstances, where the C library is not installable (for example, diff --git a/src/univers/semver.py b/src/univers/semver.py new file mode 100644 index 00000000..2baefca1 --- /dev/null +++ b/src/univers/semver.py @@ -0,0 +1,86 @@ +# +# Copyright (c) nexB Inc. and others. +# SPDX-License-Identifier: Apache-2.0 +# +# Visit https://aboutcode.org and https://github.com/nexB/univers for support and download. + +import semantic_version + +from univers.utils import remove_spaces +from univers.version_constraint import VersionConstraint + +""" +node-semver and Rubygems semver-like related utilities. +""" + + +def get_caret_constraints(string): + """ + Return a tuple of two VersionConstraint representing the lower and upper + bound of version constraint ``string`` that contains a caret node-semver- + like range. Raise a ValueError if this is not a caret range. + + For example: + >>> lower_bound, upper_bound = get_caret_constraints("^1.0.2") + >>> vlow = semantic_version.Version("1.0.2") + >>> vup = semantic_version.Version("2.0.0") + >>> assert lower_bound == VersionConstraint(comparator=">=", version=vlow) + >>> assert upper_bound == VersionConstraint(comparator="<", version=vup) + """ + string = remove_spaces(string) + if not string or not string.startswith("^"): + raise ValueError(f"Invalid caret version range: {string!r}") + + version = string.lstrip("^") + lower_bound = semantic_version.Version(version) + upper_bound = lower_bound.next_major() + + return ( + VersionConstraint(comparator=">=", version=lower_bound), + VersionConstraint(comparator="<", version=upper_bound), + ) + + +def get_tilde_constraints(string, operator="~"): + """ + Return a tuple of two VersionConstraint representing the lower and upper + bound of a version range ``string`` that contains a tilde node-semver-like + range. Raise a ValueError if this is not a tilde range. + + For example: + >>> lower_bound, upper_bound = get_tilde_constraints("~1.0.2") + >>> vlow = semantic_version.Version("1.0.2") + >>> vup = semantic_version.Version("1.1.0") + >>> assert lower_bound == VersionConstraint(comparator=">=", version=vlow) + >>> assert upper_bound == VersionConstraint(comparator="<", version=vup) + """ + string = remove_spaces(string) + if not string or not string.startswith(operator): + raise ValueError(f"Invalid version range: {string!r} " f"does not start with {operator!r}") + + version = string.lstrip(operator) + lower_bound = semantic_version.Version(version) + upper_bound = lower_bound.next_minor() + + return ( + VersionConstraint(comparator=">=", version=lower_bound), + VersionConstraint(comparator="<", version=upper_bound), + ) + + +# FIXME: this is unlikely correct https://github.com/npm/node-semver/issues/112 +def get_pessimistic_constraints(string): + """ + Return a tuple of two VersionConstraint representing the lower and upper + bound of version range ``string`` that contains a pessimistic Ruby range. + Raise a ValueError if this is not a pessimistic Rubygems range. + + + For example: + >>> lower_bound, upper_bound = get_pessimistic_constraints("~>2.0.8") + >>> vlow = semantic_version.Version("2.0.8") + >>> vup = semantic_version.Version("2.1.0") + >>> assert lower_bound == VersionConstraint(comparator=">=", version=vlow) + >>> assert upper_bound == VersionConstraint(comparator="<", version=vup) + """ + return get_tilde_constraints(string, operator="~>") diff --git a/src/univers/utils.py b/src/univers/utils.py index 74fbbc08..0327087f 100644 --- a/src/univers/utils.py +++ b/src/univers/utils.py @@ -2,11 +2,11 @@ # Copyright (c) nexB Inc. and others. # SPDX-License-Identifier: Apache-2.0 # -# Visit https://aboutcode.org and https://github.com/nexB/ for support and download. +# Visit https://aboutcode.org and https://github.com/nexB/univers for support and download. def remove_spaces(string): - return string.replace(" ", "") + return "".join(string.split()) def cmp(x, y): diff --git a/src/univers/version_constraint.py b/src/univers/version_constraint.py new file mode 100644 index 00000000..8da70cfd --- /dev/null +++ b/src/univers/version_constraint.py @@ -0,0 +1,230 @@ +# +# Copyright (c) nexB Inc. and others. +# SPDX-License-Identifier: Apache-2.0 +# +# Visit https://aboutcode.org and https://github.com/nexB/univers for support and download. + +import operator +from functools import total_ordering + +import attr + +from univers.utils import remove_spaces +from univers.versions import Version + +""" +Universal version constraint object that stores a comparator such as "=" and +an ecosystem- or package-specific Version object. +""" + + +def operator_star(a, b): + """ + Comparison operator for the star "*" constraint comparator. Since it matches + any version, it is always True. + """ + return True + + +COMPARATORS = { + # note: the operators may look like inverted... but that's because we + # b in a rather than a in b as a containment test + ">=": operator.le, + "<=": operator.ge, + "!=": operator.ne, + "<": operator.gt, + ">": operator.lt, + "=": operator.eq, + "*": operator_star, +} + + +@total_ordering +@attr.s(frozen=True, repr=True, str=False, order=False, eq=True, hash=True) +class VersionConstraint: + """ + Represent a single constraint composed of a comparator and a version. + Version constraints are sortable by version then comparator + + """ + + # one of the COMPARATORS + comparator = attr.ib(type=str) + + # a Version subclass instance or None + version = attr.ib(type=Version, default=None) + + def __str__(self): + """ + Return a string representing this constraint. + For example:: + >>> assert str(VersionConstraint(comparator=">=", version="2.3")) == ">=2.3" + >>> assert str(VersionConstraint(comparator="*", version=None)) == "*" + >>> assert str(VersionConstraint(comparator="<", version="2.3")) == "<2.3" + >>> assert str(VersionConstraint(comparator="=", version="2.3.0")) == "2.3.0" + """ + if self.comparator == "*": + return "*" + elif self.comparator == "=": + return str(self.version) + else: + version = str(self.version) + return f"{self.comparator}{version}" + + to_string = __str__ + + def to_dict(self): + return dict(comparator=self.comparator, version=str(self.version)) + + def __lt__(self, other): + if not isinstance(other, self.__class__): + return NotImplemented + return self.version.__lt__ == other.version + + @classmethod + def from_string(cls, string, version_class): + """ + Return a single VersionConstraint built from a constraint ``string`` and a + ``version_class`` Version class. + """ + constraint_string = remove_spaces(string) + comparator, version = cls.split(constraint_string) + + if comparator not in COMPARATORS: + raise ValueError(f"Unknown comparator: {comparator!r}") + + if not version and comparator != "*": + raise ValueError("Empty version") + + version = version_class(version) + return cls(comparator, version) + + @staticmethod + def split(string): + """ + Return a tuple of (comparator, version) strings given a + constraint ``string`` such as ">=2.3". + + For example:: + >>> assert VersionConstraint.split(">=2.3") == (">=", "2.3",) + >>> assert VersionConstraint.split(" < = 2 . 3 ") == ("<=", "2.3",) + >>> assert VersionConstraint.split("2.3") == ("=", "2.3",) + >>> assert VersionConstraint.split("*2.3") == ("*", "",) + >>> assert VersionConstraint.split("*") == ("*", "",) + >>> assert VersionConstraint.split("<2.3") == ("<", "2.3",) + >>> assert VersionConstraint.split(">2.3") == (">", "2.3",) + >>> assert VersionConstraint.split("!=2.3") == ("!=", "2.3",) + """ + constraint_string = remove_spaces(string) + + # special case for star + if constraint_string.startswith("*"): + return "*", "" + + for comparator in COMPARATORS: + if constraint_string.startswith(comparator): + # we do not report an error if this is not valid + version = constraint_string.lstrip("><=!") + if comparator == "*": + version = "" + return comparator, version + + # default to equality + return "=", constraint_string + + # FIXME: this may be not enough to only handle "contains"? + def __contains__(self, version): + """ + Return a True if the ``version`` Version is contained in this + VersionConstraint or "satisfies" this VersionConstraint. + + For example:: + >>> from univers.versions import PypiVersion + >>> v22 = PypiVersion("2.2") + >>> v23 = PypiVersion("2.3") + >>> v24 = PypiVersion("2.4") + >>> assert v23 in VersionConstraint(comparator="=", version=v23) + >>> assert v24 not in VersionConstraint(comparator="=", version=v23) + >>> try: + ... None in VersionConstraint(comparator="=", version=v23) + ... except ValueError: + ... pass + + >>> assert v22 in VersionConstraint(comparator="!=", version=v23) + >>> assert v23 in VersionConstraint(comparator="!=", version=v24) + >>> assert v24 not in VersionConstraint(comparator="!=", version=v24) + + >>> assert v24 in VersionConstraint(comparator=">", version=v23) + >>> assert v23 not in VersionConstraint(comparator=">", version=v23) + >>> assert v24 in VersionConstraint(comparator=">=", version=v23) + >>> assert v23 in VersionConstraint(comparator=">=", version=v23) + >>> assert v22 not in VersionConstraint(comparator=">=", version=v23) + + >>> assert v23 in VersionConstraint(comparator="<", version=v24) + >>> assert v23 in VersionConstraint(comparator="<=", version=v24) + >>> assert v24 in VersionConstraint(comparator="<=", version=v24) + >>> assert v24 not in VersionConstraint(comparator="<", version=v24) + """ + if version.__class__ != self.version.__class__: + raise ValueError( + f"Cannot compare {version.__class__!r} instance " + f"with {self.version.__class__!r} instance." + ) + try: + comp_operator = COMPARATORS[self.comparator] + except KeyError as e: + raise ValueError(f"Unknown comparator: {self.comparator}") from e + + return comp_operator(self.version, version) + + contains = __contains__ + + @classmethod + def validate(cls, constraints): + """ + Raise an assertion error if the ``constraints`` is not a two-level + nested list of VersionConstraint objects. + """ + assert isinstance(constraints, (list, tuple)), constraints + for inner_constraints in constraints: + assert isinstance(inner_constraints, (list, tuple)), inner_constraints + for constraint in inner_constraints: + assert isinstance(constraint, VersionConstraint), constraint + + @classmethod + def sort(cls, constraints): + """ + Return sorted nested list of ``constraints`` using the "vers" canonical + order. Sorting is done in place. + """ + for inner_constraints in constraints: + inner_constraints.sort(key=lambda vc: str(vc)) + constraints.sort(key=lambda vc: str(vc)) + return constraints + + @classmethod + def to_constraints_string(cls, constraints): + """ + Return a string representing the provided ``constraints`` nested + list of VersionConstraint objects such that the outer sequence + VersionConstraints are joined with an "OR" e.g., a "vers" pipe "|" and + the inner sequences of VersionConstraint are each joined with an "AND" + e.g., a "vers" comma ",". + For instance: + >>> from univers.versions import PypiVersion + >>> constraints = [ + ... [VersionConstraint(comparator="=", version=PypiVersion("2"))], + ... [ + ... VersionConstraint(comparator="=>", version=PypiVersion("3")), + ... VersionConstraint(comparator="<", version=PypiVersion("4")), + ... ], + ... [VersionConstraint(comparator="=", version=PypiVersion("5"))], + ... ] + >>> assert VersionConstraint.to_constraints_string(constraints) == "2|=>3,<4|5" + """ + cls.validate(constraints) + anyof_constraints = [] + for inner_constraints in constraints: + allof_constraints = ",".join(map(str, inner_constraints)) + anyof_constraints.append(allof_constraints) + return "|".join(anyof_constraints) diff --git a/src/univers/version_range.py b/src/univers/version_range.py index 69f829c1..5604b1aa 100644 --- a/src/univers/version_range.py +++ b/src/univers/version_range.py @@ -2,73 +2,547 @@ # Copyright (c) nexB Inc. and others. # SPDX-License-Identifier: Apache-2.0 # -# Visit https://aboutcode.org and https://github.com/nexB/ for support and download. +# Visit https://aboutcode.org and https://github.com/nexB/univers for support and download. -import operator as operator_module +import attr +import semantic_version +from packaging.specifiers import SpecifierSet +from semantic_version.base import AllOf +from semantic_version.base import AnyOf +from univers import versions from univers.utils import remove_spaces -from univers.versions import version_class_by_scheme -from univers.versions import validate_scheme +from univers.version_constraint import VersionConstraint +@attr.s(frozen=True, order=False, eq=True, hash=True) class VersionRange: - # one of <> >= =< or != or = - operator = "" - version = None + """ + Base version range class. Subclasses must provide implememt. + """ - def __init__(self, version_range_string, scheme): - version_range_string = remove_spaces(version_range_string) - self.operator, self.version = self.split(version_range_string) + # Versioning scheme. By convention this is the same as the Package URL + # package type since this "defines" most commonly the versioning scheme of a + # package, such as "npm" (whose scheme is defined in the "node-semver" npm + # package. This could be something else though and a purl may be accompanied + # by a version range for another scheme; for example, a + # ``pkg:github/foo/bar`` purl could be accompanied by a a ``vers:npm/12.3`` + # range. Subclasses MUST provide this. + scheme = None - try: - validate_scheme(scheme) - self.validate() - except: - raise ValueError(f"Version range{version_range_string} has no bounds") + # Version subclass to use with this versioning scheme, such as + # PypiVersion. Subclasses MUST provide this. + version_class = None - version_class = version_class_by_scheme[scheme] + # A list of lists of VersionConstraint where the outer list is an "OR" of + # the innner lists that are each "ANDs" of atomic constraints + constraints = attr.ib(type=list, default=attr.Factory(list)) - self.version = version_class(self.version) + def __attrs_post_init__(self, *args, **kwargs): + VersionConstraint.sort(self.constraints) - def validate(self): - # self.operator will always have a valid value - if not self.version: - raise ValueError() + @classmethod + def from_native(cls, string): + """ + Return a VersionRange built from a scheme-specific, native version range + ``string``. Subclasses must implement. + """ + return NotImplementedError - @staticmethod - def split(version_range): + def to_native(self): """ - Return a tuple of (operator, range value) given a version ``range`` - string such as ">=2.3". + Return a native range string for this VersionRange. Subclasses must + implement. """ - operators = ">=", "<=", "!=", "<", ">", "=" - for operator in operators: - if version_range.startswith(operator): - return operator, version_range.lstrip("><=!") + return NotImplementedError - # Contains no operator, so assume equality - return "=", version_range + @classmethod + def from_string(cls, vers): + """ + Return a VersionRange built from a ``vers`` version range spec string, + such as "vers:npm/1.2.3,>=2.0.0" + """ + vers = remove_spaces(vers) - def __contains__(self, version): + uri_scheme, _, scheme_range_spec = vers.partition(":") + if not uri_scheme == "vers": + raise ValueError(f"{vers!r} must start with the 'vers:' URI scheme.") - if version.__class__ != self.version.__class__: + versioning_scheme, _, constraints = scheme_range_spec.partition("/") + range_class = RANGE_CLASS_BY_SCHEMES.get(versioning_scheme) + if not range_class: raise ValueError( - f"Can't compare {version.__class__} instance with {self.version.__class__} instance" + f"{vers!r} has an unknown versioning scheme: " f"{versioning_scheme!r}.", ) - operators = { - "<=": operator_module.le, - ">=": operator_module.ge, - "!=": operator_module.ne, - "<": operator_module.lt, - ">": operator_module.gt, - "=": operator_module.eq, - } - operator = operators[self.operator] - return operator(version, self.version) + if not constraints: + raise ValueError(f"{vers!r} specifies no version range constraints.") - def __eq__(self, other): - return (self.version, self.operator) == (other.version, other.operator) + # parse_constraints + version_constraints = [] + for or_constraints in constraints.split("|"): + and_constraints = [] + for constraint in or_constraints.split(","): + constraint = VersionConstraint.from_string( + string=constraint, + version_class=range_class.version_class, + ) + and_constraints.append(constraint) + version_constraints.append(and_constraints) + + return range_class(version_constraints) def __str__(self): - return f"{self.operator}{self.version}" + constraints = VersionConstraint.to_constraints_string(self.constraints) + return f"vers:{self.scheme}/{constraints}" + + to_string = __str__ + + def to_dict(self): + VersionConstraint.validate(self.constraints) + + constraints = [] + for inner_constraints in self.constraints: + constraints.append([c.to_dict() for c in inner_constraints]) + return dict(scheme=self.scheme, constraints=constraints) + + def __contains__(self, version): + """ + Return True if this VersionRange contains the ``version`` Version + object. A version is contained in a VersionRange if it satisfies its + constraints this way: + + - at least one of its ``constraints`` nested inner list of + VersionConstraint should be satisfied + + - a nested inner list of VersionConstraint is satisfied if all of its + VersionConstraints are satisfied, e.g., the ``version`` is contained in + all of the version ranges described by the constraint. + + - a VersionConstraint is "satisfied" if the ``version`` Version is "in" + this VersionConstraint. Conversely, the ``version`` satisfies a constraint. + """ + if not isinstance(version, self.version_class): + raise TypeError( + f"{version!r} is not of expected type: {self.version_class!r}", + ) + for inner_constraints in self.constraints: + if version.satisfies_all(inner_constraints): + return True + return False + + contains = __contains__ + + @classmethod + def join(cls, constraints): + """ + Return a string representing the provided ``constraints`` nested + sequence of VersionConstraint objects such that the outer sequence + VersionConstraints are joined with an "OR" e.g., a "vers" pipe "|" and + the inner sequences of VersionConstraint are each joined with an "AND" + e.g., a "vers" coma ",". + """ + cls.validate(constraints) + or_constraints = [] + for inner_constraints in constraints: + and_constraints = ",".join(str(c) for c in sorted(inner_constraints)) + or_constraints.append(and_constraints) + return "|".join(or_constraints) + + def __eq__(self, other): + return ( + self.scheme == other.scheme + and self.version_class == other.version_class + and self.constraints == other.constraints + ) + + +class NpmVersionRange(VersionRange): + scheme = "npm" + version_class = versions.SemverVersion + + vers_by_native_comparators = { + "==": "=", + "<=": "<=", + ">=": ">=", + "<": "<", + ">": ">", + } + + @classmethod + def from_native(cls, string): + """ + Return a VersionRange built from an npm "node-semver" range ``string``. + """ + # FIXME: code is entirely duplicated with the GemVersionRange + + # an NpmSpec handles parsing of both the semver versions and node-semver + # ranges at once + spec = semantic_version.NpmSpec(string) + + clause = spec.clause.simplify() + assert isinstance(clause, (AnyOf, AllOf)) + anyof_constraints = [] + if isinstance(clause, AnyOf): + for allof_clause in clause.clauses: + anyof_constraints.append(get_allof_constraints(cls, allof_clause)) + elif isinstance(clause, AllOf): + alloc = get_allof_constraints(cls, clause) + anyof_constraints.append(alloc) + else: + raise ValueError(f"Unknown clause type: {spec!r}") + + return cls(constraints=anyof_constraints) + + +def get_allof_constraints(cls, clause): + """ + Return a list of VersionConstraint given an AllOf ``clause``. + """ + assert isinstance(clause, AllOf) + allof_constraints = [] + for constraint in clause.clauses: + comparator = cls.vers_by_native_comparators[constraint.operator] + version = cls.version_class(str(constraint.target)) + constraint = VersionConstraint(comparator=comparator, version=version) + allof_constraints.append(constraint) + return allof_constraints + + +class GemVersionRange(VersionRange): + # gem need its own scheme see https//github.com/nexB/univers/issues/5 + # See https://github.com/ruby/ruby/blob/415671a28273e5bfbe9aa00a0e386f025720ac23/lib/rubygems/requirement.rb + # See https//semver.org/spec/v2.0.0.html#spec-item-11 + # See https//snyk.io/blog/differences-in-version-handling-gems-and-npm/ + # See https://github.com/npm/node-semver/issues/112 + + scheme = "gem" + version_class = versions.RubyVersion + + vers_by_native_comparators = { + "==": "=", + "!=": "!=", + "<=": "<=", + ">=": ">=", + "<": "<", + ">": ">", + } + + @classmethod + def from_native(cls, string): + """ + Return a VersionRange built from a Rubygem version range ``string``. + """ + # TODO: Gem version semantics are different from semver: + # there can be commonly more than 3 segments + # the operators are also different. + + # replace Rubygem ~> pessimistic operator by node-semver equivalent + string = string.replace("~>", "~") + spec = semantic_version.NpmSpec(string) + + clause = spec.clause.simplify() + assert isinstance(clause, (AnyOf, AllOf)) + anyof_constraints = [] + if isinstance(clause, AnyOf): + for allof_clause in clause.clauses: + anyof_constraints.append(get_allof_constraints(cls, allof_clause)) + elif isinstance(clause, AllOf): + alloc = get_allof_constraints(cls, clause) + anyof_constraints.append(alloc) + else: + raise ValueError(f"Unknown clause type: {spec!r}") + + return cls(constraints=anyof_constraints) + + +class DebianVersionRange(VersionRange): + scheme = "deb" + version_class = versions.DebianVersion + + +class PypiVersionRange(VersionRange): + scheme = "pypi" + version_class = versions.PypiVersion + + vers_by_native_comparators = { + # 01.01.01 is equal 1.1.1 e.g., with version normalization + "==": "=", + "!=": "!=", + "<=": "<=", + ">=": ">=", + "<": "<", + ">": ">", + "~=": None, + # 01.01.01 is NOT equal to 1.1.1 using === which is strict string equality + "===": None, + } + + @classmethod + def from_native(cls, string): + """ + Return a VersionRange built from a PyPI PEP440 version specifiers ``string``. + """ + # TODO: there is a complication with environment markers that are not yet supported + + # TODO: handle ~= and === operators + specifiers = SpecifierSet(string) + + # In PyPI all constraints apply + allof_constraints = [] + constraints = [allof_constraints] + + for spec in specifiers: + operator = spec.operator + version = spec.version + assert isinstance(version, cls.version_class) + comparator = cls.vers_by_native_comparators[operator] + constraint = VersionConstraint(comparator=comparator, version=version) + allof_constraints.append(constraint) + + return cls(constraints=constraints) + + +class MavenVersionRange(VersionRange): + scheme = "maven" + version_class = versions.MavenVersion + + +class NugetVersionRange(VersionRange): + scheme = "nuget" + version_class = versions.NugetVersion + + +class ComposerVersionRange(VersionRange): + # TODO composer may need its own scheme see https//github.com/nexB/univers/issues/5 + # and https//getcomposer.org/doc/articles/versions.md + scheme = "composer" + version_class = versions.SemverVersion + + +class RpmVersionRange(VersionRange): + scheme = "rpm" + version_class = versions.RpmVersion + + +class GolangVersionRange(VersionRange): + scheme = "golang" + version_class = versions.SemverVersion + + +class GenericVersionRange(VersionRange): + scheme = "generic" + version_class = versions.SemverVersion + # apache is not semver at large. And in particular we may have schemes that + # are package name-specific + + +class ApacheVersionRange(VersionRange): + scheme = "apache" + version_class = versions.SemverVersion + + +class HexVersionRange(VersionRange): + scheme = "hex" + version_class = versions.SemverVersion + + +class CargoVersionRange(VersionRange): + scheme = "cargo" + version_class = versions.SemverVersion + + +class MozillaVersionRange(VersionRange): + scheme = "mozilla" + version_class = versions.SemverVersion + + +class GitHubVersionRange(VersionRange): + scheme = "github" + version_class = versions.SemverVersion + + +class EbuildVersionRange(VersionRange): + scheme = "ebuild" + version_class = versions.GentooVersion + + +class ArchLinuxVersionRange(VersionRange): + scheme = "archlinux" + version_class = versions.ArchLinuxVersion + + +class NginxVersionRange(VersionRange): + """ + Nginx versioning is semver for version and their own syntax for ranges as + used in their security advisories. + + The documentation on these ranges is minimal. See these for details: + - https://mailman.nginx.org/pipermail/nginx/2021-September/061039.html + - https://nginx.org/en/security_advisories.html + - https://serverfault.com/questions/715049/what-s-the-difference-between-the-mainline-and-stable-branches-of-nginx + + In particular for versions: + - the versions are semver. + + - versions can be in the one "mainline" branch or one of many "stable" branches. + + - for versions in the "mainline" branch, (e.g., development) the minor + segment is an odd number. + + - versions in the "stable" branch, (e.g., a release branch) the minor + segment is an even number. Installation are typically made from branch and + its versions. + + For example: in 0.6.18 the 6 e.g., semver "minor" segment is either odd or even + - odd (as with "7") means this is the "mainline" branch + - even (as with "4") means this is in a "stable" branch + + And for ranges, we have these notations: + + - dash ranges: 0.6.18-1.20.0 where start and end are included in the range + + - comma ranges: 1.21.0+, 1.20.1+ where any of the condition applies + + - plus suffixes: 1.21.0+ where this or any later version in the branch applies + Therefore: + - 1.21.0+ would expand to >=1.21.0 because 21 is odd and this is the + mainline branch + + - 1.22.0+ would expand to >=1.22.0,<1.23.0 because 22 is even and this is + one of the stable branches + + There are two special version range values: + - "all" means all versions. + - "none" means no version and therefore no version range. It is used only + in one advisory for CVE-2009-4487 and triggers an error. + + Some vulnerable ranges are only for Windows builds but the range syntax is + the same. This could be resolved with a specific purl qualifier. + These are prefixed by the string "nginx/Window". + """ + + scheme = "nginx" + version_class = versions.SemverVersion + + vers_by_native_comparators = { + "==": "=", + "<=": "<=", + ">=": ">=", + "<": "<", + ">": ">", + } + + @classmethod + def from_native(cls, string): + """ + Return a VersionRange built from an nginx range ``string``. + + For example: + >>> result = NginxVersionRange.from_native("1.5.10") + >>> assert str(result) == "vers:nginx/1.5.10", str(result) + + >>> result = NginxVersionRange.from_native("0.7.52-0.8.39") + >>> assert str(result) == "vers:nginx/<=0.8.39,>=0.7.52", str(result) + + >>> result = NginxVersionRange.from_native("1.1.4-1.2.8, 1.3.9-1.4.0") + >>> assert str(result) == "vers:nginx/<=1.2.8,>=1.1.4|<=1.4.0,>=1.3.9", str(result) + + >>> result = NginxVersionRange.from_native("0.8.40+, 0.7.66+") + >>> assert str(result) == "vers:nginx/<0.9.0,>=0.8.40|>=0.7.66", str(result) + + >>> result = NginxVersionRange.from_native("1.5.0+, 1.4.1+") + >>> assert str(result) == "vers:nginx/<1.5.0,>=1.4.1|>=1.5.0", str(result) + + >>> result = NginxVersionRange.from_native("all") + >>> assert str(result) == "vers:nginx/*", str(result) + + >>> try: + ... NginxVersionRange.from_native("none") + ... except ValueError: + ... pass + """ + cleaned = remove_spaces(string).lower() + if cleaned == "all": + return cls(constraints=[[VersionConstraint(comparator="*")]]) + + anyof_constraints = [] + + for allof_clauses in cleaned.split(","): + + if "-" in allof_clauses: + # dash range + start, _, end = allof_clauses.partition("-") + start_version = semantic_version.Version.coerce(start) + end_version = semantic_version.Version.coerce(end) + vstart = VersionConstraint(comparator=">=", version=start_version) + vend = VersionConstraint(comparator="<=", version=end_version) + allof_constaints = [vstart, vend] + anyof_constraints.append(allof_constaints) + + elif "+" in allof_clauses: + # suffixed version + vs = allof_clauses.rstrip("+") + version = semantic_version.Version.coerce(vs) + is_stable = is_even(version.minor) + + if is_stable: + # we have a start and end in stable ranges + start_version = semantic_version.Version.coerce(vs) + end_version = start_version.next_minor() + vstart = VersionConstraint(comparator=">=", version=start_version) + vend = VersionConstraint(comparator="<", version=end_version) + allof_constaints = [vstart, vend] + anyof_constraints.append(allof_constaints) + else: + # mainline branch ranges are resolved to a singel constraint + version = semantic_version.Version.coerce(vs) + constraint = VersionConstraint(comparator=">=", version=version) + allof_constaints = [constraint] + anyof_constraints.append(allof_constaints) + + else: + # plain single version + version = semantic_version.Version.coerce(allof_clauses) + constraint = VersionConstraint(comparator="=", version=version) + allof_constaints = [constraint] + anyof_constraints.append(allof_constaints) + + return cls(constraints=anyof_constraints) + + +def is_even(s): + """ + Return True if the string "s" is an even number and False if this is an odd + number. For example: + + >>> is_even(4) + True + >>> is_even(123) + False + >>> is_even(0) + True + """ + return (int(s) % 2) == 0 + + +RANGE_CLASS_BY_SCHEMES = { + "npm": NpmVersionRange, + "deb": DebianVersionRange, + "pypi": PypiVersionRange, + "maven": MavenVersionRange, + "nuget": NugetVersionRange, + "composer": ComposerVersionRange, + "gem": GemVersionRange, + "rpm": RpmVersionRange, + "golang": GolangVersionRange, + "generic": GenericVersionRange, + "apache": ApacheVersionRange, + "hex": HexVersionRange, + "cargo": CargoVersionRange, + "mozilla": MozillaVersionRange, + "github": GitHubVersionRange, + "ebuild": EbuildVersionRange, + "archlinux": ArchLinuxVersionRange, + "nginx": NginxVersionRange, +} diff --git a/src/univers/version_specifier.py b/src/univers/version_specifier.py deleted file mode 100644 index a4e6b610..00000000 --- a/src/univers/version_specifier.py +++ /dev/null @@ -1,155 +0,0 @@ -# -# Copyright (c) nexB Inc. and others. -# SPDX-License-Identifier: Apache-2.0 -# Visit https://aboutcode.org and https://github.com/nexB/ for support and download. - - -from semantic_version import Version - -from univers.utils import remove_spaces -from univers.version_range import VersionRange -from univers.versions import parse_version - - -def normalized_caret_ranges(caret_version_range_string): - """ - Helper which returns VersionRange objects from a string which contains ranges which use - the caret operator. The scheme is 'semver'. - - Example:- - >>> lower_bound, upper_bound = normalized_caret_ranges("^1.0.2") - >>> expected_lower_bound = VersionRange(">=1.0.2", "semver") - >>> expected_upper_bound = VersionRange("<2.0.0", "semver") - >>> assert lower_bound == expected_lower_bound - >>> assert upper_bound == expected_upper_bound - """ - caret_version_range_string = remove_spaces(caret_version_range_string) - try: - _, version = caret_version_range_string.split("^") - except ValueError: - raise ValueError(f"The version range string {caret_version_range_string} is not valid.") - lower_bound = version - upper_bound = Version.coerce(version).next_major().__str__() - - return VersionRange(f">={lower_bound}", "semver"), VersionRange(f"<{upper_bound}", "semver") - - -def normalized_tilde_ranges(tilde_version_range_string): - """ - Helper which returns VersionRange objects from a string which contains ranges which use - the tilde operator. The scheme is 'semver'. - - Example:- - >>> lower_bound, upper_bound = normalized_tilde_ranges("~1.0.2") - >>> expected_lower_bound = VersionRange(">=1.0.2", "semver") - >>> expected_upper_bound = VersionRange("<1.1.0", "semver") - >>> assert lower_bound == expected_lower_bound - >>> assert upper_bound == expected_upper_bound - """ - tilde_version_range_string = remove_spaces(tilde_version_range_string) - try: - _, version = tilde_version_range_string.split("~") - except ValueError: - raise ValueError(f"The version range string {tilde_version_range_string} is not valid.") - lower_bound = version - upper_bound = Version.coerce(version).next_minor().__str__() - - return VersionRange(f">={lower_bound}", "semver"), VersionRange(f"<{upper_bound}", "semver") - - -def normalized_pessimistic_ranges(pessimistic_version_range_string): - """ - Helper which returns VersionRange objects from a string which contains ranges which use - a pessimistic operator. The scheme is 'semver' since only ruby style semver supports - this operator. - - Example:- '~>2.0.8' will get resolved into VersionRange objects of '>=2.0.8' and '<2.1.0' - """ - pessimistic_version_range_string = remove_spaces(pessimistic_version_range_string) - try: - _, version = pessimistic_version_range_string.split("~>") - except ValueError: - raise ValueError( - f"The version range string {pessimistic_version_range_string} is not valid" - ) - - lower_bound = version - upper_bound = Version.coerce(version).next_minor().__str__() - - return VersionRange(f">={lower_bound}", "semver"), VersionRange(f"<{upper_bound}", "semver") - - -class VersionSpecifier: - - scheme = "" - ranges = [] - - @classmethod - def from_version_spec_string(cls, version_spec_string): - """ - Return a VersionSpecifier built from a version spec string, prefixed by - a scheme such as "semver:1.2.3,>=2.0.0" - """ - scheme, _, version_range_expressions = version_spec_string.partition(":") - if not scheme: - raise ValueError(f"{version_spec_string} is not prefixed by scheme") - - if not version_range_expressions: - raise ValueError(f"{version_spec_string} contains no version range") - - return cls.from_scheme_version_spec_string(scheme, version_range_expressions) - - @classmethod - def from_scheme_version_spec_string(cls, scheme, value): - """ - Return a VersionSpecifier built from a scheme-specific version spec string and a scheme string. - """ - - value = remove_spaces(value) - version_ranges = value.split(",") - ranges = [] - for version_range in version_ranges: - if scheme == "semver": - if "~>" in version_range: - ranges.extend(normalized_pessimistic_ranges(version_range)) - continue - - if "~" in version_range: - ranges.extend(normalized_tilde_ranges(version_range)) - continue - - if "^" in version_range: - ranges.extend(normalized_caret_ranges(version_range)) - continue - - rng = VersionRange(version_range, scheme) - ranges.append(rng) - - ranges.sort(key=lambda rng: (rng.operator, rng.version)) - - vs = cls() - vs.ranges = ranges - vs.scheme = scheme - return vs - - def __str__(self): - """ - Return the canonical representation. - """ - - ranges = ",".join(self.ranges) - return f"{self.scheme}:{ranges}" - - def __contains__(self, version): - """ - Return True if this VersionSpecifier contains the ``version`` - Version object or scheme-prefixed version string. A version is contained - in a VersionSpecifier if it satisfies all its Range. - """ - if isinstance(version, str): - version = parse_version(version) - - return all([version in version_range for version_range in self.ranges]) - - def __eq__(self, other): - return (self.ranges, self.scheme) == (other.ranges, other.scheme) diff --git a/src/univers/versions.py b/src/univers/versions.py index 9219417f..51373839 100644 --- a/src/univers/versions.py +++ b/src/univers/versions.py @@ -2,85 +2,139 @@ # Copyright (c) nexB Inc. and others. # SPDX-License-Identifier: Apache-2.0 # -# Visit https://aboutcode.org and https://github.com/nexB/ for support and download. +# Visit https://aboutcode.org and https://github.com/nexB/univers for support and download. -import re -import attr from functools import total_ordering -from packaging import version as pypi_version +import attr import semantic_version +from packaging import version as packaging_version +from univers import arch +from univers import debian +from univers import gentoo +from univers import maven +from univers import rpm from univers.utils import remove_spaces -from univers.debian import Version as _DebianVersion -from univers.maven import Version as _MavenVersion -from univers.rpm import vercmp as rpm_vercmp -from univers.gentoo import vercmp as gentoo_vercmp -from univers.gentoo import parse_version_and_revision as parse_gentoo_version_and_revision -from univers.arch import vercmp as arch_vercmp + +""" +Version classes encapsulating the details of each version syntax. +For instance semver is a version syntax. Python and Debian use another syntax. + +Each subclass primary responsability to is be comparable and orderable +""" + +# TODO: Add mozilla versions https://github.com/mozilla-releng/mozilla-version +# TODO: Add conda versions https://github.com/conda/conda/blob/master/conda/models/version.py +# and https://docs.conda.io/projects/conda-build/en/latest/resources/package-spec.html#build-version-spec class InvalidVersion(ValueError): pass -class BaseVersion: +@attr.s(frozen=True, order=False, hash=True) +class Version: """ - Base version object to subclass for each version scheme. + Base version mixin to subclass for each version syntax implementation. - Each version value should be comparable e.g., implement - functools.total_ordering + Each version subclass is: + - comparable and orderable e.g., implement functools.total_ordering + - immutable and hashable """ - # the version scheme is a class attribute - scheme = None - value = attr.ib(type=str) + # the original string used to build this Version + string = attr.ib(type=str) + + # the normalized string for this Version, stored without spaces and + # lowercased. Any leading v is removed too. + normalized_string = attr.ib(type=str, default=None, repr=False) + + # a comparable version object constructed from the version string + value = attr.ib(default=None, repr=False) - def validate(self): + def __attrs_post_init__(self): + normalized_string = self.normalize(self.string) + if not self.is_valid(normalized_string): + raise InvalidVersion(f"{self.string!r} is not a valid {self.__class__!r}") + + # See https://www.attrs.org/en/stable/init.html?#post-init + # we use a post init on frozen objects + + # use the normalized string as default value + object.__setattr__(self, "normalized_string", normalized_string) + value = self.build_value(normalized_string) + object.__setattr__(self, "value", value) + + @classmethod + def is_valid(cls, string): """ - Validate that the version is valid for its scheme + Return True if the ``string`` is a valid version for its scheme or False + if not valid. The empty string, None, False and 0 are considered invalid. + Subclasses should implement this. """ - raise NotImplementedError - - def __str__(self): - return f"{self.scheme}:{self.value}" + return bool(string) + @classmethod + def normalize(cls, string): + """ + Return a normalized version string from ``string ``. Subclass can override. + """ + # FIXME: Is lowercase and strip v the right thing to do? + return remove_spaces(string).lower().rstrip("v") -@total_ordering -@attr.s(frozen=True, init=False, order=False, hash=True) -class PYPIVersion(BaseVersion): - scheme = "pypi" + @classmethod + def build_value(self, string): + """ + Return a wrapped version "value" object for a version ``string``. + Subclasses can override. The default is a no-op and returns the string + as-is, and is called by default at init time with the computed + normalized_string. + """ + return string - def __init__(self, version_string): - # TODO the `pypi_version.Version` class's constructor also does the same validation - # but it has a fallback option by creating an object of pypi_version.LegacyVersion class. - # Avoid the double validation and the fallback. + def satisfies(self, constraint): + """ + Return True is this Version satifies the ``constraint`` + VersionConstraint. Satisfying means that this version is "within" the + ``constraint``. + """ + return self in constraint - self.validate(version_string) - object.__setattr__(self, "value", pypi_version.Version(version_string)) - object.__setattr__(self, "version_string", version_string) + def satisfies_all(self, constraints, explain=True): + """ + Return True is this version satifies all the ``constraints`` list of + VersionConstraint. + If ``explain`` is True, prints de debug explanation. + """ + if explain: + print() + for constraint in constraints: + if self not in constraint: + print(f"{self!r} not in constraint : {constraint!r}") + else: + print(f"{self!r} in constraint : {constraint!r}") + return all(self in constraint for constraint in constraints) - @staticmethod - def validate(version_string): - match = pypi_version.Version._regex.search(version_string) # NOQA - if not match: - raise InvalidVersion(f"Invalid version: '{version_string}'") + def __str__(self): + return str(self.value) def __eq__(self, other): - # TBD: Should this verify the type of `other` + if not isinstance(other, self.__class__): + return NotImplemented return self.value.__eq__(other.value) def __lt__(self, other): + if not isinstance(other, self.__class__): + return NotImplemented return self.value.__lt__(other.value) -class GenericVersion: - scheme = "generic" - - def validate(self): - """ - Validate that the version is valid for its scheme - """ +@total_ordering +@attr.s(frozen=True, order=False, hash=True) +class GenericVersion(Version): + @classmethod + def is_valid(cls, string): # generic implementation ... # TODO: Should use # https://github.com/repology/libversion/blob/master/doc/ALGORITHM.md#core-algorithm @@ -89,211 +143,145 @@ def validate(self): # All other characters are treated as separators. Empty components are # not generated. # 10.2alpha3..patch.4. → 10, 2, alpha, 3, patch, 4 + return super(GenericVersion, cls).is_valid(string) -@attr.s(frozen=True, init=False, order=False, eq=False, hash=True, repr=False) @total_ordering -class SemverVersion(BaseVersion): - scheme = "semver" +@attr.s(frozen=True, order=False, eq=False, hash=True) +class PypiVersion(Version): + """ + PEP 440 as implemented in packaging with fallback to "legacy" + """ - def __init__(self, version_string): - version_string = version_string.lower() - version_string = version_string.lstrip("v") - object.__setattr__(self, "value", semantic_version.Version.coerce(version_string)) - object.__setattr__(self, "version_string", version_string) + # TODO: ensure we deal with tripple equal - @staticmethod - def validate(version_string): - pass + @classmethod + def build_value(cls, string): + return packaging_version.Version(string) - def __eq__(self, other): - # TBD: Should this verify the type of `other` - return self.value.__eq__(other.value) + @classmethod + def is_valid(cls, string): + try: + # Note: we consider only modern pep440 versions as valid. legacy + # will fail validation for now. + cls.build_value(string) + return True + except packaging_version.InvalidVersion: + return False - def __lt__(self, other): - return self.value.__lt__(other.value) + return False -@attr.s(frozen=True, init=False, order=False, eq=False, hash=True, repr=False) @total_ordering -class ArchVersion(BaseVersion): - scheme = "arch" +@attr.s(frozen=True, order=False, eq=False, hash=True) +class SemverVersion(Version): + """ + Strict semver v2.0 with 3 segments. + """ - def __init__(self, version_string): - version_string = version_string.lower() - version_string = remove_spaces(version_string) - object.__setattr__(self, "version_string", version_string) - object.__setattr__(self, "value", version_string) + @classmethod + def build_value(cls, string): + return semantic_version.Version.coerce(string) - @staticmethod - def validate(version_string): - pass + @classmethod + def is_valid(cls, string): + try: + cls.build_value(string) + return True + except ValueError: + return False - def __eq__(self, other): - # TBD: Should this verify the type of `other` - return arch_vercmp(self.value, other.value) == 0 - def __lt__(self, other): - return arch_vercmp(self.value, other.value) == -1 +@total_ordering +@attr.s(frozen=True, order=False, eq=False, hash=True) +class RubyVersion(Version): + """ + Ruby version encourages but does not enforce semver + """ + # FIXME: Ruby is NOT semver support 4 or more segments in versions such as https://rubygems.org/gems/rails/versions/5.0.0.1 + # See https://github.com/ruby/ruby/blob/415671a28273e5bfbe9aa00a0e386f025720ac23/lib/rubygems/requirement.rb -@total_ordering -@attr.s(frozen=True, init=False, order=False, eq=False, hash=True, repr=False) -class DebianVersion(BaseVersion): - scheme = "debian" + @classmethod + def build_value(cls, string): + return semantic_version.Version.coerce(string) - def __init__(self, version_string): - version_string = remove_spaces(version_string) - object.__setattr__(self, "value", _DebianVersion.from_string(version_string)) - object.__setattr__(self, "version_string", version_string) + @classmethod + def is_valid(cls, string): + try: + semantic_version.Version.parse(string) + return True + except ValueError: + return False - @staticmethod - def validate(version_string): - pass +@total_ordering +@attr.s(frozen=True, order=False, eq=False, hash=True) +class ArchLinuxVersion(Version): def __eq__(self, other): - return self.value.__eq__(other.value) + if not isinstance(other, self.__class__): + return NotImplemented + return arch.vercmp(self.value, other.value) == 0 def __lt__(self, other): - return self.value.__lt__(other.value) + if not isinstance(other, self.__class__): + return NotImplemented + return arch.vercmp(self.value, other.value) == -1 @total_ordering -@attr.s(frozen=True, init=False, order=False, eq=False, hash=True, repr=False) -class MavenVersion(BaseVersion): - scheme = "maven" - - def __init__(self, version_string): - version_string = remove_spaces(version_string) - object.__setattr__(self, "value", _MavenVersion(version_string)) - object.__setattr__(self, "version_string", version_string) +@attr.s(frozen=True, order=False, eq=False, hash=True) +class DebianVersion(Version): + @classmethod + def build_value(cls, string): + return debian.Version.from_string(string) - @staticmethod - def validate(version_string): - # Defined for compatibility - pass - def __eq__(self, other): - return self.value.__eq__(other.value) +@total_ordering +@attr.s(frozen=True, order=False, eq=False, hash=True) +class MavenVersion(Version): + # See https://maven.apache.org/enforcer/enforcer-rules/versionRanges.html + # https://github.com/apache/maven/tree/master/maven-artifact/src/main/java/org/apache/maven/artifact/versioning - def __lt__(self, other): - return self.value.__lt__(other.value) + @classmethod + def build_value(cls, string): + return maven.Version(string) -# See https://docs.microsoft.com/en-us/nuget/concepts/package-versioning @total_ordering -@attr.s(frozen=True, init=False, order=False, eq=False, hash=True, repr=False) +@attr.s(frozen=True, order=False, eq=False, hash=True) class NugetVersion(SemverVersion): - scheme = "nuget" + # See https://docs.microsoft.com/en-us/nuget/concepts/package-versioning pass @total_ordering -@attr.s(frozen=True, init=False, order=False, eq=False, hash=True, repr=False) -class RPMVersion(BaseVersion): - scheme = "rpm" - - def __init__(self, version_string): - version_string = remove_spaces(version_string) - self.validate(version_string) - object.__setattr__(self, "value", version_string) - object.__setattr__(self, "version_string", version_string) - - @staticmethod - def validate(version_string): - pass - +@attr.s(frozen=True, order=False, eq=False, hash=True) +class RpmVersion(Version): def __eq__(self, other): - result = rpm_vercmp(self.value, other.value) - return result == 0 + if not isinstance(other, self.__class__): + return NotImplemented + return rpm.vercmp(self.value, other.value) == 0 def __lt__(self, other): - result = rpm_vercmp(self.value, other.value) - return result == -1 + if not isinstance(other, self.__class__): + return NotImplemented + return rpm.vercmp(self.value, other.value) == -1 @total_ordering -@attr.s(frozen=True, init=False, order=False, eq=False, hash=True, repr=False) -class GentooVersion(BaseVersion): - scheme = "ebuild" - version_re = re.compile(r"^(?:\d+)(?:\.\d+)*[a-zA-Z]?(?:_(p(?:re)?|beta|alpha|rc)\d*)*$") - - def __init__(self, version_string): - version_string = remove_spaces(version_string) - self.validate(version_string) - object.__setattr__(self, "value", version_string) - object.__setattr__(self, "version_string", version_string) - - @staticmethod - def validate(version_string): - version, _ = parse_gentoo_version_and_revision(version_string) - if not GentooVersion.version_re.match(version): - raise InvalidVersion(f"Invalid version: '{version_string}'") +@attr.s(frozen=True, order=False, eq=False, hash=True) +class GentooVersion(Version): + @classmethod + def is_valid(cls, string): + return gentoo.is_valid(string) def __eq__(self, other): - result = gentoo_vercmp(self.value, other.value) - return result == 0 + if not isinstance(other, self.__class__): + return NotImplemented + return gentoo.vercmp(self.value, other.value) == 0 def __lt__(self, other): - result = gentoo_vercmp(self.value, other.value) - return result == -1 - - -# TODO : Should these be upper case global constants ? - - -version_class_by_scheme = { - "generic": GenericVersion, - "semver": SemverVersion, - "debian": DebianVersion, - "pypi": PYPIVersion, - "maven": MavenVersion, - "nuget": NugetVersion, - "rpm": RPMVersion, - "ebuild": GentooVersion, -} - - -version_class_by_package_type = { - "deb": DebianVersion, - "pypi": PYPIVersion, - "maven": MavenVersion, - "nuget": NugetVersion, - # TODO: composer may need its own scheme see https://github.com/nexB/univers/issues/5 - # and https://getcomposer.org/doc/articles/versions.md - "composer": SemverVersion, - # TODO: gem may need its own scheme see https://github.com/nexB/univers/issues/5 - # and https://snyk.io/blog/differences-in-version-handling-gems-and-npm/ - # https://semver.org/spec/v2.0.0.html#spec-item-11 - "gem": SemverVersion, - "npm": SemverVersion, - "rpm": RPMVersion, - "golang": SemverVersion, - "generic": SemverVersion, - # apache is not semver at large. And in particular we may have schemes that - # are package name-specific - "apache": SemverVersion, - "hex": SemverVersion, - "cargo": SemverVersion, - "mozilla": SemverVersion, - "github": SemverVersion, - "ebuild": GentooVersion, -} - - -def validate_scheme(scheme): - if scheme not in version_class_by_scheme: - raise ValueError(f"Invalid scheme {scheme}") - - -def parse_version(version): - """ - Return a Version object from a scheme-prefixed string - """ - if ":" in version: - scheme, _, version = version.partition(":") - else: - scheme = "generic" - - cls = version_class_by_scheme[scheme] - return cls(version) + if not isinstance(other, self.__class__): + return NotImplemented + return gentoo.vercmp(self.value, other.value) == -1 diff --git a/tests/test_data/gpl-2.0.LICENSE b/tests/data/gpl-2.0.LICENSE similarity index 100% rename from tests/test_data/gpl-2.0.LICENSE rename to tests/data/gpl-2.0.LICENSE diff --git a/tests/test_data/rpmvercmp.at b/tests/data/rpmvercmp.at similarity index 100% rename from tests/test_data/rpmvercmp.at rename to tests/data/rpmvercmp.at diff --git a/tests/test_data/rpmvercmp.at.ABOUT b/tests/data/rpmvercmp.at.ABOUT similarity index 100% rename from tests/test_data/rpmvercmp.at.ABOUT rename to tests/data/rpmvercmp.at.ABOUT diff --git a/tests/test_data/rpmvercmp.at.NOTICE b/tests/data/rpmvercmp.at.NOTICE similarity index 100% rename from tests/test_data/rpmvercmp.at.NOTICE rename to tests/data/rpmvercmp.at.NOTICE diff --git a/tests/data/test-suite-data.json b/tests/data/test-suite-data.json new file mode 100644 index 00000000..fdfe8702 --- /dev/null +++ b/tests/data/test-suite-data.json @@ -0,0 +1,24 @@ +[ + { + "description": "valid simple semver version", + "vers": "vers:semver/=1.3.4", + "canonical_vers": "vers:semver/1.3.4", + "scheme": "semver", + "constraints": [ + [{"comparator": "=", "version": "1.3.4"}] + ], + "is_invalid": false + }, + { + "description": "valid complex debian version", + "vers": "vers:debian/ 5.0A , > = 2.6 & < 3, > = 3.4.4+reloaded2-13+deb9u1 ", + "canonical_vers": "vers:debian/>=2.6&<3,>=3.4.4+reloaded2-13+deb9u1,5.0a", + "scheme": "debian", + "constraints": [ + [{"comparator": ">=", "version": "2.6"}, {"comparator": "<", "version": "3"}], + [{"comparator": ">=", "version": "3.4.4+reloaded2-13+deb9u1"}], + [{"comparator": "=", "version": "5.0a"}] + ], + "is_invalid": false + } +] diff --git a/tests/test_codestyle.py b/tests/test_codestyle.py index d089b7e2..2b60e786 100644 --- a/tests/test_codestyle.py +++ b/tests/test_codestyle.py @@ -2,7 +2,7 @@ # Copyright (c) nexB Inc. and others. # SPDX-License-Identifier: Apache-2.0 # -# Visit https://aboutcode.org and https://github.com/nexB/ for support and download. +# Visit https://aboutcode.org and https://github.com/nexB/univers for support and download. import subprocess import unittest diff --git a/tests/test_debian_version.py b/tests/test_debian_version.py index 7cab968e..c25f56fe 100644 --- a/tests/test_debian_version.py +++ b/tests/test_debian_version.py @@ -8,6 +8,8 @@ # SPDX-License-Identifier: Apache-2.0 # this has been significantly modified from the original +# +# Visit https://aboutcode.org and https://github.com/nexB/univers for support and download. from unittest import TestCase diff --git a/tests/test_debian_version_python_deb_pkg_tools.py b/tests/test_debian_version_python_deb_pkg_tools.py index 2bd01556..ca0c4657 100644 --- a/tests/test_debian_version_python_deb_pkg_tools.py +++ b/tests/test_debian_version_python_deb_pkg_tools.py @@ -2,6 +2,8 @@ # Copyright (c) Peter Odding # URL: https://github.com/xolox/python-deb-pkg-tools # SPDX-License-Identifier: MIT +# +# Visit https://aboutcode.org and https://github.com/nexB/univers for support and download. from unittest import TestCase diff --git a/tests/test_gentoo.py b/tests/test_gentoo.py index d5a84313..f87b2898 100644 --- a/tests/test_gentoo.py +++ b/tests/test_gentoo.py @@ -2,6 +2,8 @@ # Copyright 2006 Gentoo Foundation # SPDX-License-Identifier: GPL-2.0-only # this has been significantly modified from the original +# +# Visit https://aboutcode.org and https://github.com/nexB/univers for support and download. from unittest import TestCase diff --git a/tests/test_gentoo_pkgcore.py b/tests/test_gentoo_pkgcore.py index 321ba948..bb417c1c 100644 --- a/tests/test_gentoo_pkgcore.py +++ b/tests/test_gentoo_pkgcore.py @@ -1,7 +1,9 @@ # # Copyright (c) 2006-2019, pkgcore contributors # SPDX-License-Identifier: BSD-3-Clause -# Version comparision utility extracted from pkgcore and further stripped down. +# Version comparison utility extracted from pkgcore and further stripped down. +# +# Visit https://aboutcode.org and https://github.com/nexB/univers for support and download. from random import shuffle diff --git a/tests/test_gentoo_pkgcore.py.ABOUT b/tests/test_gentoo_pkgcore.py.ABOUT index d116e181..5c8d370b 100644 --- a/tests/test_gentoo_pkgcore.py.ABOUT +++ b/tests/test_gentoo_pkgcore.py.ABOUT @@ -3,5 +3,5 @@ package_url: pkg:pypi/pkgcore@0.11.8#tests/ebuild/test_cpv.py copyright: Copyright (c) 2006-2019, pkgcore contributors license_expression: BSD-3-Clause homepage_url: https://github.com/pkgcore/pkgcore/blob/master/tests/ebuild/test_cpv.py -notes: The version comparision utility code is extracted from pkgcore and further stripped down. +notes: The version comparison utility code is extracted from pkgcore and further stripped down. notice_file: gentoo.py.NOTICE \ No newline at end of file diff --git a/tests/test_maven_version.py b/tests/test_maven_version.py index f5d36680..388fe416 100644 --- a/tests/test_maven_version.py +++ b/tests/test_maven_version.py @@ -2,6 +2,8 @@ # Copyright (c) SAS Institute Inc. # SPDX-License-Identifier: Apache-2.0 # this has been significantly modified from the original# +# +# Visit https://aboutcode.org and https://github.com/nexB/univers for support and download. import unittest diff --git a/tests/test_pacman_vercmp.py b/tests/test_pacman_vercmp.py index e0fd6aa8..278947a2 100644 --- a/tests/test_pacman_vercmp.py +++ b/tests/test_pacman_vercmp.py @@ -2,90 +2,92 @@ # Copyright (c) 2008 by Dan McGee # SPDX-License-Identifier: Apache-2.0 # this has been significantly modified from the original +# +# Visit https://aboutcode.org and https://github.com/nexB/univers for support and download. -from univers.versions import ArchVersion +from univers.versions import ArchLinuxVersion def test_same_length(): - assert ArchVersion("1.5.0") == ArchVersion("1.5.0") - assert ArchVersion("1.5.1") > ArchVersion("1.5.0") + assert ArchLinuxVersion("1.5.0") == ArchLinuxVersion("1.5.0") + assert ArchLinuxVersion("1.5.1") > ArchLinuxVersion("1.5.0") def test_mixed_length(): - assert ArchVersion("1.5.1") > ArchVersion("1.5") + assert ArchLinuxVersion("1.5.1") > ArchLinuxVersion("1.5") def test_with_pkgrel_same_length(): - assert ArchVersion("1.5.0-1") == ArchVersion("1.5.0-1") - assert ArchVersion("1.5.0-1") < ArchVersion("1.5.0-2") - assert ArchVersion("1.5.0-1") < ArchVersion("1.5.1-1") - assert ArchVersion("1.5.0-2") < ArchVersion("1.5.1-1") + assert ArchLinuxVersion("1.5.0-1") == ArchLinuxVersion("1.5.0-1") + assert ArchLinuxVersion("1.5.0-1") < ArchLinuxVersion("1.5.0-2") + assert ArchLinuxVersion("1.5.0-1") < ArchLinuxVersion("1.5.1-1") + assert ArchLinuxVersion("1.5.0-2") < ArchLinuxVersion("1.5.1-1") def test_alpha_dotted_versions(): - assert ArchVersion("1.5.a") > ArchVersion("1.5") - assert ArchVersion("1.5.b") > ArchVersion("1.5.a") - assert ArchVersion("1.5.1") > ArchVersion("1.5.b") + assert ArchLinuxVersion("1.5.a") > ArchLinuxVersion("1.5") + assert ArchLinuxVersion("1.5.b") > ArchLinuxVersion("1.5.a") + assert ArchLinuxVersion("1.5.1") > ArchLinuxVersion("1.5.b") def test_with_epoch(): - assert ArchVersion("0:1.0") == ArchVersion("0:1.0") - assert ArchVersion("0:1.0") < ArchVersion("0:1.1") - assert ArchVersion("1:1.0") > ArchVersion("0:1.0") - assert ArchVersion("1:1.0") > ArchVersion("0:1.1") - assert ArchVersion("1:1.0") < ArchVersion("2:1.1") + assert ArchLinuxVersion("0:1.0") == ArchLinuxVersion("0:1.0") + assert ArchLinuxVersion("0:1.0") < ArchLinuxVersion("0:1.1") + assert ArchLinuxVersion("1:1.0") > ArchLinuxVersion("0:1.0") + assert ArchLinuxVersion("1:1.0") > ArchLinuxVersion("0:1.1") + assert ArchLinuxVersion("1:1.0") < ArchLinuxVersion("2:1.1") def test_with_epoch_mixed_pkgrel(): - assert ArchVersion("1:1.0") > ArchVersion("0:1.0-1") - assert ArchVersion("1:1.0-1") > ArchVersion("0:1.1-1") + assert ArchLinuxVersion("1:1.0") > ArchLinuxVersion("0:1.0-1") + assert ArchLinuxVersion("1:1.0-1") > ArchLinuxVersion("0:1.1-1") def test_with_only_one_version_with_epoch(): - assert ArchVersion("0:1.0") == ArchVersion("1.0") - assert ArchVersion("0:1.0") < ArchVersion("1.1") - assert ArchVersion("0:1.1") > ArchVersion("1.0") - assert ArchVersion("1:1.0") > ArchVersion("1.0") - assert ArchVersion("1:1.0") > ArchVersion("1.1") - assert ArchVersion("1:1.1") > ArchVersion("1.1") + assert ArchLinuxVersion("0:1.0") == ArchLinuxVersion("1.0") + assert ArchLinuxVersion("0:1.0") < ArchLinuxVersion("1.1") + assert ArchLinuxVersion("0:1.1") > ArchLinuxVersion("1.0") + assert ArchLinuxVersion("1:1.0") > ArchLinuxVersion("1.0") + assert ArchLinuxVersion("1:1.0") > ArchLinuxVersion("1.1") + assert ArchLinuxVersion("1:1.1") > ArchLinuxVersion("1.1") def test_alpha_dot_and_dashes(): - assert ArchVersion("1.5.b-1") == ArchVersion("1.5.b") - assert ArchVersion("1.5-1") < ArchVersion("1.5.b") + assert ArchLinuxVersion("1.5.b-1") == ArchLinuxVersion("1.5.b") + assert ArchLinuxVersion("1.5-1") < ArchLinuxVersion("1.5.b") # def test_same_content_different_separators(): -# assert ArchVersion("2.0") == ArchVersion("2_0") -# assert ArchVersion("2.0_a") == ArchVersion("2_0.a") -# assert ArchVersion("2.0a ") < ArchVersion("2.0.a") -# assert ArchVersion("2___a") == ArchVersion("2_a") +# assert ArchLinuxVersion("2.0") == ArchLinuxVersion("2_0") +# assert ArchLinuxVersion("2.0_a") == ArchLinuxVersion("2_0.a") +# assert ArchLinuxVersion("2.0a ") < ArchLinuxVersion("2.0.a") +# assert ArchLinuxVersion("2___a") == ArchLinuxVersion("2_a") def test_with_pkgrel_mixed_length(): - assert ArchVersion("1.5-1") < ArchVersion("1.5.1-1") - assert ArchVersion("1.5-2") < ArchVersion("1.5.1-1") - assert ArchVersion("1.5-2") < ArchVersion("1.5.1-2") + assert ArchLinuxVersion("1.5-1") < ArchLinuxVersion("1.5.1-1") + assert ArchLinuxVersion("1.5-2") < ArchLinuxVersion("1.5.1-1") + assert ArchLinuxVersion("1.5-2") < ArchLinuxVersion("1.5.1-2") def test_with_mixed_pkgrel_inclusion(): - assert ArchVersion("1.5") == ArchVersion("1.5-1") - assert ArchVersion("1.5-1") == ArchVersion("1.5") - assert ArchVersion("1.1-1") == ArchVersion("1.1") - assert ArchVersion("1.0-1") < ArchVersion("1.1") - assert ArchVersion("1.1-1") > ArchVersion("1.0") + assert ArchLinuxVersion("1.5") == ArchLinuxVersion("1.5-1") + assert ArchLinuxVersion("1.5-1") == ArchLinuxVersion("1.5") + assert ArchLinuxVersion("1.1-1") == ArchLinuxVersion("1.1") + assert ArchLinuxVersion("1.0-1") < ArchLinuxVersion("1.1") + assert ArchLinuxVersion("1.1-1") > ArchLinuxVersion("1.0") def test_alphanumeric_versions(): - assert ArchVersion("1.5b-1") < ArchVersion("1.5-1") - assert ArchVersion("1.5b ") < ArchVersion("1.5 ") - assert ArchVersion("1.5b-1") < ArchVersion("1.5 ") - assert ArchVersion("1.5b ") < ArchVersion("1.5.1") + assert ArchLinuxVersion("1.5b-1") < ArchLinuxVersion("1.5-1") + assert ArchLinuxVersion("1.5b ") < ArchLinuxVersion("1.5 ") + assert ArchLinuxVersion("1.5b-1") < ArchLinuxVersion("1.5 ") + assert ArchLinuxVersion("1.5b ") < ArchLinuxVersion("1.5.1") def test_manpage_cases(): - assert ArchVersion("1.0a") < ArchVersion("1.0alpha") - assert ArchVersion("1.0alpha") < ArchVersion("1.0b") - assert ArchVersion("1.0b") < ArchVersion("1.0beta") - assert ArchVersion("1.0beta") < ArchVersion("1.0rc") - assert ArchVersion("1.0rc") < ArchVersion("1.0") + assert ArchLinuxVersion("1.0a") < ArchLinuxVersion("1.0alpha") + assert ArchLinuxVersion("1.0alpha") < ArchLinuxVersion("1.0b") + assert ArchLinuxVersion("1.0b") < ArchLinuxVersion("1.0beta") + assert ArchLinuxVersion("1.0beta") < ArchLinuxVersion("1.0rc") + assert ArchLinuxVersion("1.0rc") < ArchLinuxVersion("1.0") diff --git a/tests/test_pypi_version.py b/tests/test_pypi_version.py index c828dbcd..f2331d82 100644 --- a/tests/test_pypi_version.py +++ b/tests/test_pypi_version.py @@ -2,20 +2,19 @@ # Copyright (c) nexB Inc. and others. # SPDX-License-Identifier: Apache-2.0 # -# Visit https://aboutcode.org and https://github.com/nexB/ for support and download. +# Visit https://aboutcode.org and https://github.com/nexB/univers for support and download. +from packaging import version as packaging_version from unittest import TestCase -from packaging import version from univers import versions +# version comparison is already tested at: +# https://github.com/pypa/packaging/blob/main/tests/test_version.py + class TestPYPIVersion(TestCase): def test_constructor(self): - pypi_version = versions.PYPIVersion("2.4.5") - assert pypi_version.value == version.Version("2.4.5") - assert pypi_version.scheme == "pypi" - - self.assertRaises(versions.InvalidVersion, versions.PYPIVersion, "2.//////") - - # comparison is already tested at https://github.com/pypa/packaging/blob/main/tests/test_version.py + pypi_version = versions.PypiVersion("2.4.5") + assert pypi_version.value == packaging_version.Version("2.4.5") + self.assertRaises(versions.InvalidVersion, versions.PypiVersion, "2.//////") diff --git a/tests/test_rpm_vercmp.py b/tests/test_rpm_vercmp.py index 80a39375..e9036cba 100644 --- a/tests/test_rpm_vercmp.py +++ b/tests/test_rpm_vercmp.py @@ -3,6 +3,8 @@ # Copyright (c) SAS Institute Inc. # SPDX-License-Identifier: Apache-2.0 # this has been significantly modified from the original +# +# Visit https://aboutcode.org and https://github.com/nexB/univers for support and download. import io import os @@ -85,7 +87,7 @@ def get_tests(): """ Yield test function from rpmvercmp.at data. """ - test_file = os.path.join(os.path.dirname(__file__), "test_data", "rpmvercmp.at") + test_file = os.path.join(os.path.dirname(__file__), "data", "rpmvercmp.at") with io.open(test_file, encoding="utf-8") as rpmtests: tests = list(parse_rpmvercmp_tests(rpmtests, with_buggy_comparisons=True)) diff --git a/tests/test_vers.py b/tests/test_vers.py new file mode 100644 index 00000000..57eb8a68 --- /dev/null +++ b/tests/test_vers.py @@ -0,0 +1,117 @@ +# -*- coding: utf-8 -*- +# +# Copyright (c) nexB Inc. and others. +# SPDX-License-Identifier: Apache-2.0 +# +# Visit https://aboutcode.org and https://github.com/nexB/univers for support and download. + +import json +import os +import re +import unittest + +from univers.version_range import VersionRange +from unittest.case import expectedFailure + + +def create_test_function( + description, + vers, + canonical_vers, + is_invalid, + scheme, + constraints, + test_func_prefix="test_vers_", + **kwargs +): + """ + Return a new (test function, test_name) where the test_function closed on + test arguments. If is_error is True the tests are expected to raise an + Exception. + """ + if is_invalid: + + def test_vers(self): + try: + VersionRange.from_string(vers) + self.fail("Should raise a ValueError") + except ValueError: + pass + + try: + VersionRange.from_string(canonical_vers) + self.fail("Should raise a ValueError") + except ValueError: + pass + + else: + + def test_vers(self): + # parsing the test canonical `vers` then re-building a `vers` from these + # parsed components should return the test canonical `vers` + cano = VersionRange.from_string(vers) + assert canonical_vers == cano.to_string() + + # parsing the test `vers` should return the components parsed from the + # test canonical `vers` + parsed = VersionRange.from_string(canonical_vers) + assert str(cano) == str(parsed) + + # parsing the test `vers` then re-building a `vers` from these parsed + # components should return the test canonical `vers` + assert canonical_vers == parsed.to_string() + + # building a `vers` from the test ranges should return the test + # canonical `vers` + built = VersionRange(scheme, constraints) + assert canonical_vers == built.to_string() + + # create a good function name for use in test discovery + if not description: + description = vers + if is_invalid: + test_func_prefix += "is_invalid_" + test_name = python_safe_name(test_func_prefix + description) + test_vers.__name__ = test_name + test_vers.funcname = test_name + return test_vers, test_name + + +def python_safe_name(s): + """ + Return a name derived from string `s` safe to use as a Python function name. + + For example: + >>> s = "not `\\a /`good` -safe name ??" + >>> assert python_safe_name(s) == 'not_good_safe_name' + """ + no_punctuation = re.compile(r"[\W_]", re.MULTILINE).sub + s = s.lower() + s = no_punctuation(" ", s) + s = "_".join(s.split()) + return s + + +class VersTest(unittest.TestCase): + pass + + +def build_tests(clazz=VersTest, test_file="test-suite-data.json"): + """ + Dynamically build test methods for each vers test found in the `test_file` + JSON file and attach a test method to the `clazz` class. + """ + test_data_dir = os.path.join(os.path.dirname(__file__), "data") + test_file = os.path.join(test_data_dir, test_file) + + with open(test_file) as tf: + tests_data = json.load(tf) + for items in tests_data: + test_func, test_name = create_test_function(**items) + # TODO: remove once implemented + test_func = expectedFailure(test_func) + # attach that method to the class + setattr(clazz, test_name, test_func) + + +# build_tests() diff --git a/tests/test_version_constraint.py b/tests/test_version_constraint.py new file mode 100644 index 00000000..ca4ecf60 --- /dev/null +++ b/tests/test_version_constraint.py @@ -0,0 +1,62 @@ +# +# Copyright (c) nexB Inc. and others. +# SPDX-License-Identifier: Apache-2.0 +# +# Visit https://aboutcode.org and https://github.com/nexB/univers for support and download. + +import pytest + +from univers import versions +from univers.version_constraint import VersionConstraint + + +@pytest.mark.parametrize( + "version, spec, expected", + [ + ("2.7", "<=3.4", True), + ("2.7.1", "<=3.4", True), + ("2.7.1rc1", "<=3.4", True), + ("2.7.15", "<=3.4", True), + ("2.7.15rc1", "<=3.4", True), + ("2.7", ">=3.4", False), + ("2.7.1", ">=3.4", False), + ("2.7.1rc1", ">=3.4", False), + ("2.7.15", ">=3.4", False), + ("2.7.15rc1", ">=3.4", False), + ("0.0.0", ">=1.0.0", False), + ("1.2.3", ">=1.0.0", True), + ("1.2.3b1", ">=1.0.0", True), + ("1.0.1b1", ">=1.0.0", True), + ("1.0.0b1", ">=1.0.0", False), + ("1.0.0b1", ">=1.0.0b1", True), + ], +) +def test_pypi_comparison(version, spec, expected): + version = versions.PypiVersion(version) + constraint = VersionConstraint.from_string( + string=spec, + version_class=versions.PypiVersion, + ) + assert (version in constraint) is expected + + +@pytest.mark.parametrize( + "version, spec, expected", + [ + ("2.7.1", "<=3.4.3", True), + ("1.1.0", ">1.0.0", True), + ("2.0.0", "<=2.0.0", True), + ("1.9999.9999", "<=2.0.0", True), + ("0.2.9", "<=2.0.0", True), + ("1.9999.9999", "<2.0.0", True), + ("0.1.1-alpha", ">=0.1.1-beta", False), + ("1.0.0+20130313144700", "=1.0.0+9999999999", False), + ], +) +def test_semver_comparison(version, spec, expected): + version = versions.SemverVersion(version) + constraint = VersionConstraint.from_string( + string=spec, + version_class=versions.SemverVersion, + ) + assert (version in constraint) is expected diff --git a/tests/test_version_range.py b/tests/test_version_range.py index 338b0522..3ed058c2 100644 --- a/tests/test_version_range.py +++ b/tests/test_version_range.py @@ -2,55 +2,60 @@ # Copyright (c) nexB Inc. and others. # SPDX-License-Identifier: Apache-2.0 # -# Visit https://aboutcode.org and https://github.com/nexB/ for support and download. +# Visit https://aboutcode.org and https://github.com/nexB/univers for support and download. -import pytest +from unittest import TestCase +from univers.version_constraint import VersionConstraint +from univers.version_range import GemVersionRange from univers.version_range import VersionRange -from univers.versions import version_class_by_scheme - - -@pytest.mark.parametrize( - "version, spec, result", - [ - ("2.7", "<=3.4", True), - ("2.7.1", "<=3.4", True), - ("2.7.1rc1", "<=3.4", True), - ("2.7.15", "<=3.4", True), - ("2.7.15rc1", "<=3.4", True), - ("2.7", ">=3.4", False), - ("2.7.1", ">=3.4", False), - ("2.7.1rc1", ">=3.4", False), - ("2.7.15", ">=3.4", False), - ("2.7.15rc1", ">=3.4", False), - ("0.0.0", ">=1.0.0", False), - ("1.2.3", ">=1.0.0", True), - ("1.2.3b1", ">=1.0.0", True), - ("1.0.1b1", ">=1.0.0", True), - ("1.0.0b1", ">=1.0.0", False), - ("1.0.0b1", ">=1.0.0b1", True), - ], -) -def test_pypi_comparison(version, spec, result): - version_class = version_class_by_scheme["pypi"] - version_object = version_class(version) - assert (version_object in VersionRange(spec, "pypi")) == result - - -@pytest.mark.parametrize( - "version, spec, result", - [ - ("2.7.1", "<=3.4.3", True), - ("1.1.0", ">1.0.0", True), - ("2.0.0", "<=2.0.0", True), - ("1.9999.9999", "<=2.0.0", True), - ("0.2.9", "<=2.0.0", True), - ("1.9999.9999", "<2.0.0", True), - ("0.1.1-alpha", ">=0.1.1-beta", False), - ("1.0.0+20130313144700", "=1.0.0+9999999999", False), - ], -) -def test_semver_comparison(version, spec, result): - version_class = version_class_by_scheme["semver"] - version_object = version_class(version) - assert (version_object in VersionRange(spec, "semver")) == result +from univers.versions import PypiVersion +from univers.versions import RubyVersion + + +class TestVersionRange(TestCase): + def test_VersionRange_to_string(self): + vers = "vers:pypi/0.0.2,0.0.6,>=0.0.0,0.0.1,0.0.4,0.0.5,0.0.3" + version_range = VersionRange.from_string(vers) + # note the sorting taking place + assert str(version_range) == "vers:pypi/0.0.1,0.0.2,0.0.3,0.0.4,0.0.5,0.0.6,>=0.0.0" + + def test_VersionRange_not_contains(self): + vers = "vers:pypi/0.0.2,0.0.6,>=0.0.0,0.0.1,0.0.4,0.0.5,0.0.3" + version_range = VersionRange.from_string(vers) + assert not version_range.contains(PypiVersion("2.0.3")) + + def test_VersionRange_contains(self): + version_range = VersionRange.from_string("vers:pypi/>0.0.2") + assert PypiVersion("0.0.3") in version_range + + def test_VersionRange_from_string_pypi(self): + vers = "vers:pypi/0.0.2,0.0.6,0.0.0,0.0.1,0.0.4,0.0.5,0.0.3" + version_range = VersionRange.from_string(vers) + assert version_range.scheme == "pypi" + # note the sorting taking place + expected = [ + [ + VersionConstraint(comparator="=", version=PypiVersion(string="0.0.0")), + VersionConstraint(comparator="=", version=PypiVersion(string="0.0.1")), + VersionConstraint(comparator="=", version=PypiVersion(string="0.0.2")), + VersionConstraint(comparator="=", version=PypiVersion(string="0.0.3")), + VersionConstraint(comparator="=", version=PypiVersion(string="0.0.4")), + VersionConstraint(comparator="=", version=PypiVersion(string="0.0.5")), + VersionConstraint(comparator="=", version=PypiVersion(string="0.0.6")), + ] + ] + assert version_range.constraints == expected + # note the sorting taking place + assert str(version_range) == "vers:pypi/0.0.0,0.0.1,0.0.2,0.0.3,0.0.4,0.0.5,0.0.6" + + def test_GemVersionRange_from_native_range_with_pessimistic_operator(self): + gem_range = "~>2.0.8" + version_range = GemVersionRange.from_native(gem_range) + assert version_range.to_string() == "vers:gem/<2.1.0,>=2.0.8" + assert version_range.constraints == [ + [ + VersionConstraint(comparator="<", version=RubyVersion(string="2.1.0")), + VersionConstraint(comparator=">=", version=RubyVersion(string="2.0.8")), + ], + ] diff --git a/tests/test_version_specifier.py b/tests/test_version_specifier.py deleted file mode 100644 index f3c8e6d1..00000000 --- a/tests/test_version_specifier.py +++ /dev/null @@ -1,26 +0,0 @@ -# -# Copyright (c) nexB Inc. and others. -# SPDX-License-Identifier: Apache-2.0 -# -# Visit https://aboutcode.org and https://github.com/nexB/ for support and download. - - -from unittest import TestCase - -from univers.version_specifier import VersionSpecifier - - -class TestVersionSpecifier(TestCase): - def test_from_version_spec_string(self): - spec_string = "pypi:0.0.2,0.0.6,0.0.0,0.0.1,0.0.4,0.0.5,0.0.3" - version_spec = VersionSpecifier.from_version_spec_string(spec_string) - - assert len(version_spec.ranges) == 7 - assert all(["pypi" == vrange.version.scheme for vrange in version_spec.ranges]) - - def test_resolving_pessimsitic_operator(self): - version_range_string = "~>2.0.8" - version_spec = VersionSpecifier.from_scheme_version_spec_string( - "semver", version_range_string - ) - assert len(version_spec.ranges) == 2