Skip to content

Commit

Permalink
Fix a problem with wildcard versions and the version is_possible chec…
Browse files Browse the repository at this point in the history
…k. (#91)
  • Loading branch information
sputt authored Sep 29, 2024
1 parent 4e380c8 commit 3c09c38
Show file tree
Hide file tree
Showing 5 changed files with 155 additions and 42 deletions.
2 changes: 1 addition & 1 deletion MODULE.bazel
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@

module(
name = "rules_req_compile",
version = "1.0.0rc29",
version = "1.0.0rc30",
)

bazel_dep(name = "platforms", version = "0.0.9")
Expand Down
4 changes: 2 additions & 2 deletions req_compile/repos/repository.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,9 +9,9 @@
import re
import sys
import sysconfig
from collections import defaultdict
from typing import (
Any,
DefaultDict,
Iterable,
Iterator,
List,
Expand Down Expand Up @@ -66,7 +66,7 @@ def _get_platform_tags() -> Sequence[str]:
if sys.platform == "darwin":
# Compile a sorted list where later entries are considered
# higher ranked platform tags.
mac_platforms = DefaultDict(set)
mac_platforms = defaultdict(set)

for plat in packaging.tags.mac_platforms():
match = re.match(MACOSX_REGEX, plat)
Expand Down
106 changes: 82 additions & 24 deletions req_compile/versions.py
Original file line number Diff line number Diff line change
@@ -1,14 +1,15 @@
import packaging.version
from typing import Tuple

import pkg_resources
from packaging.version import Version
from pkg_resources import Requirement

from req_compile.utils import parse_version

PART_MAX = "999999999"


def _offset_minor_version(
version: packaging.version.Version, offset: int, pos: int = 2
) -> packaging.version.Version:
def _offset_minor_version(version: Version, offset: int, pos: int = 2) -> Version:
parts = str(version).split(".")

for idx, part in enumerate(parts):
Expand All @@ -35,6 +36,15 @@ def _offset_minor_version(
return parse_version(".".join(parts))


def _build_wildcard_min_max(version: str) -> Tuple[Version, Version]:
pre_wildcard_portion, _, _ = version.partition("*")
if pre_wildcard_portion[-1] != ".":
pre_wildcard_portion += "."
return parse_version(pre_wildcard_portion + "0"), parse_version(
pre_wildcard_portion + PART_MAX
)


def is_possible(
req: pkg_resources.Requirement,
) -> bool: # pylint: disable=too-many-branches
Expand All @@ -46,42 +56,90 @@ def is_possible(
Returns:
Whether the constraint can be satisfied.
"""
lower_bound = pkg_resources.parse_version("0.0.0")
lower_bound = parse_version("0.0.0")

# The current exact match, as seen in a == specifier.
exact = None

# Collection of "!=" specifier versions. We can't see an exact match
# the is equal to any of these.
not_equal = []
upper_bound = pkg_resources.parse_version("{max}.{max}.{max}".format(max=PART_MAX))

upper_bound = parse_version("{max}.{max}.{max}".format(max=PART_MAX))
if len(req.specifier) == 1: # type: ignore[attr-defined]
return True

for spec in req.specifier: # type: ignore[attr-defined]
version = parse_version(spec.version)
if spec.operator == "==":
if exact is None:
exact = version
if exact != version:
return False
# Special block just for ==, since it may refer to wildcard versions
# which are not parseable as a packaging.version Version.
if spec.operator in "==":
# Is it a wild card version? That actually means a range.
if "*" in spec.version:
possible_new_lower, new_possible_upper = _build_wildcard_min_max(
spec.version
)
if possible_new_lower > lower_bound:
lower_bound = possible_new_lower
if new_possible_upper < upper_bound:
upper_bound = new_possible_upper
else:
if exact is None:
exact = parse_version(spec.version)
# Cannot have two == specifies with different versions.
elif exact != parse_version(spec.version):
return False
continue

if spec.operator == "!=":
not_equal.append(version)
elif spec.operator == ">":
if version > lower_bound:
lower_bound = _offset_minor_version(version, 1)
if "*" in spec.version:
# With != wildcards, we have our only "OR" condition in a requirement
# expression. Try both branches along with all other specs.
# This effectively transforms the first wildcard expression into 2
# new requirements:
# project >=2, <4, !=4.2.*
# becomes
# project >=2, <4, <4.2.0
# project >=2, <4, >4.2.MAX
all_specs = list(req.specifier)
all_specs.remove(spec)
new_specs = ",".join(str(spec) for spec in all_specs)

spec_lower, spec_upper = _build_wildcard_min_max(spec.version)
req_upper = Requirement.parse(
req.project_name + new_specs + ",>{}".format(spec_upper)
)
req_lower = Requirement.parse(
req.project_name + new_specs + ",<{}".format(spec_lower)
)
return is_possible(req_lower) or is_possible(req_upper)
else:
not_equal.append(parse_version(spec.version))
continue

parsed_version = parse_version(spec.version)
if spec.operator == ">":
if parsed_version > lower_bound:
lower_bound = _offset_minor_version(parsed_version, 1)
elif spec.operator == ">=":
if version >= lower_bound:
lower_bound = version
if parsed_version >= lower_bound:
lower_bound = parsed_version
elif spec.operator == "<":
if version < upper_bound:
upper_bound = _offset_minor_version(version, -1)
if parsed_version < upper_bound:
upper_bound = _offset_minor_version(parsed_version, -1)
elif spec.operator == "<=":
if version <= upper_bound:
upper_bound = version
if parsed_version <= upper_bound:
upper_bound = parsed_version
# Some kind of parsing error occurred
if upper_bound is None or lower_bound is None:
return True

# No possible versions.
if upper_bound < lower_bound:
return False

if exact is not None:
if exact > upper_bound or exact < lower_bound:
return False
return exact in req # type: ignore[operator]

for check in not_equal:
if check == exact:
return False
Expand Down
83 changes: 69 additions & 14 deletions tests/test_versions.py
Original file line number Diff line number Diff line change
Expand Up @@ -22,58 +22,113 @@ def test_offset_version(version, offset, result):
assert _offset_minor_version(version, offset) == pkg_resources.parse_version(result)


def test_two_equals():
def test_two_equals() -> None:
assert not is_possible(parse_req("thing==1,==2"))


def test_greater_less():
def test_greater_less() -> None:
assert not is_possible(parse_req("thing>1.12,<1"))


def test_greater_equal():
def test_greater_equal() -> None:
assert not is_possible(parse_req("thing>1.12,==1.0.2"))


def test_equals_not_equals():
def test_equals_not_equals() -> None:
assert not is_possible(parse_req("thing==1,!=1"))


def test_dev_version():
def test_dev_version() -> None:
assert not is_possible(parse_req("thing<1.6,<2.0dev,>=1.5,>=1.6.0"))


def test_beta_version():
def test_beta_version() -> None:
assert is_possible(parse_req("thing<20b0"))


def test_no_constraints():
def test_no_constraints() -> None:
assert is_possible(parse_req("thing"))


def test_two_greater():
def test_edge_equals() -> None:
assert pkg_resources.parse_version("2.1.1") in parse_req("thing>2.1")
assert is_possible(parse_req("thing==2.1.1,>2.1"))

assert pkg_resources.parse_version("2.1.0") not in parse_req("thing>2.1")
assert not is_possible(parse_req("thing==2.1.0,>2.1"))


def test_two_greater() -> None:
assert is_possible(parse_req("thing>1,>2,<3"))


def test_two_greater_equals():
def test_two_greater_equals() -> None:
assert is_possible(parse_req("thing>1,>=2,==2"))


def test_gre_lte():
def test_gre_lte() -> None:
assert is_possible(parse_req("thing>=1,<=1"))


def test_gre_lte_equals():
def test_gre_lte_equals() -> None:
assert is_possible(parse_req("thing>=1,<=1,==1"))


def test_not_equals():
def test_not_equals() -> None:
assert is_possible(parse_req("thing!=1"))
assert is_possible(parse_req("thing!=1,!=2,!=3"))


def test_gr():
def test_gr() -> None:
assert is_possible(parse_req("thing>1"))


def test_lt():
def test_lt() -> None:
assert is_possible(parse_req("thing<1"))


def test_wildcard_possible() -> None:
assert is_possible(parse_req("thing>1,==2.*,<3"))
assert is_possible(parse_req("thing>1,==2.*,==2.1.2,<3"))

# Show that with this wildcard expression, a version can satisfy it.
wildcard_req = parse_req("thing>2.1.0,==2.1.*")
assert pkg_resources.parse_version("2.1.2") in wildcard_req
# Sanity check one that does not satisfy it.
assert pkg_resources.parse_version("2.2.0") not in wildcard_req
# Run the is possible check.
assert is_possible(wildcard_req)


def test_wildcard_not_possible() -> None:
assert not is_possible(parse_req("thing<1,==2.*"))
assert not is_possible(parse_req("thing>2.1,==2.0.*"))


def test_wildcard_not_equal_possible() -> None:
wildcard_req = parse_req("thing>2.1.0,!=2.1.*")
assert pkg_resources.parse_version("3.0") in wildcard_req
assert pkg_resources.parse_version("2.1.1") not in wildcard_req

assert is_possible(wildcard_req)


def test_wildcard_subrange() -> None:
wildcard_req = parse_req("thing==2.*,!=2.1.*")
assert pkg_resources.parse_version("2.2") in wildcard_req
assert pkg_resources.parse_version("2.1.1") not in wildcard_req

assert is_possible(wildcard_req)

wildcard_req = parse_req("thing==2.1.*,!=2.*")
assert pkg_resources.parse_version("2.1.1") not in wildcard_req
assert not is_possible(wildcard_req)


def test_wildcard_double_not() -> None:
wildcard_req = parse_req("thing!=2.*,!=3.*,>1")
assert pkg_resources.parse_version("4") in wildcard_req
assert pkg_resources.parse_version("2") not in wildcard_req
assert pkg_resources.parse_version("3") not in wildcard_req

assert is_possible(wildcard_req)
2 changes: 1 addition & 1 deletion version.bzl
Original file line number Diff line number Diff line change
@@ -1,3 +1,3 @@
"""req-compile version"""

VERSION = "1.0.0rc29"
VERSION = "1.0.0rc30"

0 comments on commit 3c09c38

Please sign in to comment.