Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

semver/pep508: handle wildcard exclusions #343

Merged
Merged
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
8 changes: 7 additions & 1 deletion src/poetry/core/packages/dependency.py
Original file line number Diff line number Diff line change
Expand Up @@ -260,7 +260,13 @@ def base_pep_508_name(self) -> str:

constraint = self.constraint
if isinstance(constraint, VersionUnion):
if constraint.excludes_single_version():
if (
constraint.excludes_single_version()
or constraint.excludes_single_wildcard_range()
abn marked this conversation as resolved.
Show resolved Hide resolved
):
# This branch is a short-circuit logic for special cases and
# avoids having to split and parse constraint again. This has
# no functional difference with the logic in the else branch.
requirement += f" ({str(constraint)})"
else:
constraints = self.pretty_constraint.split(",")
Expand Down
156 changes: 155 additions & 1 deletion src/poetry/core/semver/version_union.py
Original file line number Diff line number Diff line change
Expand Up @@ -238,6 +238,157 @@ def _ranges_for(

raise ValueError(f"Unknown VersionConstraint type {constraint}")

def _exclude_single_wildcard_range_string(self) -> str:
"""
Helper method to convert this instance into a wild card range
string.
"""
if not self.excludes_single_wildcard_range():
raise ValueError("Not a valid wildcard range")

# we assume here that since it is a single exclusion range
# that it is one of "< 2.0.0 || >= 2.1.0" or ">= 2.1.0 || < 2.0.0"
# and the one with the max is the first part
idx_order = (0, 1) if self._ranges[0].max else (1, 0)
one = self._ranges[idx_order[0]].max
two = self._ranges[idx_order[1]].min

# versions can have both semver and non semver parts
parts_one = [
one.major,
one.minor or 0,
one.patch or 0,
*list(one.non_semver_parts or []),
]
parts_two = [
two.major,
two.minor or 0,
two.patch or 0,
*list(two.non_semver_parts or []),
]

# we assume here that a wildcard range implies that the part following the
# first part that is different in the second range is the wildcard, this means
# that multiple wildcards are not supported right now.
parts = []

for idx, part in enumerate(parts_one):
parts.append(str(part))
if parts_two[idx] != part:
# since this part is different the next one is the wildcard
# for example, "< 2.0.0 || >= 2.1.0" gets us a wildcard range
# 2.0.*
parts.append("*")
break
else:
# we should not ever get here, however it is likely that poorly
# constructed metadata exists
raise ValueError("Not a valid wildcard range")

return f"!={'.'.join(parts)}"

@staticmethod
def _excludes_single_wildcard_range_check_is_valid_range(
one: VersionRangeConstraint, two: VersionRangeConstraint
) -> bool:
"""
Helper method to determine if two versions define a single wildcard range.
In cases where !=2.0.* was parsed by us, the union is of the range
<2.0.0 || >=2.1.0. In user defined ranges, precision might be different.
For example, a union <2.0 || >= 2.1.0 is still !=2.0.*. In order to
handle these cases we make sure that if precisions do not match, extra
checks are performed to validate that the constraint is a valid single
wildcard range.
"""

max_precision = max(one.max.precision, two.min.precision)

if max_precision <= 3:
# In cases where both versions have a precision less than 3,
# we can make use of the next major/minor/patch versions.
return two.min in {
one.max.next_major(),
one.max.next_minor(),
one.max.next_patch(),
}
else:
# When there are non-semver parts in one of the versions, we need to
# ensure we use zero padded version and in addition to next major/minor/
# patch versions, also check each next release for the extra parts.
from_parts = one.max.__class__.from_parts

_extras: list[list[int]] = []
_versions: list[Version] = []

for _version in [one.max, two.min]:
_extra = list(_version.non_semver_parts or [])

while len(_extra) < (max_precision - 3):
# pad zeros for extra parts to ensure precisions are equal
_extra.append(0)

# create a new release with unspecified parts padded with zeros
_padded_version: Version = from_parts(
major=_version.major,
minor=_version.minor or 0,
patch=_version.patch or 0,
extra=tuple(_extra),
)

_extras.append(_extra)
_versions.append(_padded_version)

_extra_one = _extras[0]
_padded_version_one = _versions[0]
_padded_version_two = _versions[1]

_check_versions = {
_padded_version_one.next_major(),
_padded_version_one.next_minor(),
_padded_version_one.next_patch(),
}

# for each non-semver (extra) part, bump a version
for idx in range(len(_extra_one)):
_extra = [
*_extra_one[: idx - 1],
(_extra_one[idx] + 1),
*_extra_one[idx + 1 :],
]
_check_versions.add(
from_parts(
_padded_version_one.major,
_padded_version_one.minor,
_padded_version_one.patch,
*_extra,
abn marked this conversation as resolved.
Show resolved Hide resolved
)
)

return _padded_version_two in _check_versions

def excludes_single_wildcard_range(self) -> bool:
from poetry.core.semver.version_range import VersionRange

if len(self._ranges) != 2:
return False

idx_order = (0, 1) if self._ranges[0].max else (1, 0)
one = self._ranges[idx_order[0]]
two = self._ranges[idx_order[1]]

is_range_exclusion = (
one.max and not one.include_max and two.min and two.include_min
)

if not is_range_exclusion:
return False

if not self._excludes_single_wildcard_range_check_is_valid_range(one, two):
return False

return isinstance(VersionRange().difference(self), VersionRange)

def excludes_single_version(self) -> bool:
from poetry.core.semver.version import Version
from poetry.core.semver.version_range import VersionRange
Expand All @@ -264,7 +415,10 @@ def __str__(self) -> str:
if self.excludes_single_version():
return f"!={VersionRange().difference(self)}"

return " || ".join([str(r) for r in self._ranges])
try:
return self._exclude_single_wildcard_range_string()
except ValueError:
return " || ".join([str(r) for r in self._ranges])

def __repr__(self) -> str:
return f"<VersionUnion {str(self)}>"
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
[tool.poetry]
name = "my-package"
version = "1.2.3"
description = "Some description."
authors = [
"People Everywhere <people@everywhere.com>"
]

[tool.poetry.dependencies]
python = "^3.10"
google-api-python-client = ">=1.8,!=2.0.*"
12 changes: 12 additions & 0 deletions tests/masonry/builders/test_builder.py
Original file line number Diff line number Diff line change
Expand Up @@ -285,3 +285,15 @@ def test_metadata_with_readme_files() -> None:
description = "\n".join([readme1.read_text(), readme2.read_text(), ""])

assert metadata.get_payload() == description


def test_metadata_with_wildcard_dependency_constraint() -> None:
test_path = (
Path(__file__).parent / "fixtures" / "with_wildcard_dependency_constraint"
)
builder = Builder(Factory().create_poetry(test_path))

metadata = Parser().parsestr(builder.get_metadata_content())

requires = metadata.get_all("Requires-Dist")
assert requires == ["google-api-python-client (>=1.8,!=2.0.*)"]
37 changes: 34 additions & 3 deletions tests/packages/test_dependency.py
Original file line number Diff line number Diff line change
Expand Up @@ -151,10 +151,18 @@ def test_to_pep_508_in_extras_parsed() -> None:
assert result == "foo[bar,baz] (>=1.23,<2.0)"


def test_to_pep_508_with_single_version_excluded() -> None:
dependency = Dependency("foo", "!=1.2.3")
@pytest.mark.parametrize(
("exclusion", "expected"),
[
("!=1.2.3", "!=1.2.3"),
("!=1.2.*", "!=1.2.*"),
("<2.0 || >=2.1", "!=2.0.*"),
],
)
def test_to_pep_508_with_excluded_versions(exclusion: str, expected: str) -> None:
dependency = Dependency("foo", exclusion)

assert dependency.to_pep_508() == "foo (!=1.2.3)"
assert dependency.to_pep_508() == f"foo ({expected})"


@pytest.mark.parametrize(
Expand Down Expand Up @@ -245,6 +253,29 @@ def test_complete_name() -> None:
["x"],
"A[x] (>=1.6.5,!=1.8.0,<3.1.0)",
),
# test single version range exclusions
("A", ">=1.8,!=2.0.*", None, "A (>=1.8,!=2.0.*)"),
("A", "!=0.0.*", None, "A (!=0.0.*)"),
("A", "!=0.1.*", None, "A (!=0.1.*)"),
("A", "!=0.*", None, "A (>=1.0.0)"),
("A", ">=1.8,!=2.*", None, "A (>=1.8,!=2.*)"),
("A", ">=1.8,!=2.*.*", None, "A (>=1.8,!=2.*)"),
("A", ">=1.8,<2.0 || >=2.1.0", None, "A (>=1.8,!=2.0.*)"),
("A", ">=1.8,<2.0.0 || >=3.0.0", None, "A (>=1.8,!=2.*)"),
("A", ">=1.8,<2.0 || >=3", None, "A (>=1.8,!=2.*)"),
("A", ">=1.8,<2 || >=2.1.0", None, "A (>=1.8,!=2.0.*)"),
("A", ">=1.8,<2 || >=2.1", None, "A (>=1.8,!=2.0.*)"),
("A", ">=1.8,!=2.0.*,!=3.0.*", None, "A (>=1.8,!=2.0.*,!=3.0.*)"),
("A", ">=1.8.0.0,<2.0.0.0 || >=2.0.1.0", None, "A (>=1.8.0.0,!=2.0.0.*)"),
("A", ">=1.8.0.0,<2 || >=2.0.1.0", None, "A (>=1.8.0.0,!=2.0.0.*)"),
# we verify that the range exclusion logic is not too eager
("A", ">=1.8,<2.0 || >=2.2.0", None, "A (>=1.8,<2.0 || >=2.2.0)"),
("A", ">=1.8,<2.0 || >=2.1.5", None, "A (>=1.8,<2.0 || >=2.1.5)"),
("A", ">=1.8.0.0,<2 || >=2.0.1.5", None, "A (>=1.8.0.0,<2 || >=2.0.1.5)"),
# non-semver version test is ignored due to existing bug in wildcard
# constraint parsing that ignores non-semver versions
# TODO: re-enable for verification once fixed
# ("A", ">=1.8.0.0,!=2.0.0.*", None, "A (>=1.8.0.0,!=2.0.0.*)"), # noqa: E800
],
)
def test_dependency_string_representation(
Expand Down