diff --git a/src/poetry/core/constraints/generic/union_constraint.py b/src/poetry/core/constraints/generic/union_constraint.py index 0fb4bec2f..091e5b71e 100644 --- a/src/poetry/core/constraints/generic/union_constraint.py +++ b/src/poetry/core/constraints/generic/union_constraint.py @@ -1,5 +1,7 @@ from __future__ import annotations +import itertools + from poetry.core.constraints.generic import AnyConstraint from poetry.core.constraints.generic.base_constraint import BaseConstraint from poetry.core.constraints.generic.constraint import Constraint @@ -132,19 +134,28 @@ def union(self, other: BaseConstraint) -> BaseConstraint: new_constraints: list[BaseConstraint] = [] if isinstance(other, UnionConstraint): # (A or B) or (C or D) => A or B or C or D + our_new_constraints: list[BaseConstraint] = [] + their_new_constraints: list[BaseConstraint] = [] + merged_new_constraints: list[BaseConstraint] = [] for our_constraint in self._constraints: for their_constraint in other.constraints: union = our_constraint.union(their_constraint) if union.is_any(): return AnyConstraint() if isinstance(union, Constraint): - if union not in new_constraints: - new_constraints.append(union) + if union not in merged_new_constraints: + merged_new_constraints.append(union) else: - if our_constraint not in new_constraints: - new_constraints.append(our_constraint) - if their_constraint not in new_constraints: - new_constraints.append(their_constraint) + if our_constraint not in our_new_constraints: + our_new_constraints.append(our_constraint) + if their_constraint not in their_new_constraints: + their_new_constraints.append(their_constraint) + new_constraints = our_new_constraints + for constraint in itertools.chain( + their_new_constraints, merged_new_constraints + ): + if constraint not in new_constraints: + new_constraints.append(constraint) else: assert isinstance(other, MultiConstraint) diff --git a/src/poetry/core/packages/dependency.py b/src/poetry/core/packages/dependency.py index 868a2586c..eaf627384 100644 --- a/src/poetry/core/packages/dependency.py +++ b/src/poetry/core/packages/dependency.py @@ -176,8 +176,12 @@ def marker(self, marker: str | BaseMarker) -> None: self.deactivate() for or_ in markers["extra"]: - for _, extra in or_: - self.in_extras.append(canonicalize_name(extra)) + for op, extra in or_: + if op == "==": + self.in_extras.append(canonicalize_name(extra)) + elif op == "" and "||" in extra: + for _extra in extra.split(" || "): + self.in_extras.append(canonicalize_name(_extra)) # Recalculate python versions. self._python_versions = "*" diff --git a/src/poetry/core/packages/utils/utils.py b/src/poetry/core/packages/utils/utils.py index 8ff38e340..2cbbec800 100644 --- a/src/poetry/core/packages/utils/utils.py +++ b/src/poetry/core/packages/utils/utils.py @@ -18,6 +18,7 @@ from poetry.core.constraints.version import VersionRange from poetry.core.constraints.version import parse_constraint from poetry.core.pyproject.toml import PyProjectTOML +from poetry.core.version.markers import SingleMarkerLike from poetry.core.version.markers import dnf @@ -179,10 +180,18 @@ def add_constraint( for i, sub_marker in enumerate(conjunctions): if isinstance(sub_marker, MultiMarker): for m in sub_marker.markers: - assert isinstance(m, SingleMarker) - add_constraint(m.name, (m.operator, m.value), i) - elif isinstance(sub_marker, SingleMarker): - add_constraint(sub_marker.name, (sub_marker.operator, sub_marker.value), i) + assert isinstance(m, SingleMarkerLike) + if isinstance(m, SingleMarker): + add_constraint(m.name, (m.operator, m.value), i) + else: + add_constraint(m.name, ("", str(m.constraint)), i) + elif isinstance(sub_marker, SingleMarkerLike): + if isinstance(sub_marker, SingleMarker): + add_constraint( + sub_marker.name, (sub_marker.operator, sub_marker.value), i + ) + else: + add_constraint(sub_marker.name, ("", str(sub_marker.constraint)), i) for group_name in requirements: # remove duplicates diff --git a/src/poetry/core/version/markers.py b/src/poetry/core/version/markers.py index f4485fa57..a5c265751 100644 --- a/src/poetry/core/version/markers.py +++ b/src/poetry/core/version/markers.py @@ -9,8 +9,15 @@ from typing import TYPE_CHECKING from typing import Any from typing import Callable +from typing import Generic from typing import Iterable +from typing import TypeVar +from typing import Union +from poetry.core.constraints.generic import BaseConstraint +from poetry.core.constraints.generic import Constraint +from poetry.core.constraints.generic import MultiConstraint +from poetry.core.constraints.generic import UnionConstraint from poetry.core.constraints.version import VersionConstraint from poetry.core.version.grammars import GRAMMAR_PEP_508_MARKERS from poetry.core.version.parser import Parser @@ -19,8 +26,6 @@ if TYPE_CHECKING: from lark import Tree - from poetry.core.constraints.generic import BaseConstraint - class InvalidMarker(ValueError): """ @@ -57,6 +62,15 @@ class UndefinedEnvironmentName(ValueError): class BaseMarker(ABC): + @property + def complexity(self) -> tuple[int, int]: + """ + first element: number of single markers, where SingleMarkerLike count as + actual number + second element: number of single markers, where SingleMarkerLike count as 1 + """ + return 1, 1 + @abstractmethod def intersect(self, other: BaseMarker) -> BaseMarker: raise NotImplementedError() @@ -169,7 +183,7 @@ def without_extras(self) -> BaseMarker: def exclude(self, marker_name: str) -> EmptyMarker: return self - def only(self, *marker_names: str) -> EmptyMarker: + def only(self, *marker_names: str) -> BaseMarker: return self def invert(self) -> AnyMarker: @@ -191,7 +205,95 @@ def __eq__(self, other: object) -> bool: return isinstance(other, EmptyMarker) -class SingleMarker(BaseMarker): +SingleMarkerConstraint = TypeVar( + "SingleMarkerConstraint", bound=Union[BaseConstraint, VersionConstraint] +) + + +class SingleMarkerLike(BaseMarker, ABC, Generic[SingleMarkerConstraint]): + def __init__(self, name: str, constraint: SingleMarkerConstraint) -> None: + from poetry.core.constraints.generic import ( + parse_constraint as parse_generic_constraint, + ) + from poetry.core.constraints.version import ( + parse_constraint as parse_version_constraint, + ) + + self._name = ALIASES.get(name, name) + self._constraint = constraint + self._parser: Callable[[str], BaseConstraint | VersionConstraint] + if isinstance(constraint, VersionConstraint): + self._parser = parse_version_constraint + else: + self._parser = parse_generic_constraint + + @property + def name(self) -> str: + return self._name + + @property + def constraint(self) -> SingleMarkerConstraint: + return self._constraint + + def validate(self, environment: dict[str, Any] | None) -> bool: + if environment is None: + return True + + if self._name not in environment: + return True + + # The type of constraint returned by the parser matches our constraint: either + # both are BaseConstraint or both are VersionConstraint. But it's hard for mypy + # to know that. + constraint = self._parser(environment[self._name]) + return self._constraint.allows(constraint) # type: ignore[arg-type] + + def without_extras(self) -> BaseMarker: + return self.exclude("extra") + + def exclude(self, marker_name: str) -> BaseMarker: + if self.name == marker_name: + return AnyMarker() + + return self + + def only(self, *marker_names: str) -> BaseMarker: + if self.name not in marker_names: + return AnyMarker() + + return self + + def intersect(self, other: BaseMarker) -> BaseMarker: + if isinstance(other, SingleMarkerLike): + merged = _merge_single_markers(self, other, MultiMarker) + if merged is not None: + return merged + + return MultiMarker(self, other) + + return other.intersect(self) + + def union(self, other: BaseMarker) -> BaseMarker: + if isinstance(other, SingleMarkerLike): + merged = _merge_single_markers(self, other, MarkerUnion) + if merged is not None: + return merged + + return MarkerUnion(self, other) + + return other.union(self) + + def __eq__(self, other: object) -> bool: + if not isinstance(other, SingleMarkerLike): + return NotImplemented + + return self._name == other.name and self._constraint == other.constraint + + def __hash__(self) -> int: + return hash((self._name, self._constraint)) + + +class SingleMarker(SingleMarkerLike[Union[BaseConstraint, VersionConstraint]]): _CONSTRAINT_RE = re.compile(r"(?i)^(~=|!=|>=?|<=?|==?=?|in|not in)?\s*(.+)$") _VERSION_LIKE_MARKER_NAME = { "python_version", @@ -209,9 +311,8 @@ def __init__( parse_constraint as parse_version_constraint, ) - self._constraint: BaseConstraint | VersionConstraint - self._parser: Callable[[str], BaseConstraint | VersionConstraint] - self._name = ALIASES.get(name, name) + parsed_constraint: BaseConstraint | VersionConstraint + parser: Callable[[str], BaseConstraint | VersionConstraint] constraint_string = str(constraint) # Extract operator and value @@ -224,10 +325,10 @@ def __init__( self._operator = "==" self._value = m.group(2) - self._parser = parse_generic_constraint + parser = parse_generic_constraint if name in self._VERSION_LIKE_MARKER_NAME: - self._parser = parse_version_constraint + parser = parse_version_constraint if self._operator in {"in", "not in"}: versions = [] @@ -245,9 +346,9 @@ def __init__( if self._operator == "in": glue = " || " - self._constraint = self._parser(glue.join(versions)) + parsed_constraint = parser(glue.join(versions)) else: - self._constraint = self._parser(constraint_string) + parsed_constraint = parser(constraint_string) else: # if we have a in/not in operator we split the constraint # into a union/multi-constraint of single constraint @@ -256,15 +357,9 @@ def __init__( values = re.split("[ ,]+", self._value) constraint_string = glue.join(f"{op} {value}" for value in values) - self._constraint = self._parser(constraint_string) + parsed_constraint = parser(constraint_string) - @property - def name(self) -> str: - return self._name - - @property - def constraint(self) -> BaseConstraint | VersionConstraint: - return self._constraint + super().__init__(name, parsed_constraint) @property def operator(self) -> str: @@ -274,54 +369,6 @@ def operator(self) -> str: def value(self) -> str: return self._value - def intersect(self, other: BaseMarker) -> BaseMarker: - if isinstance(other, SingleMarker): - merged = _merge_single_markers(self, other, MultiMarker) - if merged is not None: - return merged - - return MultiMarker(self, other) - - return other.intersect(self) - - def union(self, other: BaseMarker) -> BaseMarker: - if isinstance(other, SingleMarker): - merged = _merge_single_markers(self, other, MarkerUnion) - if merged is not None: - return merged - - return MarkerUnion(self, other) - - return other.union(self) - - def validate(self, environment: dict[str, Any] | None) -> bool: - if environment is None: - return True - - if self._name not in environment: - return True - - # The type of constraint returned by the parser matches our constraint: either - # both are BaseConstraint or both are VersionConstraint. But it's hard for mypy - # to know that. - constraint = self._parser(environment[self._name]) - return self._constraint.allows(constraint) # type: ignore[arg-type] - - def without_extras(self) -> BaseMarker: - return self.exclude("extra") - - def exclude(self, marker_name: str) -> BaseMarker: - if self.name == marker_name: - return AnyMarker() - - return self - - def only(self, *marker_names: str) -> SingleMarker | AnyMarker: - if self.name not in marker_names: - return AnyMarker() - - return self - def invert(self) -> BaseMarker: if self._operator in ("===", "=="): operator = "!=" @@ -367,17 +414,62 @@ def invert(self) -> BaseMarker: return parse_marker(f"{self._name} {operator} '{self._value}'") - def __eq__(self, other: object) -> bool: - if not isinstance(other, SingleMarker): - return False + def __str__(self) -> str: + return f'{self._name} {self._operator} "{self._value}"' - return self._name == other.name and self._constraint == other.constraint - def __hash__(self) -> int: - return hash((self._name, self._constraint)) +class AtomicMultiMarker(SingleMarkerLike[MultiConstraint]): + def __init__(self, name: str, constraint: MultiConstraint) -> None: + assert all(c.operator == "!=" for c in constraint.constraints) + super().__init__(name, constraint) + self._values: list[str] = [] + + @property + def complexity(self) -> tuple[int, int]: + return len(self._constraint.constraints), 1 + + def invert(self) -> BaseMarker: + return AtomicMarkerUnion(self._name, self._constraint.invert()) + + def expand(self) -> MultiMarker: + return MultiMarker( + *(SingleMarker(self._name, c) for c in self._constraint.constraints) + ) def __str__(self) -> str: - return f'{self._name} {self._operator} "{self._value}"' + return " and ".join( + f'{self._name} != "{c.value}"' for c in self._constraint.constraints + ) + + +class AtomicMarkerUnion(SingleMarkerLike[UnionConstraint]): + def __init__(self, name: str, constraint: UnionConstraint) -> None: + assert all( + isinstance(c, Constraint) and c.operator == "==" + for c in constraint.constraints + ) + super().__init__(name, constraint) + + @property + def complexity(self) -> tuple[int, int]: + return len(self._constraint.constraints), 1 + + def invert(self) -> BaseMarker: + return AtomicMultiMarker(self._name, self._constraint.invert()) + + def expand(self) -> MarkerUnion: + return MarkerUnion( + *(SingleMarker(self._name, c) for c in self._constraint.constraints) + ) + + def __str__(self) -> str: + # In __init__ we've made sure that we have a UnionConstraint that + # contains only elements of type Constraint (instead of BaseConstraint) + # but mypy can't see that. + return " or ".join( + f'{self._name} == "{c.value}"' # type: ignore[attr-defined] + for c in self._constraint.constraints + ) def _flatten_markers( @@ -409,6 +501,12 @@ def __init__(self, *markers: BaseMarker) -> None: def markers(self) -> list[BaseMarker]: return self._markers + @property + def complexity(self) -> tuple[int, int]: + return tuple( # type: ignore[return-value] + sum(c) for c in zip(*(m.complexity for m in self._markers)) + ) + @classmethod def of(cls, *markers: BaseMarker) -> BaseMarker: new_markers = _flatten_markers(markers, MultiMarker) @@ -428,12 +526,12 @@ def of(cls, *markers: BaseMarker) -> BaseMarker: for i, mark in enumerate(new_markers): # If we have a SingleMarker then with any luck after intersection # it'll become another SingleMarker. - if isinstance(mark, SingleMarker): - new_marker = marker.intersect(mark) + if isinstance(mark, SingleMarkerLike): + new_marker = mark.intersect(marker) if new_marker.is_empty(): return EmptyMarker() - if isinstance(new_marker, SingleMarker): + if isinstance(new_marker, SingleMarkerLike): new_markers[i] = new_marker intersected = True break @@ -506,7 +604,7 @@ def union_simplify(self, other: BaseMarker) -> BaseMarker | None: unique_union = MultiMarker(*unique_markers).union( MultiMarker(*other_unique_markers) ) - if isinstance(unique_union, (SingleMarker, AnyMarker)): + if isinstance(unique_union, (SingleMarkerLike, AnyMarker)): # Use list instead of set for deterministic order. common_markers = [ marker for marker in self.markers if marker in shared_markers @@ -525,7 +623,7 @@ def exclude(self, marker_name: str) -> BaseMarker: new_markers = [] for m in self._markers: - if isinstance(m, SingleMarker) and m.name == marker_name: + if isinstance(m, SingleMarkerLike) and m.name == marker_name: # The marker is not relevant since it must be excluded continue @@ -576,6 +674,12 @@ def __init__(self, *markers: BaseMarker) -> None: def markers(self) -> list[BaseMarker]: return self._markers + @property + def complexity(self) -> tuple[int, int]: + return tuple( # type: ignore[return-value] + sum(c) for c in zip(*(m.complexity for m in self._markers)) + ) + @classmethod def of(cls, *markers: BaseMarker) -> BaseMarker: new_markers = _flatten_markers(markers, MarkerUnion) @@ -595,12 +699,12 @@ def of(cls, *markers: BaseMarker) -> BaseMarker: for i, mark in enumerate(new_markers): # If we have a SingleMarker then with any luck after union it'll # become another SingleMarker. - if isinstance(mark, SingleMarker): - new_marker = marker.union(mark) + if isinstance(mark, SingleMarkerLike): + new_marker = mark.union(marker) if new_marker.is_any(): return AnyMarker() - if isinstance(new_marker, SingleMarker): + if isinstance(new_marker, SingleMarkerLike): new_markers[i] = new_marker included = True break @@ -679,7 +783,7 @@ def intersect_simplify(self, other: BaseMarker) -> BaseMarker | None: unique_intersection = MarkerUnion(*unique_markers).intersect( MarkerUnion(*other_unique_markers) ) - if isinstance(unique_intersection, (SingleMarker, EmptyMarker)): + if isinstance(unique_intersection, (SingleMarkerLike, EmptyMarker)): # Use list instead of set for deterministic order. common_markers = [ marker for marker in self.markers if marker in shared_markers @@ -698,7 +802,7 @@ def exclude(self, marker_name: str) -> BaseMarker: new_markers = [] for m in self._markers: - if isinstance(m, SingleMarker) and m.name == marker_name: + if isinstance(m, SingleMarkerLike) and m.name == marker_name: # The marker is not relevant since it must be excluded continue @@ -842,19 +946,34 @@ def intersection(*markers: BaseMarker) -> BaseMarker: def union(*markers: BaseMarker) -> BaseMarker: - conjunction = cnf(MarkerUnion(*markers)) + # Sometimes normalization makes it more complicate instead of simple + # -> choose candidate with the least complexity + unnormalized: BaseMarker = MarkerUnion(*markers) + while ( + isinstance(unnormalized, (MultiMarker, MarkerUnion)) + and len(unnormalized.markers) == 1 + ): + unnormalized = unnormalized.markers[0] + + conjunction = cnf(unnormalized) if not isinstance(conjunction, MultiMarker): return conjunction - return dnf(conjunction) + disjunction = dnf(conjunction) + if not isinstance(disjunction, MarkerUnion): + return disjunction + + return min(disjunction, conjunction, unnormalized, key=lambda x: x.complexity) def _merge_single_markers( - marker1: SingleMarker, - marker2: SingleMarker, + marker1: SingleMarkerLike[SingleMarkerConstraint], + marker2: SingleMarkerLike[SingleMarkerConstraint], merge_class: type[MultiMarker | MarkerUnion], ) -> BaseMarker | None: if {marker1.name, marker2.name} == PYTHON_VERSION_MARKERS: + assert isinstance(marker1, SingleMarker) + assert isinstance(marker2, SingleMarker) return _merge_python_version_single_markers(marker1, marker2, merge_class) if marker1.name != marker2.name: @@ -877,11 +996,20 @@ def _merge_single_markers( result_marker = marker1 elif result_constraint == marker2.constraint: result_marker = marker2 - elif ( + elif isinstance(result_constraint, Constraint) or ( isinstance(result_constraint, VersionConstraint) and result_constraint.is_simple() ): result_marker = SingleMarker(marker1.name, result_constraint) + elif isinstance(result_constraint, UnionConstraint) and all( + isinstance(c, Constraint) and c.operator == "==" + for c in result_constraint.constraints + ): + result_marker = AtomicMarkerUnion(marker1.name, result_constraint) + elif isinstance(result_constraint, MultiConstraint) and all( + c.operator == "!=" for c in result_constraint.constraints + ): + result_marker = AtomicMultiMarker(marker1.name, result_constraint) return result_marker diff --git a/tests/packages/test_main.py b/tests/packages/test_main.py index 5a880e12a..4768592f6 100644 --- a/tests/packages/test_main.py +++ b/tests/packages/test_main.py @@ -102,9 +102,9 @@ def test_dependency_from_pep_508_complex() -> None: assert dep.python_versions == ">=2.7 !=3.2.*" assert ( str(dep.marker) - == 'python_version >= "2.7" and python_version != "3.2" and sys_platform ==' - ' "win32" and extra == "foo" or python_version >= "2.7" and python_version' - ' != "3.2" and sys_platform == "darwin" and extra == "foo"' + == 'python_version >= "2.7" and python_version != "3.2" ' + 'and (sys_platform == "win32" or sys_platform == "darwin") ' + 'and extra == "foo"' ) diff --git a/tests/version/test_markers.py b/tests/version/test_markers.py index 1f64b2a3c..5520c99d8 100644 --- a/tests/version/test_markers.py +++ b/tests/version/test_markers.py @@ -6,7 +6,10 @@ import pytest +from poetry.core.constraints.generic import UnionConstraint +from poetry.core.constraints.generic import parse_constraint as parse_generic_constraint from poetry.core.version.markers import AnyMarker +from poetry.core.version.markers import AtomicMarkerUnion from poetry.core.version.markers import EmptyMarker from poetry.core.version.markers import MarkerUnion from poetry.core.version.markers import MultiMarker @@ -22,6 +25,34 @@ from poetry.core.version.markers import BaseMarker +@pytest.mark.parametrize( + "marker", + [ + 'sys_platform == "linux" or sys_platform == "win32"', + 'sys_platform == "win32" or sys_platform == "linux"', + ( + 'sys_platform == "linux" or sys_platform == "win32"' + ' or sys_platform == "darwin"' + ), + ( + 'python_version >= "3.6" and extra == "foo"' + ' or implementation_name == "pypy" and extra == "bar"' + ), + ( + 'python_version < "3.9" or python_version >= "3.10"' + ' and sys_platform == "linux" or sys_platform == "win32"' + ), + ( + 'sys_platform == "win32" and python_version < "3.6" or sys_platform ==' + ' "linux" and python_version < "3.6" and python_version >= "3.3" or' + ' sys_platform == "darwin" and python_version < "3.3"' + ), + ], +) +def test_parse_marker(marker: str) -> None: + assert str(parse_marker(marker)) == marker + + def test_single_marker() -> None: m = parse_marker('sys_platform == "darwin"') @@ -257,9 +288,10 @@ def test_single_marker_union_with_multi_duplicate() -> None: def test_single_marker_union_with_multi_is_single_marker( single_marker: str, multi_marker: str, expected: str ) -> None: - m = parse_marker(single_marker) - union = m.union(parse_marker(multi_marker)) - assert str(union) == expected + m1 = parse_marker(single_marker) + m2 = parse_marker(multi_marker) + assert str(m1.union(m2)) == expected + assert str(m2.union(m1)) == expected def test_single_marker_union_with_multi_cannot_be_simplified() -> None: @@ -941,7 +973,7 @@ def test_parse_version_like_markers(marker: str, env: dict[str, str]) -> None: 'python_version >= "3.6" or extra == "foo" and implementation_name ==' ' "pypy" or extra == "bar"' ), - 'python_version >= "3.6" or implementation_name == "pypy"', + "", ), ('extra == "foo"', ""), ('extra == "foo" or extra == "bar"', ""), @@ -1256,12 +1288,15 @@ def test_union_should_drop_markers_if_their_complement_is_present( SingleMarker("implementation_name", "cpython"), ), MarkerUnion( - SingleMarker("python_version", "<3.7"), SingleMarker("python_version", ">=3.8"), + SingleMarker("python_version", "<3.7"), ), - MarkerUnion( - SingleMarker("sys_platform", "win32"), - SingleMarker("sys_platform", "linux"), + AtomicMarkerUnion( + "sys_platform", + UnionConstraint( + parse_generic_constraint("win32"), + parse_generic_constraint("linux"), + ), ), ), ), @@ -1460,31 +1495,35 @@ def test_cnf(scheme: str, marker: BaseMarker, expected: BaseMarker) -> None: MarkerUnion( MultiMarker( SingleMarker("python_version", ">=3.9"), - SingleMarker("sys_platform", "win32"), - ), - MultiMarker( - SingleMarker("python_version", ">=3.9"), - SingleMarker("sys_platform", "linux"), - ), - MultiMarker( - SingleMarker("implementation_name", "cpython"), - SingleMarker("python_version", "<3.7"), - SingleMarker("sys_platform", "win32"), + AtomicMarkerUnion( + "sys_platform", + UnionConstraint( + parse_generic_constraint("win32"), + parse_generic_constraint("linux"), + ), + ), ), MultiMarker( SingleMarker("implementation_name", "cpython"), SingleMarker("python_version", "<3.7"), - SingleMarker("sys_platform", "linux"), - ), - MultiMarker( - SingleMarker("implementation_name", "cpython"), - SingleMarker("python_version", ">=3.8"), - SingleMarker("sys_platform", "win32"), + AtomicMarkerUnion( + "sys_platform", + UnionConstraint( + parse_generic_constraint("win32"), + parse_generic_constraint("linux"), + ), + ), ), MultiMarker( SingleMarker("implementation_name", "cpython"), SingleMarker("python_version", ">=3.8"), - SingleMarker("sys_platform", "linux"), + AtomicMarkerUnion( + "sys_platform", + UnionConstraint( + parse_generic_constraint("win32"), + parse_generic_constraint("linux"), + ), + ), ), ), ), @@ -1554,8 +1593,10 @@ def test_empty_marker_is_found_in_complex_parse() -> None: def test_complex_union() -> None: - # real world example on the way to get mutually exclusive markers - # for numpy(>=1.21.2) of https://pypi.org/project/opencv-python/4.6.0.66/ + """ + real world example on the way to get mutually exclusive markers + for numpy(>=1.21.2) of https://pypi.org/project/opencv-python/4.6.0.66/ + """ markers = [ parse_marker(m) for m in [ @@ -1585,8 +1626,10 @@ def test_complex_union() -> None: def test_complex_intersection() -> None: - # inverse of real world example on the way to get mutually exclusive markers - # for numpy(>=1.21.2) of https://pypi.org/project/opencv-python/4.6.0.66/ + """ + inverse of real world example on the way to get mutually exclusive markers + for numpy(>=1.21.2) of https://pypi.org/project/opencv-python/4.6.0.66/ + """ markers = [ parse_marker(m).invert() for m in [ @@ -1615,6 +1658,64 @@ def test_complex_intersection() -> None: ) +def test_union_avoids_combinatorial_explosion() -> None: + """ + combinatorial explosion without AtomicMultiMarker and AtomicMarkerUnion + based gevent constraint of sqlalchemy 2.0.7 + see https://github.com/python-poetry/poetry/issues/7689 for details + """ + expected = ( + 'python_full_version >= "3.11.0" and python_version < "4.0"' + ' and (platform_machine == "aarch64" or platform_machine == "ppc64le"' + ' or platform_machine == "x86_64" or platform_machine == "amd64"' + ' or platform_machine == "AMD64" or platform_machine == "win32"' + ' or platform_machine == "WIN32")' + ) + m1 = parse_marker(expected) + m2 = parse_marker( + 'python_full_version >= "3.11.0" and python_full_version < "4.0.0"' + ' and (platform_machine == "aarch64" or platform_machine == "ppc64le"' + ' or platform_machine == "x86_64" or platform_machine == "amd64"' + ' or platform_machine == "AMD64" or platform_machine == "win32"' + ' or platform_machine == "WIN32")' + ) + assert str(m1.union(m2)) == expected + assert str(m2.union(m1)) == expected + + +def test_intersection_avoids_combinatorial_explosion() -> None: + """ + combinatorial explosion without AtomicMultiMarker and AtomicMarkerUnion + based gevent constraint of sqlalchemy 2.0.7 + see https://github.com/python-poetry/poetry/issues/7689 for details + """ + m1 = parse_marker( + 'python_full_version >= "3.11.0" and python_full_version < "4.0.0"' + ) + m2 = parse_marker( + 'python_version >= "3" and (platform_machine == "aarch64" ' + 'or platform_machine == "ppc64le" or platform_machine == "x86_64" ' + 'or platform_machine == "amd64" or platform_machine == "AMD64" ' + 'or platform_machine == "win32" or platform_machine == "WIN32")' + ) + assert ( + str(m1.intersect(m2)) + == 'python_full_version >= "3.11.0" and python_full_version < "4.0.0"' + ' and (platform_machine == "aarch64" or platform_machine == "ppc64le"' + ' or platform_machine == "x86_64" or platform_machine == "amd64"' + ' or platform_machine == "AMD64" or platform_machine == "win32"' + ' or platform_machine == "WIN32")' + ) + assert ( + str(m2.intersect(m1)) + == 'python_full_version >= "3.11.0"' + ' and (platform_machine == "aarch64" or platform_machine == "ppc64le"' + ' or platform_machine == "x86_64" or platform_machine == "amd64"' + ' or platform_machine == "AMD64" or platform_machine == "win32"' + ' or platform_machine == "WIN32") and python_full_version < "4.0.0"' + ) + + @pytest.mark.parametrize( ( "python_version, python_full_version, "