diff --git a/src/poetry/core/constraints/version/parser.py b/src/poetry/core/constraints/version/parser.py index 3e39d9968..38aa337ec 100644 --- a/src/poetry/core/constraints/version/parser.py +++ b/src/poetry/core/constraints/version/parser.py @@ -14,6 +14,26 @@ from poetry.core.constraints.version.version_constraint import VersionConstraint +def _canonical_strict_max( + version: Version, *, is_marker_constraint: bool = False +) -> Version: + """Canonicalize an exclusive ``1, <3) - {2}`` allows + ``2.dev0`` in the interior fragment ``(1, 2)``. + + In a marker-constraint context (``python_version`` etc.) the observable + values are always concrete release strings, so canonicalization would be + a semantic no-op; we skip it there to keep marker rendering stable. + """ + if is_marker_constraint or version.is_unstable(): + return version + return version.first_devrelease() + + @functools.cache def parse_constraint(constraints: str) -> VersionConstraint: return _parse_constraint(constraints=constraints) @@ -94,6 +114,9 @@ def parse_single_constraint( from poetry.core.constraints.version.version_range import VersionRange from poetry.core.constraints.version.version_union import VersionUnion + def canon_max(v: Version) -> Version: + return _canonical_strict_max(v, is_marker_constraint=is_marker_constraint) + m = re.match(r"(?i)^v?[xX*](\.[xX*])*$", constraint) if m: return VersionRange() @@ -112,7 +135,7 @@ def parse_single_constraint( if version.release.precision == 1: high = version.stable.next_major() - return VersionRange(version, high, include_min=True) + return VersionRange(version, canon_max(high), include_min=True) # PEP 440 Tilde range (~=) m = TILDE_PEP440_CONSTRAINT.match(constraint) @@ -129,7 +152,7 @@ def parse_single_constraint( else: high = version.stable.next_minor() - return VersionRange(version, high, include_min=True) + return VersionRange(version, canon_max(high), include_min=True) # Caret range m = CARET_CONSTRAINT.match(constraint) @@ -141,7 +164,9 @@ def parse_single_constraint( f"Could not parse version constraint: {constraint}" ) from e - return VersionRange(version, version.next_breaking(), include_min=True) + return VersionRange( + version, canon_max(version.next_breaking()), include_min=True + ) # X Range m = X_CONSTRAINT.match(constraint) @@ -174,7 +199,7 @@ def parse_single_constraint( ) from e if op == "<": - return VersionRange(max=version) + return VersionRange(max=canon_max(version)) if op == "<=": return VersionRange(max=version, include_max=True) if op == ">": @@ -190,7 +215,13 @@ def parse_single_constraint( ) if op == "!=": - return VersionUnion(VersionRange(max=version), VersionRange(min=version)) + # PEP 440 strict equality: ``!=V`` excludes exactly ``V``, *not* its + # prereleases (unlike the ordered ``": @@ -220,7 +251,11 @@ def parse_single_constraint( if op == ">=": return VersionRange(min=version, include_min=True) if op == "!=": - return VersionUnion(VersionRange(max=version), VersionRange(min=version)) + # See PEP 440 ``!=V`` note above (basic constraint branch). + return VersionUnion( + VersionRange(max=version), + VersionRange(min=version), + ) return version raise ParseConstraintError(f"Could not parse version constraint: {constraint}") diff --git a/src/poetry/core/constraints/version/version_range.py b/src/poetry/core/constraints/version/version_range.py index 42e0518c3..6666331b2 100644 --- a/src/poetry/core/constraints/version/version_range.py +++ b/src/poetry/core/constraints/version/version_range.py @@ -20,6 +20,41 @@ from poetry.core.constraints.version.version_constraint import VersionConstraint +def _is_canonical_strict_max(version: Version) -> bool: + """True if ``version`` is the dev0 of a stable release — i.e., the + canonical exclusive upper bound that the parser produces for a + user-typed `` str: + """Render an exclusive `` VersionConstraint: + """Construct a ``VersionRange``, returning ``EmptyConstraint`` if the + constructed range is empty. ``VersionRange.__init__`` cannot return a + different type, so a tail-side normalisation is required for arithmetic + operations whose result may be empty (e.g. + ``VersionRange(V, V, True, False)`` is empty).""" + range_ = VersionRange(min, max, include_min, include_max) + if range_.is_empty(): + return EmptyConstraint() + return range_ + + class VersionRange(VersionRangeConstraint): def __init__( self, @@ -50,7 +85,16 @@ def include_max(self) -> bool: return self._include_max def is_empty(self) -> bool: - return False + # A bounded range is non-empty only when its min is strictly below + # its max, or when both bounds coincide and are inclusive (the + # single-point range ``[V, V]``). Coincident-bound non-inclusive + # ranges and inverted ranges (min > max) are empty. Previously + # this unconditionally returned False. + if self._min is None or self._max is None: + return False + if self._min == self._max: + return not (self._include_min and self._include_max) + return self._min > self._max def is_any(self) -> bool: return self._min is None and self._max is None @@ -89,7 +133,7 @@ def allows(self, other: Version) -> bool: return False if self.max is not None: - _this, _other = self.allowed_max, other + _this, _other = self.max, other assert _this is not None @@ -150,7 +194,7 @@ def intersect(self, other: VersionConstraint) -> VersionConstraint: from poetry.core.constraints.version.version import Version if other.is_empty(): - return other + return EmptyConstraint() if isinstance(other, VersionUnion): return other.intersect(self) @@ -163,7 +207,7 @@ def intersect(self, other: VersionConstraint) -> VersionConstraint: # `>=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): upper = other.stable.next_patch() - return VersionRange( + return _range_or_empty( min=self.min, max=upper, include_min=self.include_min, @@ -208,7 +252,7 @@ def intersect(self, other: VersionConstraint) -> VersionConstraint: return intersect_min # If we got here, there is an actual range. - return VersionRange( + return _range_or_empty( intersect_min, intersect_max, intersect_include_min, intersect_include_max ) @@ -278,17 +322,17 @@ def difference(self, other: VersionConstraint) -> VersionConstraint: if not self.include_min: return self - return VersionRange(self.min, self.max, False, self.include_max) + return _range_or_empty(self.min, self.max, False, self.include_max) if other == self.max: if not self.include_max: return self - return VersionRange(self.min, self.max, self.include_min, False) + return _range_or_empty(self.min, self.max, self.include_min, False) return VersionUnion.of( - VersionRange(self.min, other, self.include_min, False), - VersionRange(other, self.max, False, self.include_max), + _range_or_empty(self.min, other, self.include_min, False), + _range_or_empty(other, self.max, False, self.include_max), ) elif isinstance(other, VersionRangeConstraint): if not self.allows_any(other): @@ -300,7 +344,7 @@ def difference(self, other: VersionConstraint) -> VersionConstraint: elif self.min == other.min: before = self.min else: - before = VersionRange( + before = _range_or_empty( self.min, other.min, self.include_min, not other.include_min ) @@ -310,7 +354,7 @@ def difference(self, other: VersionConstraint) -> VersionConstraint: elif self.max == other.max: after = self.max else: - after = VersionRange( + after = _range_or_empty( other.max, self.max, not other.include_max, self.include_max ) @@ -441,6 +485,27 @@ def _compare_max(self, other: VersionRangeConstraint) -> int: return 0 + def __repr__(self) -> str: + text = "" + + if self.min is not None: + text += ">=" if self.include_min else ">" + text += self.min.text + + if self.max is not None: + if self.min is not None: + text += "," + + op = "<=" if self.include_max else "<" + # In contrast to __str__, we want to display the actual max so that + # we can distinguish between `< 1` and `< 1.dev0` in test output. + text += f"{op}{self.max}" + + if self.min is None and self.max is None: + return "*" + + return f"<{self.__class__.__name__} {text}>" + def __str__(self) -> str: with suppress(ValueError): return self._single_wildcard_range_string @@ -456,7 +521,7 @@ def __str__(self) -> str: text += "," op = "<=" if self.include_max else "<" - text += f"{op}{self.max.text}" + text += f"{op}{_display_max_text(self.max, self.include_max)}" if self.min is None and self.max is None: return "*" diff --git a/src/poetry/core/constraints/version/version_range_constraint.py b/src/poetry/core/constraints/version/version_range_constraint.py index 324dbd061..2eece8876 100644 --- a/src/poetry/core/constraints/version/version_range_constraint.py +++ b/src/poetry/core/constraints/version/version_range_constraint.py @@ -1,7 +1,6 @@ from __future__ import annotations from abc import abstractmethod -from functools import cached_property from typing import TYPE_CHECKING from poetry.core.constraints.version.version_constraint import VersionConstraint @@ -45,23 +44,6 @@ def allowed_min(self) -> Version | None: # the callers of allowed_min. return self.min - @cached_property - def allowed_max(self) -> Version | None: - if self.max is None: - return None - - if self.include_max or self.max.is_unstable(): - return self.max - - if self.min == self.max and (self.include_min or self.include_max): - # this is an equality range - return self.max - - # The exclusive ordered comparison bool: return self.max is not None @@ -83,7 +65,7 @@ def allows_lower(self, other: VersionRangeConstraint) -> bool: return self.include_min and not other.include_min def allows_higher(self, other: VersionRangeConstraint) -> bool: - _this, _other = self.allowed_max, other.allowed_max + _this, _other = self.max, other.max if _this is None: return _other is not None @@ -100,7 +82,7 @@ def allows_higher(self, other: VersionRangeConstraint) -> bool: return self.include_max and not other.include_max def is_strictly_lower(self, other: VersionRangeConstraint) -> bool: - _this, _other = self.allowed_max, other.allowed_min + _this, _other = self.max, other.allowed_min if _this is None or _other is None: return False diff --git a/src/poetry/core/constraints/version/version_union.py b/src/poetry/core/constraints/version/version_union.py index 10d2603c8..c2d92a6d2 100644 --- a/src/poetry/core/constraints/version/version_union.py +++ b/src/poetry/core/constraints/version/version_union.py @@ -4,6 +4,7 @@ from functools import cached_property from functools import reduce +from itertools import pairwise from typing import TYPE_CHECKING from poetry.core.constraints.version.empty_constraint import EmptyConstraint @@ -21,6 +22,27 @@ from poetry.core.constraints.version.version import Version +def _render_punctured_range(group: list[VersionRangeConstraint]) -> str: + """Render a contiguous run of pieces sharing single-point puncture + seams as a punctured range ``>=A,!=V1,!=V2,=' if first.include_min else '>'}{first.min.text}") + for r in group[:-1]: + assert r.max is not None # by construction: r is not the last piece + parts.append(f"!={r.max.text}") + if last.max is not None: + max_op = "<=" if last.include_max else "<" + parts.append(f"{max_op}{_display_max_text(last.max, last.include_max)}") + return ",".join(parts) + + class VersionUnion(VersionConstraint): """ A version constraint representing a union of multiple disjoint version @@ -305,6 +327,50 @@ def _inverted(self) -> VersionConstraint: return VersionRange().difference(self) + @cached_property + def _union_string(self) -> str: + """Render the union as one or more punctured ranges joined by + ``||``. A punctured range is a maximal run of pieces whose + internal seams are single-point exclusions: ``V`` excludes only ``{V}`` (both bounds raw + exclusive), so the run renders as ``>=A,!=V1,!=V2,V`` instead spans a whole range and + is *not* collapsible -- ``ranges[i].max == ranges[i+1].min`` + distinguishes the two cases. + + This makes results like ``(>1).intersect(!=2)`` round-trip: + ``>1,!=2`` re-parses to the same internal raw structure, whereas + ``>1,<2 || >2`` would re-parse with the canonicalized ``<2.dev0`` + and yield a strictly smaller set. + + When no seam is collapsible every group is a singleton and the + result is identical to the naive ``" || ".join(map(str, ...))`` + rendering -- so this method also handles that case directly. + """ + # ``VersionUnion.of`` produces sorted ranges, but the bare + # constructor doesn't enforce that, so sort defensively. + ranges = sorted(self._ranges) # type: ignore[type-var] + + groups: list[list[VersionRangeConstraint]] = [[ranges[0]]] + for prev, cur in pairwise(ranges): + # A puncture seam requires both bounds to be raw-exclusive at the + # same Version. Adjacent inclusive bounds would mean the seam + # version is actually allowed by one of the pieces -- well-formed + # unions from ``VersionUnion.of`` never produce that shape, but + # the assertion-as-condition keeps us safe against direct + # ``VersionUnion(...)`` construction. + if ( + prev.max is not None + and prev.max == cur.min + and not prev.include_max + and not cur.include_min + ): + groups[-1].append(cur) + else: + groups.append([cur]) + + return " || ".join(_render_punctured_range(g) for g in groups) + def __eq__(self, other: object) -> bool: if not isinstance(other, VersionUnion): return False @@ -315,10 +381,7 @@ def __hash__(self) -> int: return reduce(op.xor, map(hash, self._ranges)) def __str__(self) -> str: - if self.excludes_single_version: - return f"!={self._excluded_single_version}" - try: return self._exclude_single_wildcard_range_string except ValueError: - return " || ".join([str(r) for r in self._ranges]) + return self._union_string diff --git a/src/poetry/core/packages/dependency.py b/src/poetry/core/packages/dependency.py index 97f889d4d..fee772b0d 100644 --- a/src/poetry/core/packages/dependency.py +++ b/src/poetry/core/packages/dependency.py @@ -12,6 +12,7 @@ from poetry.core.constraints.generic import parse_constraint as parse_generic_constraint from poetry.core.constraints.version import parse_constraint +from poetry.core.constraints.version import parse_marker_version_constraint from poetry.core.packages.dependency_group import MAIN_GROUP from poetry.core.packages.specification import PackageSpecification from poetry.core.packages.utils.utils import contains_group_without_marker @@ -79,7 +80,7 @@ def __init__( self._develop = False self._python_versions = "*" - self._python_constraint = parse_constraint("*") + self._python_constraint = parse_marker_version_constraint("*") self._transitive_marker: BaseMarker | None = None self._in_extras: Sequence[NormalizedName] = [] @@ -122,7 +123,7 @@ def python_versions(self) -> str: @python_versions.setter def python_versions(self, value: str) -> None: self._python_versions = value - self._python_constraint = parse_constraint(value) + self._python_constraint = parse_marker_version_constraint(value) if not self._python_constraint.is_any(): self._marker = self._marker.intersect( parse_marker( @@ -136,7 +137,7 @@ def marker(self) -> BaseMarker: @marker.setter def marker(self, marker: str | BaseMarker) -> None: - from poetry.core.constraints.version import parse_constraint + from poetry.core.constraints.version import parse_marker_version_constraint from poetry.core.packages.utils.utils import convert_markers from poetry.core.version.markers import BaseMarker from poetry.core.version.markers import parse_marker @@ -176,7 +177,7 @@ def marker(self, marker: str | BaseMarker) -> None: python_version_markers ) - self._python_constraint = parse_constraint(self._python_versions) + self._python_constraint = parse_marker_version_constraint(self._python_versions) @property def transitive_marker(self) -> BaseMarker: diff --git a/src/poetry/core/packages/package.py b/src/poetry/core/packages/package.py index 596aee78a..f2f1841c2 100644 --- a/src/poetry/core/packages/package.py +++ b/src/poetry/core/packages/package.py @@ -9,7 +9,7 @@ from packaging.utils import canonicalize_name -from poetry.core.constraints.version import parse_constraint +from poetry.core.constraints.version import parse_marker_version_constraint from poetry.core.constraints.version.exceptions import ParseConstraintError from poetry.core.packages.dependency_group import MAIN_GROUP from poetry.core.packages.specification import PackageSpecification @@ -134,7 +134,7 @@ def __init__( self.classifiers: Sequence[str] = [] self._python_versions = "*" - self._python_constraint = parse_constraint("*") + self._python_constraint = parse_marker_version_constraint("*") self.marker: BaseMarker = AnyMarker() @@ -283,7 +283,7 @@ def python_versions(self) -> str: @python_versions.setter def python_versions(self, value: str) -> None: try: - constraint = parse_constraint(value) + constraint = parse_marker_version_constraint(value) except ParseConstraintError: raise ParseConstraintError(f"Invalid python versions '{value}' on {self}") @@ -351,7 +351,7 @@ def all_classifiers(self) -> list[str]: # Automatically set python classifiers if self.python_versions == "*": - python_constraint = parse_constraint("~2.7 || ^3.4") + python_constraint = parse_marker_version_constraint("~2.7 || ^3.4") else: python_constraint = self.python_constraint @@ -364,7 +364,7 @@ def all_classifiers(self) -> list[str]: self.AVAILABLE_PYTHONS, key=lambda x: tuple(map(int, x.split("."))) ): if len(version) == 1: - constraint = parse_constraint(version + ".*") + constraint = parse_marker_version_constraint(version + ".*") else: constraint = Version.parse(version) diff --git a/src/poetry/core/packages/project_package.py b/src/poetry/core/packages/project_package.py index 21b11d57e..74f373694 100644 --- a/src/poetry/core/packages/project_package.py +++ b/src/poetry/core/packages/project_package.py @@ -3,7 +3,7 @@ from typing import TYPE_CHECKING from typing import Any -from poetry.core.constraints.version import parse_constraint +from poetry.core.constraints.version import parse_marker_version_constraint if TYPE_CHECKING: @@ -38,7 +38,7 @@ def __init__( self.entry_points: Mapping[str, dict[str, str]] = {} if self._python_versions == "*": - self._python_constraint = parse_constraint("~2.7 || >=3.4") + self._python_constraint = parse_marker_version_constraint("~2.7 || >=3.4") @property def build_script(self) -> str | None: @@ -80,8 +80,8 @@ def python_versions(self, value: str) -> None: ) value = "~2.7 || >=3.4" - self._python_constraint = parse_constraint(value) - if not parse_constraint(self._requires_python).allows_all( + self._python_constraint = parse_marker_version_constraint(value) + if not parse_marker_version_constraint(self._requires_python).allows_all( self._python_constraint ): raise ValueError( diff --git a/tests/constraints/version/test_parse_constraint.py b/tests/constraints/version/test_parse_constraint.py index 46629bb8e..91b8cdc4e 100644 --- a/tests/constraints/version/test_parse_constraint.py +++ b/tests/constraints/version/test_parse_constraint.py @@ -19,7 +19,7 @@ ("*.*", VersionRange()), ("v*.*", VersionRange()), (">1.0.0", VersionRange(min=Version.from_parts(1, 0, 0))), - ("<1.2.3", VersionRange(max=Version.from_parts(1, 2, 3))), + ("<1.2.3", VersionRange(max=Version.from_parts(1, 2, 3).first_devrelease())), ("<=1.2.3", VersionRange(max=Version.from_parts(1, 2, 3), include_max=True)), (">=1.2.3", VersionRange(min=Version.from_parts(1, 2, 3), include_min=True)), ("=1.2.3", Version.from_parts(1, 2, 3)), @@ -107,38 +107,48 @@ def test_parse_constraint_wildcard(input: str, constraint: VersionRange) -> None ( "~v1", VersionRange( - Version.from_parts(1, 0, 0), Version.from_parts(2, 0, 0), True + Version.from_parts(1, 0, 0), + Version.from_parts(2, 0, 0).first_devrelease(), + True, ), ), ( "~1.0", VersionRange( - Version.from_parts(1, 0, 0), Version.from_parts(1, 1, 0), True + Version.from_parts(1, 0, 0), + Version.from_parts(1, 1, 0).first_devrelease(), + True, ), ), ( "~1.0.0", VersionRange( - Version.from_parts(1, 0, 0), Version.from_parts(1, 1, 0), True + Version.from_parts(1, 0, 0), + Version.from_parts(1, 1, 0).first_devrelease(), + True, ), ), ( "~1.2", VersionRange( - Version.from_parts(1, 2, 0), Version.from_parts(1, 3, 0), True + Version.from_parts(1, 2, 0), + Version.from_parts(1, 3, 0).first_devrelease(), + True, ), ), ( "~1.2.3", VersionRange( - Version.from_parts(1, 2, 3), Version.from_parts(1, 3, 0), True + Version.from_parts(1, 2, 3), + Version.from_parts(1, 3, 0).first_devrelease(), + True, ), ), ( "~1.0.0a1", VersionRange( min=Version.from_parts(1, 0, 0, pre=ReleaseTag("a", 1)), - max=Version.from_parts(1, 1, 0), + max=Version.from_parts(1, 1, 0).first_devrelease(), include_min=True, ), ), @@ -148,7 +158,7 @@ def test_parse_constraint_wildcard(input: str, constraint: VersionRange) -> None min=Version.from_parts( 1, 0, 0, pre=ReleaseTag("a", 1), dev=ReleaseTag("dev", 0) ), - max=Version.from_parts(1, 1, 0), + max=Version.from_parts(1, 1, 0).first_devrelease(), include_min=True, ), ), @@ -156,7 +166,7 @@ def test_parse_constraint_wildcard(input: str, constraint: VersionRange) -> None "~1.2-beta", VersionRange( Version.from_parts(1, 2, 0, pre=ReleaseTag("beta")), - Version.from_parts(1, 3, 0), + Version.from_parts(1, 3, 0).first_devrelease(), True, ), ), @@ -164,39 +174,47 @@ def test_parse_constraint_wildcard(input: str, constraint: VersionRange) -> None "~1.2-b2", VersionRange( Version.from_parts(1, 2, 0, pre=ReleaseTag("beta", 2)), - Version.from_parts(1, 3, 0), + Version.from_parts(1, 3, 0).first_devrelease(), True, ), ), ( "~0.3", VersionRange( - Version.from_parts(0, 3, 0), Version.from_parts(0, 4, 0), True + Version.from_parts(0, 3, 0), + Version.from_parts(0, 4, 0).first_devrelease(), + True, ), ), ( "~3.5", VersionRange( - Version.from_parts(3, 5, 0), Version.from_parts(3, 6, 0), True + Version.from_parts(3, 5, 0), + Version.from_parts(3, 6, 0).first_devrelease(), + True, ), ), ( "~=3.5", VersionRange( - Version.from_parts(3, 5, 0), Version.from_parts(4, 0, 0), True + Version.from_parts(3, 5, 0), + Version.from_parts(4, 0, 0).first_devrelease(), + True, ), ), # PEP 440 ( "~=3.5.3", VersionRange( - Version.from_parts(3, 5, 3), Version.from_parts(3, 6, 0), True + Version.from_parts(3, 5, 3), + Version.from_parts(3, 6, 0).first_devrelease(), + True, ), ), # PEP 440 ( "~=3.5.3rc1", VersionRange( Version.from_parts(3, 5, 3, pre=ReleaseTag("rc", 1)), - Version.from_parts(3, 6, 0), + Version.from_parts(3, 6, 0).first_devrelease(), True, ), ), # PEP 440 @@ -212,65 +230,86 @@ def test_parse_constraint_tilde(input: str, constraint: VersionRange) -> None: ( "^v1", VersionRange( - Version.from_parts(1, 0, 0), Version.from_parts(2, 0, 0), True + Version.from_parts(1, 0, 0), + Version.from_parts(2, 0, 0).first_devrelease(), + True, + ), + ), + ( + "^0", + VersionRange( + Version.from_parts(0), Version.from_parts(1).first_devrelease(), True ), ), - ("^0", VersionRange(Version.from_parts(0), Version.from_parts(1), True)), ( "^0.0", VersionRange( - Version.from_parts(0, 0, 0), Version.from_parts(0, 1, 0), True + Version.from_parts(0, 0, 0), + Version.from_parts(0, 1, 0).first_devrelease(), + True, ), ), ( "^1.2", VersionRange( - Version.from_parts(1, 2, 0), Version.from_parts(2, 0, 0), True + Version.from_parts(1, 2, 0), + Version.from_parts(2, 0, 0).first_devrelease(), + True, ), ), ( "^1.2.3-beta.2", VersionRange( Version.from_parts(1, 2, 3, pre=ReleaseTag("beta", 2)), - Version.from_parts(2, 0, 0), + Version.from_parts(2, 0, 0).first_devrelease(), True, ), ), ( "^1.2.3", VersionRange( - Version.from_parts(1, 2, 3), Version.from_parts(2, 0, 0), True + Version.from_parts(1, 2, 3), + Version.from_parts(2, 0, 0).first_devrelease(), + True, ), ), ( "^0.2.3", VersionRange( - Version.from_parts(0, 2, 3), Version.from_parts(0, 3, 0), True + Version.from_parts(0, 2, 3), + Version.from_parts(0, 3, 0).first_devrelease(), + True, ), ), ( "^0.2", VersionRange( - Version.from_parts(0, 2, 0), Version.from_parts(0, 3, 0), True + Version.from_parts(0, 2, 0), + Version.from_parts(0, 3, 0).first_devrelease(), + True, ), ), ( "^0.2.0", VersionRange( - Version.from_parts(0, 2, 0), Version.from_parts(0, 3, 0), True + Version.from_parts(0, 2, 0), + Version.from_parts(0, 3, 0).first_devrelease(), + True, ), ), ( "^0.0.3", VersionRange( - Version.from_parts(0, 0, 3), Version.from_parts(0, 0, 4), True + Version.from_parts(0, 0, 3), + Version.from_parts(0, 0, 4).first_devrelease(), + True, ), ), ( "^0.0.3-alpha.21", VersionRange( Version.from_parts(0, 0, 3, pre=ReleaseTag("alpha", 21)), - Version.from_parts(0, 0, 4), + Version.from_parts(0, 0, 4).first_devrelease(), True, ), ), @@ -278,7 +317,7 @@ def test_parse_constraint_tilde(input: str, constraint: VersionRange) -> None: "^0.1.3-alpha.21", VersionRange( Version.from_parts(0, 1, 3, pre=ReleaseTag("alpha", 21)), - Version.from_parts(0, 2, 0), + Version.from_parts(0, 2, 0).first_devrelease(), True, ), ), @@ -286,7 +325,7 @@ def test_parse_constraint_tilde(input: str, constraint: VersionRange) -> None: "^0.0.0-alpha.21", VersionRange( Version.from_parts(0, 0, 0, pre=ReleaseTag("alpha", 21)), - Version.from_parts(0, 0, 1), + Version.from_parts(0, 0, 1).first_devrelease(), True, ), ), @@ -294,7 +333,7 @@ def test_parse_constraint_tilde(input: str, constraint: VersionRange) -> None: "^1.0.0a1", VersionRange( min=Version.from_parts(1, 0, 0, pre=ReleaseTag("a", 1)), - max=Version.from_parts(2, 0, 0), + max=Version.from_parts(2, 0, 0).first_devrelease(), include_min=True, ), ), @@ -304,7 +343,7 @@ def test_parse_constraint_tilde(input: str, constraint: VersionRange) -> None: min=Version.from_parts( 1, 0, 0, pre=ReleaseTag("a", 1), dev=ReleaseTag("dev", 0) ), - max=Version.from_parts(2, 0, 0), + max=Version.from_parts(2, 0, 0).first_devrelease(), include_min=True, ), ), @@ -339,7 +378,7 @@ def test_parse_constraint_multi() -> None: ">=1!2,<2!3", VersionRange( Version.from_parts(2, 0, 0, epoch=1), - Version.from_parts(3, 0, 0, epoch=2), + Version.from_parts(3, 0, 0, epoch=2).first_devrelease(), include_min=True, include_max=False, ), @@ -405,24 +444,33 @@ def test_parse_constraints_negative_wildcard( (">3.7 , ", VersionRange(min=Version.parse("3.7"))), ( ">3.7,<3.8,", - VersionRange(min=Version.parse("3.7"), max=Version.parse("3.8")), + VersionRange( + min=Version.parse("3.7"), + max=Version.parse("3.8").first_devrelease(), + ), ), ( ">3.7,||<3.6,", VersionRange(min=Version.parse("3.7")).union( - VersionRange(max=Version.parse("3.6")) + VersionRange(max=Version.parse("3.6").first_devrelease()) ), ), ( ">3.7 , || <3.6 , ", VersionRange(min=Version.parse("3.7")).union( - VersionRange(max=Version.parse("3.6")) + VersionRange(max=Version.parse("3.6").first_devrelease()) ), ), ( ">3.7, <3.8, || <3.6, >3.5", - VersionRange(min=Version.parse("3.7"), max=Version.parse("3.8")).union( - VersionRange(min=Version.parse("3.5"), max=Version.parse("3.6")) + VersionRange( + min=Version.parse("3.7"), + max=Version.parse("3.8").first_devrelease(), + ).union( + VersionRange( + min=Version.parse("3.5"), + max=Version.parse("3.6").first_devrelease(), + ) ), ), ], @@ -471,13 +519,13 @@ def test_constraints_keep_version_precision(input: str, expected: str) -> None: (["==", "3.8"], Version.from_parts(3, 8)), ([">", "3.8"], VersionRange(min=Version.from_parts(3, 8))), ([">=", "3.8"], VersionRange(min=Version.from_parts(3, 8), include_min=True)), - (["<", "3.8"], VersionRange(max=Version.from_parts(3, 8))), + (["<", "3.8"], VersionRange(max=Version.from_parts(3, 8).first_devrelease())), (["<=", "3.8"], VersionRange(max=Version.from_parts(3, 8), include_max=True)), ( ["^", "3.8"], VersionRange( min=Version.from_parts(3, 8), - max=Version.from_parts(4, 0), + max=Version.from_parts(4, 0).first_devrelease(), include_min=True, ), ), @@ -485,7 +533,7 @@ def test_constraints_keep_version_precision(input: str, expected: str) -> None: ["~", "3.8"], VersionRange( min=Version.from_parts(3, 8), - max=Version.from_parts(3, 9), + max=Version.from_parts(3, 9).first_devrelease(), include_min=True, ), ), @@ -493,7 +541,7 @@ def test_constraints_keep_version_precision(input: str, expected: str) -> None: ["~=", "3.8"], VersionRange( min=Version.from_parts(3, 8), - max=Version.from_parts(4, 0), + max=Version.from_parts(4, 0).first_devrelease(), include_min=True, ), ), @@ -541,12 +589,12 @@ def test_constraints_keep_version_precision(input: str, expected: str) -> None: VersionUnion( VersionRange( min=Version.from_parts(2, 7), - max=Version.from_parts(2, 8), + max=Version.from_parts(2, 8).first_devrelease(), include_min=True, ), VersionRange( min=Version.from_parts(3, 8), - max=Version.from_parts(3, 9), + max=Version.from_parts(3, 9).first_devrelease(), include_min=True, ), ), @@ -556,17 +604,17 @@ def test_constraints_keep_version_precision(input: str, expected: str) -> None: VersionUnion( VersionRange( min=Version.from_parts(2, 7), - max=Version.from_parts(2, 8), + max=Version.from_parts(2, 8).first_devrelease(), include_min=True, ), VersionRange( min=Version.from_parts(3, 8), - max=Version.from_parts(3, 9), + max=Version.from_parts(3, 9).first_devrelease(), include_min=True, ), VersionRange( min=Version.from_parts(3, 10), - max=Version.from_parts(3, 12), + max=Version.from_parts(3, 12).first_devrelease(), include_min=True, ), ), diff --git a/tests/constraints/version/test_version_range.py b/tests/constraints/version/test_version_range.py index e61bc3410..dd167f206 100644 --- a/tests/constraints/version/test_version_range.py +++ b/tests/constraints/version/test_version_range.py @@ -683,3 +683,107 @@ def test_is_single_wildcard_range( ) def test_str(version: str, expected: str) -> None: assert str(parse_constraint(version)) == expected + + +@pytest.mark.parametrize( + ("include_min", "include_max", "expected_empty"), + [ + (True, True, False), # [V, V] = {V} + (True, False, True), # [V, V) = ∅ + (False, True, True), # (V, V] = ∅ + (False, False, True), # (V, V) = ∅ + ], +) +def test_is_empty_for_coincident_bounds( + include_min: bool, include_max: bool, expected_empty: bool +) -> None: + """A range with coincident min/max is only non-empty when both bounds + are inclusive (the single-point range ``[V, V]``).""" + v = Version.parse("1.2.3") + assert ( + VersionRange(v, v, include_min=include_min, include_max=include_max).is_empty() + is expected_empty + ) + + +def test_is_empty_for_inverted_bounds() -> None: + """A range whose min is greater than its max has no members.""" + lo = Version.parse("1.0") + hi = Version.parse("2.0") + assert VersionRange(hi, lo, include_min=True, include_max=True).is_empty() + + +def test_intersect_returns_empty_constraint_not_empty_range() -> None: + """Operations whose result is empty due to canonicalization must + normalize to ``EmptyConstraint``. ``VersionRange.__init__`` cannot + return a different type, so a tail-side check is required: e.g. + ``[V, V] ∩ [V, V)`` canonicalizes the rhs max to ``V.dev0`` and the + intersection is empty.""" + v = Version.parse("1.2.3") + point = VersionRange(v, v, include_min=True, include_max=True) + half_open = VersionRange(v, v, include_min=True, include_max=False) + result = point.intersect(half_open) + assert isinstance(result, EmptyConstraint) + + +def test_difference_returns_empty_constraint_not_empty_range() -> None: + """Subtracting a range that fully covers ``self`` yields + ``EmptyConstraint`` even when canonicalization is involved.""" + v = Version.parse("2.0") + rng = VersionRange(Version.parse("1.0"), v, include_min=True, include_max=False) + result = rng.difference(rng) + assert isinstance(result, EmptyConstraint) + + +def test_parsed_strict_max_excludes_dev_releases_of_stable() -> None: + """PEP 440: `` None: + """The raw (non-canonical) `` None: + """PEP 440: ``!=V`` is strict equality and must allow prereleases of V + (since e.g. ``2.0.dev1 != 2``). Distinct from ``V`` typed by the + user, where ``V`` are PEP 440 ordered comparisons that DO + exclude pre-/post-releases.""" + rng = parse_constraint("!=2") + assert not rng.allows(Version.parse("2")) + assert rng.allows(Version.parse("2.dev0")) + assert rng.allows(Version.parse("2.post1")) + + +def test_punctured_range_round_trips_through_string() -> None: + """Algebraic results that puncture single points must serialize so they + re-parse to an equivalent constraint -- otherwise lockfile round-trips + silently change the allowed set (see PR #645). The renderer collapses + ``V`` (raw) to ``!=V`` to achieve this.""" + rng = parse_constraint(">1").intersect(parse_constraint("!=2")) + assert str(rng) == ">1,!=2" + assert parse_constraint(str(rng)) == rng + + +def test_punctured_range_handles_mixed_seams() -> None: + """A union with both puncture seams and gap seams partitions into + contiguous punctured ranges joined by ``||``.""" + rng = ( + parse_constraint(">=1,<10") + .difference(Version.parse("2")) + .difference(Version.parse("3")) + ) + # (1 <= x < 10) - {2,3}: three contiguous pieces, both seams are punctures + assert str(rng) == ">=1,!=2,!=3,<10" + assert parse_constraint(str(rng)) == rng diff --git a/tests/constraints/version/test_version_union.py b/tests/constraints/version/test_version_union.py index c98ae72b4..92b05bb89 100644 --- a/tests/constraints/version/test_version_union.py +++ b/tests/constraints/version/test_version_union.py @@ -138,6 +138,13 @@ def test_excludes_single_wildcard_range(max: str, min: str, expected: bool) -> N # version exclusions ("!=1.0", "!=1.0"), ("!=1.0+local", "!=1.0+local"), + # punctured ranges round-trip through string serialization + (">1,!=2", ">1,!=2"), + (">=1,!=2,<5", ">=1,!=2,<5"), + (">=1,!=2,!=3,<5", ">=1,!=2,!=3,<5"), + # canonical (PEP 440 ordered) `V` is *not* `!=V` -- it + # excludes V's prereleases too -- so it must NOT collapse + ("<2 || >2", "<2 || >2"), # wildcard exclusions ("!=1.*", "!=1.*"), ("!=1.0.*", "!=1.0.*"), diff --git a/tests/packages/test_dependency.py b/tests/packages/test_dependency.py index b8715441b..2887b8c84 100644 --- a/tests/packages/test_dependency.py +++ b/tests/packages/test_dependency.py @@ -248,12 +248,17 @@ def test_complete_name() -> None: [ ("A", ">2.7,<3.0", None, "A (>2.7,<3.0)"), ("A", ">2.7,<3.0", ["x"], "A[x] (>2.7,<3.0)"), - ("A", ">=1.6.5,<1.8.0 || >1.8.0,<3.1.0", None, "A (>=1.6.5,!=1.8.0,<3.1.0)"), + ( + "A", + ">=1.6.5,<1.8.0 || >1.8.0,<3.1.0", + None, + "A (>=1.6.5,<1.8.0 || >1.8.0,<3.1.0)", + ), ( "A", ">=1.6.5,<1.8.0 || >1.8.0,<3.1.0", ["x"], - "A[x] (>=1.6.5,!=1.8.0,<3.1.0)", + "A[x] (>=1.6.5,<1.8.0 || >1.8.0,<3.1.0)", ), # test single version range (wildcard) ("A", "==2.*", None, "A (==2.*)"), diff --git a/tests/packages/test_main.py b/tests/packages/test_main.py index 58c4d6a61..f68161359 100644 --- a/tests/packages/test_main.py +++ b/tests/packages/test_main.py @@ -43,7 +43,7 @@ def test_dependency_from_pep_508_with_constraint() -> None: dep = Dependency.create_from_pep_508(name) assert dep.name == "requests" - assert str(dep.constraint) == ">=2.12.0,<2.17.dev0 || >=2.18.dev0,<3.0" + assert str(dep.constraint) == ">=2.12.0,<2.17 || >=2.18.dev0,<3.0" def test_dependency_from_pep_508_with_extras() -> None: diff --git a/tests/test_factory.py b/tests/test_factory.py index 1cfaa24c3..d8b14d212 100644 --- a/tests/test_factory.py +++ b/tests/test_factory.py @@ -12,7 +12,7 @@ from packaging.utils import canonicalize_name -from poetry.core.constraints.version import parse_constraint +from poetry.core.constraints.version import parse_marker_version_constraint from poetry.core.factory import Factory from poetry.core.packages.dependency import Dependency from poetry.core.packages.dependency_group import MAIN_GROUP @@ -1277,7 +1277,7 @@ def test_create_dependency_marker_variants( constraint["version"] = "1.0.0" dep = Factory.create_dependency("foo", constraint) assert dep.python_versions == exp_python - assert dep.python_constraint == parse_constraint(exp_python) + assert dep.python_constraint == parse_marker_version_constraint(exp_python) assert str(dep.marker) == exp_marker