Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions src/univers/datetime.py
Original file line number Diff line number Diff line change
Expand Up @@ -105,6 +105,9 @@ def __gt__(self, other):
def __ge__(self, other):
return self.parsed_stamp >= other.parsed_stamp

def __str__(self):
return self.original

@classmethod
def is_valid(cls, string):
return bool(cls.VERSION_PATTERN.fullmatch(string))
Expand Down
3 changes: 3 additions & 0 deletions src/univers/intdot.py
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,9 @@ def __gt__(self, other):
def __ge__(self, other):
return self.__cmp__(other) >= 0

def __str__(self):
return self.original

@classmethod
def is_valid(cls, string):
return re.compile(IntdotVersion.VERSION_PATTERN).match(string)
Expand Down
7 changes: 7 additions & 0 deletions src/univers/utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,13 @@ def remove_spaces(string):
return "".join(string.split())


ASCII_WHITESPACE = frozenset(" \t\n\r\f\v")


def contains_ascii_whitespace(string):
return any(character in ASCII_WHITESPACE for character in string)


def cmp(x, y):
"""
Replacement for built-in Python 2 function cmp that was removed in Python 3
Expand Down
25 changes: 16 additions & 9 deletions src/univers/version_constraint.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@

import attr

from univers.utils import contains_ascii_whitespace
from univers.utils import remove_spaces
from univers.versions import Version

Expand Down Expand Up @@ -166,7 +167,10 @@ def from_string(cls, string, version_class):
Return a single VersionConstraint built from a constraint ``string`` and
a ``version_class`` Version class.
"""
constraint_string = remove_spaces(string)
if contains_ascii_whitespace(string):
raise ValueError(f"Invalid ASCII whitespace in constraint: {string!r}")

constraint_string = string

# A version range specifier contains only printable ASCII letters, digits and
# punctuation.
Expand Down Expand Up @@ -195,7 +199,7 @@ def split(string):

