diff --git a/src/poetry/core/semver/empty_constraint.py b/src/poetry/core/semver/empty_constraint.py index e65a03afe..56d1f2108 100644 --- a/src/poetry/core/semver/empty_constraint.py +++ b/src/poetry/core/semver/empty_constraint.py @@ -7,6 +7,7 @@ if TYPE_CHECKING: from poetry.core.semver.version import Version + from poetry.core.semver.version_range_constraint import VersionRangeConstraint class EmptyConstraint(VersionConstraint): @@ -37,5 +38,8 @@ def union(self, other: VersionConstraint) -> VersionConstraint: def difference(self, other: VersionConstraint) -> EmptyConstraint: return self + def flatten(self) -> list[VersionRangeConstraint]: + return [] + def __str__(self) -> str: return "" diff --git a/src/poetry/core/semver/util.py b/src/poetry/core/semver/util.py new file mode 100644 index 000000000..b70e85df4 --- /dev/null +++ b/src/poetry/core/semver/util.py @@ -0,0 +1,58 @@ +from __future__ import annotations + +from typing import TYPE_CHECKING + +from poetry.core.semver.version_range import VersionRange + + +if TYPE_CHECKING: + from poetry.core.semver.version_constraint import VersionConstraint + + +def constraint_regions(constraints: list[VersionConstraint]) -> list[VersionRange]: + """ + Transform a list of VersionConstraints into a list of VersionRanges that mark out + the distinct regions of version-space. + + eg input >=3.6 and >=2.7,<3.0.0 || >=3.4.0 + output <2.7, >=2.7,<3.0.0, >=3.0.0,<3.4.0, >=3.4.0,<3.6, >=3.6. + """ + flattened = [] + for constraint in constraints: + flattened += constraint.flatten() + + mins = { + (constraint.min, not constraint.include_min) + for constraint in flattened + if constraint.min is not None + } + maxs = { + (constraint.max, constraint.include_max) + for constraint in flattened + if constraint.max is not None + } + + edges = sorted(mins | maxs) + if not edges: + return [VersionRange(None, None)] + + start = edges[0] + regions = [ + VersionRange(None, start[0], include_max=start[1]), + ] + + for low, high in zip(edges, edges[1:]): + version_range = VersionRange( + low[0], + high[0], + include_min=not low[1], + include_max=high[1], + ) + regions.append(version_range) + + end = edges[-1] + regions.append( + VersionRange(end[0], None, include_min=not end[1]), + ) + + return regions diff --git a/src/poetry/core/semver/version.py b/src/poetry/core/semver/version.py index 63d67fddc..417d23a1d 100644 --- a/src/poetry/core/semver/version.py +++ b/src/poetry/core/semver/version.py @@ -140,6 +140,9 @@ def difference(self, other: VersionConstraint) -> Version | EmptyConstraint: return self + def flatten(self) -> list[VersionRangeConstraint]: + return [self] + def __str__(self) -> str: return self.text diff --git a/src/poetry/core/semver/version_constraint.py b/src/poetry/core/semver/version_constraint.py index a454ab8f5..8a35a33d0 100644 --- a/src/poetry/core/semver/version_constraint.py +++ b/src/poetry/core/semver/version_constraint.py @@ -6,6 +6,7 @@ if TYPE_CHECKING: from poetry.core.semver.version import Version + from poetry.core.semver.version_range_constraint import VersionRangeConstraint class VersionConstraint: @@ -44,3 +45,7 @@ def union(self, other: VersionConstraint) -> VersionConstraint: @abstractmethod def difference(self, other: VersionConstraint) -> VersionConstraint: raise NotImplementedError() + + @abstractmethod + def flatten(self) -> list[VersionRangeConstraint]: + raise NotImplementedError() diff --git a/src/poetry/core/semver/version_range.py b/src/poetry/core/semver/version_range.py index 6e2539a2f..3a6aff195 100644 --- a/src/poetry/core/semver/version_range.py +++ b/src/poetry/core/semver/version_range.py @@ -332,6 +332,9 @@ def difference(self, other: VersionConstraint) -> VersionConstraint: raise ValueError(f"Unknown VersionConstraint type {other}.") + def flatten(self) -> list[VersionRangeConstraint]: + return [self] + def __eq__(self, other: object) -> bool: if not isinstance(other, VersionRangeConstraint): return False diff --git a/src/poetry/core/semver/version_union.py b/src/poetry/core/semver/version_union.py index 0a2e665cf..da4502d6c 100644 --- a/src/poetry/core/semver/version_union.py +++ b/src/poetry/core/semver/version_union.py @@ -91,7 +91,7 @@ def allows(self, version: Version) -> bool: def allows_all(self, other: VersionConstraint) -> bool: our_ranges = iter(self._ranges) - their_ranges = iter(self._ranges_for(other)) + their_ranges = iter(other.flatten()) our_current_range = next(our_ranges, None) their_current_range = next(their_ranges, None) @@ -106,7 +106,7 @@ def allows_all(self, other: VersionConstraint) -> bool: def allows_any(self, other: VersionConstraint) -> bool: our_ranges = iter(self._ranges) - their_ranges = iter(self._ranges_for(other)) + their_ranges = iter(other.flatten()) our_current_range = next(our_ranges, None) their_current_range = next(their_ranges, None) @@ -124,7 +124,7 @@ def allows_any(self, other: VersionConstraint) -> bool: def intersect(self, other: VersionConstraint) -> VersionConstraint: our_ranges = iter(self._ranges) - their_ranges = iter(self._ranges_for(other)) + their_ranges = iter(other.flatten()) new_ranges = [] our_current_range = next(our_ranges, None) @@ -148,7 +148,7 @@ def union(self, other: VersionConstraint) -> VersionConstraint: def difference(self, other: VersionConstraint) -> VersionConstraint: our_ranges = iter(self._ranges) - their_ranges = iter(self._ranges_for(other)) + their_ranges = iter(other.flatten()) new_ranges: list[VersionConstraint] = [] state = { @@ -230,19 +230,8 @@ def our_next_range(include_current: bool = True) -> bool: return VersionUnion.of(*new_ranges) - def _ranges_for( - self, constraint: VersionConstraint - ) -> list[VersionRangeConstraint]: - if constraint.is_empty(): - return [] - - if isinstance(constraint, VersionUnion): - return constraint.ranges - - if isinstance(constraint, VersionRangeConstraint): - return [constraint] - - raise ValueError(f"Unknown VersionConstraint type {constraint}") + def flatten(self) -> list[VersionRangeConstraint]: + return self.ranges def _exclude_single_wildcard_range_string(self) -> str: """ diff --git a/tests/semver/test_utils.py b/tests/semver/test_utils.py new file mode 100644 index 000000000..413cac69a --- /dev/null +++ b/tests/semver/test_utils.py @@ -0,0 +1,83 @@ +from __future__ import annotations + +from typing import TYPE_CHECKING + +import pytest + +from poetry.core.semver.empty_constraint import EmptyConstraint +from poetry.core.semver.util import constraint_regions +from poetry.core.semver.version import Version +from poetry.core.semver.version_range import VersionRange + + +if TYPE_CHECKING: + from poetry.core.semver.version_constraint import VersionConstraint + + +PY27 = Version.parse("2.7") +PY30 = Version.parse("3") +PY36 = Version.parse("3.6.0") +PY37 = Version.parse("3.7") +PY38 = Version.parse("3.8.0") +PY40 = Version.parse("4.0.0") + + +@pytest.mark.parametrize( + "versions, expected", + [ + ([VersionRange(None, None)], [VersionRange(None, None)]), + ([EmptyConstraint()], [VersionRange(None, None)]), + ( + [VersionRange(PY27, None, include_min=True)], + [ + VersionRange(None, PY27, include_max=False), + VersionRange(PY27, None, include_min=True), + ], + ), + ( + [VersionRange(None, PY40, include_max=False)], + [ + VersionRange(None, PY40, include_max=False), + VersionRange(PY40, None, include_min=True), + ], + ), + ( + [VersionRange(PY27, PY27, include_min=True, include_max=True)], + [ + VersionRange(None, PY27, include_max=False), + VersionRange(PY27, PY27, include_min=True, include_max=True), + VersionRange(PY27, None, include_min=False), + ], + ), + ( + [VersionRange(PY27, PY30, include_min=True, include_max=False)], + [ + VersionRange(None, PY27, include_max=False), + VersionRange(PY27, PY30, include_min=True, include_max=False), + VersionRange(PY30, None, include_min=True), + ], + ), + ( + [ + VersionRange(PY27, PY30, include_min=True, include_max=False).union( + VersionRange(PY37, PY40, include_min=False, include_max=True) + ), + VersionRange(PY36, PY38, include_min=True, include_max=False), + ], + [ + VersionRange(None, PY27, include_max=False), + VersionRange(PY27, PY30, include_min=True, include_max=False), + VersionRange(PY30, PY36, include_min=True, include_max=False), + VersionRange(PY36, PY37, include_min=True, include_max=True), + VersionRange(PY37, PY38, include_min=False, include_max=False), + VersionRange(PY38, PY40, include_min=True, include_max=True), + VersionRange(PY40, None, include_min=False), + ], + ), + ], +) +def test_constraint_regions( + versions: list[VersionConstraint], expected: list[VersionRange] +) -> None: + regions = constraint_regions(versions) + assert regions == expected