diff --git a/src/poetry/core/constraints/version/version_range.py b/src/poetry/core/constraints/version/version_range.py index 6666331b2..cdf82b2f1 100644 --- a/src/poetry/core/constraints/version/version_range.py +++ b/src/poetry/core/constraints/version/version_range.py @@ -205,7 +205,16 @@ def intersect(self, other: VersionConstraint) -> VersionConstraint: return other # `>=1.2.3+local` intersects `1.2.3` to return `>=1.2.3+local,<1.2.4`. - if self.min is not None and self.min.is_local() and other.allows(self.min): + # Skip when ``other`` is itself a local version: for an exclusive + # lower bound like ``>1.2.3+local`` intersected with the excluded + # point ``1.2.3+local``, broadening would wrongly return a + # non-empty range instead of the correct empty constraint. + if ( + self.min is not None + and self.min.is_local() + and not other.is_local() + and other.allows(self.min) + ): upper = other.stable.next_patch() return _range_or_empty( min=self.min, diff --git a/tests/constraints/version/test_version_range.py b/tests/constraints/version/test_version_range.py index dd167f206..841c53a0f 100644 --- a/tests/constraints/version/test_version_range.py +++ b/tests/constraints/version/test_version_range.py @@ -735,6 +735,83 @@ def test_difference_returns_empty_constraint_not_empty_range() -> None: assert isinstance(result, EmptyConstraint) +def test_intersect_with_local_version_other_does_not_broaden_exclusive_min() -> None: + """Regression test: ``>0.21.0+cpu,<0.22.0 ∩ ==0.21.0+cpu`` must be + empty. Previously returned ``>0.21.0+cpu,<0.21.1`` because the + ``>=X+local ∩ public_X`` broadening fired for local ``other`` too. + """ + excluded_point = Version.parse("0.21.0+cpu") + upper = Version.parse("0.22.0") + + exclusive = VersionRange( + excluded_point, upper, include_min=False, include_max=False + ) + assert isinstance(exclusive.intersect(excluded_point), EmptyConstraint) + assert isinstance(excluded_point.intersect(exclusive), EmptyConstraint) + + # Inclusive-lower case still returns the literally-equal point. + inclusive = VersionRange(excluded_point, upper, include_min=True, include_max=False) + assert inclusive.intersect(excluded_point) == excluded_point + assert excluded_point.intersect(inclusive) == excluded_point + + # Original motivating case (``>=X+local ∩ public X``) still broadens. + public = Version.parse("0.21.0") + range_ge_local = VersionRange( + excluded_point, Version.parse("1.0"), include_min=True, include_max=False + ) + broadened_range = VersionRange( + excluded_point, + public.next_patch(), + include_min=True, + include_max=False, + ) + assert range_ge_local.intersect(public) == broadened_range + assert public.intersect(range_ge_local) == broadened_range + + +@pytest.mark.parametrize( + ("min_local", "other_local"), + [ + # other lex-orders after min: in range → return other. + ("a", "b"), + ("cpu", "cu124"), + ("cpu", "cpu1"), + # other lex-orders before min: out of range → empty. + ("b", "a"), + ("cu124", "cpu"), + # literal equal: handled by self.allows(other) on line 204 for + # inclusive; falls through to the special case for exclusive. + ("cpu", "cpu"), + ], +) +@pytest.mark.parametrize("include_min", [True, False]) +def test_intersect_with_two_local_versions( + min_local: str, other_local: str, include_min: bool +) -> None: + """Cross-local intersection: ``self.min`` and ``other`` both carry + local segments. ``==X+other`` matches only the literal point + ``X+other``, so the result is just the point if it falls in the + range and empty otherwise — never a broadened range.""" + self_min = Version.parse(f"1.2.3+{min_local}") + other = Version.parse(f"1.2.3+{other_local}") + upper = Version.parse("2.0") + + rng = VersionRange(self_min, upper, include_min=include_min) + expected = other if rng.allows(other) else EmptyConstraint() + assert rng.intersect(other) == expected + assert other.intersect(rng) == expected + + +def test_intersect_punctured_range_with_excluded_point_is_empty() -> None: + """A punctured ``VersionUnion`` (``>=A,!=V,=0.21.0,!=0.21.0+cpu,<0.22.0") + excluded_point = parse_constraint("==0.21.0+cpu") + assert punctured.intersect(excluded_point).is_empty() + assert excluded_point.intersect(punctured).is_empty() + + def test_parsed_strict_max_excludes_dev_releases_of_stable() -> None: """PEP 440: ``