diff --git a/README.rst b/README.rst index c9d968c4..483db93d 100644 --- a/README.rst +++ b/README.rst @@ -66,18 +66,20 @@ For each scheme, **univers** provides an implementation for: 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``, +- 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`` +- or converting "^1.0.2" as used in an npm package dependency declaration 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. + This is supported in part by the `semantic_version + `_ library. -- pypi: handled by Python's packaging library and the standard ``packaging.version`` module. +- pypi: handled by Python's packaging library and the standard + ``packaging.version`` module. - Rubygems which use a semver-like but not-quite-semver scheme and there can be commonly more than three version segments. @@ -85,21 +87,28 @@ include: 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). + Gem are handled by Python port of the Rubygems requirements and version + handling code from the `puppeteer tool + `_ -- debian: handled by the `debian-inspector `_ - library. +- debian: handled by the `debian-inspector library + `_. -- maven: handled by the embedded `pymaven `_ library. +- maven: handled by the embedded `pymaven library + `_. -- rpm: handled by the embedded `rpm_vercmp `_ library. +- rpm: handled by the embedded `rpm_vercmp library + `_. - golang (using semver) - PHP composer -- ebuild/gentoo: handled by the embedded `gentoo_vercmp `_ module. +- ebuild/gentoo: handled by the embedded `gentoo_vercmp module + `_. -- arch linux : handled by the embedded `arch utility borrowed from msys2 `_ module. +- arch linux: handled by the embedded `arch utility module borrowed from msys2 + `_. 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. @@ -140,7 +149,7 @@ Normalize a version range from an npm: from univers.version_range import NpmVersionRange range = NpmVersionRange.from_native("^1.0.2") - assert str(range) == "vers:npm/>=1.0.2,<2.0.0" + assert str(range) == "vers:npm/>=1.0.2|<2.0.0" Test if a version is within or outside a version range: diff --git a/VERSION-RANGE-SPEC.rst b/VERSION-RANGE-SPEC.rst deleted file mode 100644 index 101a6644..00000000 --- a/VERSION-RANGE-SPEC.rst +++ /dev/null @@ -1,646 +0,0 @@ -====================================================== -vers: a mostly universal version range specifier -====================================================== - -This specification is a new syntax for dependency and vulnerable version ranges. - - -Context --------- - -Software package version ranges and version constraints are essential: - -- When resolving the dependencies of a package to express which subset of the - versions are supported. For instance a dependency or requirement statement - such as "I require package foo, version 2.0 or later versions" defines a - range of acceptable foo versions. - -- When stating that a known vulnerability or bug affects a range of package - versions. For instance a security advisory such as "vulnerability 123 affects - package bar, version 3.1 and version 4.2 but not version 5" defines a range of - vulnerable "bar" package versions. - -Version ranges can be replaced by a list enumerating all the versions of -interest. But in practice, all the versions may not yet exist when defining an -open version range such as "v2.0 or later". - -Therefore, a version range is a necessary, compact and practical way to -reference multiple versions rather than listing all the versions. - - -Problem --------- - -Several version range notations exist and have evolved separately to serve the -specific needs of each package ecosystem, vulnerability databases and tools. - -There is no (mostly) universal notation for version ranges and there is no -universal way to compare two versions, even though the concepts that exist in -most version range notations are similar. - -Each package type or ecosystem may define their own ranges notation and version -comparison semantics for dependencies. And for security advisories, the lack of -a portable and compact notation for vulnerable package version ranges means that -these ranges may be either ambiguous or hard to compute and may be best replaced -by complete enumerations of all impacted versions, such as in the `NVD CPE Match -feed `_. - -Because of this, expressing and resolving a version range is often a complex, or -error prone task. - -In particular the need for common notation for version has emerged based on the -usage of Package URLs referencing vulnerable package version ranges such as in -vulnerability databases like `VulnerableCode -`_. - -To better understand the problem, here are some of the notations and conventions -in use: - -- ``semver`` https://semver.org/ is a popular specification to structure version - strings, but does not provide a way to express version ranges. - -- Rubygems strongly suggest using ``semver`` for version but does not enforce it. - As a result some use semver and several popular package do not use strict - semver. Rubygems use their own notation for version ranges which ressembles - the ``node-semver`` notation with some subtle differences. - See https://guides.rubygems.org/patterns/#semantic-versioning - -- ``node-semver`` ranges are used in npm at https://github.com/npm/node-semver#ranges - with range semantics that are specific to ``semver`` and npm. - -- Dart pub versioning scheme is similar to ``node-semver`` and the documentation - at https://dart.dev/tools/pub/versioning provides a comprehensive coverage of - the topic of versioning. Version resolution uses its own algorithm. - -- Python uses its own version and version ranges notation with notable - specificities on how how pre- and post-release suffixes are used - https://www.python.org/dev/peps/pep-0440/ - -- Debian and Ubuntu use their own notation and are remarkabel for their use of - ``epochs`` to disambiguate versions. - https://www.debian.org/doc/debian-policy/ch-relationships.html - -- RPM distros use their own range notation and use epochs like Debian. - https://rpm-software-management.github.io/rpm/manual/dependencies.html - -- Perl CPAN defines its own version range notation similar to this specification - and uses two-segment versions. https://metacpan.org/pod/CPAN::Meta::Spec#Version-Ranges - -- Apache Maven and NuGet use similar math intervals notation using brackets - https://en.wikipedia.org/wiki/Interval_(mathematics) - - - Apache Maven http://maven.apache.org/enforcer/enforcer-rules/versionRanges.html - - NuGet https://docs.microsoft.com/en-us/nuget/concepts/package-versioning#version-ranges - -- gradle uses Apache Maven notation with some extensions - https://docs.gradle.org/current/userguide/single_versions.html - -- Gentoo and Alpine Linux use comparison operators similar to this specification: - - Gentoo https://wiki.gentoo.org/wiki/Version_specifier - - Alpine linux https://gitlab.alpinelinux.org/alpine/apk-tools/-/blob/master/src/version.c - -- Arch Linux https://wiki.archlinux.org/title/PKGBUILD#Dependencies use its - own simplified notation for its PKGBUILD depends array. - -- Go modules https://golang.org/ref/mod#versions use semver versions with - specific version resolution algorithms. - -- Haskell Package Versioning Policy https://pvp.haskell.org/ provides a notation - similar to this specification based on a modified semver with extra notations - such as star and caret. - -- The NVD https://nvd.nist.gov/vuln/data-feeds#cpeMatch defines CPE ranges as - lists of version start and end either including or excluding the start or end - version. And also provides a concrete enumeration of the available ranges as - a daily feed. - -- The version 5 of the NVD CVE JSON data format at - https://github.com/CVEProject/cve-schema/blob/master/schema/v5.0/CVE_JSON_5.0.schema#L303 - defines version ranges with a starting version, a versionType, and an upper - limit for the version range as lessThan or lessThanOrEqual. Or an enumeration - of versions. The versionType is defined as ``"The version numbering system - used for specifying the range. This defines the exact semantics of the - comparison (less-than) operation on versions, which is required to understand - the range itself"``. - -- The OSSF OSV schema https://ossf.github.io/osv-schema/ defines vulnerable - ranges with version events using "introduced" and "limit" fields and an - enumeration of all the versions in these ranges, except for semver-based - versions. A range may be ecosystem-specific based on a provided package - "ecosystem" value that ressembles closely the Package URL package "type". - - -The way two versions are compared as equal, lesser or greater is a closely -related topic: - -- Each package ecosystem may have evolved its own peculiar version string - conventions, semantics and comparison procedure. - -- For instance, ``semver`` is a prominent specification in this domain but this is - just one of the many ways to structure a version string. - -- Debian, RPM, PyPI, Rubygems, and Composer have their own subtly different - approach on how to determine which version is greater or lesser. - - -Solution ---------- - -A solution to the many version range syntaxes is to design a new notation to -unify them all with: - -- a mostly universal and minimalist, compact notation to express version ranges - from many different package types and ecosystems. - -- the package type-specific definitions to normalize existing range expressions - to this common notation. - -- the designation of which algorithm or procedure to use when comparing two - versions such that it is possible to resolve if a version is within or - outside of a version range. - -We call this solution "version range specifier" or "vers" and it is described -in this document. - - -Version range specifier -~~~~~~~~~~~~~~~~~~~~~~~~~~~~ - -A version range specifier (aka. "vers") is a URI string using the ``vers`` -URI-scheme with this syntax:: - - vers:/|,... - -For example to define a set of versions that contains either version ``1.2.3``, -or any versions greater than or equal to ``2.0.0`` but less than ``5.0.0`` using -the ``node-semver`` versioning scheme, the version range specifier will be:: - - vers:npm/1.2.3|>=2.0.0,<5.0.0 - -Each ```` in the pipe-separated list is either a simple -constraint such as:: - - - -Or a composite constraint grouping multiple ```` joined by -a comma such as:: - - ,... - -The pipe is a logical `OR` and the comma is a logical `AND`. - -A version range specifier is therefore an "OR of ANDs" where there are two -levels of constraints that a version should satisfy to be part of the range: - -- At the first level, anyone of the constraints should be satisfied -- At the second level, all of the constraints must be satisfied - -This is also called a "disjunctive normal form" in boolean logic. -See https://en.wikipedia.org/wiki/Disjunctive_normal_form for details. - -``vers`` is the URI-scheme and is an acronym for "VErsion Range Specifier". It -has been selected because it is short, obviously about version and available -for a future formal registration for this URI-scheme at the IANA registry. - - -```` ------------------------- - -The ```` (such as ``npm``, ``deb``, etc.) determines: - -- the specific notation and conventions used for a version string encoded in - this scheme. Versioning schemes often specify a version segments separator and - the meaning of each version segments, such as [major.minor.patch] in semver. - -- how two versions are compared as greater or lesser to determine if a version - is within or outside a range. - -- how a versioning scheme-specific range notation can be transformed in the - ``vers`` simplified notation defined here. - -- by convention the versioning scheme should be the same string as the Package - URL package type for a given package ecosystem. It is OK to have other schemes - beyond the purl type and even schemes that are specific to a single package. - -The ```` is followed by a slash "/". - - -```` ----------------------------- - -After the ```` and "/" there are one or more -```` separated by a pipe "|". The pipe "|" means that -**any** of these constraints must be satisfied for a version to be resolved as -within this version range. - -Each ```` of this pipe-separated list can be either a -single constraint or a list of constraints separated in turn by an comma "," as -in ``1.2.3|>=2.0.0,<5.0.0``. - -Multiple ```` combined with a comma means that **all** these -constraints must be satisfied for a version to be resolved as contained in this -range. - -Each simple version constraint has this syntax:: - - - -The ```` is one of these comparison operators: - -- "=": Version equality comparator. It is the default and implied if not - present and means that a version must be equal to the provided version. - For example: "=1.2.3". It must be omitted in the canonical representation. - Equality is based on the equality of two lower-cased and normalized version - strings and is typically not versioning scheme-specific, though some - scheme such as pypi PEP440 may apply some version string normalization - before testing for equality. - -- "!=": Version exclusion or inequality comparator. This means a version must - not be equal to the provided version and this version must be excluded from - the range. For example: "!=1.2.3" means that version "1.2.3" is excluded. - -- "<", "<=": Less than or less-or-equal version comparators points to all - versions less than or equal to the provided version. For example "<=1.2.3" - means less than or equal to "1.2.3". - -- ">", ">=": Greater than or greater-or-equal version comparators points to - all versions greater than or equal to the provided version. For example - ">=1.2.3" means greater than or equal to "1.2.3". - -- The way two version strings are compared using these comparators is defined - by the ````. - -- The structure and meaning of a version string such as "1.2.3" is defined by - the ````. For instance, ``semver`` defines three - dot-separated segments name major, minor and patch. - -- The special star "*" ```` matches any version. This star - constraint must be used **alone** in a version range, exclusive of any other - constraint. For example "vers:deb/\*" resolves to any version of a Debian - package. - -- The way each of these comparators work when doing a version comparison is - specific to a versioning scheme. - - -Examples -~~~~~~~~~ - -TODO. - - -Normalized or canonical representation -~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ - -- A version range specifier contains only printable ASCII letters, digits and - punctuation. - -- Spaces are not significant and are removed in the canonical form. For example - "!=1.2.3" and " ! = 1.2. 3" are equivalent. And so are "1.2.3 & < = 2.0.0" and - "1.2.3&<=2.0.0" - -- A version range specifier is case-insensitive and lowercase in canonical form. - -- The ordering of multiple ```` in a range specifier is not - significant. The canonical ordering is by sorting these by lexicographical - order applied with this two steps approach: - - - first to each sub-list of comma-separated ````. - - then to the top level list of pipe-separated ````. - -- A version in a ```` can only contain printable ASCII - characters excluding the special characters used as separators and comparators - ``><=!,&*|``. If it contains special characters (which should be rare in - practice) the version string in a constraint must be quoted using the URL - quoting rules. - - -Using version range specifiers -~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ - -``vers`` primary usage is to test if a version is within or outside a range. - -An version is within a version range if satisfies or is "contained" in -**any one** of the first level of constraints. To satisfy or be "contained" in -a first level constraint, a version must satisfy or be "contained" in -**all** the second level of constraints. Otherwise, the input version is outside -of the version range. - -Some important usages derived from this primary usage include: - -- **Resolving a version range specifier to a list of concrete versions.** - In this case, the input is the set of known versions of a package (typically - obtained from some package repository or registry). Each version is then - tested individually to check if it is within or outside the range. For - example, this can be used to determine which existing package versions are - affected by a known vulnerability if they match the vulnerability version - range specifier. - -- **Selecting one of several versions that are within a range.** - In this case, given several versions that are within a range and several - packages that each expression inter dependencies together with version ranges, - package management tools need to determine and select a set of package versions - that satify all the version ranges of all dependencies. This usually requires - deploying heuristics and algorithms (possibly complex such as sat solvers) - that are ecosystem- and tool-specific and outside of the scope for this - specification; yet ``vers`` could be used in tandem with ``purl`` to provide - an input to a dependencies resolution process. - - -Parsing version range specifiers -~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ - -To parse a version range specifier string: - -- Remove all spaces and tabs. -- Start from left, and split once on colon ":". -- The left hand side is the URI-scheme that must be lowercase. - - Verify that the URI-scheme value is ``vers``. -- The right hand side is the specifier. - -- Split the specifier from left once on a slash "/". -- The left hand side is the that must be lowercase. -- The right hand side is a list of one or more constraints. - -- If the constraints string is equal to "*", the is "*". - Parsing is done and no further processing is needed for this ``vers``. A tool - may be strict and report an error if there are extra characters beyond "*" or - be lenient. - -- Split the constraints on pipe "|". The result is a list of top-level - lists. Consecutive pipes should be treated as one. - -- For each list: - - - Split on comma ",". Consecutive commas should be treated as one. The result - is a sub-list of . - - - For each in this sub-list: - - - Identify the comparator and version based on the - start of the in this sequence: - - - If it starts with "=", then the comparator is "=" - - If it starts with "!=", then the comparator is "!=". - - If it starts with "<=", then the comparator is "<=". - - If it starts with ">=", then the comparator is ">=". - - If it starts with "<", then the comparator is "<". - - If it starts with ">", then the comparator is ">". - - Else the comparator is "=" (default) and the - version is the full string. - - - After the operation and removing the comparator from - string, the remaining string is the version. - - - Validate that the version is not empty. - - - If the version contains a percent "%" character, apply URL quoting rules - to unquote this string. - - - Append the comparator and version of this constraint to the inner list - of constraints. - - - Append the accumulated list of (comparator and version) that must apply to - the top level list of constraints. - -- Finally return the and the nested list of - - -Notes and caveats -~~~~~~~~~~~~~~~~~~~ - -- Comparing versions from two different versioning schemes is unspecified. Even - though there may be some similarities between the ``semver`` version of an npm - and the `debian` version of its Debian packaging, these similarities are - specific to each versioning scheme. Tools should report an error in these - cases as it does not make sense to perform these comparisons. - - -Some of the known versioning schemes -~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ - -TODO: add details on how to convert to and from ``vers`` for a given versioning -scheme and package type. - -- ``deb``: Debian and Ubuntu https://www.debian.org/doc/debian-policy/ch-relationships.html - The comparators are <<, <=, =, >= and >>. - -- ``rpm``: RPM distros https://rpm-software-management.github.io/rpm/manual/dependencies.html - The version comparison routine of rmpvercmp is also used by archlinux Pacman. - -- ``gem``: Rubygems https://guides.rubygems.org/patterns/#semantic-versioning - which is almost but not exactly ``node-semver``. - -- ``npm``: npm uses node-semver which is based on semver with its own range - notation https://github.com/npm/node-semver#ranges - A similar but different scheme is used by Rust - https://doc.rust-lang.org/cargo/reference/specifying-dependencies.html - and several other package types may use ``node-semver``-like ranges. But most - of these related schemes are not strictly the same as what is implemented in - ``node-semver``. For instance PHP ``composer`` may need its own scheme as this - is not strictly ``node-semver``. - -- ``pypi``: Python https://www.python.org/dev/peps/pep-0440/ - -- ``perl``: Perl https://perlmaven.com/how-to-compare-version-numbers-in-perl-and-for-cpan-modules - -- ``go``: Go modules https://golang.org/ref/mod#versions use semver versions - with a specific minimum version resolution algorithm. - -- ``maven``: Apache Maven http://maven.apache.org/enforcer/enforcer-rules/versionRanges.html - -- ``nuget``: NuGet https://docs.microsoft.com/en-us/nuget/concepts/package-versioning#version-ranges - Note that Apache Maven and NuGet are following a similar approach with a - math-derived intervals syntax as in https://en.wikipedia.org/wiki/Interval_(mathematics) - -- ``gentoo``: Gentoo https://wiki.gentoo.org/wiki/Version_specifier - -- ``alpine``: Alpine linux https://gitlab.alpinelinux.org/alpine/apk-tools/-/blob/master/src/version.c - which is using Gentoo-like conventions. - -- ``generic``: a generic version comparison algorithm (which is TBD, likely a - split on punctuation and dealing with digit vs. strings comparisons, like is - done in libversion) - -TODO: add Rust, composer and archlinux - - -Implementations -~~~~~~~~~~~~~~~~~~~~~~~~~~~~ - -- Python: https://github.com/nexB/univers -- Yours! - - -Why not reuse existing version range notations? -~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ - -Most existing version range notations are tied to a specific version string -syntax and are therefore not readily applicable to other contexts. For example, -the use of elements such as tilde and caret ranges in Rubygems, npm or Dart -notations implies that a certain structure exists in the version string (semver -or semver- like). The inclusion of these additional comparators is a result of -the history and evolution in a given package ecosystem to address specific needs. - -In practice, the unified and reduced set of comparators and syntax defined for -``vers`` has been designed such that all these notations can be converted to a -``vers`` and back from a ``vers`` to the original notation. - -In contrast, this would not be possible with existing notations. For instance, -the Python notation may not work with npm semver versions and reciprocally. - -There are likely to be a few rare cases where round tripping from and to -``vers`` may not be possible, and in any case round tripping to and from ``vers`` -should produce equivalent results and even if not strictly the same original -strings. - -Another issue with existing version range notations is that they are primarily -meant to be used for dependency constraints and may not readily be reusable for -the definitions of vulnerable ranges. In particular, a vulnerability may exist -for multiple "version branches" of a given package such as with Django 2.x and -3.x. Several version range notations have difficulties to communicate these -as typically all the version constraints must be satisfied. In constrast, -a vulnerability can affect multiple disjoint version ranges of a package and any -version satisfying these constraints would be vulnerable: it may not be possible -to express this with a notation designed exclusively for dependent versions -resolution. - - -Why not use the NVD CPE Ranges? -############################### - -See: - -- https://nvd.nist.gov/vuln/vulnerability-detail-pages#divRange -- https://nvd.nist.gov/developers/vulnerabilities#divResponse -- https://csrc.nist.gov/schema/nvd/feed/1.1/nvd_cve_feed_json_1.1.schema - -The version ranges notation defined in the JSON schema of the CVE API payload -uses these four fields: ``versionStartIncluding``, ``versionStartExcluding``, -``versionEndIncluding`` and ``versionEndExcluding``. For example:: - - "versionStartIncluding": "7.3.0", - "versionEndExcluding": "7.3.31", - "versionStartExcluding" : "9.0.0", - "versionEndIncluding" : "9.0.46", - -In addition to these ranges, the NVD publishes a list of concrete CPE with -versions resolved for a range with daily updates at -https://nvd.nist.gov/vuln/data-feeds#cpeMatch - -Note that the NVD CVE configuration is a complex specification that goes well -beyond version ranges and is used to match comprehensive configurations across -multiple products and version ranges. ``vers`` focus is exclusively versions. - -The NVD JSON notation is verbose in contrast with ``vers`` that attempts to -provide a compact notation. It provides the same =, <=, < and > comparators -specified in ``vers`` and found in other notations. - - -Why not use node-semver ranges? -############################### - -- https://github.com/npm/node-semver#ranges - -The node-semver spec is similar to this spec but is an AND of ORs constraints -with a few practical issues: - -- The space means "AND", therefore whitespaces are significant. Having - significant whitespaces in a string makes normalization more complicated and - may be a source of confusion if you remove the spaces from the string. Using - a comma as an "AND" operator in ``vers`` makes this explicit and avoids the - ambiguity of a space. - -- There is no negation "!=" operator meaning that some version constraints are - difficult to express and require combining < and > comparators. For instance - stating that a vulnerability affects babel 6.2 or later but not babel 7.0 is - possible but complicated. - -- The advanced range syntax has grown to be rather complex using hyphen, stars, - carets and tilde constructs that are all tied to the JavaScript and npm ways - of handling versions in their ecosystem and are bound furthermore to the - semver semantics and its npm implementation. These are not readily reusable - elsewhere and these extended multiple comparators and modifiers make the - notation grammar more complex to parse for a machine and harder to read for - human. - -Notations that are directly derived from node-semver as used in Rust and PHP -Composer have the same issues. - - -Why not use Python pep-0440 ranges? -##################################### - -See: - -- https://www.python.org/dev/peps/pep-0440/#version-specifiers - -The Python pep-0440 "Version Identification and Dependency Specification" -provides a comprehensive specification for Python package versioning and a -notation for "version specifiers" to express the version constraints of -dependencies. - -This specification is similar to this ``vers`` spec, but has a richer notation -with some aspects specific to the versions used only in the Python ecosystem. - -- In particular pep-0440 uses tilde, triple equal and wildcard star operators - that are specific to how two Python versions are compared. - -- The comma separator between constraints is a logical "AND" rather than an - "OR". The "OR" does not exist in the syntax making some version ranges - harder to express, in particular for vulnerabilities that may affect several - exact versions or version ranges such as when there are multiple release - branches that exist in parallel. For instance a statement such as: Django 1.2 - or later, or Django 2.2 or later or Django 3.2 or later is difficult to - express without an "OR" logic. - - -Why not use Rubygems requirements notation? -############################################### - -- https://guides.rubygems.org/patterns/#declaring-dependencies - -The rubygems specification suggests but does not enforce using semver. It is -similar to this spec's operators with the addition of the "~>" aka. pessimistic -operator or tilde-wakka which is similar to the "tilde" used in node-semver and -implies semver versioning. This makes the notation impractical to reuse -in places that do not use the same semver-like semantics. - - -Why not use fancier comparators such as a tilde, caret and star? -################################################################## - -Several existing notations such as used with npm, gem or python or composer -provide syntactic shorthands such as: - -- a tilde prefix or ~> prefix or =~ as in "~1.3" or "~>1.2.3" -- a caret ^ prefix as in "^ 1.2" -- using a star in a segment of a version as in "1.2.*" -- dash-separated ranges as in "1.2 - 1.4" - -These range syntaxes can typcially be reduced to a set of simpler operators. -Furthermore they are designed for the structure of a version string (most often -semver) as used in one ecosystem and therefore are not reusable in another -ecosystem that would not use the version string conventions. - - -Why not use mathematical interval notation for ranges? -####################################################### - -Apache Maven and NuGet make use of a mathematical interval with "[" and ")" as a -syntax for version ranges. - -All other notations are using >, <, and = as base symbols for ranges. ``vers`` -reuses this approach because it is more common across package ecosystems. - - -References -~~~~~~~~~~~~~~~~~~~~ - -Here are some of the discussions that led to the creation of this specification: - -- https://github.com/package-url/purl-spec/issues/66 -- https://github.com/package-url/purl-spec/issues/84 -- https://github.com/package-url/purl-spec/pull/93 -- https://github.com/nexB/vulnerablecode/issues/119 -- https://github.com/nexB/vulnerablecode/issues/140 diff --git a/src/univers/debian.py b/src/univers/debian.py index 4e243cae..b9520df4 100644 --- a/src/univers/debian.py +++ b/src/univers/debian.py @@ -69,6 +69,13 @@ class Version(object): >>> print([str(v) for v in sorted(Version.from_string(s) for s in unsorted)]) ['0.1', '0.5', '1.0', '2.0', '3.0', '1:0.4', '2:0.3'] + We also accept trailing punctuations in the version and release: + + >>> v = "2:4.13.1-0ubuntu0.16.04.1.1~" + >>> assert str(Version.from_string(v)) == v + >>> v = "2:4.13.1~" + >>> assert str(Version.from_string(v)) == v + This example uses 'epoch' numbers (the numbers before the colons) to demonstrate that this version sorting order is different from regular sorting and 'natural order sorting'. @@ -96,30 +103,32 @@ def __hash__(self): return hash(self.tuple()) def __eq__(self, other): - return type(self) is type(other) and self.tuple() == other.tuple() + if not isinstance(other, self.__class__): + return NotImplemented + return self.tuple() == other.tuple() def __ne__(self, other): return not self.__eq__(other) def __lt__(self, other): - if type(self) is type(other): - return eval_constraint(self, "<<", other) - return NotImplemented + if not isinstance(other, self.__class__): + return NotImplemented + return eval_constraint(self, "<<", other) def __le__(self, other): - if type(self) is type(other): - return eval_constraint(self, "<=", other) - return NotImplemented + if not isinstance(other, self.__class__): + return NotImplemented + return eval_constraint(self, "<=", other) def __gt__(self, other): - if type(self) is type(other): - return eval_constraint(self, ">>", other) - return NotImplemented + if not isinstance(other, self.__class__): + return NotImplemented + return eval_constraint(self, ">>", other) def __ge__(self, other): - if type(self) is type(other): - return eval_constraint(self, ">=", other) - return NotImplemented + if not isinstance(other, self.__class__): + return NotImplemented + return eval_constraint(self, ">=", other) @classmethod def from_string(cls, version): @@ -128,7 +137,7 @@ def from_string(cls, version): version = version.strip() if not version: raise ValueError('Invalid version string: "{}"'.format(version)) - if not _is_valid_version(version): + if not cls.is_valid(version): raise ValueError('Invalid version string: "{}"'.format(version)) if ":" in version: @@ -144,6 +153,10 @@ def from_string(cls, version): revision = "0" return cls(epoch=epoch, upstream=upstream, revision=revision) + @classmethod + def is_valid(cls, version): + return is_valid_debian_version(version) + def compare(self, other_version): return compare_versions(self, other_version) @@ -154,7 +167,7 @@ def tuple(self): return self.epoch, self.upstream, self.revision -_is_valid_version = re.compile( +is_valid_debian_version = re.compile( r"^" # epoch must start with a digit r"(\d+:)?" @@ -163,12 +176,10 @@ def tuple(self): r"(" # upstream can contain only alphanumerics and the characters . + - # ~ (full stop, plus, hyphen, tilde) - # we are adding the extra check that it must end with alphanum - r"[A-Za-z0-9\.\+\-\~]*[A-Za-z0-9]" + r"[A-Za-z0-9\.\+\~\-]+" r"|" - # If there is no debian_revision then hyphens are not allowed. - # we are adding the extra check that it must end with alphanum - r"[A-Za-z0-9\.\+\~]*[A-Za-z0-9]-[A-Za-z0-9\+\.\~]*[A-Za-z0-9]" + # If there is no debian_revision then hyphens are not allowed in version. + r"[A-Za-z0-9\.\+\~]+-[A-Za-z0-9\+\.\~]+" r")?" r"$" ).match @@ -187,15 +198,14 @@ def eval_constraint(version1, operator, version2): result = compare_versions(version1, version2) # See https://www.debian.org/doc/debian-policy/ch-relationships.html#syntax-of-relationship-fields operators = { + "<<": operator_module.lt, "<=": operator_module.le, - # legacy for compat - "<": operator_module.le, + "=": operator_module.eq, ">=": operator_module.ge, - # legacy for compat - ">": operator_module.ge, - "<<": operator_module.lt, ">>": operator_module.gt, - "=": operator_module.eq, + # legacy for compat + "<": operator_module.lt, + ">": operator_module.gt, } try: diff --git a/src/univers/gem.py b/src/univers/gem.py new file mode 100644 index 00000000..36eb2a68 --- /dev/null +++ b/src/univers/gem.py @@ -0,0 +1,735 @@ +# Copyright (c) nexB, Inc. and others. +# Copyright (c) Center for Information Technology, http://coi.gov.pl +# Copyright (c) Chad Fowler, Rich Kilmer, Jim Weirich and others. +# Copyright (c) Engine Yard and Andre Arko, Facebook, Inc. and its affiliates. +# +# SPDX-License-Identifier: Apache-2.0 AND MIT +# This has been significantly modified from the original +# +# Visit https://aboutcode.org and https://github.com/nexB/univers for support and download. + +# notes: This has been substantially modified and enhanced from the original +# puppeteer code to extract the Ruby version hanlding code. +# This code is in turn originally based and updated from the Rubygems code +# Originally from https://github.com/rubygems/rubygems and +# https://github.com/coi-gov-pl/puppeter + +import operator +import re +from collections import namedtuple +from itertools import dropwhile + + +class InvalidRequirementError(AttributeError): + pass + + +class InvalidVersionError(ValueError): + pass + + +class GemVersion: + """ + The Rubygems version.rb has this documentation + The Version class processes string versions into comparable + values. A version string should normally be a series of numbers + separated by periods. Each part (digits separated by periods) is + considered its own number, and these are used for sorting. So for + instance, 3.10 sorts higher than 3.2 because ten is greater than + two. + + If any part contains letters (currently only a-z are supported) then + that version is considered prerelease. Versions with a prerelease + part in the Nth part sort less than versions with N-1 + parts. Prerelease parts are sorted alphabetically using the normal + Ruby string sorting rules. If a prerelease part contains both + letters and numbers, it will be broken into multiple parts to + provide expected sort behavior (1.0.a10 becomes 1.0.a.10, and is + greater than 1.0.a9). + + Prereleases sort between real releases (newest to oldest): + + 1. 1.0 + 2. 1.0.b1 + 3. 1.0.a.2 + 4. 0.9 + + If you want to specify a version restriction that includes both prereleases + and regular releases of the 1.x series this is the best way: + + s.add_dependency 'example', '>= 1.0.0.a', '< 2.0.0' + + == How Software Changes + + Users expect to be able to specify a version constraint that gives them + some reasonable expectation that new versions of a library will work with + their software if the version constraint is true, and not work with their + software if the version constraint is false. In other words, the perfect + system will accept all compatible versions of the library and reject all + incompatible versions. + + Libraries change in 3 ways (well, more than 3, but stay focused here!). + + 1. The change may be an implementation detail only and have no effect on + the client software. + 2. The change may add new features, but do so in a way that client software + written to an earlier version is still compatible. + 3. The change may change the public interface of the library in such a way + that old software is no longer compatible. + + Some examples are appropriate at this point. Suppose I have a Stack class + that supports a push and a pop method. + + === Examples of Category 1 changes: + + * Switch from an array based implementation to a linked-list based + implementation. + * Provide an automatic (and transparent) backing store for large stacks. + + === Examples of Category 2 changes might be: + + * Add a depth method to return the current depth of the stack. + * Add a top method that returns the current top of stack (without + changing the stack). + * Change push so that it returns the item pushed (previously it + had no usable return value). + + === Examples of Category 3 changes might be: + + * Changes pop so that it no longer returns a value (you must use + top to get the top of the stack). + * Rename the methods to push_item and pop_item. + + == RubyGems Rational Versioning + + * Versions shall be represented by three non-negative integers, separated + by periods (e.g. 3.1.4). The first integers is the "major" version + number, the second integer is the "minor" version number, and the third + integer is the "build" number. + + * A category 1 change (implementation detail) will increment the build + number. + + * A category 2 change (backwards compatible) will increment the minor + version number and reset the build number. + + * A category 3 change (incompatible) will increment the major build number + and reset the minor and build numbers. + + * Any "public" release of a gem should have a different version. Normally + that means incrementing the build number. This means a developer can + generate builds all day long, but as soon as they make a public release, + the version must be updated. + + === Examples + + Let's work through a project lifecycle using our Stack example from above. + + Version 0.0.1:: The initial Stack class is release. + Version 0.0.2:: Switched to a linked=list implementation because it is + cooler. + Version 0.1.0:: Added a depth method. + Version 1.0.0:: Added top and made pop return nil + (pop used to return the old top item). + Version 1.1.0:: push now returns the value pushed (it used it + return nil). + Version 1.1.1:: Fixed a bug in the linked list implementation. + Version 1.1.2:: Fixed a bug introduced in the last fix. + + Client A needs a stack with basic push/pop capability. They write to the + original interface (no top), so their version constraint looks like: + + gem 'stack', '>= 0.0' + + Essentially, any version is OK with Client A. An incompatible change to + the library will cause them grief, but they are willing to take the chance + (we call Client A optimistic). + + Client B is just like Client A except for two things: (1) They use the + depth method and (2) they are worried about future + incompatibilities, so they write their version constraint like this: + + gem 'stack', '~> 0.1' + + The depth method was introduced in version 0.1.0, so that version + or anything later is fine, as long as the version stays below version 1.0 + where incompatibilities are introduced. We call Client B pessimistic + because they are worried about incompatible future changes (it is OK to be + pessimistic!). + + == Preventing Version Catastrophe: + + From: http://blog.zenspider.com/2008/10/rubygems-howto-preventing-cata.html + + Let's say you're depending on the fnord gem version 2.y.z. If you + specify your dependency as ">= 2.0.0" then, you're good, right? What + happens if fnord 3.0 comes out and it isn't backwards compatible + with 2.y.z? Your stuff will break as a result of using ">=". The + better route is to specify your dependency with an "approximate" version + specifier ("~>"). They're a tad confusing, so here is how the dependency + specifiers work: + + Specification From ... To (exclusive) + ">= 3.0" 3.0 ... ∞ + "~> 3.0" 3.0 ... 4.0 + "~> 3.0.0" 3.0.0 ... 3.1 + "~> 3.5" 3.5 ... 4.0 + "~> 3.5.0" 3.5.0 ... 3.6 + "~> 3" 3.0 ... 4.0 + + For the last example, single-digit versions are automatically extended with + a zero to give a sensible result. + """ + + VERSION_PATTERN = r"[0-9]+(?:\.[0-9a-zA-Z]+)*(-[0-9A-Za-z-]+(\.[0-9A-Za-z-]+)*)?" + is_correct = re.compile(rf"^\s*({VERSION_PATTERN})?\s*$").match + + def __init__(self, version): + """ + Construct a Version from the ``version`` string. A version string is a + series of digits or ASCII letters separated by dots and may contain dash + "-". + """ + if isinstance(version, (int, GemVersion)): + version = str(version) + + if not isinstance(version, str): + raise InvalidVersionError(version) + + if not self.is_correct(version): + raise InvalidVersionError(version) + + # If version is an empty string convert it to 0 + version = str(version).strip() + + self.original = version + + if not version: + version = "0" + + self.version = version.replace("-", ".pre.") + self._segments = () + self._canonical_segments = () + self._bump = None + self._release = None + + def __str__(self): + return self.original + + to_string = __str__ + + def __repr__(self): + return f"GemVersion({self.original!r})" + + def equal_strictly(self, other): + return self.version == other.version + + def __hash__(self): + return hash(self.canonical_segments) + + def __eq__(self, other): + return self.canonical_segments == other.canonical_segments + + def __lt__(self, other): + return self.__cmp__(other) < 0 + + def __le__(self, other): + return self.__cmp__(other) <= 0 + + def __gt__(self, other): + return self.__cmp__(other) > 0 + + def __ge__(self, other): + return self.__cmp__(other) >= 0 + + def bump(self): + """ + Return a new version object where the next to the last revision number + is one greater (e.g., 5.3.1 => 5.4) i.e., incrementing this GemVersion + last numeric segment. + + For example:: + >>> assert GemVersion("5.3.1").bump() == GemVersion("5.4"), repr(GemVersion("5.3.1").bump()) + >>> assert GemVersion("5.3.1.4-2").bump() == GemVersion("5.3.2"), GemVersion("5.3.1.4-2").bump() + """ + if not self._bump: + segments = [] + for seg in self.segments: + if isinstance(seg, str): + break + else: + segments.append(seg) + + if len(segments) > 1: + segments.pop() + + segments[-1] += 1 + segments = [str(r) for r in segments] + self._bump = GemVersion(".".join(segments)) + + return self._bump + + def release(self): + """ + Return a new GemVersion which is the release for this version (e.g., + 1.2.0.a -> 1.2.0). Non-prerelease versions return themselves. A release + is composed only of numeric segments. + """ + if not self._release: + if self.prerelease(): + segments = self.segments + while any(isinstance(s, str) for s in segments): + segments.pop() + segments = (str(s) for s in segments) + self._release = GemVersion(".".join(segments)) + else: + self._release = self + + return self._release + + def prerelease(self): + """ + Return True if this is considered as a prerelease version. + A version is considered a prerelease if it contains a letter. + """ + return any(not str(s).isdigit() for s in self.segments) + + @property + def segments(self): + """ + Return a new sequence of segments for this version where segments are + ints or strings parsed from the original version string. + """ + if not self._segments: + self._segments = self.get_segments() + return list(self._segments) + + def get_segments(self): + """ + Return a sequence of segments for this version where segments are ints + or strings parsed from the original version string. + """ + find_segments = re.compile(r"[0-9]+|[a-z]+", re.IGNORECASE).findall + segments = [] + for seg in find_segments(self.version): + if seg.isdigit(): + seg = int(seg) + segments.append(seg) + return tuple(segments) + + @property + def canonical_segments(self): + if not self._canonical_segments: + self._canonical_segments = self.get_canonical_segments() + return list(self._canonical_segments) + + def get_canonical_segments(self): + """ + Return a new sequence of "canonical segments" for this version using + the Rubygems way. + """ + canonical_segments = [] + for segments in self.split_segments(): + segs = list(dropwhile(lambda s: s == 0, reversed(segments))) + segs = reversed(segs) + canonical_segments.extend(segs) + return tuple(canonical_segments) + + def split_segments(self): + """ + Return a two-tuple of segments: + - the first is a list of numeric-only segments starting from the left + - the second is a list of alpha or numericsegments starting with the + first alpha segment from the left. + """ + numeric_segments = [] + string_segments = [] + for seg in self.segments: + is_numeric = isinstance(seg, int) # or (isinstance(seg, str) and seg.isdigit()) + if is_numeric: + if string_segments: + string_segments.append(seg) + else: + numeric_segments.append(seg) + else: + string_segments.append(seg) + return numeric_segments, string_segments + + def __cmp__(self, other, trace=False): + """ + Compare this version with ``other`` returning -1, 0, or 1 if the + other version is larger, the same, or smaller than this + one. Attempts to compare to something that's not a + ``GemVersion raises an exception. + + The comparison results have the same semantics as the legacy "cmp()" + built-in function. + """ + if trace: + print(f"\nComparing: {self!r} with {other!r}") + if isinstance(other, str): + other = GemVersion(other) + if trace: + print(f" Converted to GemVersion: {other!r}") + + if not isinstance(other, GemVersion): + if trace: + print(f" Not a GemVersion: {other!r}") + return + + if self.version == other.version: + return 0 + + lhsegments = self.canonical_segments + if trace: + print(f" lhsegments: canonical_segments: {lhsegments!r}") + + rhsegments = other.canonical_segments + if trace: + print(f" rhsegments: canonical_segments: {rhsegments!r}") + + if lhsegments == rhsegments: + if trace: + print(f" lhsegments == rhsegments: returning 0") + return 0 + + lhsize = len(lhsegments) + rhsize = len(rhsegments) + if trace: + print(f" lhsize: {lhsize!r}") + if trace: + print(f" rhsize: {rhsize!r}") + + if lhsize > rhsize: + if trace: + print(f" lhsize > rhsize: limit = lhsize: {lhsize!r}") + limit = lhsize + else: + if trace: + print(f" lhsize <= rhsize: limit = rhsize: {rhsize!r}") + limit = rhsize + + limit -= 1 + + i = 0 + + if trace: + print(f" limit: {limit!r}, i: {i!r}") + + while i <= limit: + if trace: + print(f" limit: {limit!r}, i: {i!r}") + + try: + lhs = lhsegments[i] + except IndexError: + lhs = 0 + + try: + rhs = rhsegments[i] + except IndexError: + rhs = 0 + + i += 1 + + if trace: + print(f" lhs: {lhs} rhs: {rhs} i: {i!r}") + + if lhs == rhs: + if trace: + print(f" lhs == rhs: continue") + continue + + if isinstance(lhs, str) and isinstance(rhs, int): + if trace: + print(f" isinstance(lhs, str): {type(lhs)!r}") + print(f" isinstance(rhs, int): {type(rhs)!r}") + print(f" return -1") + return -1 + + if isinstance(lhs, int) and isinstance(rhs, str): + if trace: + print(f" isinstance(lhs, int): {type(lhs)!r}") + print(f" isinstance(rhs, str): {type(rhs)!r}") + print(f" return 1") + return 1 + + result = (lhs > rhs) - (lhs < rhs) + if trace: + print(f" (lhs > rhs) - (lhs < rhs):{result!r}") + print(f" return {result}") + + return result + + if trace: + print(f" all options evaluated: return 0") + return 0 + + +GemConstraint = namedtuple("GemConstraint", ["op", "version"]) +GemConstraint.to_string = lambda gc: f"{gc.op} {gc.version}" + + +def sort_constraints(constraints): + """ + Return a sorted sequence of unique GemConstraints. + """ + constraints = sorted(constraints, key=lambda gc: (gc.version, gc.op)) + consts = [] + for gc in constraints: + if gc in consts: + continue + consts.append(gc) + return consts + + +def tilde_comparator(version, requirement, trace=False): + """ + Return True if ``version`` GemVersion satisfies ``requirement`` GemVersion + according to the Rubygems tilde semantics. + """ + if trace: + print(f" tilde_comparator: version: {version!r}, requirement: {requirement!r}") + print(f" version >= requirement: {version >= requirement!r}") + print() + print( + f" version.release() < requirement.bump(): {version.release()!r} " + f"< {requirement.bump()!r}: {version.release() < requirement.bump()!r}" + ) + + return version >= requirement and version.release() < requirement.bump() + + +class GemRequirement: + """ + A gem requirement using the Gem notation. + """ + + equal_op = operator.eq + comparators_by_op = { + "=": equal_op, + "!=": operator.ne, + ">": operator.gt, + "<": operator.lt, + ">=": operator.ge, + "<=": operator.le, + "~>": tilde_comparator, + } + + quoted = "|".join(re.escape(op) for op in comparators_by_op) + + PATTERN_RAW = f"\\s*({quoted})?\\s*({GemVersion.VERSION_PATTERN})\\s*" + + # A regular expression that matches a requirement + PATTERN = re.compile(f"^{PATTERN_RAW}$") + + # The default requirement matches any version + DEFAULT_CONSTRAINT = GemConstraint(">=", GemVersion(0)) + + def __init__(self, *requirements): + """ + Initialize a GemRequirement from a sequence of ``requirements`` + converted to a constraints sequence of GemConstraint. + """ + if not requirements: + self.constraints = (GemRequirement.DEFAULT_CONSTRAINT,) + else: + self.constraints = tuple([GemRequirement.parse(r) for r in requirements]) + + def __str__(self): + gcs = [gc.to_string() for gc in sort_constraints(self.constraints)] + return ", ".join(gcs) + + def __repr__(self): + gcs = ", ".join(repr(gc.to_string()) for gc in sort_constraints(self.constraints)) + return f"GemRequirement({gcs})" + + @classmethod + def from_string(cls, requirements): + """ + Return a GemRequirement build from a lockfile-style ``requirements`` + string. + + For example:: + >>> gr1 = GemRequirement(">= 1.0.1", "~> 1.0") + >>> gr2 = GemRequirement.from_string(" (>= 1.0.1, ~> 1.0)") + >>> assert gr1 == gr2, (gr1, gr2) + """ + reqs = requirements.strip().strip("()") + reqs = [r.strip() for r in reqs.split(",")] + return cls(*reqs) + + def for_lockfile(self): + """ + Return a string representing this list of requirements suitable for use + in a lockfile. + + For example:: + >>> gr = GemRequirement(">= 1.0.1", "~> 1.0") + >>> gf_flf = gr.for_lockfile() + >>> assert gf_flf == " (~> 1.0, >= 1.0.1)", gf_flf + """ + gcs = [gc.to_string() for gc in sort_constraints(self.constraints)] + gcs = ", ".join(gcs) + return f" ({gcs})" + + def dedupe(self): + """ + Return a new GemRequirement with sorted and unique constraints. + """ + return GemRequirement(*sort_constraints(self.constraints)) + + def simplify(self): + """ + Return a new simplified GemRequirement with: + - sorted and unique constraints. + - where ~> constraints are replaced by simpler contrainsts. + """ + constraints = [] + for const in self.constraints: + if const.op == "~>": + low_high = get_tilde_constraints(const) + constraints.extend(low_high) + else: + constraints.append(const) + return GemRequirement(*sort_constraints(constraints)) + + def __eq__(self, other): + if not isinstance(other, self.__class__): + return False + + # An == check is always necessary + if sort_constraints(self.constraints) == sort_constraints(other.constraints): + stilde = self.tilde_requirements() + if not stilde: + # An == check is sufficient unless any requirements use ~> + return True + else: + # If any requirements use ~> we use the stricter `#eql?` that + # also checks that version precision is the same + otilde = other.tilde_requirements() + if len(stilde) != len(otilde): + return False + for st, ot in zip(stilde, otilde): + if st.op != ot.op or not st.version.equal_strictly(ot.version): + return False + return True + return False + + def exact(self): + """ + Return True if the requirement is for only an exact version. + + For example: + >>> GemRequirement(">= 1.0.1", "~> 1.0").exact() + False + >>> GemRequirement("= 1.0.1", "~> 1.0").exact() + False + >>> GemRequirement("= 1.0.1").exact() + True + """ + return len(self.constraints) == 1 and self.constraints[0].op == "=" + + @classmethod + def create(cls, reqs): + """ + Return a GemRequirement built from a single requirement string or a list + of requirement strings. + """ + if isinstance(reqs, list): + return cls(*reqs) + else: + return cls(reqs) + + @classmethod + def parse(cls, requirement): + """ + Return a GemConstraint tuple of (operator string, GemVersion object) + parsed from a single ``requirement`` string such as "> 3.0". Also + accepts a two-tuple or list of ("op", "version") or a single GemVersion or a + GemConstraint). + """ + if isinstance(requirement, GemVersion): + return GemConstraint("=", requirement) + + if isinstance(requirement, (tuple, list, GemConstraint)): + return GemConstraint(*requirement) + + if not isinstance(requirement, str): + raise InvalidRequirementError("Illformed requirement {requirement!r}") + + match = cls.PATTERN.match(str(requirement)) + if not match: + raise InvalidRequirementError("Illformed requirement {requirement!r}") + + if match.group(1) == ">=" and match.group(2) == "0": + return cls.DEFAULT_CONSTRAINT + else: + op = match.group(1) if match.group(1) else "=" + return GemConstraint(op, GemVersion(match.group(2))) + + def satisfied_by(self, version, trace=False): + """ + Return True if the ``version`` GemVersion or version string or int + satisfies all the constraints of this requirement. Raise an + InvalidVersionError with an invalid ``version``. + """ + if trace: + print(f"\nis {self!r} satisfied_by: {version!r} ?") + if not isinstance(version, GemVersion): + version = GemVersion(version) + if trace: + print(f" converting version to GemVersion: {version!r}") + + if not self.constraints: + raise InvalidRequirementError(self) + + for constraint in self.constraints: + if trace: + print(f" processing: {constraint!r}") + + op = constraint.op + comparator = self.comparators_by_op[op] + if trace: + print(f" got comparator: {comparator!r}") + satisfying = comparator(version, constraint.version) + if trace: + print(f" {self!r} is satisfied by: {version!r}: {satisfying!r}") + print(f" {version!r} {op} {constraint.version!r}: {satisfying!r}") + if not satisfying: + return False + + return True + + def tilde_requirements(self): + """ + Return a sorted sequence of all pessimistic "~>" GemConstraint. + """ + constraints = sort_constraints(self.constraints) + return [gc for gc in constraints if gc.op == "~>"] + + +def get_tilde_constraints(constraint): + """ + Return a tuple of two GemConstraint representing the lower and upper + bound of a version range ``string`` that uses a tilde "~>" pessimistic operator. + Raise a ValueError if this is not a tilde range. + + For example: + >>> lower_bound, upper_bound = get_tilde_constraints(GemConstraint("~>", GemVersion("1.0.2"))) + >>> vlow = GemVersion("1.0.2") + >>> vup = GemVersion("1.1.0") + >>> assert lower_bound == GemConstraint(op=">=", version=vlow) + >>> assert upper_bound == GemConstraint(op="<", version=vup) + """ + if not isinstance(constraint, GemConstraint) or not constraint.op == "~>": + raise ValueError(f"Invalid tilde GemConstraint: {constraint!r}") + version = constraint.version + assert isinstance(version, GemVersion) + lower_bound = version.release() + upper_bound = lower_bound.bump() + + return ( + GemConstraint(op=">=", version=lower_bound), + GemConstraint(op="<", version=upper_bound), + ) diff --git a/src/univers/gem.py.ABOUT b/src/univers/gem.py.ABOUT new file mode 100644 index 00000000..d2e8542f --- /dev/null +++ b/src/univers/gem.py.ABOUT @@ -0,0 +1,23 @@ +about_resource: gem.py +license_expression: apache-2.0 AND mit +download_url: https://raw.githubusercontent.com/coi-gov-pl/puppeter/04e2a2008bd89a0429b734fdde6da83813688865/puppeter/domain/model/gemrequirement.py +copyright: | + Copyright (c) nexB, Inc. and others. + Copyright (c) Center for Information Technology, http://coi.gov.pl + Copyright (c) Chad Fowler, Rich Kilmer, Jim Weirich and others. + Copyright (c) Engine Yard and Andre Arko, Facebook, Inc. and its affiliates. + +package_url: pkg:pypi/puppeter@0.8.3#src/domain/model/gemrequirement.py +homepage_url: https://github.com/coi-gov-pl/puppeter +notice_file: gem.py.NOTICE + +notes: This file started as a subset of the coi.gov/pl code modified for + use in univers. The original Apache-licensed puppeteer code was used as a base, + extracting the Ruby version handling code. That coi code was in turn + originally based on MIT-licensed Rubygems code ported to Python. + This has been substantially modified and enhanced to pass correctly all the + upstream Rubygems tests and work with univers. This mixed code has been further + updated from the Rubygems ruby code from + https://github.com/rubygems/rubygems specifically + lib/rubygems/version.rb and lib/rubygems/requirement.rb + diff --git a/src/univers/gem.py.NOTICE b/src/univers/gem.py.NOTICE new file mode 100644 index 00000000..6616bc4d --- /dev/null +++ b/src/univers/gem.py.NOTICE @@ -0,0 +1,13 @@ +# Copyright 2017 Center for Information Technology +# +# 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. \ No newline at end of file diff --git a/src/univers/rpm.py b/src/univers/rpm.py index 7589f591..88b1b558 100644 --- a/src/univers/rpm.py +++ b/src/univers/rpm.py @@ -1,26 +1,152 @@ # # Copyright (c) SAS Institute Inc. -# SPDX-License-Identifier: Apache-2.0 +# Copyright (c) Facebook, Inc. and its affiliates. +# +# SPDX-License-Identifier: MIT AND Apache-2.0 # Version comparison utility extracted from python-rpm-vercmp and further # stripped down and significantly modified from the original at python-rpm-vercmp +# Also includes updates from Facebook antlir merged in. # # Visit https://aboutcode.org and https://github.com/nexB/univers for support and download. import re +from typing import NamedTuple +from typing import Union + + +class RpmVersion(NamedTuple): + """ + Represent an RPM version. It is ordered. + """ + + epoch: int + version: str + release: str + + def __str__(self, *args, **kwargs): + return self.to_string() + + def to_string(self): + if self.release: + vr = f"{self.version}-{self.release}" + else: + vr = self.version + + if self.epoch: + vr = f"{self.epoch}:{vr}" + return vr + + @classmethod + def from_string(cls, s): + s.strip() + e, v, r = from_evr(s) + return cls(e, v, r) + + def __lt__(self, other): + return compare_rpm_versions(self, other) < 0 + + def __gt__(self, other): + return compare_rpm_versions(self, other) > 0 + + def __eq__(self, other): + return compare_rpm_versions(self, other) == 0 + + def __le__(self, other): + return compare_rpm_versions(self, other) <= 0 + + def __ge__(self, other): + return compare_rpm_versions(self, other) >= 0 + + +def from_evr(s): + """ + Return an (E, V, R) tuple given a string by splitting + [e:]version-release into the three possible subcomponents. + Default epoch to 0, version and release to empty string if not specified. + + >>> assert from_evr("1:11.13.2.0-1") == (1, "11.13.2.0", "1") + >>> assert from_evr("11.13.2.0-1") == (0, "11.13.2.0", "1") + """ + if ":" in s: + e, _, vr = s.partition(":") + else: + e = "0" + vr = s + + e = int(e) + + if "-" in vr: + v, _, r = vr.partition("-") + else: + v = vr + r = "" + return e, v, r + + +def compare_rpm_versions(a: Union[RpmVersion, str], b: Union[RpmVersion, str]) -> int: + """ + Compare two RPM versions ``a`` and ``b`` and return: + - 1 if the version of a is newer than b + - 0 if the versions match + - -1 if the version of a is older than b + + These are the legacy "cmp()" function semantics. + + This implementation is adapted from both this blog post: + https://blog.jasonantman.com/2014/07/how-yum-and-rpm-compare-versions/ + and this Apache 2 licensed implementation: + https://github.com/sassoftware/python-rpm-vercmp/blob/master/rpm_vercmp/vercmp.py + + For example:: + >>> assert compare_rpm_versions("1.0", "1.1") == -1 + >>> assert compare_rpm_versions("1.1", "1.0") == 1 + >>> assert compare_rpm_versions("11.13.2-1", "11.13.2.0-1") == -1 + >>> assert compare_rpm_versions("11.13.2.0-1", "11.13.2-1") == 1 + """ + if isinstance(a, str): + a = RpmVersion.from_string(a) + if isinstance(b, str): + b = RpmVersion.from_string(b) + if not isinstance(a, RpmVersion) and not isinstance(b, RpmVersion): + raise TypeError(f"{a!r} and {b!r} must be RpmVersion or strings") + + # First compare the epoch, if set. If the epoch's are not the same, then + # the higher one wins no matter what the rest of the EVR is. + if a.epoch != b.epoch: + if a.epoch > b.epoch: + return 1 # a > b + else: + return -1 # a < b + + # Epoch is the same, if version + release are the same we have a match + if (a.version == b.version) and (a.release == b.release): + return 0 # a == b + + # Compare version first, if version is equal then compare release + compare_res = vercmp(a.version, b.version) + if compare_res != 0: # a > b || a < b + return compare_res + else: + return vercmp(a.release, b.release) class Vercmp: - R_NONALNUMTILDE = re.compile(br"^([^a-zA-Z0-9~]*)(.*)$") + R_NONALNUMTILDE_CARET = re.compile(br"^([^a-zA-Z0-9~\^]*)(.*)$") R_NUM = re.compile(br"^([\d]+)(.*)$") R_ALPHA = re.compile(br"^([a-zA-Z]+)(.*)$") @classmethod def compare(cls, first, second): + # Rpm versions can only be ascii, anything else is just ignored first = first.encode("ascii", "ignore") second = second.encode("ascii", "ignore") + + if first == second: + return 0 + while first or second: - m1 = cls.R_NONALNUMTILDE.match(first) - m2 = cls.R_NONALNUMTILDE.match(second) + m1 = cls.R_NONALNUMTILDE_CARET.match(first) + m2 = cls.R_NONALNUMTILDE_CARET.match(second) m1_head, first = m1.group(1), m1.group(2) m2_head, second = m2.group(1), m2.group(2) if m1_head or m2_head: @@ -36,6 +162,28 @@ def compare(cls, first, second): if second.startswith(b"~"): return 1 + # Now look at the caret, which is like the tilde but pointier. + if first.startswith(b"^"): + # first has a caret but second has ended + if not second: + return 1 # first > second + + # first has a caret but second continues on + elif not second.startswith(b"^"): + return -1 # first < second + + # strip the ^ and start again + first, second = first[1:], second[1:] + continue + + # Caret means the version is less... Unless the other version + # has ended, then do the exact opposite. + if second.startswith(b"^"): + return -1 if not first else 1 + + # We've run out of characters to compare. + # Note: we have to do this after we compare the ~ and ^ madness + # because ~'s and ^'s take precedance. # If we ran to the end of either, we are finished with the loop if not first or not second: break @@ -51,15 +199,10 @@ def compare(cls, first, second): else: m1 = cls.R_ALPHA.match(first) m2 = cls.R_ALPHA.match(second) + if not m2: + return -1 isnum = False - if not m1: - # this cannot happen, as we previously tested to make sure that - # the first string has a non-null segment - return -1 # arbitrary - if not m2: - return 1 if isnum else -1 - m1_head, first = m1.group(1), m1.group(2) m2_head, second = m2.group(1), m2.group(2) diff --git a/src/univers/rpm.py.antlir.ABOUT b/src/univers/rpm.py.antlir.ABOUT new file mode 100644 index 00000000..d016f150 --- /dev/null +++ b/src/univers/rpm.py.antlir.ABOUT @@ -0,0 +1,16 @@ +about_resource: rpm.py +package_url: pkg:github/facebookincubator/antlir@120b20de91c55244ceacf61f82c5154a28446590#antlir/rpm/rpm_metadata.py +copyright: | + Copyright (c) Facebook, Inc. and its affiliates. + Copyright (c) SAS Institute Inc. + +license_expression: mit AND Apache-2.0 +homepage_url: https://github.com/facebookincubator/antlir/ + +notes: | + This has been substantially modified and enhanced from the original code + at https://github.com/facebookincubator/antlir/blob/120b20de91c55244ceacf61f82c5154a28446590/antlir/rpm/rpm_metadata.py + itself taken from + itself originally derived from the Apache-licensed + +notice_file: rpm.py.antlir.NOTICE \ No newline at end of file diff --git a/src/univers/rpm.py.antlir.LICENSE b/src/univers/rpm.py.antlir.LICENSE new file mode 100644 index 00000000..f710a76e --- /dev/null +++ b/src/univers/rpm.py.antlir.LICENSE @@ -0,0 +1,239 @@ + +MIT License + +Copyright (c) Facebook, Inc. and its affiliates. + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. + + +Copyright (c) SAS Institute Inc. + +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. + + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright [yyyy] [name of copyright owner] + + 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. diff --git a/src/univers/rpm.py.antlir.NOTICE b/src/univers/rpm.py.antlir.NOTICE new file mode 100644 index 00000000..0d734aef --- /dev/null +++ b/src/univers/rpm.py.antlir.NOTICE @@ -0,0 +1,17 @@ + +SPDX-License-Identifier: MIT AND Apache-2.0 + +Copyright (c) Facebook, Inc. and its affiliates. +Copyright (c) SAS Institute Inc. + +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. diff --git a/src/univers/semver.py b/src/univers/semver.py index 2baefca1..e7abb3b3 100644 --- a/src/univers/semver.py +++ b/src/univers/semver.py @@ -75,7 +75,6 @@ def get_pessimistic_constraints(string): 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") diff --git a/src/univers/utils.py b/src/univers/utils.py index 0327087f..b283d983 100644 --- a/src/univers/utils.py +++ b/src/univers/utils.py @@ -21,13 +21,9 @@ def cmp(x, y): if x == y: return 0 elif x is None: - if y is None: - return 0 - else: - return -1 + return -1 elif y is None: return 1 else: - # TODO: consider casting the values to string or int or floats? # note that this is the minimal replacement function return (x > y) - (x < y) diff --git a/src/univers/version_constraint.py b/src/univers/version_constraint.py index 8da70cfd..a9acd1ae 100644 --- a/src/univers/version_constraint.py +++ b/src/univers/version_constraint.py @@ -8,13 +8,26 @@ from functools import total_ordering import attr - from univers.utils import remove_spaces from univers.versions import Version +try: + # only stanadard in Python 3.10 and up + from itertools import pairwise # NOQA +except ImportError: + # back from docs at https://docs.python.org/3/library/itertools.html#itertools.pairwise + import itertools + + def pairwise(iterable): + a, b = itertools.tee(iterable) + next(b, None) + return zip(a, b) + + """ -Universal version constraint object that stores a comparator such as "=" and -an ecosystem- or package-specific Version object. +Universal version constraint object that stores a comparator such as "=" or "<=" +and a Version object using a class that is specific to a package type (aka. +ecosystem) """ @@ -26,14 +39,13 @@ def operator_star(a, b): return True +# note: ORDER MATTER here: we tests startswith(key) for each key in sequence 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.ge, + "<=": operator.le, "!=": operator.ne, - "<": operator.gt, - ">": operator.lt, + "<": operator.lt, + ">": operator.gt, "=": operator.eq, "*": operator_star, } @@ -44,59 +56,93 @@ def operator_star(a, b): class VersionConstraint: """ Represent a single constraint composed of a comparator and a version. - Version constraints are sortable by version then comparator + VersionConstraint is: + - comparable and orderable e.g., implements functools.total_ordering + by version then comparator. + - immutable and hashable. """ # one of the COMPARATORS - comparator = attr.ib(type=str) + comparator = attr.ib(type=str, default="=") # a Version subclass instance or None version = attr.ib(type=Version, default=None) + # a function for the comparator + comp_operator = attr.ib(default=None, repr=False) + + def __attrs_post_init__(self): + # Notes: setattr is used because this is an immutable frozen instance. + # See https://www.attrs.org/en/stable/init.html?#post-init + try: + object.__setattr__(self, "comp_operator", COMPARATORS[self.comparator]) + except KeyError as e: + raise ValueError(f"Unknown comparator: {self.comparator}") from e + 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="*")) == "*" >>> assert str(VersionConstraint(comparator="<", version="2.3")) == "<2.3" >>> assert str(VersionConstraint(comparator="=", version="2.3.0")) == "2.3.0" + >>> assert str(VersionConstraint(version="2.3.0")) == "2.3.0" """ if self.comparator == "*": return "*" - elif self.comparator == "=": + + if self.comparator == "=": return str(self.version) - else: - version = str(self.version) - return f"{self.comparator}{version}" + + 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 __hash__(self): + return hash(str(self)) + + def __eq__(self, other): + if not isinstance(other, self.__class__): + return NotImplemented + return self.comparator == other.comparator and self.version == other.version + def __lt__(self, other): if not isinstance(other, self.__class__): return NotImplemented - return self.version.__lt__ == other.version + # we compare tuples, version first + return (self.version, self.comparator).__lt__((other.version, other.comparator)) @classmethod def from_string(cls, string, version_class): """ - Return a single VersionConstraint built from a constraint ``string`` and a - ``version_class`` 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) + # A version range specifier contains only printable ASCII letters, digits and + # punctuation. + is_ascii = len(constraint_string) + 2 == len(ascii(constraint_string)) + if not is_ascii: + raise ValueError(f"Invalid non ASCII characters: {string!r}") + + 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) + if comparator == "*": + version = None + else: + version = version_class(version) return cls(comparator, version) @staticmethod @@ -123,8 +169,8 @@ def split(string): for comparator in COMPARATORS: if constraint_string.startswith(comparator): - # we do not report an error if this is not valid - version = constraint_string.lstrip("><=!") + # NOTE: we do not report an error if this is not valid + version = constraint_string.lstrip(comparator) if comparator == "*": version = "" return comparator, version @@ -132,7 +178,6 @@ def split(string): # 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 @@ -165,66 +210,306 @@ def __contains__(self, version): >>> assert v24 in VersionConstraint(comparator="<=", version=v24) >>> assert v24 not in VersionConstraint(comparator="<", version=v24) """ - if version.__class__ != self.version.__class__: + + if not isinstance(version, 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) + return self.comp_operator(version, self.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. + Raise an assertion error if the ``constraints`` is not a list of + VersionConstraint objects or if two VersionConstraint contain the same + Version (ignoring the comparator). + Also validate that the sequence of comparators is valid. + Return True otherwise. """ - 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 + + if not isinstance(constraints, (list, tuple)): + raise ValueError(f"{constraints!r} is a not list or tuple") + + if not all(isinstance(c, VersionConstraint) for c in constraints): + raise ValueError(f"{constraints!r} can contain only VersionConstraint") + + # Versions are unique. Each ``version`` must be unique in a range and can + # occur only once in any ```` of a range specifier, + # irrespective of its comparators. Tools must report an error for duplicated + # versions. + if len(set(c.version for c in constraints)) != len(constraints): + raise ValueError(f"{constraints!r} cannot contain duplicated Version") + + # Constraints are sorted by version**. The canonical ordering is the versions + # order. The ordering of ```` is not significant otherwise + # but this sort order is needed when check if a version is contained in a range. + constraints.sort() + + return validate_comparators(constraints) @classmethod - def sort(cls, constraints): + def simplify(cls, constraints): """ - Return sorted nested list of ``constraints`` using the "vers" canonical - order. Sorting is done in place. + Return a new simplified ``constraints`` list with duplicated constraints + removed. This includes removing exact duplicates adn redundant + constraints. """ - for inner_constraints in constraints: - inner_constraints.sort(key=lambda vc: str(vc)) - constraints.sort(key=lambda vc: str(vc)) + constraints = deduplicate(constraints) + constraints = simplify_constraints(constraints) 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) + +def deduplicate(constraints): + """ + Return a new ``constraints`` list with exact duplicated constraints removed. + """ + seen = set() + unique = [] + for c in constraints: + if c not in seen: + unique.append(c) + seen.add(c) + return unique + + +def validate_comparators(constraints): + """ + Raise an assertion error if the ``constraints`` list contains an invalid + sequence of constraint comparators according to ``vers`` rules. + Return True otherwise. + + The following are the validity rules for contiguous constraints where the + constraints are canonical e.g., sorted by version and versions are unique + ignoring comparators: + + - "*" can only occur alone + - "!=" can be followed by anything, i.e., one of "=", "!=", ">", ">=", "<", "<=" + + And ignoring all "!=": + - "=" can be followed only by one of "=", ">", ">=" + + And ignoring all "=" and "!=", there must be an alternation of greater and lesser: + - "<" and "<=" can only be followed by one of ">", ">=" + - ">" and ">=" can only be followed by one of "<", "<=" + """ + + # Starting from a de-duplicated and sorted list of constraints, these extra rules + # apply to the comparators of any two contiguous constraints to be valid: + + # There is only one star: "*" must only occur once and alone in a range, + # without any other constraint or version. + if any(c.comparator == "*" for c in constraints): + if len(constraints) != 1: + raise ValueError(f"Invalid {constraints!r}: can contain only one star '*'") + return True + + # "!=" constraint can be followed by a constraint using any comparator, i.e., + # any of "=", "!=", ">", ">=", "<", "<=" as comparator (or no constraint). + + # Ignoring all constraints with "!=" comparators: + # --> discard != that can occur anywhere + constraints = [c for c in constraints if c.comparator != "!="] + if not constraints: + return True + + # A "=" constraint must be followed only by a constraint with one of "=", ">", + # ">=" as comparator (or no constraint). + # --> check that equals is followed only by "=", ">", ">=" + invalid_equal = [ + (cur, nxt) + for cur, nxt in pairwise(constraints) + if cur.comparator == "=" and nxt.comparator not in ("=", ">", ">=") + ] + if invalid_equal: + c = "|".join(map(str, constraints)) + i = ", ".join(f"{x}|{y}" for x, y in invalid_equal) + raise ValueError( + f"Invalid {c!r}: where {i!r} " "cannot contain an equal = followed by either < or <=" + ) + + # And ignoring all constraints with "=" or "!=" comparators: + # --> discard = that have been validated above + constraints = [c for c in constraints if c.comparator != "="] + if not constraints: + return True + + # the sequence of constraint comparators must be an alternation of greater + # and lesser comparators: + # --> from now on this must be an alternation of greater/lesser + for cur_constraint, nxt_constraint in pairwise(constraints): + cur_comp = cur_constraint.comparator + nxt_comp = nxt_constraint.comparator + + # "<" and "<=" must be followed by one of ">", ">=" (or no constraint). + # ">" and ">=" must be followed by one of "<", "<=" (or no constraint). + # Tools must report an error for such invalid ranges. + if (cur_comp in ("<", "<=") and nxt_comp not in (">", ">=")) or ( + cur_comp in (">", ">=") and nxt_comp not in ("<", "<=") + ): + + raise ValueError( + f"Invalid {constraints!r}: {cur_constraint!r} " + f"cannot be followed by {nxt_constraint!r}" + ) + + return True + + +def simplify_constraints(constraints): + """ + Return a list of VersionConstraint given a ``constraints`` list by + discarding redundant constraints according to ``vers`` rules. + """ + # Start from a list of constraints of comparator and version, sorted by version + # and where each version occurs only once in any constraint. + + # If the constraints list contains a single constraint (star, equal or anything) + # return this list and simplification is finished. + if len(constraints) < 2: + return constraints + + # Split the constraints list in two sub lists: + # a list of "unequal constraints" where the comparator is "!=" + # a remainder list of "constraints" where the comparator is not "!=" + unequal_constraints = [c for c in constraints if c.comparator == "!="] + constraints = [c for c in constraints if c.comparator != "!="] + + # If the remainder list of "constraints" is empty, return the "unequal constraints" + # list and de-duplication is finished. + if not constraints: + return unequal_constraints + + # Iterate over the current and next contiguous constraints of this list: + i = 0 + j = 0 + + while i < len(constraints) - 1: + j = i + 1 + + cur = constraints[i] + nxt = constraints[j] + cur_comp = cur.comparator + nxt_comp = nxt.comparator + + # If current comparator is ">" or ">=" and next comparator is "=", ">" or ">=", + if cur_comp in (">", ">=") and nxt_comp in ("=", ">", ">="): + # discard next constraint + constraints.pop(j) + + # If current comparator is "=", "<" or "<=" and next comparator is <" or <=", + if cur_comp in ("=", "<", "<=") and nxt_comp in ("<", "<="): + # discard current constraint + constraints.pop(i) + # Previous constraint becomes current if if exists. + if i > 0: + i -= 1 + + # If there is a previous constraint: + if i > 0: + + prv = constraints[i - 1] + prv_comp = prv.comparator + + # If previous comparator is ">" or ">=" and current comparator is "=", ">" or ">=", + if prv_comp in (">", ">=") and cur_comp in ("=", ">", ">="): + # discard current constraint + constraints.pop(i) + + # If previous comparator is "=", "<" or "<=" and current comparator is <" or <=", + if prv_comp in ("=", "<", "<=") and cur_comp in ("<", "<="): + # discard previous constraint. + constraints.pop(i - 1) + + i += 1 + + # Concatenate the "unequal constraints" list and the filtered "constraints" list + # Sort by version and return. + return sorted(set(unequal_constraints + constraints)) + + +def contains_version(version, constraints): + """ + Return True an assertion error if the ``constraints`` list contains the + ``version`` Version object according to ``vers`` rules. + """ + # If the constraint list contains only one item and the comparator is "*", + # then the "tested version" is IN the range. Check is finished. + + # If the constraint list contains only one item and and the "tested version" + # satisfies the comparator then the "tested version" is IN the range. + # Check is finished. + if len(constraints) == 1: + return version in constraints[0] + + # If the "tested version" is equal to the any of the constraint version + # where the constraint comparator is for equality (any of "=", "<=", or ">=") + # then the "tested version" is in the range. Check is finished. + for constraint in constraints: + if "=" in constraint.comparator and version == constraint.version: + return True + + # If the "tested version" is equal to the any of the constraint version where + # the constraint comparator is "=!" then the "tested version" is NOT in the + # range. Check is finished. + for constraint in constraints: + if "!=" in constraint.comparator and version == constraint.version: + return False + + # Split the constraint list in two sub lists: + # a first list where the comparator is "=" or "!=" + # a second list where the comparator is neither "=" nor "!=" + constraints = [c for c in constraints if c.comparator not in ("=", "!=")] + if not constraints: + return False + + # Iterate over the current and next contiguous constraints pairs (aka. pairwise) + # in the second list. + # For each current and next constraint: + + cur_comp = nxt_comp = cur_constraint = nxt_constraint = None + first_iteration = True + for cur_constraint, nxt_constraint in pairwise(constraints): + cur_comp = cur_constraint.comparator + nxt_comp = nxt_constraint.comparator + + # If this is the first iteration and current comparator is "<" or <=" + # and the "tested version" is less than the current version + # then the "tested version" is IN the range. Check is finished. + if first_iteration: + if cur_comp in ("<", "<=") and version < cur_constraint.version: + return True + first_iteration = False + + # If current comparator is ">" or >=" and next comparator is "<" or <=" + # and the "tested version" is greater than the current version + # and the "tested version" is less than the next version + # then the "tested version" is IN the range. Check is finished. + if ( + cur_comp in (">", ">=") + and nxt_comp in ("<", "<=") + and version > cur_constraint.version + and version < nxt_constraint.version + ): + return True + + # If current comparator is "<" or <=" and next comparator is ">" or >=" + # then these versions are out the range. Continue to the next iteration. + elif cur_comp in ("<", "<=") and nxt_comp in (">", ">="): + pass + + else: + # this should never happen as the constraints must be valid going in + raise Exception(f"Invalid constraints sequence: {constraints }") + + # If this is the last iteration and next comparator is ">" or >=" + # and the "tested version" is greater than the next version + # then the "tested version" is IN the range. Check is finished. + if nxt_comp in (">", ">=") and version > nxt_constraint.version: + return True + + # Reaching here without having finished the check before means that the + # "tested version" is NOT in the range. + return False diff --git a/src/univers/version_range.py b/src/univers/version_range.py index 5604b1aa..d7bc141f 100644 --- a/src/univers/version_range.py +++ b/src/univers/version_range.py @@ -10,15 +10,19 @@ from semantic_version.base import AllOf from semantic_version.base import AnyOf +from univers import gem from univers import versions from univers.utils import remove_spaces from univers.version_constraint import VersionConstraint +from univers.version_constraint import contains_version @attr.s(frozen=True, order=False, eq=True, hash=True) class VersionRange: """ Base version range class. Subclasses must provide implememt. + A VersionRange represents a list of constraints on the versions "timeline" + of a package. """ # Versioning scheme. By convention this is the same as the Package URL @@ -34,121 +38,127 @@ class VersionRange: # PypiVersion. Subclasses MUST provide this. version_class = None - # A list of lists of VersionConstraint where the outer list is an "OR" of - # the innner lists that are each "ANDs" of atomic constraints + # A list of lists of VersionConstraint that are signposts on the versions + # timeline constraints = attr.ib(type=list, default=attr.Factory(list)) def __attrs_post_init__(self, *args, **kwargs): - VersionConstraint.sort(self.constraints) + self.constraints.sort() @classmethod def from_native(cls, string): """ Return a VersionRange built from a scheme-specific, native version range - ``string``. Subclasses must implement. + ``string``. Subclasses can implement. """ return NotImplementedError - def to_native(self): + @classmethod + def from_natives(cls, strings): + """ + Return a VersionRange built from a ``strings`` list of scheme- + specific native version range strings. Subclasses can implement. + """ + return NotImplementedError + + def to_native(self, *args, **kwargs): """ - Return a native range string for this VersionRange. Subclasses must - implement. + Return a native range string for this VersionRange. Subclasses can + implement. Opetional ``args`` and ``kwargs`` allow subclass to require + extra arguments (such as a package name that some scheme may require + like for deb and rpm.) """ return NotImplementedError @classmethod - def from_string(cls, vers): + def from_string(cls, vers, simplify=False, validate=False): """ Return a VersionRange built from a ``vers`` version range spec string, such as "vers:npm/1.2.3,>=2.0.0" """ + # Spaces are not significant and removed in a canonical form. vers = remove_spaces(vers) + # A version range specifier contains only printable ASCII letters, digits and + # punctuation. + is_ascii = len(vers) + 2 == len(ascii(vers)) + if not is_ascii: + raise ValueError(f"Invalid non ASCII characters: {vers!r}") + + # The URI scheme and versioning scheme are always lowercase as in ``vers:npm``. uri_scheme, _, scheme_range_spec = vers.partition(":") - if not uri_scheme == "vers": + uri_scheme = uri_scheme.lower() + + if uri_scheme != "vers": raise ValueError(f"{vers!r} must start with the 'vers:' URI scheme.") versioning_scheme, _, constraints = scheme_range_spec.partition("/") + versioning_scheme = versioning_scheme.lower() range_class = RANGE_CLASS_BY_SCHEMES.get(versioning_scheme) if not range_class: raise ValueError( f"{vers!r} has an unknown versioning scheme: " f"{versioning_scheme!r}.", ) + version_class = range_class.version_class + + constraints = remove_spaces(constraints) if not constraints: raise ValueError(f"{vers!r} specifies no version range constraints.") - # 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) + # There is only one star: "*" must only occur once and alone in a range, + # without any other constraint or version. + if constraints.startswith("*"): + if constraints != "*": + raise ValueError(f"{vers!r} contains an invalid '*' constraint.") + return range_class([VersionConstraint.from_string(string="*", version_class=None)]) + + parsed_constraints = [] + + constraints = constraints.strip("|") + for const in constraints.split("|"): + constraint = VersionConstraint.from_string( + string=const, + version_class=version_class, + ) + parsed_constraints.append(constraint) - return range_class(version_constraints) + # Constraints are sorted by version**. The canonical ordering is the versions + # order. The ordering of ```` is not significant otherwise + # but this sort order is needed when check if a version is contained in a range. + parsed_constraints.sort() + + if simplify: + parsed_constraints = VersionConstraint.simplify(parsed_constraints) + if validate: + VersionConstraint.validate(parsed_constraints) + + return range_class(parsed_constraints) def __str__(self): - constraints = VersionConstraint.to_constraints_string(self.constraints) + constraints = "|".join(str(c) for c in sorted(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]) + constraints = [c.to_dict() for c in self.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. + constraints according to ``vers`` rules. """ 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 + return contains_version(version, self.constraints) 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 @@ -157,6 +167,33 @@ def __eq__(self, other): ) +def from_cve_v4(data, scheme): + """ + Return a VersionRange build from the provided CVE V4 API ``data`` using the + provided versioning vers ``scheme``. + """ + + +def from_cve_v5(data, scheme): + """ + Return a VersionRange build from the provided CVE V5 API ``data`` using the + provided versioning vers ``scheme``. + + See https://github.com/CVEProject/cve-schema/tree/master/schema/v5.0 + ``data`` can be: + - a mapping of collectionURL and versions: + {"collectionURL": "some URL", "versions": [{"versionValue": "1.0"}]} + + """ + + +def from_osv_v1(data, scheme): + """ + Return a VersionRange build from the provided CVE V4 API data using the + provided versioning vers ``scheme``. + """ + + class NpmVersionRange(VersionRange): scheme = "npm" version_class = versions.SemverVersion @@ -210,17 +247,22 @@ def get_allof_constraints(cls, clause): 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 + """ + A version range implementation for Rubygems. + + gem need its own versioning scheme as this is not semver. + 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 + version_class = versions.RubygemsVersion vers_by_native_comparators = { - "==": "=", + "=": "=", "!=": "!=", "<=": "<=", ">=": ">=", @@ -232,36 +274,210 @@ class GemVersionRange(VersionRange): def from_native(cls, string): """ Return a VersionRange built from a Rubygem version range ``string``. + + Gem version semantics are different from semver: there can be commonly + more than three segments and the operators are also different. """ - # 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) + gr = gem.GemRequirement.from_string(string).simplify() - 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}") + constraints = [] + for gc in gr.constraints: + version = cls.version_class(str(gc.version)) + op = cls.vers_by_native_comparators[gc.op] + vc = VersionConstraint(comparator=op, version=version) + constraints.append(vc) - return cls(constraints=anyof_constraints) + return cls(constraints=constraints) + + +def split_req(string, comparators, default=None, strip=""): + """ + Return a tuple of (vers comparator, version) strings given an common version + requirement``string`` such as "> 2.3" or "<= 2.3" using the ``comparators`` + mapping of {native comparator: vers comparator}. Strip the ``string`` from + the provided leading of training characters in ``strip``. + + If there is none of the ``comparators`` found in ``string``: + + - Return the ``default`` vers comparator string if provided. + - Otherwise, raise a ValueError for an unknown comparator. + + For example:: + + >>> comps = {"=": "=", "<=": "<=", ">=": ">="} + >>> assert split_req("= 2.3", comparators=comps) == ("=", "2.3",) + >>> assert split_req(" < = 2 . 3 ", comparators=comps) == ("<=", "2.3",) + >>> assert split_req(">= 2.3", comparators=comps) == (">=", "2.3",) + >>> assert split_req(">= 2.3", comparators=comps) == (">=", "2.3",) + >>> assert split_req("<= 2.3", comparators=comps) == ("<=", "2.3",) + >>> assert split_req("(< = 2.3 )", comparators=comps, strip=")(") == ("<=", "2.3",) + + With a default, we return the default comparator:: + + >>> assert split_req("2.3,", comparators=comps, default="=", strip=",") == ("=", "2.3",) + + Otherwise, a ValuaeError:: + + >>> try: + ... split_req("~2.3", comparators=comps, ) + ... raise Exception("ValueError should be raised") + ... except ValueError: + ... pass + """ + constraint_string = remove_spaces(string).strip(strip) + + for native_comparator, vers_comparator in comparators.items(): + if constraint_string.startswith(native_comparator): + version = constraint_string.lstrip(native_comparator) + return vers_comparator, version + + if default: + return default, constraint_string + + raise ValueError(f"Unknown comparator in version requirement: {string!r} ") class DebianVersionRange(VersionRange): + """ + Debian version ranges as seen in Debian manual for relationships: + https://www.debian.org/doc/debian-policy/ch-relationships.html + + These are for defined one expression at a time. Multiple expressions each + com with a package name. Therefore there is no "range string" per se, instead + there is always a list of version constraints as an input. For instance:: + + libc6 (>> 2.23), libc6 (<< 2.24)' + + Therefore native conversions are different. + """ + scheme = "deb" version_class = versions.DebianVersion + vers_by_native_comparators = { + "=": "=", + "<=": "<=", + ">=": ">=", + "<<": "<", + ">>": ">", + # legacy + "<": "<", + ">": ">", + } + + @classmethod + def split(cls, string): + """ + Return a tuple of (vers comparator, version) strings given a Debian + version relationship ``string`` such as ">>2.3" or "(<< 2.3)". Raise a + ValueError for unknown comparators. + + For example:: + >>> assert DebianVersionRange.split("=2.3") == ("=", "2.3",) + >>> assert DebianVersionRange.split(" < = 2 . 3 ") == ("<=", "2.3",) + >>> assert DebianVersionRange.split("(>=2.3)") == (">=", "2.3",) + >>> assert DebianVersionRange.split(">=2.3") == (">=", "2.3",) + >>> assert DebianVersionRange.split("<=2.3") == ("<=", "2.3",) + >>> assert DebianVersionRange.split("<<2.3") == ("<", "2.3",) + >>> assert DebianVersionRange.split(">>2.3") == (">", "2.3",) + >>> assert DebianVersionRange.split(">2.3") == (">", "2.3",) + >>> assert DebianVersionRange.split("<2.3") == ("<", "2.3",) + >>> try: + ... DebianVersionRange.split("~2.3") + ... raise Exception("ValueError should be raised") + ... except ValueError: + ... pass + """ + return split_req( + string=string, + comparators=cls.vers_by_native_comparators, + strip=")(", + ) + + @classmethod + def build_constraint_from_string(cls, string): + """ + Return a VersionConstraint built from a single Debian version + relationship ``string``. + + >>> vr = DebianVersionRange.build_constraint_from_string("= 5.0") + >>> assert str(vr) == "5.0" + >>> vr = DebianVersionRange.build_constraint_from_string("(>> 2.23)") + >>> assert str(vr) == ">2.23" + >>> vr = DebianVersionRange.build_constraint_from_string("<= 2.24") + >>> assert str(vr) == "<=2.24" + """ + comparator, version = cls.split(string) + version = cls.version_class(version) + return VersionConstraint(comparator=comparator, version=version) + + @classmethod + def from_native(cls, string): + """ + Return a VersionRange built from a ``string`` single Debian + version relationship string. + + For example:: + + >>> vr = DebianVersionRange.from_native("(= 3.5.6)") + >>> assert str(vr) == "vers:deb/3.5.6" + """ + return cls(constraints=[cls.build_constraint_from_string(string)]) + + @classmethod + def from_natives(cls, strings): + """ + Return a VersionRange built from a ``strings`` list of Debian + version relationships or a single relationship string. + + For example:: + + >>> vr = DebianVersionRange.from_natives("= 3.5.6") + >>> assert str(vr) == "vers:deb/3.5.6" + + >>> rels = ["(>= 2.8.16)"] + >>> vr = DebianVersionRange.from_natives(rels) + >>> assert str(vr) == "vers:deb/>=2.8.16" + + >>> rels = [">= 1:1.1.4", "(>= 2.8.16)", "<= 2.8.16-z"] + >>> vr = DebianVersionRange.from_natives(rels) + >>> assert str(vr) == "vers:deb/>=2.8.16|<=2.8.16-z|>=1:1.1.4" + + >>> rels = ["(>= 2:4.13.1)", "(<= 2:4.13.1-0ubuntu0.16.04.1.1~)"] + >>> vr = DebianVersionRange.from_natives(rels) + >>> assert str(vr) == "vers:deb/>=2:4.13.1|<=2:4.13.1-0ubuntu0.16.04.1.1~" + + >>> rels = ["= 5.0", "(>> 2.23)", "< 2.24"] + >>> vr = DebianVersionRange.from_natives(rels) + >>> assert str(vr) == "vers:deb/>2.23|<2.24|5.0" + + >>> rels = ["(<< 3:1.1.25~)", "(>> 2:1.1.24~)"] + >>> vr = DebianVersionRange.from_natives(rels) + >>> assert str(vr) == "vers:deb/>2:1.1.24~|<3:1.1.25~" + """ + + if isinstance(strings, str): + return cls.from_native(strings) + constraints = [cls.build_constraint_from_string(rel) for rel in strings] + return cls(constraints=constraints) class PypiVersionRange(VersionRange): + """ + PyPI PEP 440 version range. + + For example: + >>> from univers.versions import PypiVersion + >>> constraints = [ + ... VersionConstraint(version=PypiVersion("2")), + ... VersionConstraint(comparator=">=", version=PypiVersion("3")), + ... VersionConstraint(comparator="<", version=PypiVersion("4")), + ... VersionConstraint(version=PypiVersion("5")), + ... ] + >>> range = PypiVersionRange(constraints=constraints) + >>> assert str(range) == "vers:pypi/2|>=3|<4|5" + """ + scheme = "pypi" version_class = versions.PypiVersion @@ -289,8 +505,7 @@ def from_native(cls, string): specifiers = SpecifierSet(string) # In PyPI all constraints apply - allof_constraints = [] - constraints = [allof_constraints] + constraints = [] for spec in specifiers: operator = spec.operator @@ -298,17 +513,26 @@ def from_native(cls, string): assert isinstance(version, cls.version_class) comparator = cls.vers_by_native_comparators[operator] constraint = VersionConstraint(comparator=comparator, version=version) - allof_constraints.append(constraint) + constraints.append(constraint) return cls(constraints=constraints) class MavenVersionRange(VersionRange): + """ + Maven version range as documented at + https://maven.apache.org/enforcer/enforcer-rules/versionRanges.html + """ + scheme = "maven" version_class = versions.MavenVersion class NugetVersionRange(VersionRange): + """ + NuGet range as in:[3.10.1,4) + """ + scheme = "nuget" version_class = versions.NugetVersion @@ -321,11 +545,94 @@ class ComposerVersionRange(VersionRange): class RpmVersionRange(VersionRange): + # http://ftp.rpm.org/api/4.4.2.2/dependencies.html + # http://ftp.rpm.org/max-rpm/s1-rpm-depend-manual-dependencies.html scheme = "rpm" version_class = versions.RpmVersion + vers_by_native_comparators = { + "=": "=", + "<=": "<=", + ">=": ">=", + "<": "<", + ">": ">", + # seen in RPM code but never seen in the doc or in the wild so far + "<>": "!=", + # seen in a specfile parser code + "!=": "!=", + "==": "=", + } + + @classmethod + def build_constraint_from_string(cls, string): + """ + Return a VersionConstraint built from a single RPM version + relationship ``string``. + + >>> vr = RpmVersionRange.build_constraint_from_string("= 5.0") + >>> assert str(vr) == "5.0", str(vr) + >>> vr = RpmVersionRange.build_constraint_from_string("> 2.23,") + >>> assert str(vr) == ">2.23", str(vr) + >>> vr = RpmVersionRange.build_constraint_from_string("<= 2.24") + >>> assert str(vr) == "<=2.24", str(vr) + """ + comparator, version = split_req( + string=string, + comparators=cls.vers_by_native_comparators, + strip=",", + ) + version = cls.version_class(version) + return VersionConstraint(comparator=comparator, version=version) + + @classmethod + def from_native(cls, string): + """ + Return a VersionRange built from a ``string`` single RPM + version requirement string. + + For example:: + + >>> vr = RpmVersionRange.from_native("= 3.5.6") + >>> assert str(vr) == "vers:rpm/3.5.6", str(vr) + """ + return cls(constraints=[cls.build_constraint_from_string(string)]) + + @classmethod + def from_natives(cls, strings): + """ + Return a VersionRange built from a ``strings`` list of RPM + version requirements or a single requirement string. + + For example:: + + >>> vr = RpmVersionRange.from_natives("= 3.5.6") + >>> assert str(vr) == "vers:rpm/3.5.6", str(vr) + + >>> reqs = [">= 2.8.16"] + >>> vr = RpmVersionRange.from_natives(reqs) + >>> assert str(vr) == "vers:rpm/>=2.8.16", str(vr) + + >>> reqs = [">= 1:1.1.4", ">= 2.8.16", "<= 2.8.16-z"] + >>> vr = RpmVersionRange.from_natives(reqs) + >>> assert str(vr) == "vers:rpm/>=2.8.16|<=2.8.16-z|>=1:1.1.4", str(vr) + + >>> reqs = ["= 5.0", "> 2.23,", "< 2.24"] + >>> vr = RpmVersionRange.from_natives(reqs) + >>> assert str(vr) == "vers:rpm/>2.23|<2.24|5.0", str(vr) + """ + + if isinstance(strings, str): + return cls.from_native(strings) + constraints = [cls.build_constraint_from_string(rel) for rel in strings] + return cls(constraints=constraints) + class GolangVersionRange(VersionRange): + """ + Go modules use strict semver with pseudo numbering for Git repos + https://go.dev/doc/modules/version-numbers + """ + scheme = "golang" version_class = versions.SemverVersion @@ -333,11 +640,11 @@ class GolangVersionRange(VersionRange): 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): + # apache is not semver at large. And in particular we may have schemes that + # are package name-specific scheme = "apache" version_class = versions.SemverVersion @@ -443,16 +750,16 @@ def from_native(cls, string): >>> 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) + >>> assert str(result) == "vers:nginx/>=0.7.52|<=0.8.39", 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) + >>> assert str(result) == "vers:nginx/>=1.1.4|<=1.2.8|>=1.3.9|<=1.4.0", 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) + >>> assert str(result) == "vers:nginx/>=0.7.66|>=0.8.40|<0.9.0", 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) + >>> assert str(result) == "vers:nginx/>=1.4.1|<1.5.0|>=1.5.0", str(result) >>> result = NginxVersionRange.from_native("all") >>> assert str(result) == "vers:nginx/*", str(result) @@ -464,25 +771,24 @@ def from_native(cls, string): """ cleaned = remove_spaces(string).lower() if cleaned == "all": - return cls(constraints=[[VersionConstraint(comparator="*")]]) + return cls(constraints=[VersionConstraint(comparator="*")]) - anyof_constraints = [] + constraints = [] - for allof_clauses in cleaned.split(","): + for clauses in cleaned.split(","): - if "-" in allof_clauses: + if "-" in clauses: # dash range - start, _, end = allof_clauses.partition("-") + start, _, end = 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) + constraints.extend([vstart, vend]) - elif "+" in allof_clauses: + elif "+" in clauses: # suffixed version - vs = allof_clauses.rstrip("+") + vs = clauses.rstrip("+") version = semantic_version.Version.coerce(vs) is_stable = is_even(version.minor) @@ -492,23 +798,20 @@ def from_native(cls, string): 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) + constraints.extend([vstart, vend]) 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) + constraints.append(constraint) else: # plain single version - version = semantic_version.Version.coerce(allof_clauses) + version = semantic_version.Version.coerce(clauses) constraint = VersionConstraint(comparator="=", version=version) - allof_constaints = [constraint] - anyof_constraints.append(allof_constaints) + constraints.append(constraint) - return cls(constraints=anyof_constraints) + return cls(constraints=constraints) def is_even(s): diff --git a/src/univers/versions.py b/src/univers/versions.py index 51373839..82c81d30 100644 --- a/src/univers/versions.py +++ b/src/univers/versions.py @@ -12,6 +12,7 @@ from univers import arch from univers import debian +from univers import gem from univers import gentoo from univers import maven from univers import rpm @@ -39,8 +40,11 @@ class Version: Base version mixin to subclass for each version syntax implementation. Each version subclass is: - - comparable and orderable e.g., implement functools.total_ordering + - immutable and hashable + - comparable and orderable e.g., such as implementing all rich comparison + operators or implementing functools.total_ordering. The default is to + compare the value as-is. """ # the original string used to build this Version @@ -50,7 +54,7 @@ class Version: # 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 + # a comparable scheme-specific version object constructed from the version string value = attr.ib(default=None, repr=False) def __attrs_post_init__(self): @@ -58,10 +62,10 @@ def __attrs_post_init__(self): 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 + # Set the normalized string as default value - # use the normalized string as default value + # Notes: setattr is used because this is an immutable frozen instance. + # See https://www.attrs.org/en/stable/init.html?#post-init object.__setattr__(self, "normalized_string", normalized_string) value = self.build_value(normalized_string) object.__setattr__(self, "value", value) @@ -80,8 +84,8 @@ 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") + # FIXME: Is removing spaces and strip v the right thing to do? + return remove_spaces(string).rstrip("v") @classmethod def build_value(self, string): @@ -101,21 +105,6 @@ def satisfies(self, constraint): """ return self in constraint - 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) - def __str__(self): return str(self.value) @@ -129,8 +118,22 @@ def __lt__(self, other): return NotImplemented return self.value.__lt__(other.value) + def __gt__(self, other): + if not isinstance(other, self.__class__): + return NotImplemented + return self.value.__gt__(other.value) + + def __le__(self, other): + if not isinstance(other, self.__class__): + return NotImplemented + return self.value.__le__(other.value) + + def __ge__(self, other): + if not isinstance(other, self.__class__): + return NotImplemented + return self.value.__ge__(other.value) + -@total_ordering @attr.s(frozen=True, order=False, hash=True) class GenericVersion(Version): @classmethod @@ -146,7 +149,6 @@ def is_valid(cls, string): return super(GenericVersion, cls).is_valid(string) -@total_ordering @attr.s(frozen=True, order=False, eq=False, hash=True) class PypiVersion(Version): """ @@ -172,7 +174,6 @@ def is_valid(cls, string): return False -@total_ordering @attr.s(frozen=True, order=False, eq=False, hash=True) class SemverVersion(Version): """ @@ -192,30 +193,23 @@ def is_valid(cls, string): return False -@total_ordering @attr.s(frozen=True, order=False, eq=False, hash=True) -class RubyVersion(Version): +class RubygemsVersion(Version): """ - Ruby version encourages but does not enforce semver + Rubygems encourages semver version but does not enforce it. + Rubygems supports 4 or more segments in versions such + as with https://rubygems.org/gems/rails/versions/5.0.0.1 """ - # 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 - @classmethod def build_value(cls, string): - return semantic_version.Version.coerce(string) + return gem.GemVersion(string) @classmethod def is_valid(cls, string): - try: - semantic_version.Version.parse(string) - return True - except ValueError: - return False + return gem.GemVersion.is_correct(string) -@total_ordering @attr.s(frozen=True, order=False, eq=False, hash=True) class ArchLinuxVersion(Version): def __eq__(self, other): @@ -226,18 +220,35 @@ def __eq__(self, other): def __lt__(self, other): if not isinstance(other, self.__class__): return NotImplemented - return arch.vercmp(self.value, other.value) == -1 + return arch.vercmp(self.value, other.value) < 0 + + def __gt__(self, other): + if not isinstance(other, self.__class__): + return NotImplemented + return arch.vercmp(self.value, other.value) > 0 + + def __le__(self, other): + if not isinstance(other, self.__class__): + return NotImplemented + return arch.vercmp(self.value, other.value) <= 0 + + def __ge__(self, other): + if not isinstance(other, self.__class__): + return NotImplemented + return arch.vercmp(self.value, other.value) >= 0 -@total_ordering @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) + @classmethod + def is_valid(cls, string): + return debian.Version.is_valid(string) + -@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 @@ -248,25 +259,25 @@ def build_value(cls, string): return maven.Version(string) -@total_ordering @attr.s(frozen=True, order=False, eq=False, hash=True) class NugetVersion(SemverVersion): # See https://docs.microsoft.com/en-us/nuget/concepts/package-versioning pass -@total_ordering @attr.s(frozen=True, order=False, eq=False, hash=True) class RpmVersion(Version): - def __eq__(self, other): - if not isinstance(other, self.__class__): - return NotImplemented - return rpm.vercmp(self.value, other.value) == 0 + """ + Represent an RPM version. - def __lt__(self, other): - if not isinstance(other, self.__class__): - return NotImplemented - return rpm.vercmp(self.value, other.value) == -1 + For example:: + + # 1:1.1.4|>=2.8.16|<=2.8.16-z + """ + + @classmethod + def build_value(cls, string): + return rpm.RpmVersion.from_string(string) @total_ordering diff --git a/tests/test_bundler_version_ranges_spec.py b/tests/test_bundler_version_ranges_spec.py new file mode 100644 index 00000000..82ee5838 --- /dev/null +++ b/tests/test_bundler_version_ranges_spec.py @@ -0,0 +1,82 @@ +# +# Copyright (c) Chad Fowler, Rich Kilmer, Jim Weirich and others. +# Portions copyright (c) Engine Yard and Andre ArkoFacebook, Inc. and its affiliates. +# +# SPDX-License-Identifier: MIT +# +# Originally from https://github.com/rubygems/rubygems + +from univers.gem import GemRequirement + + +def test_satisfied_by(): + + assert not GemRequirement("!= 1").satisfied_by("1") + assert GemRequirement("!= 1").satisfied_by("2") + + assert not GemRequirement("!= 1", "= 2").satisfied_by("1") + assert GemRequirement("!= 1", "= 2").satisfied_by("2") + + assert not GemRequirement("!= 1", "> 1").satisfied_by("1") + assert GemRequirement("!= 1", "> 1").satisfied_by("2") + + assert not GemRequirement("!= 1", ">= 1").satisfied_by("1") + assert GemRequirement("!= 1", ">= 1").satisfied_by("2") + + assert not GemRequirement("= 1", ">= 0.1", "<= 1.1").satisfied_by("0.2") + assert GemRequirement("= 1", ">= 0.1", "<= 1.1").satisfied_by("1") + assert not GemRequirement("= 1", ">= 0.1", "<= 1.1").satisfied_by("3") + + assert GemRequirement("= 1", ">= 1", "<= 1").satisfied_by("1") + assert not GemRequirement("= 1", ">= 1", "<= 1").satisfied_by("2") + assert not GemRequirement("= 1", ">= 1", "<= 1").satisfied_by("0.1") + + assert GemRequirement("= 1", "~> 1").satisfied_by("1") + assert not GemRequirement("= 1", "~> 1").satisfied_by("1.1") + + assert GemRequirement(">= 0.z", "= 0").satisfied_by("0") + assert not GemRequirement(">= 0.z", "= 0").satisfied_by("1") + assert not GemRequirement(">= 0.z", "= 0").satisfied_by("0.1") + assert not GemRequirement(">= 0.z", "= 0").satisfied_by("0.z") + + assert GemRequirement(">= 0").satisfied_by("1") + assert GemRequirement(">= 0").satisfied_by("2") + assert GemRequirement(">= 0").satisfied_by("0") + + assert not GemRequirement(">= 1.0.0", "< 2.0.0").satisfied_by("3") + assert GemRequirement(">= 1.0.0", "< 2.0.0").satisfied_by("1.5.1") + + assert GemRequirement("~> 1").satisfied_by("1") + assert GemRequirement("~> 1").satisfied_by("1.1") + assert not GemRequirement("~> 1").satisfied_by("2") + + assert not GemRequirement("~> 2.0", "~> 2.1").satisfied_by("1") + assert GemRequirement("~> 2.0", "~> 2.1").satisfied_by("2.1.2") + + assert not GemRequirement(">= 4.1.0", "< 5.0", "= 5.2.1").satisfied_by("1") + assert not GemRequirement(">= 4.1.0", "< 5.0", "= 5.2.1").satisfied_by("5.2.1") + assert not GemRequirement( + "< 5.0", + "< 5.3", + "< 6.0", + "< 6", + "= 5.2.0", + "> 2", + ">= 3.0", + ">= 3.1", + ">= 3.2", + ">= 4.0.0", + ">= 4.1.0", + ">= 4.2.0", + ">= 4.2", + ">= 4", + ).satisfied_by("5.2.0") + assert not GemRequirement("!= 1", "< 2", "> 2").satisfied_by("1") + assert not GemRequirement("!= 1", "<= 1", ">= 1").satisfied_by("1") + assert not GemRequirement("< 2", "> 2").satisfied_by("1") + assert not GemRequirement("< 2", "> 2", "= 2").satisfied_by("1") + assert not GemRequirement("= 1", "!= 1").satisfied_by("1") + assert not GemRequirement("= 1", "= 2").satisfied_by("1") + assert not GemRequirement("= 1", "~> 2").satisfied_by("1") + assert not GemRequirement(">= 0", "<= 0.a").satisfied_by("1") + assert not GemRequirement("~> 2.0", "~> 3").satisfied_by("1") diff --git a/tests/test_bundler_version_ranges_spec.py.ABOUT b/tests/test_bundler_version_ranges_spec.py.ABOUT new file mode 100644 index 00000000..86df2182 --- /dev/null +++ b/tests/test_bundler_version_ranges_spec.py.ABOUT @@ -0,0 +1,13 @@ +about_resource: test_bundler_version_ranges_spec.py +package_url: pkg:github.com/rubygems/rubygems@5768c2bc5542ce05466d379981a433ba1ee1e10a +copyright: | + Portions copyright (c) AndrĂ© Arko + Portions copyright (c) Engine Yard + +license_expression: mit +homepage_url: https://github.com/rubygems/rubygems + +notes: This has been substantially modified and enhanced from the original code + to port tests cases to Python + +notice_file: test_bundler_edgecases_spec.py.NOTICE \ No newline at end of file diff --git a/tests/test_bundler_version_ranges_spec.py.NOTICE b/tests/test_bundler_version_ranges_spec.py.NOTICE new file mode 100644 index 00000000..52b5c213 --- /dev/null +++ b/tests/test_bundler_version_ranges_spec.py.NOTICE @@ -0,0 +1,22 @@ +The MIT License + +Portions copyright (c) 2010-2019 AndrĂ© Arko +Portions copyright (c) 2009 Engine Yard + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +THE SOFTWARE. diff --git a/tests/test_gem.py b/tests/test_gem.py new file mode 100644 index 00000000..5065bbd8 --- /dev/null +++ b/tests/test_gem.py @@ -0,0 +1,42 @@ +# Copyright (c) Center for Information Technology, http://coi.gov.pl +# 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. + +# notes: This has been substantially modified and enhanced from the original +# puppeteer code to extract the Ruby version hanlding code. + +from univers.gem import GemRequirement +from univers.gem import GemVersion + + +def test_gem_version_release(): + assert GemVersion("1.2.4.beta").release() == GemVersion("1.2.4") + + +def test_gem_version_bump(): + assert GemVersion("1.2.4").bump() == GemVersion("1.3.0") + + +def test_gem_version_compare(): + assert GemVersion("1.3.0") == GemVersion("1.3") + assert GemVersion("1.3.0") <= GemVersion("1.3") + assert GemVersion("1.1.3") <= GemVersion("1.3") + assert GemVersion("1.4.pre") >= GemVersion("1.3") + assert GemVersion("1.4.pre") != GemVersion("1.4") + + +def test_gem_requirement(): + assert GemRequirement("3.4").satisfied_by("3.4.0") + assert GemRequirement("~> 3.4").satisfied_by("3.4.8") + assert GemRequirement(">= 3.4").satisfied_by("4.4.8") + assert GemRequirement(">= 3.4", "<4").satisfied_by("3.45.8") + + +def test_gem_requirement_fails1(): + assert GemRequirement(">= 3.4", "<4").satisfied_by("4.1") is False + + +def test_gem_requirement_fails2(): + assert GemRequirement("~> 3").satisfied_by("4.1.0.pre") is False diff --git a/tests/test_gem.py.ABOUT b/tests/test_gem.py.ABOUT new file mode 100644 index 00000000..4e8a5d2e --- /dev/null +++ b/tests/test_gem.py.ABOUT @@ -0,0 +1,8 @@ +about_resource: test_gem.py +license_expression: apache-2.0 +download_url: https://raw.githubusercontent.com/coi-gov-pl/puppeter/develop/tests/domain/model/test_gemrequirement.py +copyright: Copyright (c) Center for Information Technology http://coi.gov.pl +package_url: pkg:pypi/puppeter@0.8.3#tests/domain/model/test_gemrequirement.py +notes: this subset of tests has been modified to tests version comparison and parsing +homepage_url: https://github.com/coi-gov-pl/puppeter +notice_file: test_gem.py.NOTICE diff --git a/tests/test_gem.py.NOTICE b/tests/test_gem.py.NOTICE new file mode 100644 index 00000000..6616bc4d --- /dev/null +++ b/tests/test_gem.py.NOTICE @@ -0,0 +1,13 @@ +# Copyright 2017 Center for Information Technology +# +# 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. \ No newline at end of file diff --git a/tests/test_maven_version.py b/tests/test_maven_version.py index 388fe416..5cfc6912 100644 --- a/tests/test_maven_version.py +++ b/tests/test_maven_version.py @@ -620,9 +620,9 @@ def test_compare(self): assert 1 < vr1 def test_str(self): - for input in ("[1.0,2.0]", "1.0"): - actual = str(VersionRange(input)) - assert input == actual, "VersionRange(%s) == %s, wanted %s" % (input, actual, input) + for inp in ("[1.0,2.0]", "1.0"): + actual = str(VersionRange(inp)) + assert inp == actual, "VersionRange(%s) == %s, wanted %s" % (inp, actual, inp) def test_fromversion(self): v = Version("1.0") diff --git a/tests/test_rpm.py b/tests/test_rpm.py new file mode 100644 index 00000000..4b0a23e7 --- /dev/null +++ b/tests/test_rpm.py @@ -0,0 +1,49 @@ +# Copyright (c) Facebook, Inc. and its affiliates. +# SPDX-License-Identifier: MIT + +import unittest + +from univers.rpm import RpmVersion +from univers.rpm import compare_rpm_versions + + +class RpmMetadataTestCase(unittest.TestCase): + def test_rpm_compare_versions(self): + # Taste data was generated with: + # rpmdev-vercmp + # which also uses the same Python rpm lib. + # + # This number of test cases is excessive but does show how interesting + # RPM version comparisons can be. + test_evr_data = [ + # Non-alphanumeric (except ~) are ignored for equality + ((1, "2", "3"), (1, "2", "3"), 0), # 1:2-3 == 1:2-3 + ((1, ":2>", "3"), (1, "-2-", "3"), 0), # 1::2>-3 == 1:-2--3 + ((1, "2", "3?"), (1, "2", "?3"), 0), # 1:2-?3 == 1:2-3? + # epoch takes precedence no matter what + ((0, "2", "3"), (1, "2", "3"), -1), # 0:2-3 < 1:2-3 + ((1, "1", "3"), (0, "2", "3"), 1), # 1:1-3 > 0:2-3 + # version and release trigger the real comparison rules + ((0, "1", "3"), (0, "2", "3"), -1), # 0:1-3 < 0:2-3 + ((0, "~2", "3"), (0, "1", "3"), -1), # 0:~2-3 < 0:1-3 + ((0, "~", "3"), (0, "1", "3"), -1), # 0:~-3 < 0:1-3 + ((0, "1", "3"), (0, "~", "3"), 1), # 0:1-3 > 0:~-3 + ((0, "^1", "3"), (0, "^", "3"), 1), # 0:^1-3 > 0:^-3 + ((0, "^", "3"), (0, "^1", "3"), -1), # 0:^-3 < 0:^1-3 + ((0, "0333", "b"), (0, "0033", "b"), 1), # 0:0333-b > 0:0033-b + ((0, "0033", "b"), (0, "0333", "b"), -1), # 0:0033-b < 0:0333-b + ((0, "3", "~~"), (0, "3", "~~~"), 1), # 0:3-~~ > 0:3-~~~ + ((0, "3", "~~~"), (0, "3", "~~"), -1), # 0:3-~~~ < 0:3-~~ + ((0, "3", "~~~"), (0, "3", "~~~"), 0), # 0:3-~~~ == 0:3-~~~ + ((0, "a2aa", "b"), (0, "a2a", "b"), 1), # 0:a2aa-b > 0:a2a-b + ((0, "33", "b"), (0, "aaa", "b"), 1), # 0:33-b > 0:aaa-b + ] + + for evr1, evr2, expected in test_evr_data: + a = RpmVersion(*evr1) + b = RpmVersion(*evr2) + self.assertEqual( + compare_rpm_versions(a, b), + expected, + f"failed: {evr1}, {evr2}, {expected}", + ) diff --git a/tests/test_rpm.py.antlir.ABOUT b/tests/test_rpm.py.antlir.ABOUT new file mode 100644 index 00000000..b0abb98b --- /dev/null +++ b/tests/test_rpm.py.antlir.ABOUT @@ -0,0 +1,12 @@ +about_resource: test_rpm.py +package_url: pkg:github/facebookincubator/antlir@120b20de91c55244ceacf61f82c5154a28446590#antlir/rpm/tests/test_rpm_metadata.py +copyright: | + Copyright (c) Facebook, Inc. and its affiliates. + +license_expression: mit +homepage_url: https://github.com/facebookincubator/antlir/ + +notes: | + This has been substantially modified and enhanced from the original code + +notice_file: test_rpm.py.antlir.NOTICE \ No newline at end of file diff --git a/tests/test_rpm.py.mit.NOTICE b/tests/test_rpm.py.mit.NOTICE new file mode 100644 index 00000000..b96dcb04 --- /dev/null +++ b/tests/test_rpm.py.mit.NOTICE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) Facebook, Inc. and its affiliates. + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/tests/test_rpm_vercmp.py b/tests/test_rpm_vercmp.py index e9036cba..3bc1925a 100644 --- a/tests/test_rpm_vercmp.py +++ b/tests/test_rpm_vercmp.py @@ -93,12 +93,7 @@ def get_tests(): tests = list(parse_rpmvercmp_tests(rpmtests, with_buggy_comparisons=True)) for test_count, (ver1, ver2, expected) in enumerate(tests, 1): name = f"test_rpm_version_{test_count}" - func = create_test_function(ver1, ver2, expected, name) - if "^" in ver1 or "^" in ver2: - result = vercmp.vercmp(ver1, ver2) - if result != expected: - func = unittest.expectedFailure(func) - yield func + yield create_test_function(ver1, ver2, expected, name) # Make sure we still test something, in case the m4 file drops # content this will fail the test diff --git a/tests/test_rubygems_gem_requirement.py b/tests/test_rubygems_gem_requirement.py new file mode 100644 index 00000000..6ccb4a5f --- /dev/null +++ b/tests/test_rubygems_gem_requirement.py @@ -0,0 +1,367 @@ +# +# Copyright (c) Chad Fowler, Rich Kilmer, Jim Weirich and others. +# Portions copyright (c) Engine Yard and Andre ArkoFacebook, Inc. and its affiliates. +# +# SPDX-License-Identifier: MIT +# +# Originally from https://github.com/rubygems/rubygems + +from univers.gem import GemConstraint +from univers.gem import GemRequirement +from univers.gem import GemVersion +from univers.gem import InvalidRequirementError + + +def test_equals(): + refute_requirement_equal("= 1.2", "= 1.3") + refute_requirement_equal("~> 1.3", "~> 1.3.0") + assert_requirement_equal(["> 2", "~> 1.3", "~> 1.3.1"], ["~> 1.3.1", "~> 1.3", "> 2"]) + assert_requirement_equal(["> 2", "~> 1.3"], ["> 2.0", "~> 1.3"]) + + +def test_initialize(): + assert_requirement_equal("= 2", "2") + assert_requirement_equal("= 2", ["2"]) + assert_requirement_equal("= 2", GemVersion(2)) + assert_requirement_equal("2.0", "2") + + +def test_create(): + r = GemRequirement(">= 1", "< 2") + assert r.constraints == ( + GemConstraint(">=", GemVersion(1)), + GemConstraint("<", GemVersion(2)), + ) + assert GemRequirement("= 1") == GemRequirement("= 1") + assert GemRequirement(">= 1.2", "<= 1.3") == GemRequirement("<= 1.3", ">= 1.2") + + +def test_explicit_default_is_not_none(): + r = GemRequirement(">= 0") + assert r + + +def test_basic_non_none(): + r = GemRequirement("= 1") + assert r + + +def test_for_lockfile(): + assert GemRequirement("~> 1.0").for_lockfile() == " (~> 1.0)" + assert GemRequirement(">= 1.0.1", "~> 1.0").for_lockfile() == " (~> 1.0, >= 1.0.1)" + duped = GemRequirement("= 1.0", ["=", GemVersion("1.0")]) + assert duped.for_lockfile() == " (= 1.0)" + + +def test_parse(): + assert GemRequirement.parse(" 1") == GemConstraint("=", GemVersion(1)) + assert GemRequirement.parse("= 1") == GemConstraint("=", GemVersion(1)) + assert GemRequirement.parse("> 1") == GemConstraint(">", GemVersion(1)) + assert GemRequirement.parse("=\n1") == GemConstraint("=", GemVersion(1)) + assert GemRequirement.parse("1.0") == GemConstraint("=", GemVersion(1)) + + assert GemRequirement.parse(GemVersion("2")) == GemConstraint("=", GemVersion(2)) + + +def test_parse_deduplication(): + assert GemRequirement.parse("~> 1") == GemConstraint("~>", GemVersion("1")) + + +def test_parse_bad(): + bads = [ + None, + "", + "! 1", + "= junk", + "1..2", + ] + for bad in bads: + try: + GemRequirement.parse(bad) + raise Exception("exception not raised") + except InvalidRequirementError: + pass + + +def test_prerelease_eh(): + r = GemVersion("1") + assert not r.prerelease() + + r = GemVersion("1.a") + assert r.prerelease() + + r = GemVersion("1.x") + assert r.prerelease() + + +def test_satisfied_by_eh_bang_equal(): + r = GemRequirement("!= 1.2") + + assert_satisfied_by("1.1", r) + refute_satisfied_by("1.2", r) + assert_satisfied_by("1.3", r) + + +def test_satisfied_by_eh_blank(): + r = GemRequirement("1.2") + + refute_satisfied_by("1.1", r) + assert_satisfied_by("1.2", r) + refute_satisfied_by("1.3", r) + + +def test_satisfied_by_eh_equal(): + r = GemRequirement("= 1.2") + + refute_satisfied_by("1.1", r) + assert_satisfied_by("1.2", r) + refute_satisfied_by("1.3", r) + + +def test_satisfied_by_eh_gt(): + r = GemRequirement("> 1.2") + + refute_satisfied_by("1.1", r) + refute_satisfied_by("1.2", r) + assert_satisfied_by("1.3", r) + + +def test_satisfied_by_eh_gte(): + r = GemRequirement(">= 1.2") + + refute_satisfied_by("1.1", r) + assert_satisfied_by("1.2", r) + assert_satisfied_by("1.3", r) + + +def test_satisfied_by_eh_list(): + r = GemRequirement("> 1.1", "< 1.3") + + refute_satisfied_by("1.1", r) + assert_satisfied_by("1.2", r) + refute_satisfied_by("1.3", r) + + +def test_satisfied_by_eh_lt(): + r = GemRequirement("< 1.2") + + assert_satisfied_by("1.1", r) + refute_satisfied_by("1.2", r) + refute_satisfied_by("1.3", r) + + +def test_satisfied_by_eh_lte(): + r = GemRequirement("<= 1.2") + + assert_satisfied_by("1.1", r) + assert_satisfied_by("1.2", r) + refute_satisfied_by("1.3", r) + + +def test_satisfied_by_eh_tilde_gt(): + r = GemRequirement("~> 1.2") + + refute_satisfied_by("1.1", r) + assert_satisfied_by("1.2", r) + assert_satisfied_by("1.3", r) + + +def test_satisfied_by_eh_tilde_gt_v0(): + r = GemRequirement("~> 0.0.1") + + refute_satisfied_by("0.1.1", r) + assert_satisfied_by("0.0.2", r) + assert_satisfied_by("0.0.1", r) + + +def test_satisfied_by_eh_good_problematic(): + assert_satisfied_by("0.0.1.0", "> 0.0.0.1") + + +def test_satisfied_by_eh_good(): + assert_satisfied_by("0.2.33", "= 0.2.33") + assert_satisfied_by("0.2.34", "> 0.2.33") + assert_satisfied_by("1.0", "= 1.0") + assert_satisfied_by("1.0.0", "= 1.0") + assert_satisfied_by("1.0", "= 1.0.0") + assert_satisfied_by("1.0", "1.0") + assert_satisfied_by("1.8.2", "> 1.8.0") + assert_satisfied_by("1.112", "> 1.111") + assert_satisfied_by("0.2", "> 0.0.0") + assert_satisfied_by("0.0.0.0.0.2", "> 0.0.0") + assert_satisfied_by("10.3.2", "> 9.3.2") + assert_satisfied_by("1.0.0.0", "= 1.0") + assert_satisfied_by("10.3.2", "!= 9.3.4") + assert_satisfied_by("10.3.2", "> 9.3.2") + assert_satisfied_by(" 9.3.2", ">= 9.3.2") + assert_satisfied_by("9.3.2 ", ">= 9.3.2") + assert_satisfied_by("", "= 0") + assert_satisfied_by("", "< 0.1") + assert_satisfied_by(" ", "< 0.1 ") + assert_satisfied_by("", " < 0.1") + assert_satisfied_by(" ", "> 0.a ") + assert_satisfied_by("", " > 0.a") + assert_satisfied_by("3.1", "< 3.2.rc1") + + assert_satisfied_by("3.2.0", "> 3.2.0.rc1") + assert_satisfied_by("3.2.0.rc2", "> 3.2.0.rc1") + + assert_satisfied_by("3.0.rc2", "< 3.0") + assert_satisfied_by("3.0.rc2", "< 3.0.0") + assert_satisfied_by("3.0.rc2", "< 3.0.1") + + assert_satisfied_by("3.0.rc2", "> 0") + + assert_satisfied_by("5.0.0.rc2", "~> 5.a") + refute_satisfied_by("5.0.0.rc2", "~> 5.x") + + assert_satisfied_by("5.0.0", "~> 5.a") + assert_satisfied_by("5.0.0", "~> 5.x") + + +def test_illformed_requirements(): + bads = [">>> 1.3.5", "> blah"] + for bad in bads: + try: + GemRequirement.parse(bad) + raise Exception("exception not raised") + except InvalidRequirementError: + pass + + +def test_satisfied_by_eh_boxed(): + refute_satisfied_by("1.3", "~> 1.4") + assert_satisfied_by("1.4", "~> 1.4") + assert_satisfied_by("1.5", "~> 1.4") + refute_satisfied_by("2.0", "~> 1.4") + + refute_satisfied_by("1.3", "~> 1.4.4") + refute_satisfied_by("1.4", "~> 1.4.4") + assert_satisfied_by("1.4.4", "~> 1.4.4") + assert_satisfied_by("1.4.5", "~> 1.4.4") + refute_satisfied_by("1.5", "~> 1.4.4") + refute_satisfied_by("2.0", "~> 1.4.4") + + refute_satisfied_by("1.1.pre", "~> 1.0.0") + refute_satisfied_by("1.1.pre", "~> 1.1") + refute_satisfied_by("2.0.a", "~> 1.0") + refute_satisfied_by("2.0.a", "~> 2.0") + + refute_satisfied_by("0.9", "~> 1") + assert_satisfied_by("1.0", "~> 1") + assert_satisfied_by("1.1", "~> 1") + refute_satisfied_by("2.0", "~> 1") + + +def test_satisfied_by_eh_multiple(): + req = [">= 1.4", "<= 1.6", "!= 1.5"] + + refute_satisfied_by("1.3", req) + assert_satisfied_by("1.4", req) + refute_satisfied_by("1.5", req) + assert_satisfied_by("1.6", req) + refute_satisfied_by("1.7", req) + refute_satisfied_by("2.0", req) + + +def test_satisfied_by_boxed(): + refute_satisfied_by("1.3", "~> 1.4") + assert_satisfied_by("1.4", "~> 1.4") + assert_satisfied_by("1.4.0", "~> 1.4") + assert_satisfied_by("1.5", "~> 1.4") + refute_satisfied_by("2.0", "~> 1.4") + + refute_satisfied_by("1.3", "~> 1.4.4") + refute_satisfied_by("1.4", "~> 1.4.4") + assert_satisfied_by("1.4.4", "~> 1.4.4") + assert_satisfied_by("1.4.5", "~> 1.4.4") + refute_satisfied_by("1.5", "~> 1.4.4") + refute_satisfied_by("2.0", "~> 1.4.4") + + +def test_satisfied_by_explicitly_bounded(): + req = [">= 1.4.4", "< 1.5"] + + assert_satisfied_by("1.4.5", req) + assert_satisfied_by("1.5.0.rc1", req) + refute_satisfied_by("1.5.0", req) + + req = [">= 1.4.4", "< 1.5.a"] + + assert_satisfied_by("1.4.5", req) + refute_satisfied_by("1.5.0.rc1", req) + refute_satisfied_by("1.5.0", req) + + +def test_bad(): + refute_satisfied_by("", "> 0.1") + refute_satisfied_by("1.2.3", "!= 1.2.3") + refute_satisfied_by("1.2.003.0.0", "!= 1.02.3") + refute_satisfied_by("4.5.6", "< 1.2.3") + refute_satisfied_by("1.0", "> 1.1") + refute_satisfied_by("", "= 0.1") + refute_satisfied_by("1.1.1", "> 1.1.1") + refute_satisfied_by("1.2", "= 1.1") + refute_satisfied_by("1.40", "= 1.1") + refute_satisfied_by("1.3", "= 1.40") + refute_satisfied_by("9.3.3", "<= 9.3.2") + refute_satisfied_by("9.3.1", ">= 9.3.2") + refute_satisfied_by("9.3.03", "<= 9.3.2") + refute_satisfied_by("1.0.0.1", "= 1.0") + + +def test_equal_with_multiple_versions(): + r1 = GemRequirement("1.0", "2.0") + r2 = GemRequirement("2.0", "1.0") + assert r1 == r2 + + r1 = GemRequirement("1.0", "2.0", "3.0") + r2 = GemRequirement("3.0", "1.0", "2.0") + assert r1 == r2 + + +def test_equivalent_requirements_are_equal(): + refute_requirement_equal("= 1.2", "= 1.3") + refute_requirement_equal("= 1.3", "= 1.2") + + refute_requirement_equal("~> 1.3", "~> 1.3.0") + refute_requirement_equal("~> 1.3.0", "~> 1.3") + + assert_requirement_equal(["> 2", "~> 1.3", "~> 1.3.1"], ["~> 1.3.1", "~> 1.3", "> 2"]) + + assert_requirement_equal(["> 2", "~> 1.3"], ["> 2.0", "~> 1.3"]) + assert_requirement_equal(["> 2.0", "~> 1.3"], ["> 2", "~> 1.3"]) + + assert_requirement_equal("= 1.0", "= 1.0.0") + assert_requirement_equal("= 1.1", "= 1.1.0") + assert_requirement_equal("= 1", "= 1.0.0") + + assert_requirement_equal("1.0", "1.0.0") + assert_requirement_equal("1.1", "1.1.0") + assert_requirement_equal("1", "1.0.0") + + +def assert_requirement_equal(expected, actual): + # Assert that two requirements are equal. Handles GemRequirements, + # strings, arrays, numbers, and versions. + assert GemRequirement.create(actual) == GemRequirement.create(expected) + + +def refute_requirement_equal(unexpected, actual): + # Refute the assumption that two requirements are equal. + assert GemRequirement.create(actual) != GemRequirement.create(unexpected) + assert GemRequirement.create(unexpected) != GemRequirement.create(actual) + + +def assert_satisfied_by(version, requirement): + # Assert that +version+ satisfies +requirement+. + if not isinstance(requirement, GemRequirement): + requirement = GemRequirement.create(requirement) + assert requirement.satisfied_by(GemVersion(version)) + + +def refute_satisfied_by(version, requirement): + # Refute the assumption that +version+ satisfies +requirement+. + if not isinstance(requirement, GemRequirement): + requirement = GemRequirement.create(requirement) + assert not requirement.satisfied_by(GemVersion(version)) diff --git a/tests/test_rubygems_gem_requirement.py.ABOUT b/tests/test_rubygems_gem_requirement.py.ABOUT new file mode 100644 index 00000000..88863578 --- /dev/null +++ b/tests/test_rubygems_gem_requirement.py.ABOUT @@ -0,0 +1,14 @@ +about_resource: test_rubygems_gem_requirement.py +package_url: pkg:github.com/rubygems/rubygems@5768c2bc5542ce05466d379981a433ba1ee1e10a +copyright: | + Copyright (c) Chad Fowler, Rich Kilmer, Jim Weirich and others. + Portions copyright (c) Engine Yard and Andre Arko + +license_expression: mit +homepage_url: https://github.com/rubygems/rubygems + +notes: This has been substantially modified and enhanced from the original code + to port tests cases to Python. The original license is a choice of MIT or Ruby + license. We selected to use the MIT license here. + +notice_file: test_rubygems_gem_requirement.py.NOTICE \ No newline at end of file diff --git a/tests/test_rubygems_gem_requirement.py.NOTICE b/tests/test_rubygems_gem_requirement.py.NOTICE new file mode 100644 index 00000000..a90b9bb2 --- /dev/null +++ b/tests/test_rubygems_gem_requirement.py.NOTICE @@ -0,0 +1,21 @@ +Copyright (c) Chad Fowler, Rich Kilmer, Jim Weirich and others. +Portions copyright (c) Engine Yard and Andre Arko + +Permission is hereby granted, free of charge, to any person obtaining +a copy of this software and associated documentation files (the +'Software'), to deal in the Software without restriction, including +without limitation the rights to use, copy, modify, merge, publish, +distribute, sublicense, and/or sell copies of the Software, and to +permit persons to whom the Software is furnished to do so, subject to +the following conditions: + +The above copyright notice and this permission notice shall be +included in all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED 'AS IS', WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. +IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY +CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, +TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE +SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. diff --git a/tests/test_rubygems_gem_version.py b/tests/test_rubygems_gem_version.py new file mode 100644 index 00000000..6c14a00d --- /dev/null +++ b/tests/test_rubygems_gem_version.py @@ -0,0 +1,248 @@ +# +# Copyright (c) Chad Fowler, Rich Kilmer, Jim Weirich and others. +# Portions copyright (c) Engine Yard and Andre ArkoFacebook, Inc. and its affiliates. +# +# SPDX-License-Identifier: MIT +# +# Originally from https://github.com/rubygems/rubygems + +from univers.gem import GemRequirement +from univers.gem import GemVersion +from univers.gem import InvalidVersionError + + +def assert_bumped_version_equal(expected, unbumped): + # Assert that bumping the +unbumped+ version yields the +expected+. + + assert_version_eql(expected, GemVersion(unbumped).bump()) + + +def test_bump(): + assert_bumped_version_equal("5.3", "5.2.4") + + +def test_bump_alpha(): + assert_bumped_version_equal("5.3", "5.2.4.a") + + +def test_bump_alphanumeric(): + assert_bumped_version_equal("5.3", "5.2.4.a10") + + +def test_bump_trailing_zeros(): + assert_bumped_version_equal("5.1", "5.0.0") + + +def test_bump_one_level(): + assert_bumped_version_equal("6", "5") + + +def test_eql_is_same(): + assert_version_eql("1.2", "1.2") + assert_version_strict_equal("1.2", "1.2") + + refute_version_eql("1.2", "1.3") + refute_version_strict_equal("1.2", "1.3") + + refute_version_strict_equal("1.2", "1.2.0") + assert_version_eql("1.2", "1.2.0") + + assert_version_eql("1.2.b1", "1.2.b.1") + refute_version_strict_equal("1.2.b1", "1.2.b.1") + + refute_version_strict_equal("1.2.pre.1", "1.2.0.pre.1.0") + assert_version_eql("1.2.pre.1", "1.2.0.pre.1.0") + + +def test_initialize(): + for good in ["1.0", "1.0 ", " 1.0 ", "1.0\n", "\n1.0\n", "1.0"]: + assert_version_eql("1.0", good) + + assert_version_eql("1", 1) + + +def test_initialize_invalid(): + invalid_versions = [ + "whatever", + "junk", + "1.0\n2.0" "1..2", + "1.2\ 3.4", + ] + + # DON'T TOUCH THIS WITHOUT CHECKING CVE-2013-4287 + invalid_versions += ["2.3422222.222.222222222.22222.ads0as.dasd0.ddd2222.2.qd3e."] + + for invalid in invalid_versions: + try: + GemVersion(invalid) + raise Exception(f"exception not raised for: {invalid!r}") + except InvalidVersionError: + pass + + +def test_empty_version(): + assert GemVersion("").version == "0" + assert GemVersion(" ").version == "0" + assert GemVersion(" ").version == "0" + + +def test_prerelease(): + assert_prerelease("1.2.0.a") + assert_prerelease("2.9.b") + assert_prerelease("22.1.50.0.d") + assert_prerelease("1.2.d.42") + + assert_prerelease("1.A") + + assert_prerelease("1-1") + assert_prerelease("1-a") + + refute_prerelease("1.2.0") + refute_prerelease("2.9") + refute_prerelease("22.1.50.0") + + +def test_release(): + assert_release_equal("1.2.0", "1.2.0.a") + assert_release_equal("1.1", "1.1.rc10") + assert_release_equal("1.9.3", "1.9.3.alpha.5") + assert_release_equal("1.9.3", "1.9.3") + + +def test_spaceship_cmp(): + def cmp(a, b): + return a.__cmp__(b) + + # Ruby spaceship <=> is the same as Python legacy cmp() + assert cmp(GemVersion("1.0"), GemVersion("1.0.0")) == 0 + assert cmp(GemVersion("1.0"), GemVersion("1.0.a")) == 1 + assert cmp(GemVersion("1.8.2"), GemVersion("0.0.0")) == 1 + assert cmp(GemVersion("1.8.2"), GemVersion("1.8.2.a")) == 1 + assert cmp(GemVersion("1.8.2.b"), GemVersion("1.8.2.a")) == 1 + assert cmp(GemVersion("1.8.2.a"), GemVersion("1.8.2")) == -1 + assert cmp(GemVersion("1.8.2.a10"), GemVersion("1.8.2.a9")) == 1 + assert cmp(GemVersion(""), GemVersion("0")) == 0 + assert cmp(GemVersion("0.beta.1"), GemVersion("0.0.beta.1")) == 0 + assert cmp(GemVersion("0.0.beta"), GemVersion("0.0.beta.1")) == -1 + assert cmp(GemVersion("0.0.beta"), GemVersion("0.beta.1")) == -1 + assert cmp(GemVersion("5.a"), GemVersion("5.0.0.rc2")) == -1 + assert cmp(GemVersion("5.x"), GemVersion("5.0.0.rc2")) == 1 + + +def assert_version_satisfies_requirement(requirement, version): + # Assert that +version+ satisfies the "approximate" ~> +requirement+. + req = GemRequirement.create(requirement) + ver = GemVersion(version) + assert req.satisfied_by(ver) + + +def test_satisfies_requirement(): + assert_version_satisfies_requirement("~> 1.0", "1") + assert_version_satisfies_requirement("~> 1.0", "1.0") + assert_version_satisfies_requirement("~> 1.2", "1.2") + assert_version_satisfies_requirement("~> 1.2", "1.2.0") + assert_version_satisfies_requirement("~> 1.2", "1.2.3") + assert_version_satisfies_requirement("~> 1.2.a", "1.2.3.a.4") + assert_version_satisfies_requirement("~> 1.9.a", "1.9.0.dev") + + +def test_to_s(): + assert GemVersion("5.2.4").to_string() == "5.2.4" + + +def test_compare(): + assert GemVersion("0.0.1.0") > GemVersion("0.0.0.1") + assert not GemVersion("0.0.1.0") < GemVersion("0.0.0.1") + assert GemVersion("0.0.1.0") >= GemVersion("0.0.0.1") + assert not GemVersion("0.0.1.0") <= GemVersion("0.0.0.1") + + +def test_semver(): + assert_less_than("1.0.0-alpha", "1.0.0-alpha.1") + assert_less_than("1.0.0-alpha.1", "1.0.0-beta.2") + assert_less_than("1.0.0-beta.2", "1.0.0-beta.11") + assert_less_than("1.0.0-beta.11", "1.0.0-rc.1") + assert_less_than("1.0.0-rc1", "1.0.0") + assert_less_than("1.0.0-1", "1") + + +def test_segments(): + # modifying the segments of a version should not affect the segments of the cached version object + ver = GemVersion("9.8.7") + secondseg = ver.segments[2] + secondseg += 1 + + refute_version_eql("9.8.8", "9.8.7") + assert GemVersion("9.8.7").segments == [9, 8, 7] + + +def test_split_segments(): + assert GemVersion("3.2.4-2").split_segments() == ([3, 2, 4], ["pre", 2]) + + +def test_canonical_segments(): + assert GemVersion("1.0.0").canonical_segments == [1] + assert GemVersion("1.0.0.a.1.0").canonical_segments == [1, "a", 1] + assert GemVersion("1.2.3-1").canonical_segments == [1, 2, 3, "pre", 1] + + +def test_frozen_version(): + ver = GemVersion("1.test") + assert_less_than(ver, GemVersion("1")) + assert_version_eql(GemVersion("1"), ver.release()) + assert_version_eql(GemVersion("2"), ver.bump()) + + +def assert_prerelease(version): + # Asserts that +version+ is a prerelease. + assert GemVersion(version).prerelease(), "#{version} is a prerelease" + + +def assert_release_equal(release, version): + # Assert that +release+ is the correct non-prerelease +version+. + assert_version_eql(release, GemVersion(version).release()) + + +def assert_version_eql(first, second): + # Assert that two versions are eql?. Checks both directions. + first = GemVersion(first) + second = GemVersion(second) + assert first is not second + assert first == second + assert second == first + + +def refute_version_eql(first, second): + # Refute the assumption that two versions are eql?. Checks both + # directions. + first = GemVersion(first) + second = GemVersion(second) + assert first is not second + assert first != second + assert second != first + + +def assert_version_strict_equal(first, second): + # Assert that two versions are strictly equal + first = GemVersion(first) + second = GemVersion(second) + assert first is not second + assert first.equal_strictly(second) + assert second.equal_strictly(first) + + +def refute_version_strict_equal(first, second): + first = GemVersion(first) + second = GemVersion(second) + assert first is not second + assert not first.equal_strictly(second) + assert not second.equal_strictly(first) + + +def assert_less_than(left, right): + assert GemVersion(left) < GemVersion(right) + + +def refute_prerelease(version): + # Refute the assumption that +version+ is a prerelease. + assert not GemVersion(version).prerelease() diff --git a/tests/test_rubygems_gem_version.py.ABOUT b/tests/test_rubygems_gem_version.py.ABOUT new file mode 100644 index 00000000..4f5a2aeb --- /dev/null +++ b/tests/test_rubygems_gem_version.py.ABOUT @@ -0,0 +1,14 @@ +about_resource: test_rubygems_gem_version.py +package_url: pkg:github.com/rubygems/rubygems@5768c2bc5542ce05466d379981a433ba1ee1e10a +copyright: | + Copyright (c) Chad Fowler, Rich Kilmer, Jim Weirich and others. + Portions copyright (c) Engine Yard and Andre Arko + +license_expression: mit +homepage_url: https://github.com/rubygems/rubygems + +notes: This has been substantially modified and enhanced from the original code + to port tests cases to Python. The original license is a choice of MIT or Ruby + license. We selected to use the MIT license here. + +notice_file: test_rubygems_gem_version.py.NOTICE \ No newline at end of file diff --git a/tests/test_rubygems_gem_version.py.NOTICE b/tests/test_rubygems_gem_version.py.NOTICE new file mode 100644 index 00000000..a90b9bb2 --- /dev/null +++ b/tests/test_rubygems_gem_version.py.NOTICE @@ -0,0 +1,21 @@ +Copyright (c) Chad Fowler, Rich Kilmer, Jim Weirich and others. +Portions copyright (c) Engine Yard and Andre Arko + +Permission is hereby granted, free of charge, to any person obtaining +a copy of this software and associated documentation files (the +'Software'), to deal in the Software without restriction, including +without limitation the rights to use, copy, modify, merge, publish, +distribute, sublicense, and/or sell copies of the Software, and to +permit persons to whom the Software is furnished to do so, subject to +the following conditions: + +The above copyright notice and this permission notice shall be +included in all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED 'AS IS', WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. +IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY +CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, +TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE +SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. diff --git a/tests/test_version_range.py b/tests/test_version_range.py index 3ed058c2..e5ad4328 100644 --- a/tests/test_version_range.py +++ b/tests/test_version_range.py @@ -8,20 +8,27 @@ from univers.version_constraint import VersionConstraint from univers.version_range import GemVersionRange +from univers.version_range import PypiVersionRange from univers.version_range import VersionRange from univers.versions import PypiVersion -from univers.versions import RubyVersion +from univers.versions import RubygemsVersion class TestVersionRange(TestCase): + def test_VersionRange_afrom_string(self): + version_range = VersionRange.from_string("vers:pypi/>0.0.2") + assert version_range == PypiVersionRange( + constraints=[VersionConstraint(comparator=">", version=PypiVersion(string="0.0.2"))] + ) + 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" + 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" + 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_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" + 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")) @@ -30,32 +37,116 @@ def test_VersionRange_contains(self): 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" + 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")), - ] + 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" + 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" + + version_range1 = VersionRange.from_string(vers, simplify=False, validate=True) + assert version_range1.constraints == expected + + version_range2 = VersionRange.from_string(vers, simplify=True, validate=False) + assert version_range2.constraints == expected + + version_range3 = VersionRange.from_string(vers, simplify=True, validate=True) + assert version_range3.constraints == expected + + def test_VersionRange_from_string_pypi_complex_simplify(self): + vers = "vers:pypi/>0.0.0|>=0.0.1|0.0.2|<0.0.3|0.0.4|<0.0.5|>=0.0.6" + version_range = VersionRange.from_string(vers, simplify=True) + assert str(version_range) == "vers:pypi/>0.0.0|<0.0.5|>=0.0.6" + try: + version_range = VersionRange.from_string(vers, validate=True) + raise Exception(f"Exception not raised: {vers}") + except ValueError: + pass + version_range = VersionRange.from_string(vers, validate=True, simplify=True) + assert str(version_range) == "vers:pypi/>0.0.0|<0.0.5|>=0.0.6" + + def test_VersionRange_from_string_pypi_complex_simplify_and_validate(self): + vers = "vers:pypi/>0.0.0|>=0.0.1|0.0.2|0.0.3|0.0.4|<0.0.5|>=0.0.6|!=0.8" + version_range = VersionRange.from_string(vers, simplify=True) + assert str(version_range) == "vers:pypi/>0.0.0|<0.0.5|>=0.0.6|!=0.8" + version_range = VersionRange.from_string(vers, simplify=True, validate=True) + + def test_VersionRange_from_string_pypi_complex_simplify2(self): + vers = ( + "vers:pypi/>0.0.0|>=0.0.1|>=0.0.1|0.0.2|0.0.3|0.0.4|<0.0.5|<=0.0.6|!=0.7|8.0|>12|<15.3" + ) + version_range = VersionRange.from_string(vers, simplify=True) + assert str(version_range) == "vers:pypi/>0.0.0|<=0.0.6|!=0.7|8.0|>12|<15.3" + + def test_VersionRange_from_string_pypi_simple_cases(self): + vers = "vers:pypi/>0.0.1" + version_range = VersionRange.from_string(vers, simplify=True, validate=True) + assert str(version_range) == vers + + vers = "vers:pypi/>=0.0.1" + version_range = VersionRange.from_string(vers, simplify=True, validate=True) + assert str(version_range) == vers + + vers = "vers:pypi/<0.0.1" + version_range = VersionRange.from_string(vers, simplify=True, validate=True) + assert str(version_range) == vers + + vers = "vers:pypi/<=0.0.1" + version_range = VersionRange.from_string(vers, simplify=True, validate=True) + assert str(version_range) == vers + + vers = "vers:pypi/0.0.1" + version_range = VersionRange.from_string(vers, simplify=True, validate=True) + assert str(version_range) == vers + + vers = "vers:pypi/!=0.0.1" + version_range = VersionRange.from_string(vers, simplify=True, validate=True) + assert str(version_range) == vers + + vers = "vers:pypi/*" + version_range = VersionRange.from_string(vers, simplify=True, validate=True) + assert str(version_range) == vers + + def test_VersionRange_from_string_pypi_two_cases(self): + vers = "vers:pypi/>0.0.1|<0.1" + version_range = VersionRange.from_string(vers, simplify=True, validate=True) + assert str(version_range) == vers + + vers = "vers:pypi/>=0.0.1|<0.1" + version_range = VersionRange.from_string(vers, simplify=True, validate=True) + assert str(version_range) == vers + + vers = "vers:pypi/<0.0.1|>0.1" + version_range = VersionRange.from_string(vers, simplify=True, validate=True) + assert str(version_range) == vers + + vers = "vers:pypi/<=0.0.1|>0.1" + version_range = VersionRange.from_string(vers, simplify=True, validate=True) + assert str(version_range) == vers + + vers = "vers:pypi/0.0.1|>0.1" + version_range = VersionRange.from_string(vers, simplify=True, validate=True) + assert str(version_range) == vers + + vers = "vers:pypi/!=0.0.1|>0.1" + version_range = VersionRange.from_string(vers, simplify=True, validate=True) + assert str(version_range) == vers 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.to_string() == "vers:gem/>=2.0.8|<2.1" assert version_range.constraints == [ - [ - VersionConstraint(comparator="<", version=RubyVersion(string="2.1.0")), - VersionConstraint(comparator=">=", version=RubyVersion(string="2.0.8")), - ], + VersionConstraint(comparator=">=", version=RubygemsVersion(string="2.0.8")), + VersionConstraint(comparator="<", version=RubygemsVersion(string="2.1")), ]