For example::
>>> assert VersionConstraint.split(">=2.3") == (">=", "2.3",)
>>> assert VersionConstraint.split(" < = 2 . 3 ") == ("<=", "2.3",)
>>> assert VersionConstraint.split("<=2.3") == ("<=", "2.3",)
>>> assert VersionConstraint.split("2.3") == ("=", "2.3",)
>>> assert VersionConstraint.split("*2.3") == ("*", "",)
>>> assert VersionConstraint.split("*") == ("*", "",)
Expand All @@ -208,7 +212,10 @@ def split(string):
... except ValueError:
... pass
"""
constraint_string = remove_spaces(string)
if contains_ascii_whitespace(string):
raise ValueError(f"Invalid ASCII whitespace in constraint: {string!r}")

constraint_string = string

# special case for star
if constraint_string.startswith("*"):
Expand Down Expand Up @@ -292,13 +299,13 @@ def validate(cls, constraints):
# occur only once in any ``<version-constraint>`` 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")
for index, constraint in enumerate(constraints):
if any(constraint.version == other.version for other in constraints[index + 1 :]):
raise ValueError(f"{constraints!r} cannot contain duplicated Version")

# Constraints are sorted by version**. The canonical ordering is the versions
# order. The ordering of ``<version-constraint>`` is not significant otherwise
# but this sort order is needed when check if a version is contained in a range.
constraints.sort()
sorted_constraints = sorted(constraints)
if list(constraints) != sorted_constraints:
raise ValueError(f"{constraints!r} must use canonical ordering by version")

return validate_comparators(constraints)

Expand Down
27 changes: 18 additions & 9 deletions src/univers/version_range.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@
from univers import maven
from univers import versions
from univers.conan.version_range import VersionRange as conan_version_range
from univers.utils import contains_ascii_whitespace
from univers.utils import remove_spaces
from univers.version_constraint import VersionConstraint
from univers.version_constraint import contains_version
Expand Down Expand Up @@ -108,8 +109,10 @@ def from_string(cls, vers, simplify=False, validate=False):
f"{vers!r} is not a valid argument, a valid ``vers`` string argument is required."
)

# Spaces are not significant and removed in a canonical form.
vers = remove_spaces(vers)
if contains_ascii_whitespace(vers):
raise ValueError(f"Invalid ASCII whitespace in VERS string: {vers!r}")

original_vers = vers

# A version range specifier contains only printable ASCII letters, digits and
# punctuation.
Expand All @@ -134,7 +137,6 @@ def from_string(cls, vers, simplify=False, validate=False):

version_class = range_class.version_class

constraints = remove_spaces(constraints)
if not constraints:
raise ValueError(f"{vers!r} specifies no version range constraints.")

Expand All @@ -149,25 +151,32 @@ def from_string(cls, vers, simplify=False, validate=False):

parsed_constraints = []

constraints = constraints.strip("|")
if constraints.startswith("|") or constraints.endswith("|"):
raise ValueError(f"{vers!r} contains a leading or trailing pipe '|'.")

if "||" in constraints:
raise ValueError(f"{vers!r} contains consecutive pipes '||'.")

for const in constraints.split("|"):
constraint = VersionConstraint.from_string(
string=const,
version_class=version_class,
)
parsed_constraints.append(constraint)

# Constraints are sorted by version**. The canonical ordering is the versions
# order. The ordering of ``<version-constraint>`` is not significant otherwise
# but this sort order is needed when check if a version is contained in a range.
parsed_constraints.sort()
VersionConstraint.validate(parsed_constraints)

if simplify:
parsed_constraints = VersionConstraint.simplify(parsed_constraints)
if validate:
VersionConstraint.validate(parsed_constraints)

return range_class(parsed_constraints)
version_range = range_class(parsed_constraints)
canonical_vers = str(version_range)
if canonical_vers != original_vers:
raise ValueError(f"{original_vers!r} is not canonical. Expected {canonical_vers!r}.")

return version_range

@classmethod
def from_versions(cls, sequence):
Expand Down
6 changes: 3 additions & 3 deletions tests/data/schema/range/pypi_range_containment.json
Original file line number Diff line number Diff line change
Expand Up @@ -44,7 +44,7 @@
"test_group": "advanced",
"test_type": "containment",
"input": {
"vers": "vers:pypi/>=3.0.0|2.0.3",
"vers": "vers:pypi/2.0.3|>=3.0.0",
"version": "2.0.3"
},
"expected_output": true
Expand Down Expand Up @@ -74,7 +74,7 @@
"test_group": "advanced",
"test_type": "containment",
"input": {
"vers": "vers:pypi/>=3.0.0|!=2.0.3",
"vers": "vers:pypi/!=2.0.3|>=3.0.0",
"version": "2.0.3"
},
"expected_output": false
Expand All @@ -94,7 +94,7 @@
"test_group": "advanced",
"test_type": "containment",
"input": {
"vers": "vers:pypi/0.0.2|0.0.6|>=3.0.0|0.0.1|0.0.4|0.0.5|0.0.3",
"vers": "vers:pypi/0.0.1|0.0.2|0.0.3|0.0.4|0.0.5|0.0.6|>=3.0.0",
"version": "2.0.3"
},
"expected_output": false
Expand Down
16 changes: 8 additions & 8 deletions tests/data/schema/range/pypi_range_roundtrip.json
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@
"test_group": "advanced",
"test_type": "roundtrip",
"input": {
"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.0|0.0.1|0.0.2|0.0.3|0.0.4|0.0.5|0.0.6"
},
"expected_output": "vers:pypi/0.0.0|0.0.1|0.0.2|0.0.3|0.0.4|0.0.5|0.0.6"
},
Expand All @@ -22,27 +22,27 @@
"test_group": "advanced",
"test_type": "roundtrip",
"input": {
"vers": "vers:pypi/>0.0.0|>=0.0.1|0.0.2|<0.0.3|0.0.4|<0.0.5|>=0.0.6"
"vers": "vers:pypi/0.0.2|0.0.4|>=0.0.6"
},
"expected_output": "vers:pypi/>0.0.0|>=0.0.1|0.0.2|<0.0.3|0.0.4|<0.0.5|>=0.0.6"
"expected_output": "vers:pypi/0.0.2|0.0.4|>=0.0.6"
},
{
"description": "Roundtrip test for pypi VERS range.",
"test_group": "advanced",
"test_type": "roundtrip",
"input": {
"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"
"vers": "vers:pypi/!=0.7|8.0|>12|<15.3"
},
"expected_output": "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"
"expected_output": "vers:pypi/!=0.7|8.0|>12|<15.3"
},
{
"description": "Roundtrip test for pypi VERS range.",
"test_group": "advanced",
"test_type": "roundtrip",
"input": {
"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"
"vers": "vers:pypi/>0.0.0|<0.0.5|>=0.0.6|!=0.8"
},
"expected_output": "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"
"expected_output": "vers:pypi/>0.0.0|<0.0.5|>=0.0.6|!=0.8"
},
{
"description": "Roundtrip test for pypi VERS range.",
Expand Down Expand Up @@ -166,7 +166,7 @@
"test_group": "advanced",
"test_type": "roundtrip",
"input": {
"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.0|0.0.1|0.0.2|0.0.3|0.0.4|0.0.5|0.0.6"
},
"expected_output": "vers:pypi/>=0.0.0|0.0.1|0.0.2|0.0.3|0.0.4|0.0.5|0.0.6"
}
Expand Down
64 changes: 64 additions & 0 deletions tests/data/schema/range/vers_parse_canonical.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
{
"$schema": "https://packageurl.org/schemas/vers-test.schema-0.1.json",
"tests": [
{
"description": "Canonical parse succeeds for an already normalized VERS.",
"test_group": "base",
"test_type": "parse",
"input": "vers:npm/>=1.0.0|<2.0.0",
"expected_output": {
"scheme": "npm",
"version_constraints": [
[
">=",
"1.0.0"
],
[
"<",
"2.0.0"
]
]
}
},
{
"description": "Parsing fails when ASCII whitespace is present.",
"test_group": "base",
"test_type": "parse",
"input": "vers:npm/>=1.0.0| <2.0.0",
"expected_failure": true,
"expected_failure_reason": "non-canonical VERS: whitespace is not permitted"
},
{
"description": "Parsing fails when constraints start with a pipe.",
"test_group": "base",
"test_type": "parse",
"input": "vers:npm/|>=1.0.0|<2.0.0",
"expected_failure": true,
"expected_failure_reason": "non-canonical VERS: leading pipe is not permitted"
},
{
"description": "Parsing fails when constraints end with a pipe.",
"test_group": "base",
"test_type": "parse",
"input": "vers:npm/>=1.0.0|<2.0.0|",
"expected_failure": true,
"expected_failure_reason": "non-canonical VERS: trailing pipe is not permitted"
},
{
"description": "Parsing fails when constraints contain consecutive pipes.",
"test_group": "base",
"test_type": "parse",
"input": "vers:npm/>=1.0.0||<2.0.0",
"expected_failure": true,
"expected_failure_reason": "non-canonical VERS: consecutive pipes are not permitted"
},
{
"description": "Parsing fails when constraints are not in canonical order.",
"test_group": "base",
"test_type": "parse",
"input": "vers:npm/>=2.0.0|<1.0.0",
"expected_failure": true,
"expected_failure_reason": "non-canonical VERS: constraints are not sorted by version"
}
]
}
9 changes: 9 additions & 0 deletions tests/test_version_constraint.py
Original file line number Diff line number Diff line change
Expand Up @@ -96,3 +96,12 @@ def test_invalid_vers_comparator_prefixes(spec):
string=spec,
version_class=versions.SemverVersion,
)


@pytest.mark.parametrize("spec", [">= 2.3", "\t>=2.3", ">=2.3\n"])
def test_invalid_vers_constraint_with_ascii_whitespace(spec):
with pytest.raises(ValueError, match="ASCII whitespace"):
VersionConstraint.from_string(
string=spec,
version_class=versions.SemverVersion,
)
Loading