From 23427029abf492863f1e119b83698c951a9e6a0c Mon Sep 17 00:00:00 2001 From: Frost Ming Date: Tue, 21 Nov 2023 17:23:36 +0800 Subject: [PATCH] feat: markers and tests Signed-off-by: Frost Ming --- .github/workflows/ci.yml | 6 +- README.md | 4 +- pdm.lock | 2 +- pyproject.toml | 11 +- src/{pkg_logical => dep_logic}/__init__.py | 0 src/dep_logic/markers/__init__.py | 101 +++ src/dep_logic/markers/any.py | 43 + src/dep_logic/markers/base.py | 59 ++ src/dep_logic/markers/empty.py | 43 + src/dep_logic/markers/multi.py | 179 ++++ src/dep_logic/markers/single.py | 377 +++++++++ src/dep_logic/markers/union.py | 174 ++++ src/dep_logic/markers/utils.py | 121 +++ src/dep_logic/specifiers/__init__.py | 123 +++ .../specifiers/base.py | 33 +- src/dep_logic/specifiers/generic.py | 102 +++ .../specifiers/range.py | 29 +- src/dep_logic/specifiers/special.py | 75 ++ .../specifiers/union.py | 115 +-- src/dep_logic/utils.py | 180 ++++ src/pkg_logical/specifiers/__init__.py | 113 --- src/pkg_logical/specifiers/empty.py | 43 - src/pkg_logical/utils.py | 62 -- tests/marker/test_common.py | 157 ++++ tests/marker/test_compound.py | 781 ++++++++++++++++++ tests/marker/test_evaluation.py | 162 ++++ tests/marker/test_expression.py | 301 +++++++ tests/marker/test_parsing.py | 83 ++ tests/specifier/test_range.py | 16 +- tests/specifier/test_union.py | 14 +- 30 files changed, 3198 insertions(+), 311 deletions(-) rename src/{pkg_logical => dep_logic}/__init__.py (100%) create mode 100644 src/dep_logic/markers/__init__.py create mode 100644 src/dep_logic/markers/any.py create mode 100644 src/dep_logic/markers/base.py create mode 100644 src/dep_logic/markers/empty.py create mode 100644 src/dep_logic/markers/multi.py create mode 100644 src/dep_logic/markers/single.py create mode 100644 src/dep_logic/markers/union.py create mode 100644 src/dep_logic/markers/utils.py create mode 100644 src/dep_logic/specifiers/__init__.py rename src/{pkg_logical => dep_logic}/specifiers/base.py (81%) create mode 100644 src/dep_logic/specifiers/generic.py rename src/{pkg_logical => dep_logic}/specifiers/range.py (91%) create mode 100644 src/dep_logic/specifiers/special.py rename src/{pkg_logical => dep_logic}/specifiers/union.py (61%) create mode 100644 src/dep_logic/utils.py delete mode 100644 src/pkg_logical/specifiers/__init__.py delete mode 100644 src/pkg_logical/specifiers/empty.py delete mode 100644 src/pkg_logical/utils.py create mode 100644 tests/marker/test_common.py create mode 100644 tests/marker/test_compound.py create mode 100644 tests/marker/test_evaluation.py create mode 100644 tests/marker/test_expression.py create mode 100644 tests/marker/test_parsing.py diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index c35ea15..e8baa87 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -12,11 +12,10 @@ on: jobs: Testing: - runs-on: ${{ matrix.os }} + runs-on: ubuntu-latest strategy: matrix: - python-version: ["3.10", "3.11", "3.12"] - os: [ubuntu-latest, windows-latest, macos-latest] + python-version: ["3.8", "3.9", "3.10", "3.11", "3.12"] steps: - uses: actions/checkout@v4 @@ -24,7 +23,6 @@ jobs: uses: pdm-project/setup-pdm@v3 with: python-version: ${{ matrix.python-version }} - architecture: ${{ matrix.arch }} cache: "true" - name: Install packages diff --git a/README.md b/README.md index de8268e..6c09c3c 100644 --- a/README.md +++ b/README.md @@ -1,3 +1,3 @@ -# pkg-logical +# Dep-Logic -Logical operational specifiers and markers. +Python dependency specifications supporting logical operations diff --git a/pdm.lock b/pdm.lock index d1a1444..34b1947 100644 --- a/pdm.lock +++ b/pdm.lock @@ -5,7 +5,7 @@ groups = ["default", "dev"] strategy = ["cross_platform"] lock_version = "4.4" -content_hash = "sha256:2882acb3c7d3870dded0684514a833c943352a3196a3cb0afbe1abe83a40f7ef" +content_hash = "sha256:23359f0338a33c4dd557e8ec2e8588044157d7094fa39ee851b81b101a0a9ee5" [[package]] name = "colorama" diff --git a/pyproject.toml b/pyproject.toml index 926dbb3..86f0f58 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,16 +1,19 @@ [project] -name = "pkg-logical" -version = "0.1.0" -description = "Logical operational specifiers and markers" +name = "dep-logic" +description = "Python dependency specifications supporting logical operations" authors = [ {name = "Frost Ming", email = "me@frostming.com"}, ] dependencies = [ "packaging>=22", ] -requires-python = ">=3.10" +requires-python = ">=3.8" readme = "README.md" license = {text = "Apache-2.0"} +dynamic = ["version"] + +[tool.pdm.version] +source = "scm" [build-system] requires = ["pdm-backend"] diff --git a/src/pkg_logical/__init__.py b/src/dep_logic/__init__.py similarity index 100% rename from src/pkg_logical/__init__.py rename to src/dep_logic/__init__.py diff --git a/src/dep_logic/markers/__init__.py b/src/dep_logic/markers/__init__.py new file mode 100644 index 0000000..e2de424 --- /dev/null +++ b/src/dep_logic/markers/__init__.py @@ -0,0 +1,101 @@ +# Adapted from poetry/core/version/markers.py +# The original work is published under the MIT license. +# Copyright (c) 2020 Sébastien Eustace +# Adapted by Frost Ming (c) 2023 + +from __future__ import annotations + +import functools +from typing import TYPE_CHECKING + +from packaging.markers import InvalidMarker as _InvalidMarker +from packaging.markers import Marker as _Marker + +from dep_logic.markers.any import AnyMarker +from dep_logic.markers.base import BaseMarker +from dep_logic.markers.empty import EmptyMarker +from dep_logic.markers.multi import MultiMarker +from dep_logic.markers.single import MarkerExpression +from dep_logic.markers.union import MarkerUnion +from dep_logic.utils import get_reflect_op + +if TYPE_CHECKING: + from typing import List, Literal, Tuple, Union + + from packaging.markers import Op, Value, Variable + + _ParsedMarker = Tuple[Variable, Op, Value] + _ParsedMarkers = Union[ + _ParsedMarker, List[Union["_ParsedMarkers", Literal["or", "and"]]] + ] + + +__all__ = [ + "parse_marker", + "from_pkg_marker", + "InvalidMarker", + "BaseMarker", + "AnyMarker", + "EmptyMarker", + "MarkerExpression", + "MarkerUnion", + "MultiMarker", +] + + +class InvalidMarker(ValueError): + """ + An invalid marker was found, users should refer to PEP 508. + """ + + +@functools.lru_cache(maxsize=None) +def parse_marker(marker: str) -> BaseMarker: + if marker == "": + return EmptyMarker() + + if not marker or marker == "*": + return AnyMarker() + try: + parsed = _Marker(marker) + except _InvalidMarker as e: + raise InvalidMarker(str(e)) from e + + markers = _build_markers(parsed._markers) + + return markers + + +def from_pkg_marker(marker: _Marker) -> BaseMarker: + return _build_markers(marker._markers) + + +def _build_markers(markers: _ParsedMarkers) -> BaseMarker: + from packaging.markers import Variable + + if isinstance(markers, tuple): + if isinstance(markers[0], Variable): + name, op, value, reversed = ( + str(markers[0]), + str(markers[1]), + str(markers[2]), + False, + ) + else: + # in reverse order + name, op, value, reversed = ( + str(markers[2]), + get_reflect_op(str(markers[1])), + str(markers[0]), + True, + ) + return MarkerExpression(name, op, value, reversed) + or_groups: list[BaseMarker] = [AnyMarker()] + for item in markers: + if item == "or": + or_groups.append(AnyMarker()) + elif item == "and": + continue + else: + or_groups[-1] &= _build_markers(item) + return MarkerUnion.of(*or_groups) diff --git a/src/dep_logic/markers/any.py b/src/dep_logic/markers/any.py new file mode 100644 index 0000000..62e729a --- /dev/null +++ b/src/dep_logic/markers/any.py @@ -0,0 +1,43 @@ +from dep_logic.markers.base import BaseMarker + + +class AnyMarker(BaseMarker): + def __and__(self, other: BaseMarker) -> BaseMarker: + return other + + __rand__ = __and__ + + def __or__(self, other: BaseMarker) -> BaseMarker: + return self + + __ror__ = __or__ + + def is_any(self) -> bool: + return True + + def evaluate(self, environment: dict[str, str] | None = None) -> bool: + return True + + def without_extras(self) -> BaseMarker: + return self + + def exclude(self, marker_name: str) -> BaseMarker: + return self + + def only(self, *marker_names: str) -> BaseMarker: + return self + + def __str__(self) -> str: + return "" + + def __repr__(self) -> str: + return "" + + def __hash__(self) -> int: + return hash("any") + + def __eq__(self, other: object) -> bool: + if not isinstance(other, BaseMarker): + return NotImplemented + + return isinstance(other, AnyMarker) diff --git a/src/dep_logic/markers/base.py b/src/dep_logic/markers/base.py new file mode 100644 index 0000000..2a6bc30 --- /dev/null +++ b/src/dep_logic/markers/base.py @@ -0,0 +1,59 @@ +from __future__ import annotations + +from abc import ABCMeta, abstractmethod +from typing import Any + + +class BaseMarker(metaclass=ABCMeta): + @property + def complexity(self) -> tuple[int, int]: + """ + The first number is the number of marker expressions, + and the second number is 1 if the marker is single-like. + """ + return 1, 1 + + @abstractmethod + def __and__(self, other: Any) -> BaseMarker: + raise NotImplementedError + + @abstractmethod + def __or__(self, other: Any) -> BaseMarker: + raise NotImplementedError + + def is_any(self) -> bool: + return False + + def is_empty(self) -> bool: + return False + + @abstractmethod + def evaluate(self, environment: dict[str, str] | None = None) -> bool: + raise NotImplementedError + + @abstractmethod + def without_extras(self) -> BaseMarker: + raise NotImplementedError + + @abstractmethod + def exclude(self, marker_name: str) -> BaseMarker: + raise NotImplementedError + + @abstractmethod + def only(self, *marker_names: str) -> BaseMarker: + raise NotImplementedError + + def __repr__(self) -> str: + return f"<{self.__class__.__name__} {self}>" + + @abstractmethod + def __str__(self) -> str: + raise NotImplementedError + + @abstractmethod + def __hash__(self) -> int: + raise NotImplementedError + + @abstractmethod + def __eq__(self, other: object) -> bool: + raise NotImplementedError diff --git a/src/dep_logic/markers/empty.py b/src/dep_logic/markers/empty.py new file mode 100644 index 0000000..f602784 --- /dev/null +++ b/src/dep_logic/markers/empty.py @@ -0,0 +1,43 @@ +from dep_logic.markers.base import BaseMarker + + +class EmptyMarker(BaseMarker): + def __and__(self, other: BaseMarker) -> BaseMarker: + return self + + __rand__ = __and__ + + def __or__(self, other: BaseMarker) -> BaseMarker: + return other + + __ror__ = __or__ + + def is_empty(self) -> bool: + return True + + def evaluate(self, environment: dict[str, str] | None = None) -> bool: + return False + + def without_extras(self) -> BaseMarker: + return self + + def exclude(self, marker_name: str) -> BaseMarker: + return self + + def only(self, *marker_names: str) -> BaseMarker: + return self + + def __str__(self) -> str: + return "" + + def __repr__(self) -> str: + return "" + + def __hash__(self) -> int: + return hash("empty") + + def __eq__(self, other: object) -> bool: + if not isinstance(other, BaseMarker): + return NotImplemented + + return isinstance(other, EmptyMarker) diff --git a/src/dep_logic/markers/multi.py b/src/dep_logic/markers/multi.py new file mode 100644 index 0000000..a59523c --- /dev/null +++ b/src/dep_logic/markers/multi.py @@ -0,0 +1,179 @@ +from typing import Iterator + +from dep_logic.markers.any import AnyMarker +from dep_logic.markers.base import BaseMarker +from dep_logic.markers.empty import EmptyMarker +from dep_logic.markers.single import MarkerExpression, SingleMarker +from dep_logic.utils import flatten_items, intersection, union + + +class MultiMarker(BaseMarker): + __slots__ = ("_markers",) + + def __init__(self, *markers: BaseMarker) -> None: + self._markers = tuple(flatten_items(markers, MultiMarker)) + + def __iter__(self) -> Iterator[BaseMarker]: + return iter(self._markers) + + @property + def markers(self) -> tuple[BaseMarker, ...]: + return self._markers + + @property + def complexity(self) -> tuple[int, int]: + return tuple(sum(c) for c in zip(*(m.complexity for m in self._markers))) + + @classmethod + def of(cls, *markers: BaseMarker) -> BaseMarker: + from dep_logic.markers.union import MarkerUnion + + new_markers = flatten_items(markers, MultiMarker) + old_markers: list[BaseMarker] = [] + + while old_markers != new_markers: + old_markers = new_markers + new_markers = [] + for marker in old_markers: + if marker in new_markers: + continue + + if marker.is_any(): + continue + + intersected = False + 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 = mark & marker + if new_marker.is_empty(): + return EmptyMarker() + + if isinstance(new_marker, SingleMarker): + new_markers[i] = new_marker + intersected = True + break + + # If we have a MarkerUnion then we can look for the simplifications + # implemented in intersect_simplify(). + elif isinstance(mark, MarkerUnion): + intersection = mark.intersect_simplify(marker) + if intersection is not None: + new_markers[i] = intersection + intersected = True + break + + if intersected: + # flatten again because intersect_simplify may return a multi + new_markers = flatten_items(new_markers, MultiMarker) + continue + + new_markers.append(marker) + + if any(m.is_empty() for m in new_markers): + return EmptyMarker() + + if not new_markers: + return AnyMarker() + + if len(new_markers) == 1: + return new_markers[0] + + return MultiMarker(*new_markers) + + def __and__(self, other: BaseMarker) -> BaseMarker: + return intersection(self, other) + + def __or__(self, other: BaseMarker) -> BaseMarker: + return union(self, other) + + __rand__ = __and__ + __ror__ = __or__ + + def union_simplify(self, other: BaseMarker) -> BaseMarker | None: + """ + Finds a couple of easy simplifications for union on MultiMarkers: + + - union with any marker that appears as part of the multi is just that + marker + + - union between two multimarkers where one is contained by the other is just + the larger of the two + + - union between two multimarkers where there are some common markers + and the union of unique markers is a single marker + """ + if other in self._markers: + return other + + if isinstance(other, MultiMarker): + our_markers = set(self.markers) + their_markers = set(other.markers) + + if our_markers.issubset(their_markers): + return self + + if their_markers.issubset(our_markers): + return other + + shared_markers = our_markers.intersection(their_markers) + if not shared_markers: + return None + + unique_markers = our_markers - their_markers + other_unique_markers = their_markers - our_markers + unique_union = MultiMarker(*unique_markers) | ( + MultiMarker(*other_unique_markers) + ) + if isinstance(unique_union, (SingleMarker, AnyMarker)): + # Use list instead of set for deterministic order. + common_markers = [ + marker for marker in self.markers if marker in shared_markers + ] + return unique_union & MultiMarker(*common_markers) + + return None + + def evaluate(self, environment: dict[str, str] | None = None) -> bool: + return all(m.evaluate(environment) for m in self._markers) + + def without_extras(self) -> BaseMarker: + return self.exclude("extra") + + def exclude(self, marker_name: str) -> BaseMarker: + new_markers = [] + + for m in self._markers: + if isinstance(m, SingleMarker) and m.name == marker_name: + # The marker is not relevant since it must be excluded + continue + + marker = m.exclude(marker_name) + + if not marker.is_empty(): + new_markers.append(marker) + + return self.of(*new_markers) + + def only(self, *marker_names: str) -> BaseMarker: + return self.of(*(m.only(*marker_names) for m in self._markers)) + + def __eq__(self, other: object) -> bool: + if not isinstance(other, MultiMarker): + return False + + return self._markers == other.markers + + def __hash__(self) -> int: + return hash(("multi", *self._markers)) + + def __str__(self) -> str: + elements = [] + for m in self._markers: + if isinstance(m, (MarkerExpression, MultiMarker)): + elements.append(str(m)) + else: + elements.append(f"({m})") + + return " and ".join(elements) diff --git a/src/dep_logic/markers/single.py b/src/dep_logic/markers/single.py new file mode 100644 index 0000000..73059d1 --- /dev/null +++ b/src/dep_logic/markers/single.py @@ -0,0 +1,377 @@ +from __future__ import annotations + +import functools +import typing as t +from dataclasses import dataclass, field, replace +from typing import Any + +from packaging.markers import Marker as _Marker + +from dep_logic.markers.any import AnyMarker +from dep_logic.markers.base import BaseMarker +from dep_logic.markers.empty import EmptyMarker +from dep_logic.specifiers import BaseSpecifier +from dep_logic.specifiers.base import VersionSpecifier +from dep_logic.specifiers.generic import GenericSpecifier +from dep_logic.utils import OrderedSet, get_reflect_op + +if t.TYPE_CHECKING: + from dep_logic.markers.multi import MultiMarker + from dep_logic.markers.union import MarkerUnion + +PYTHON_VERSION_MARKERS = {"python_version", "python_full_version"} + + +class SingleMarker(BaseMarker): + name: str + _VERSION_LIKE_MARKER_NAME: t.ClassVar[set[str]] = { + "python_version", + "python_full_version", + "platform_release", + } + + 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 evaluate(self, environment: dict[str, str] | None = None) -> bool: + pkg_marker = _Marker(str(self)) + if self.name != "extra" or not environment or not environment.get("extra"): + return pkg_marker.evaluate(environment) + extras = [extra] if isinstance(extra := environment["extra"], str) else extra + is_negated = self.op in ("not in", "!=") + if is_negated: + return all(pkg_marker.evaluate({"extra": extra}) for extra in extras) + return any(pkg_marker.evaluate({"extra": extra}) for extra in extras) + + +@dataclass(slots=True, unsafe_hash=True, repr=False) +class MarkerExpression(SingleMarker): + name: str + op: str + value: str + reversed: bool = field(default=False, compare=False, hash=False) + _specifier: BaseSpecifier | None = field(default=None, compare=False, hash=False) + + @property + def specifier(self) -> BaseSpecifier: + if self._specifier is None: + self._specifier = self._get_specifier() + return self._specifier + + @classmethod + def from_specifier(cls, name: str, specifier: BaseSpecifier) -> BaseMarker | None: + if specifier.is_any(): + return AnyMarker() + if specifier.is_empty(): + return EmptyMarker() + if isinstance(specifier, VersionSpecifier): + if not specifier.is_simple(): + return None + pkg_spec = next(iter(specifier.to_specifierset())) + pkg_version = pkg_spec.version + if ( + dot_num := pkg_version.count(".") + ) < 2 and name == "python_full_version": + for _ in range(2 - dot_num): + pkg_version += ".0" + return MarkerExpression( + name, pkg_spec.operator, pkg_version, _specifier=specifier + ) + assert isinstance(specifier, GenericSpecifier) + return MarkerExpression( + name, specifier.op, specifier.value, _specifier=specifier + ) + + def _get_specifier(self) -> BaseSpecifier: + from dep_logic.specifiers import parse_version_specifier + + if self.name not in self._VERSION_LIKE_MARKER_NAME: + return GenericSpecifier(self.op, self.value) + if self.op in ("in", "not in"): + versions: list[str] = [] + op, glue = ("==", "||") if self.op == "in" else ("!=", ",") + for part in self.value.split(","): + splitted = part.strip().split(".") + if part_num := len(splitted) < 3: + if self.name == "python_version": + splitted.append("*") + else: + splitted.extend(["0"] * (3 - part_num)) + + versions.append(op + ".".join(splitted)) + return parse_version_specifier(glue.join(versions)) + return parse_version_specifier(f"{self.op}{self.value}") + + def __str__(self) -> str: + if self.reversed: + return f'"{self.value}" {get_reflect_op(self.op)} {self.name}' + return f'{self.name} {self.op} "{self.value}"' + + def __and__(self, other: t.Any) -> BaseMarker: + from dep_logic.markers.multi import MultiMarker + + if not isinstance(other, MarkerExpression): + return NotImplemented + merged = _merge_single_markers(self, other, MultiMarker) + if merged is not None: + return merged + + return MultiMarker(self, other) + + def __or__(self, other: t.Any) -> BaseMarker: + from dep_logic.markers.union import MarkerUnion + + if not isinstance(other, MarkerExpression): + return NotImplemented + merged = _merge_single_markers(self, other, MarkerUnion) + if merged is not None: + return merged + + return MarkerUnion(self, other) + + +@dataclass(slots=True, unsafe_hash=True, repr=False) +class EqualityMarkerUnion(SingleMarker): + name: str + values: OrderedSet[str] + + def __str__(self) -> str: + return " or ".join(f'{self.name} == "{value}"' for value in self.values) + + def replace(self, values: OrderedSet[str]) -> BaseMarker: + if not values: + return EmptyMarker() + if len(values) == 1: + return MarkerExpression(self.name, "==", values.peek()) + return replace(self, values=values) + + @property + def complexity(self) -> tuple[int, int]: + return len(self.values), 1 + + def __and__(self, other: Any) -> BaseMarker: + from dep_logic.markers.multi import MultiMarker + + if not isinstance(other, SingleMarker): + return NotImplemented + + if self.name != other.name: + return MultiMarker(self, other) + if isinstance(other, MarkerExpression): + new_values = OrderedSet([v for v in self.values if v in other.specifier]) + return self.replace(new_values) + elif isinstance(other, EqualityMarkerUnion): + new_values = self.values & other.values + return self.replace(new_values) + else: + # intersection with InequalityMarkerUnion will be handled in the other class + return NotImplemented + + def __or__(self, other: Any) -> BaseMarker: + from dep_logic.markers.union import MarkerUnion + + if not isinstance(other, SingleMarker): + return NotImplemented + + if self.name != other.name: + return MarkerUnion(self, other) + + if isinstance(other, MarkerExpression): + if other.op == "==": + if other.value in self.values: + return self + return replace(self, values=self.values | {other.value}) + if other.op == "!=": + if other.value in self.values: + AnyMarker() + return other + if all(v in other.specifier for v in self.values): + return other + else: + return MarkerUnion(self, other) + elif isinstance(other, EqualityMarkerUnion): + return replace(self, values=self.values | other.values) + else: + # intersection with InequalityMarkerUnion will be handled in the other class + return NotImplemented + + __rand__ = __and__ + __ror__ = __or__ + + +@dataclass(slots=True, unsafe_hash=True, repr=False) +class InequalityMultiMarker(SingleMarker): + name: str + values: OrderedSet[str] + + def __str__(self) -> str: + return " and ".join(f'{self.name} != "{value}"' for value in self.values) + + def replace(self, values: OrderedSet[str]) -> BaseMarker: + if not values: + return AnyMarker() + if len(values) == 1: + return MarkerExpression(self.name, "!=", values.peek()) + return replace(self, values=values) + + @property + def complexity(self) -> tuple[int, int]: + return len(self.values), 1 + + def __and__(self, other: Any) -> BaseMarker: + from dep_logic.markers.multi import MultiMarker + + if not isinstance(other, SingleMarker): + return NotImplemented + if self.name != other.name: + return MultiMarker(self, other) + + if isinstance(other, MarkerExpression): + if other.op == "==": + if other.value in self.values: + return EmptyMarker() + return other + elif other.op == "!=": + if other.value in self.values: + return self + return replace(self, values=self.values | {other.value}) + elif not any(v in other.specifier for v in self.values): + return other + else: + return MultiMarker(self, other) + elif isinstance(other, EqualityMarkerUnion): + new_values = other.values - self.values + return other.replace(new_values) + else: # isinstance(other, InequalityMultiMarker) + return replace(self, values=self.values | other.values) + + def __or__(self, other: Any) -> BaseMarker: + from dep_logic.markers.union import MarkerUnion + + if not isinstance(other, SingleMarker): + return NotImplemented + + if self.name != other.name: + return MarkerUnion(self, other) + + if isinstance(other, MarkerExpression): + new_values = OrderedSet( + [v for v in self.values if v not in other.specifier] + ) + return self.replace(new_values) + elif isinstance(other, EqualityMarkerUnion): + new_values = self.values - other.values + return self.replace(new_values) + else: # isinstance(other, InequalityMultiMarker) + new_values = self.values & other.values + return self.replace(new_values) + + __rand__ = __and__ + __ror__ = __or__ + + +@functools.lru_cache(maxsize=None) +def _merge_single_markers( + marker1: MarkerExpression, + marker2: MarkerExpression, + merge_class: type[MultiMarker | MarkerUnion], +) -> BaseMarker | None: + from dep_logic.markers.multi import MultiMarker + from dep_logic.markers.union import MarkerUnion + + if {marker1.name, marker2.name} == PYTHON_VERSION_MARKERS: + return _merge_python_version_single_markers(marker1, marker2, merge_class) + + if marker1.name != marker2.name: + return None + + # "extra" is special because it can have multiple values at the same time. + # That's why we can only merge two "extra" markers if they have the same value. + if marker1.name == "extra": + if marker1.value != marker2.value: # type: ignore[attr-defined] + return None + try: + if merge_class is MultiMarker: + result_specifier = marker1.specifier & marker2.specifier + else: + result_specifier = marker1.specifier | marker2.specifier + except NotImplementedError: + if marker1.op == marker2.op == "==" and merge_class is MarkerUnion: + return EqualityMarkerUnion( + marker1.name, OrderedSet([marker1.value, marker2.value]) + ) + elif marker1.op == marker2.op == "!=" and merge_class is MultiMarker: + return InequalityMultiMarker( + marker1.name, OrderedSet([marker1.value, marker2.value]) + ) + return None + else: + if result_specifier == marker1.specifier: + return marker1 + if result_specifier == marker2.specifier: + return marker2 + return MarkerExpression.from_specifier(marker1.name, result_specifier) + + +def _merge_python_version_single_markers( + marker1: MarkerExpression, + marker2: MarkerExpression, + merge_class: type[MultiMarker | MarkerUnion], +) -> BaseMarker | None: + from dep_logic.markers.multi import MultiMarker + + if marker1.name == "python_version": + version_marker = marker1 + full_version_marker = marker2 + else: + version_marker = marker2 + full_version_marker = marker1 + + normalized_specifier = _normalize_python_version_specifier(version_marker) + + if merge_class is MultiMarker: + merged = normalized_specifier & full_version_marker.specifier + else: + merged = normalized_specifier | full_version_marker.specifier + if merged == normalized_specifier: + # prefer original marker to avoid unnecessary changes + return version_marker + + return MarkerExpression.from_specifier("python_full_version", merged) + + +def _normalize_python_version_specifier(marker: MarkerExpression) -> BaseSpecifier: + from dep_logic.specifiers import parse_version_specifier + + op, value = marker.op, marker.value + if op in ("in", "not in"): + # skip this case, so in the following code value must be a dotted version string + return marker.specifier + splitted = [p.strip() for p in value.split(".")] + if len(splitted) > 2 or "*" in splitted: + return marker.specifier + if op in ("==", "!="): + splitted.append("*") + elif op == ">": + # python_version > '3.7' is equal to python_full_version >= '3.8.0' + splitted[-1] = str(int(splitted[-1]) + 1) + op = ">=" + elif op == "<=": + # python_version <= '3.7' is equal to python_full_version < '3.8.0' + splitted[-1] = str(int(splitted[-1]) + 1) + op = "<" + + spec = parse_version_specifier(f'{op}{".".join(splitted)}') + return spec diff --git a/src/dep_logic/markers/union.py b/src/dep_logic/markers/union.py new file mode 100644 index 0000000..3cf0ff9 --- /dev/null +++ b/src/dep_logic/markers/union.py @@ -0,0 +1,174 @@ +from typing import Iterator + +from dep_logic.markers.any import AnyMarker +from dep_logic.markers.base import BaseMarker +from dep_logic.markers.empty import EmptyMarker +from dep_logic.markers.multi import MultiMarker +from dep_logic.markers.single import SingleMarker +from dep_logic.utils import flatten_items, intersection, union + + +class MarkerUnion(BaseMarker): + __slots__ = ("_markers",) + + def __init__(self, *markers: BaseMarker) -> None: + self._markers = tuple(flatten_items(markers, MarkerUnion)) + + @property + def markers(self) -> tuple[BaseMarker, ...]: + return self._markers + + def __iter__(self) -> Iterator[BaseMarker]: + return iter(self._markers) + + @property + def complexity(self) -> int: + return tuple(sum(c) for c in zip(*(m.complexity for m in self._markers))) + + @classmethod + def of(cls, *markers: BaseMarker) -> BaseMarker: + new_markers = flatten_items(markers, MarkerUnion) + old_markers: list[BaseMarker] = [] + + while old_markers != new_markers: + old_markers = new_markers + new_markers = [] + for marker in old_markers: + if marker in new_markers: + continue + + if marker.is_empty(): + continue + + included = False + 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 = mark | marker + if new_marker.is_any(): + return AnyMarker() + + if isinstance(new_marker, SingleMarker): + new_markers[i] = new_marker + included = True + break + + # If we have a MultiMarker then we can look for the simplifications + # implemented in union_simplify(). + elif isinstance(mark, MultiMarker): + union = mark.union_simplify(marker) + if union is not None: + new_markers[i] = union + included = True + break + + if included: + # flatten again because union_simplify may return a union + new_markers = flatten_items(new_markers, MarkerUnion) + continue + + new_markers.append(marker) + + if any(m.is_any() for m in new_markers): + return AnyMarker() + + if not new_markers: + return EmptyMarker() + + if len(new_markers) == 1: + return new_markers[0] + + return MarkerUnion(*new_markers) + + def __and__(self, other: BaseMarker) -> BaseMarker: + return intersection(self, other) + + def __or__(self, other: BaseMarker) -> BaseMarker: + return union(self, other) + + __rand__ = __and__ + __ror__ = __or__ + + def intersect_simplify(self, other: BaseMarker) -> BaseMarker | None: + """ + Finds a couple of easy simplifications for intersection on MarkerUnions: + + - intersection with any marker that appears as part of the union is just + that marker + + - intersection between two markerunions where one is contained by the other + is just the smaller of the two + + - intersection between two markerunions where there are some common markers + and the intersection of unique markers is not a single marker + """ + if other in self._markers: + return other + + if isinstance(other, MarkerUnion): + our_markers = set(self.markers) + their_markers = set(other.markers) + + if our_markers.issubset(their_markers): + return self + + if their_markers.issubset(our_markers): + return other + + shared_markers = our_markers.intersection(their_markers) + if not shared_markers: + return None + + unique_markers = our_markers - their_markers + other_unique_markers = their_markers - our_markers + unique_intersection = MarkerUnion(*unique_markers) & MarkerUnion( + *other_unique_markers + ) + + if isinstance(unique_intersection, (SingleMarker, EmptyMarker)): + # Use list instead of set for deterministic order. + common_markers = [ + marker for marker in self.markers if marker in shared_markers + ] + return unique_intersection | MarkerUnion(*common_markers) + + return None + + def evaluate(self, environment: dict[str, str] | None = None) -> bool: + return any(m.evaluate(environment) for m in self._markers) + + def without_extras(self) -> BaseMarker: + return self.exclude("extra") + + def exclude(self, marker_name: str) -> BaseMarker: + new_markers = [] + + for m in self._markers: + if isinstance(m, SingleMarker) and m.name == marker_name: + # The marker is not relevant since it must be excluded + continue + + marker = m.exclude(marker_name) + new_markers.append(marker) + + if not new_markers: + # All markers were the excluded marker. + return AnyMarker() + + return self.of(*new_markers) + + def only(self, *marker_names: str) -> BaseMarker: + return self.of(*(m.only(*marker_names) for m in self._markers)) + + def __eq__(self, other: object) -> bool: + if not isinstance(other, MarkerUnion): + return False + + return self._markers == other.markers + + def __hash__(self) -> int: + return hash(("union", *self._markers)) + + def __str__(self) -> str: + return " or ".join(str(m) for m in self._markers) diff --git a/src/dep_logic/markers/utils.py b/src/dep_logic/markers/utils.py new file mode 100644 index 0000000..3b2ed91 --- /dev/null +++ b/src/dep_logic/markers/utils.py @@ -0,0 +1,121 @@ +import functools +import itertools +from typing import AbstractSet, Iterable, Iterator, TypeVar + +from dep_logic.markers.base import BaseMarker + +T = TypeVar("T") + + +@functools.lru_cache(maxsize=None) +def cnf(marker: BaseMarker) -> BaseMarker: + from dep_logic.markers.multi import MultiMarker + from dep_logic.markers.union import MarkerUnion + + """Transforms the marker into CNF (conjunctive normal form).""" + if isinstance(marker, MarkerUnion): + cnf_markers = [cnf(m) for m in marker.markers] + sub_marker_lists = [ + m.markers if isinstance(m, MultiMarker) else [m] for m in cnf_markers + ] + return MultiMarker.of( + *[MarkerUnion.of(*c) for c in itertools.product(*sub_marker_lists)] + ) + + if isinstance(marker, MultiMarker): + return MultiMarker.of(*[cnf(m) for m in marker.markers]) + + return marker + + +@functools.lru_cache(maxsize=None) +def dnf(marker: BaseMarker) -> BaseMarker: + """Transforms the marker into DNF (disjunctive normal form).""" + from dep_logic.markers.multi import MultiMarker + from dep_logic.markers.union import MarkerUnion + + if isinstance(marker, MultiMarker): + dnf_markers = [dnf(m) for m in marker.markers] + sub_marker_lists = [ + m.markers if isinstance(m, MarkerUnion) else [m] for m in dnf_markers + ] + return MarkerUnion.of( + *[MultiMarker.of(*c) for c in itertools.product(*sub_marker_lists)] + ) + + if isinstance(marker, MarkerUnion): + return MarkerUnion.of(*[dnf(m) for m in marker.markers]) + + return marker + + +def intersection(*markers: BaseMarker) -> BaseMarker: + from dep_logic.markers.multi import MultiMarker + + return dnf(MultiMarker(*markers)) + + +def union(*markers: BaseMarker) -> BaseMarker: + from dep_logic.markers.multi import MultiMarker + from dep_logic.markers.union import MarkerUnion + + # 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 + + disjunction = dnf(conjunction) + if not isinstance(disjunction, MarkerUnion): + return disjunction + + return min(disjunction, conjunction, unnormalized, key=lambda x: x.complexity) + + +_op_reflect_map = { + "<": ">", + "<=": ">=", + ">": "<", + ">=": "<=", + "==": "==", + "!=": "!=", + "===": "===", + "~=": "~=", + "in": "in", + "not in": "not in", +} + + +def get_reflect_op(op: str) -> str: + return _op_reflect_map[op] + + +class OrderedSet(AbstractSet[T]): + def __init__(self, iterable: Iterable[T]) -> None: + self._data: list[T] = [] + for item in iterable: + if item in self._data: + continue + self._data.append(item) + + def __hash__(self) -> int: + return self._hash() + + def __contains__(self, obj: object) -> bool: + return obj in self._data + + def __iter__(self) -> Iterator[T]: + return iter(self._data) + + def __len__(self) -> int: + return len(self._data) + + def peek(self) -> T: + return self._data[0] diff --git a/src/dep_logic/specifiers/__init__.py b/src/dep_logic/specifiers/__init__.py new file mode 100644 index 0000000..e3b057b --- /dev/null +++ b/src/dep_logic/specifiers/__init__.py @@ -0,0 +1,123 @@ +from __future__ import annotations + +import functools +import itertools +import operator + +from packaging.specifiers import Specifier, SpecifierSet +from packaging.version import Version + +from dep_logic.specifiers.base import ( + BaseSpecifier, + InvalidSpecifier, + VersionSpecifier, +) +from dep_logic.specifiers.generic import GenericSpecifier +from dep_logic.specifiers.range import RangeSpecifier +from dep_logic.specifiers.special import AnySpecifier, EmptySpecifier +from dep_logic.specifiers.union import UnionSpecifier +from dep_logic.utils import is_not_suffix, version_split + + +def from_specifierset(spec: SpecifierSet) -> VersionSpecifier: + """Convert from a packaging.specifiers.SpecifierSet object.""" + + return functools.reduce( + operator.and_, map(_from_pkg_specifier, spec), RangeSpecifier() + ) + + +def _from_pkg_specifier(spec: Specifier) -> VersionSpecifier: + version = spec.version + min: Version | None = None + max: Version | None = None + include_min = False + include_max = False + if (op := spec.operator) in (">", ">="): + min = Version(version) + include_min = spec.operator == ">=" + elif op in ("<", "<="): + max = Version(version) + include_max = spec.operator == "<=" + elif op == "==": + if "*" not in version: + min = Version(version) + max = Version(version) + include_min = True + include_max = True + else: + version_parts = list( + itertools.takewhile(lambda x: x != "*", version_split(version)) + ) + min = Version(".".join([*version_parts, "0"])) + version_parts[-1] = str(int(version_parts[-1]) + 1) + max = Version(".".join([*version_parts, "0"])) + include_min = True + include_max = False + elif op == "~=": + min = Version(version) + version_parts = list( + itertools.takewhile(is_not_suffix, version_split(version)) + )[:-1] + version_parts[-1] = str(int(version_parts[-1]) + 1) + max = Version(".".join([*version_parts, "0"])) + include_min = True + include_max = False + elif op == "!=": + if "*" not in version: + v = Version(version) + return UnionSpecifier( + ( + RangeSpecifier(max=v, include_max=False), + RangeSpecifier(min=v, include_min=False), + ), + simplified=str(spec), + ) + else: + version_parts = list( + itertools.takewhile(lambda x: x != "*", version_split(version)) + ) + left = Version(".".join([*version_parts, "0"])) + version_parts[-1] = str(int(version_parts[-1]) + 1) + right = Version(".".join([*version_parts, "0"])) + return UnionSpecifier( + ( + RangeSpecifier(max=left, include_max=False), + RangeSpecifier(min=right, include_min=True), + ), + simplified=str(spec), + ) + else: + raise InvalidSpecifier(f'Unsupported operator "{op}" in specifier "{spec}"') + return RangeSpecifier( + min=min, + max=max, + include_min=include_min, + include_max=include_max, + simplified=str(spec), + ) + + +def parse_version_specifier(spec: str) -> VersionSpecifier: + """Parse a specifier string.""" + if spec == "": + return EmptySpecifier() + if "||" in spec: + return functools.reduce( + operator.or_, map(parse_version_specifier, spec.split("||")) + ) + return from_specifierset(SpecifierSet(spec)) + + +__all__ = [ + "from_specifierset", + "parse_version_specifier", + "VersionSpecifier", + "EmptySpecifier", + "AnySpecifier", + "RangeSpecifier", + "UnionSpecifier", + "BaseSpecifier", + "GenericSpecifier", + "InvalidSpecifier", +] diff --git a/src/pkg_logical/specifiers/base.py b/src/dep_logic/specifiers/base.py similarity index 81% rename from src/pkg_logical/specifiers/base.py rename to src/dep_logic/specifiers/base.py index 356b9f7..af7891c 100644 --- a/src/pkg_logical/specifiers/base.py +++ b/src/dep_logic/specifiers/base.py @@ -9,6 +9,10 @@ UnparsedVersion = t.Union[Version, str] +class InvalidSpecifier(ValueError): + pass + + class BaseSpecifier(metaclass=abc.ABCMeta): @abc.abstractmethod def __str__(self) -> str: @@ -44,22 +48,39 @@ def __or__(self, other: t.Any) -> BaseSpecifier: def __invert__(self) -> BaseSpecifier: raise NotImplementedError + def is_simple(self) -> bool: + return False + + def __repr__(self) -> str: + return f"<{self.__class__.__name__} {self}>" + + def is_empty(self) -> bool: + return False + + def is_any(self) -> bool: + return False + + @abc.abstractmethod + def __contains__(self, value: str) -> bool: + raise NotImplementedError + + +class VersionSpecifier(BaseSpecifier): @abc.abstractmethod def contains( self, version: UnparsedVersion, prerelease: bool | None = None ) -> bool: raise NotImplementedError + @property + @abc.abstractmethod + def num_parts(self) -> int: + raise NotImplementedError + def __contains__(self, version: UnparsedVersion) -> bool: return self.contains(version) - def __repr__(self) -> str: - return f"<{self.__class__.__name__} {self}>" - @abc.abstractmethod def to_specifierset(self) -> SpecifierSet: """Convert to a packaging.specifiers.SpecifierSet object.""" raise NotImplementedError - - def is_simple(self) -> bool: - return False diff --git a/src/dep_logic/specifiers/generic.py b/src/dep_logic/specifiers/generic.py new file mode 100644 index 0000000..92aefdc --- /dev/null +++ b/src/dep_logic/specifiers/generic.py @@ -0,0 +1,102 @@ +import operator +import typing as t +from dataclasses import dataclass + +from dep_logic.specifiers.base import BaseSpecifier, InvalidSpecifier +from dep_logic.specifiers.special import AnySpecifier, EmptySpecifier + +Operator = t.Callable[[str, str], bool] + + +@dataclass(frozen=True, slots=True, unsafe_hash=True, repr=False) +class GenericSpecifier(BaseSpecifier): + op: str + value: str + op_order: t.ClassVar[dict[str, int]] = {"==": 0, "!=": 1, "in": 2, "not in": 3} + _op_map: t.ClassVar[dict[str, Operator]] = { + "==": operator.eq, + "!=": operator.ne, + "in": lambda lhs, rhs: lhs in rhs, + "not in": lambda lhs, rhs: lhs not in rhs, + ">": operator.gt, + ">=": operator.ge, + "<": operator.lt, + "<=": operator.le, + } + + def __post_init__(self) -> None: + if self.op not in self._op_map: + raise InvalidSpecifier(f"Invalid operator: {self.op!r}") + + def __str__(self) -> str: + return f'{self.op} "{self.value}"' + + def __invert__(self) -> BaseSpecifier: + invert_map = { + "==": "!=", + "!=": "==", + "not in": "in", + "in": "not in", + "<": ">=", + "<=": ">", + ">": "<=", + ">=": "<", + } + op = invert_map[self.op] + return GenericSpecifier(op, self.value) + + def __and__(self, other: t.Any) -> BaseSpecifier: + if not isinstance(other, GenericSpecifier): + return NotImplemented + if self == other: + return self + this, that = sorted( + (self, other), key=lambda x: self.op_order.get(x.op, len(self.op_order)) + ) + if this.op == that.op == "==": + # left must be different from right + return EmptySpecifier() + elif (this.op, that.op) == ("==", "!="): + if this.value == that.value: + return EmptySpecifier() + return this + elif (this.op, that.op) == ("in", "not in") and this.value == that.value: + return EmptySpecifier() + elif (this.op, that.op) == ("==", "in"): + if this.value in that.value: + return this + return EmptySpecifier() + elif (this.op, that.op) == ("!=", "not in") and this.value in that.value: + return that + else: + raise NotImplementedError + + def __or__(self, other: t.Any) -> BaseSpecifier: + if not isinstance(other, GenericSpecifier): + return NotImplemented + if self == other: + return self + this, that = sorted( + (self, other), key=lambda x: self.op_order.get(x.op, len(self.op_order)) + ) + if this.op == "==" and that.op == "!=": + if this.value == that.value: + return AnySpecifier() + return that + elif this.op == "!=" and that.op == "!=": + return AnySpecifier() + elif this.op == "in" and that.op == "not in" and this.value == that.value: + return AnySpecifier() + elif this.op == "!=" and that.op == "in" and this.value in that.value: + return AnySpecifier() + elif this.op == "!=" and that.op == "not in": + if this.value in that.value: + return this + return AnySpecifier() + elif this.op == "==" and that.op == "in" and this.value in that.value: + return that + else: + raise NotImplementedError + + def __contains__(self, value: str) -> bool: + return self._op_map[self.op](value, self.value) diff --git a/src/pkg_logical/specifiers/range.py b/src/dep_logic/specifiers/range.py similarity index 91% rename from src/pkg_logical/specifiers/range.py rename to src/dep_logic/specifiers/range.py index a51aab0..2d2627c 100644 --- a/src/pkg_logical/specifiers/range.py +++ b/src/dep_logic/specifiers/range.py @@ -7,13 +7,17 @@ from packaging.specifiers import SpecifierSet from packaging.version import Version -from pkg_logical.specifiers.base import BaseSpecifier, UnparsedVersion -from pkg_logical.specifiers.empty import EmptySpecifier -from pkg_logical.utils import first_different_index, pad_zeros +from dep_logic.specifiers.base import ( + InvalidSpecifier, + UnparsedVersion, + VersionSpecifier, +) +from dep_logic.specifiers.special import EmptySpecifier +from dep_logic.utils import first_different_index, pad_zeros @dataclass(frozen=True, slots=True, unsafe_hash=True, repr=False) -class RangeSpecifier(BaseSpecifier): +class RangeSpecifier(VersionSpecifier): min: Version | None = None max: Version | None = None include_min: bool = False @@ -22,13 +26,17 @@ class RangeSpecifier(BaseSpecifier): def __post_init__(self) -> None: if self.min is None and self.include_min: - raise ValueError("Cannot include min when min is None") + raise InvalidSpecifier("Cannot include min when min is None") if self.max is None and self.include_max: - raise ValueError("Cannot include max when max is None") + raise InvalidSpecifier("Cannot include max when max is None") def to_specifierset(self) -> SpecifierSet: return SpecifierSet(str(self)) + @property + def num_parts(self) -> int: + return len(self.to_specifierset()) + @cached_property def _simplified_form(self) -> str | None: if self.simplified is not None: @@ -63,6 +71,7 @@ def _simplified_form(self) -> str | None: if ( all(p == 0 for p in max_stable[first_different + 1 :]) and not self.max.is_prerelease + and len(self.min.release) == first_different + 1 ): return f"~={self.min}" return None @@ -78,8 +87,8 @@ def contains( ) -> bool: return self.to_specifierset().contains(version, prerelease) - def __invert__(self) -> BaseSpecifier: - from pkg_logical.specifiers.union import UnionSpecifier + def __invert__(self) -> VersionSpecifier: + from dep_logic.specifiers.union import UnionSpecifier if self.min is None and self.max is None: return EmptySpecifier() @@ -216,8 +225,8 @@ def __and__(self, other: Any) -> RangeSpecifier | EmptySpecifier: include_max=intersect_include_max, ) - def __or__(self, other: Any) -> BaseSpecifier: - from pkg_logical.specifiers.union import UnionSpecifier + def __or__(self, other: Any) -> VersionSpecifier: + from dep_logic.specifiers.union import UnionSpecifier if not isinstance(other, RangeSpecifier): return NotImplemented diff --git a/src/dep_logic/specifiers/special.py b/src/dep_logic/specifiers/special.py new file mode 100644 index 0000000..2c4559b --- /dev/null +++ b/src/dep_logic/specifiers/special.py @@ -0,0 +1,75 @@ +import typing as t + +from dep_logic.specifiers.base import BaseSpecifier + + +class EmptySpecifier(BaseSpecifier): + def __invert__(self) -> BaseSpecifier: + return AnySpecifier() + + def __and__(self, other: t.Any) -> BaseSpecifier: + if not isinstance(other, BaseSpecifier): + return NotImplemented + return self + + __rand__ = __and__ + + def __or__(self, other: t.Any) -> BaseSpecifier: + if not isinstance(other, BaseSpecifier): + return NotImplemented + return other + + __ror__ = __or__ + + def __str__(self) -> str: + return "" + + def __hash__(self) -> int: + return hash(str(self)) + + def __eq__(self, other: object) -> bool: + if not isinstance(other, BaseSpecifier): + return NotImplemented + return isinstance(other, EmptySpecifier) + + def is_empty(self) -> bool: + return True + + def __contains__(self, value: str) -> bool: + return True + + +class AnySpecifier(BaseSpecifier): + def __invert__(self) -> BaseSpecifier: + return EmptySpecifier() + + def __and__(self, other: t.Any) -> BaseSpecifier: + if not isinstance(other, BaseSpecifier): + return NotImplemented + return other + + __rand__ = __and__ + + def __or__(self, other: t.Any) -> BaseSpecifier: + if not isinstance(other, BaseSpecifier): + return NotImplemented + return self + + __ror__ = __or__ + + def __str__(self) -> str: + return "" + + def __hash__(self) -> int: + return hash(str(self)) + + def __eq__(self, other: object) -> bool: + if not isinstance(other, BaseSpecifier): + return NotImplemented + return other.is_any() + + def is_any(self) -> bool: + return True + + def __contains__(self, value: str) -> bool: + return False diff --git a/src/pkg_logical/specifiers/union.py b/src/dep_logic/specifiers/union.py similarity index 61% rename from src/pkg_logical/specifiers/union.py rename to src/dep_logic/specifiers/union.py index 57cff80..ac25d0f 100644 --- a/src/pkg_logical/specifiers/union.py +++ b/src/dep_logic/specifiers/union.py @@ -7,19 +7,25 @@ from packaging.specifiers import SpecifierSet -from pkg_logical.specifiers.base import BaseSpecifier, UnparsedVersion -from pkg_logical.specifiers.empty import EmptySpecifier -from pkg_logical.specifiers.range import RangeSpecifier -from pkg_logical.utils import first_different_index, pad_zeros +from dep_logic.specifiers.base import UnparsedVersion, VersionSpecifier +from dep_logic.specifiers.range import RangeSpecifier +from dep_logic.specifiers.special import EmptySpecifier +from dep_logic.utils import first_different_index, pad_zeros @dataclass(frozen=True, slots=True, unsafe_hash=True, repr=False) -class UnionSpecifier(BaseSpecifier): +class UnionSpecifier(VersionSpecifier): ranges: tuple[RangeSpecifier, ...] simplified: str | None = field(default=None, compare=False, hash=False) def to_specifierset(self) -> SpecifierSet: - raise ValueError("Cannot convert UnionSpecifier to SpecifierSet") + if (simplified := self._simplified_form) is None: + raise ValueError("Cannot convert UnionSpecifier to SpecifierSet") + return SpecifierSet(simplified) + + @property + def num_parts(self) -> int: + return sum(range.num_parts for range in self.ranges) @cached_property def _simplified_form(self) -> str | None: @@ -29,44 +35,46 @@ def _simplified_form(self) -> str | None: left, right, *rest = self.ranges if rest: return None - match left, right: - case RangeSpecifier( - min=None, max=left_max, include_max=False - ), RangeSpecifier( - min=right_min, max=None, include_min=False - ) if left_max == right_min and left_max is not None: - return f"!={left_max}" - case RangeSpecifier( - min=None, max=left_max, include_max=False - ), RangeSpecifier( - min=right_min, max=None, include_min=True - ) if left_max is not None and right_min is not None: - if left_max.is_prerelease or right_min.is_prerelease: - return None - left_stable = [left_max.epoch, *left_max.release] - right_stable = [right_min.epoch, *right_min.release] - max_length = max(len(left_stable), len(right_stable)) - left_stable = pad_zeros(left_stable, max_length) - right_stable = pad_zeros(right_stable, max_length) - first_different = first_different_index(left_stable, right_stable) - if ( - first_different > 0 - and right_stable[first_different] - left_stable[first_different] - == 1 - and set( - left_stable[first_different + 1 :] - + right_stable[first_different + 1 :] - ) - == {0} - ): - epoch = "" if left_max.epoch == 0 else f"{left_max.epoch}!" - version = ( - ".".join(map(str, left_max.release[:first_different])) + ".*" - ) - return f"!={epoch}{version}" - return None - case _: + if ( + left.min is None + and right.max is None + and left.max == right.min + and left.max is not None + ): + # (-inf, version) | (version, inf) => != version + return f"!={left.max}" + + if ( + left.min is None + and right.max is None + and not left.include_max + and right.include_min + and left.max is not None + and right.min is not None + ): + # (-inf, X.Y.0) | [X.Y+1.0, inf) => != X.Y.* + if left.max.is_prerelease or right.min.is_prerelease: return None + left_stable = [left.max.epoch, *left.max.release] + right_stable = [right.min.epoch, *right.min.release] + max_length = max(len(left_stable), len(right_stable)) + left_stable = pad_zeros(left_stable, max_length) + right_stable = pad_zeros(right_stable, max_length) + first_different = first_different_index(left_stable, right_stable) + if ( + first_different > 0 + and right_stable[first_different] - left_stable[first_different] == 1 + and set( + left_stable[first_different + 1 :] + + right_stable[first_different + 1 :] + ) + == {0} + ): + epoch = "" if left.max.epoch == 0 else f"{left.max.epoch}!" + version = ".".join(map(str, left.max.release[:first_different])) + ".*" + return f"!={epoch}{version}" + + return None def __str__(self) -> str: if self._simplified_form is not None: @@ -74,14 +82,13 @@ def __str__(self) -> str: return "||".join(map(str, self.ranges)) @staticmethod - def _from_ranges(ranges: t.Sequence[RangeSpecifier]) -> BaseSpecifier: - match len(ranges): - case 0: - return EmptySpecifier() - case 1: - return ranges[0] - case _: - return UnionSpecifier(tuple(ranges)) + def _from_ranges(ranges: t.Sequence[RangeSpecifier]) -> VersionSpecifier: + if (ranges_number := len(ranges)) == 0: + return EmptySpecifier() + elif ranges_number == 1: + return ranges[0] + else: + return UnionSpecifier(tuple(ranges)) def is_simple(self) -> bool: return self._simplified_form is not None @@ -91,7 +98,7 @@ def contains( ) -> bool: return any(specifier.contains(version, prerelease) for specifier in self.ranges) - def __invert__(self) -> BaseSpecifier: + def __invert__(self) -> VersionSpecifier: to_union: list[RangeSpecifier] = [] if (first := self.ranges[0]).min is not None: to_union.append( @@ -112,7 +119,7 @@ def __invert__(self) -> BaseSpecifier: ) return self._from_ranges(to_union) - def __and__(self, other: t.Any) -> BaseSpecifier: + def __and__(self, other: t.Any) -> VersionSpecifier: if isinstance(other, RangeSpecifier): if other.is_any(): return self @@ -135,7 +142,7 @@ def __and__(self, other: t.Any) -> BaseSpecifier: __rand__ = __and__ - def __or__(self, other: t.Any) -> BaseSpecifier: + def __or__(self, other: t.Any) -> VersionSpecifier: if isinstance(other, RangeSpecifier): if other.is_any(): return other diff --git a/src/dep_logic/utils.py b/src/dep_logic/utils.py new file mode 100644 index 0000000..839034f --- /dev/null +++ b/src/dep_logic/utils.py @@ -0,0 +1,180 @@ +from __future__ import annotations + +import functools +import itertools +import re +from typing import AbstractSet, Iterable, Iterator, Literal, Protocol, TypeVar + +from dep_logic.markers.base import BaseMarker + +_prefix_regex = re.compile(r"^([0-9]+)((?:a|b|c|rc)[0-9]+)$") + + +class Unique(Protocol): + def __hash__(self) -> int: + ... + + def __eq__(self, __value: object) -> bool: + ... + + +T = TypeVar("T", bound=Unique) +V = TypeVar("V") + + +def version_split(version: str) -> list[str]: + result: list[str] = [] + for item in version.split("."): + match = _prefix_regex.search(item) + if match: + result.extend(match.groups()) + else: + result.append(item) + return result + + +def is_not_suffix(segment: str) -> bool: + return not any( + segment.startswith(prefix) for prefix in ("dev", "a", "b", "rc", "post") + ) + + +def flatten_items(items: Iterable[T], flatten_cls: type[Iterable[T]]) -> list[T]: + flattened: list[T] = [] + for item in items: + if isinstance(item, flatten_cls): + for subitem in flatten_items(item, flatten_cls): + if subitem not in flattened: + flattened.append(subitem) + elif item not in flattened: + flattened.append(item) + return flattened + + +def first_different_index( + iterable1: Iterable[object], iterable2: Iterable[object] +) -> int: + for index, (item1, item2) in enumerate(zip(iterable1, iterable2)): + if item1 != item2: + return index + return index + 1 + + +def pad_zeros(parts: list[V], to_length: int) -> list[V | Literal[0]]: + if len(parts) >= to_length: + return parts + return parts + [0] * (to_length - len(parts)) + + +@functools.lru_cache(maxsize=None) +def cnf(marker: BaseMarker) -> BaseMarker: + from dep_logic.markers.multi import MultiMarker + from dep_logic.markers.union import MarkerUnion + + """Transforms the marker into CNF (conjunctive normal form).""" + if isinstance(marker, MarkerUnion): + cnf_markers = [cnf(m) for m in marker.markers] + sub_marker_lists = [ + m.markers if isinstance(m, MultiMarker) else [m] for m in cnf_markers + ] + return MultiMarker.of( + *[MarkerUnion.of(*c) for c in itertools.product(*sub_marker_lists)] + ) + + if isinstance(marker, MultiMarker): + return MultiMarker.of(*[cnf(m) for m in marker.markers]) + + return marker + + +@functools.lru_cache(maxsize=None) +def dnf(marker: BaseMarker) -> BaseMarker: + """Transforms the marker into DNF (disjunctive normal form).""" + from dep_logic.markers.multi import MultiMarker + from dep_logic.markers.union import MarkerUnion + + if isinstance(marker, MultiMarker): + dnf_markers = [dnf(m) for m in marker.markers] + sub_marker_lists = [ + m.markers if isinstance(m, MarkerUnion) else [m] for m in dnf_markers + ] + return MarkerUnion.of( + *[MultiMarker.of(*c) for c in itertools.product(*sub_marker_lists)] + ) + + if isinstance(marker, MarkerUnion): + return MarkerUnion.of(*[dnf(m) for m in marker.markers]) + + return marker + + +def intersection(*markers: BaseMarker) -> BaseMarker: + from dep_logic.markers.multi import MultiMarker + + return dnf(MultiMarker(*markers)) + + +def union(*markers: BaseMarker) -> BaseMarker: + from dep_logic.markers.multi import MultiMarker + from dep_logic.markers.union import MarkerUnion + + # 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 + + disjunction = dnf(conjunction) + if not isinstance(disjunction, MarkerUnion): + return disjunction + + return min(disjunction, conjunction, unnormalized, key=lambda x: x.complexity) + + +_op_reflect_map = { + "<": ">", + "<=": ">=", + ">": "<", + ">=": "<=", + "==": "==", + "!=": "!=", + "===": "===", + "~=": "~=", + "in": "in", + "not in": "not in", +} + + +def get_reflect_op(op: str) -> str: + return _op_reflect_map[op] + + +class OrderedSet(AbstractSet[T]): + def __init__(self, iterable: Iterable[T]) -> None: + self._data: list[T] = [] + for item in iterable: + if item in self._data: + continue + self._data.append(item) + + def __hash__(self) -> int: + return self._hash() + + def __contains__(self, obj: object) -> bool: + return obj in self._data + + def __iter__(self) -> Iterator[T]: + return iter(self._data) + + def __len__(self) -> int: + return len(self._data) + + def peek(self) -> T: + return self._data[0] diff --git a/src/pkg_logical/specifiers/__init__.py b/src/pkg_logical/specifiers/__init__.py deleted file mode 100644 index 031d6ef..0000000 --- a/src/pkg_logical/specifiers/__init__.py +++ /dev/null @@ -1,113 +0,0 @@ -from __future__ import annotations - -import functools -import itertools -import operator - -from packaging.specifiers import Specifier, SpecifierSet -from packaging.version import Version - -from pkg_logical.specifiers.base import BaseSpecifier -from pkg_logical.specifiers.empty import EmptySpecifier -from pkg_logical.specifiers.range import RangeSpecifier -from pkg_logical.specifiers.union import UnionSpecifier -from pkg_logical.utils import is_not_suffix, version_split - - -def from_specifierset(spec: SpecifierSet) -> BaseSpecifier: - """Convert from a packaging.specifiers.SpecifierSet object.""" - - return functools.reduce( - operator.and_, map(_from_pkg_specifier, spec), RangeSpecifier() - ) - - -def _from_pkg_specifier(spec: Specifier) -> BaseSpecifier: - version = spec.version - min: Version | None = None - max: Version | None = None - include_min = False - include_max = False - match spec.operator: - case ">" | ">=": - min = Version(version) - include_min = spec.operator == ">=" - case "<" | "<=": - max = Version(version) - include_max = spec.operator == "<=" - case "==": - if "*" not in version: - min = Version(version) - max = Version(version) - include_min = True - include_max = True - else: - version_parts = list( - itertools.takewhile(lambda x: x != "*", version_split(version)) - ) - min = Version(".".join([*version_parts, "0"])) - version_parts[-1] = str(int(version_parts[-1]) + 1) - max = Version(".".join([*version_parts, "0"])) - include_min = True - include_max = False - case "~=": - min = Version(version) - version_parts = list( - itertools.takewhile(is_not_suffix, version_split(version)) - )[:-1] - version_parts[-1] = str(int(version_parts[-1]) + 1) - max = Version(".".join([*version_parts, "0"])) - include_min = True - include_max = False - case "!=": - if "*" not in version: - v = Version(version) - return UnionSpecifier( - ( - RangeSpecifier(max=v, include_max=False), - RangeSpecifier(min=v, include_min=False), - ), - simplified=str(spec), - ) - else: - version_parts = list( - itertools.takewhile(lambda x: x != "*", version_split(version)) - ) - left = Version(".".join([*version_parts, "0"])) - version_parts[-1] = str(int(version_parts[-1]) + 1) - right = Version(".".join([*version_parts, "0"])) - return UnionSpecifier( - ( - RangeSpecifier(max=left, include_max=False), - RangeSpecifier(min=right, include_min=True), - ), - simplified=str(spec), - ) - case op: - raise ValueError(f'Unsupported operator "{op}" in specifier "{spec}"') - return RangeSpecifier( - min=min, - max=max, - include_min=include_min, - include_max=include_max, - simplified=str(spec), - ) - - -def parse(spec: str) -> BaseSpecifier: - """Parse a specifier string.""" - if spec == "": - return EmptySpecifier() - if "||" in spec: - return functools.reduce(operator.or_, map(parse, spec.split("||"))) - return from_specifierset(SpecifierSet(spec)) - - -__all__ = [ - "from_specifierset", - "parse", - "BaseSpecifier", - "EmptySpecifier", - "RangeSpecifier", - "UnionSpecifier", -] diff --git a/src/pkg_logical/specifiers/empty.py b/src/pkg_logical/specifiers/empty.py deleted file mode 100644 index 603c7db..0000000 --- a/src/pkg_logical/specifiers/empty.py +++ /dev/null @@ -1,43 +0,0 @@ -import typing as t - -from pkg_logical.specifiers.base import BaseSpecifier, UnparsedVersion - - -class EmptySpecifier(BaseSpecifier): - def __invert__(self) -> BaseSpecifier: - from pkg_logical.specifiers.range import RangeSpecifier - - return RangeSpecifier() - - def __and__(self, other: t.Any) -> BaseSpecifier: - if not isinstance(other, BaseSpecifier): - return NotImplemented - return self - - __rand__ = __and__ - - def __or__(self, other: t.Any) -> BaseSpecifier: - if not isinstance(other, BaseSpecifier): - return NotImplemented - return other - - __ror__ = __or__ - - def __str__(self) -> str: - return "" - - def __hash__(self) -> int: - return hash(str(self)) - - def __eq__(self, other: object) -> bool: - if not isinstance(other, BaseSpecifier): - return NotImplemented - return isinstance(other, EmptySpecifier) - - def contains( - self, version: UnparsedVersion, prerelease: bool | None = None - ) -> bool: - return False - - def to_specifierset(self) -> t.Any: - raise NotImplementedError("Cannot convert EmptySpecifier to SpecifierSet") diff --git a/src/pkg_logical/utils.py b/src/pkg_logical/utils.py deleted file mode 100644 index 4b62c93..0000000 --- a/src/pkg_logical/utils.py +++ /dev/null @@ -1,62 +0,0 @@ -from __future__ import annotations - -import re -from typing import Iterable, Literal, Protocol, TypeVar - -_prefix_regex = re.compile(r"^([0-9]+)((?:a|b|c|rc)[0-9]+)$") - - -class Unique(Protocol): - def __hash__(self) -> int: - ... - - def __eq__(self, __value: object) -> bool: - ... - - -T = TypeVar("T", bound=Unique) -V = TypeVar("V") - - -def version_split(version: str) -> list[str]: - result: list[str] = [] - for item in version.split("."): - match = _prefix_regex.search(item) - if match: - result.extend(match.groups()) - else: - result.append(item) - return result - - -def is_not_suffix(segment: str) -> bool: - return not any( - segment.startswith(prefix) for prefix in ("dev", "a", "b", "rc", "post") - ) - - -def flatten_items(items: Iterable[T], flatten_cls: type[Iterable[T]]) -> list[T]: - flattened: list[T] = [] - for item in items: - if isinstance(item, flatten_cls): - for subitem in flatten_items(item, flatten_cls): - if subitem not in flattened: - flattened.append(subitem) - elif item not in flattened: - flattened.append(item) - return flattened - - -def first_different_index( - iterable1: Iterable[object], iterable2: Iterable[object] -) -> int: - for index, (item1, item2) in enumerate(zip(iterable1, iterable2)): - if item1 != item2: - return index - return index + 1 - - -def pad_zeros(parts: list[V], to_length: int) -> list[V | Literal[0]]: - if len(parts) >= to_length: - return parts - return parts + [0] * (to_length - len(parts)) diff --git a/tests/marker/test_common.py b/tests/marker/test_common.py new file mode 100644 index 0000000..02f284e --- /dev/null +++ b/tests/marker/test_common.py @@ -0,0 +1,157 @@ +import pytest + +from dep_logic.markers import parse_marker + + +@pytest.mark.parametrize( + "marker, expected", + [ + ('python_version >= "3.6"', 'python_version >= "3.6"'), + ('python_version >= "3.6" and extra == "foo"', 'python_version >= "3.6"'), + ( + 'python_version >= "3.6" and (extra == "foo" or extra == "bar")', + 'python_version >= "3.6"', + ), + ( + ( + 'python_version >= "3.6" and (extra == "foo" or extra == "bar") or' + ' implementation_name == "pypy"' + ), + 'python_version >= "3.6" or implementation_name == "pypy"', + ), + ( + ( + 'python_version >= "3.6" and extra == "foo" or implementation_name ==' + ' "pypy" and extra == "bar"' + ), + 'python_version >= "3.6" or implementation_name == "pypy"', + ), + ( + ( + '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"', ""), + ], +) +def test_without_extras(marker: str, expected: str) -> None: + m = parse_marker(marker) + + assert str(m.without_extras()) == expected + + +@pytest.mark.parametrize( + "marker, excluded, expected", + [ + ('python_version >= "3.6"', "implementation_name", 'python_version >= "3.6"'), + ('python_version >= "3.6"', "python_version", "*"), + ('python_version >= "3.6" and python_version < "3.11"', "python_version", "*"), + ( + 'python_version >= "3.6" and extra == "foo"', + "extra", + 'python_version >= "3.6"', + ), + ( + 'python_version >= "3.6" and (extra == "foo" or extra == "bar")', + "python_version", + 'extra == "foo" or extra == "bar"', + ), + ( + ( + 'python_version >= "3.6" and (extra == "foo" or extra == "bar") or' + ' implementation_name == "pypy"' + ), + "python_version", + 'extra == "foo" or extra == "bar" or implementation_name == "pypy"', + ), + ( + ( + 'python_version >= "3.6" and extra == "foo" or implementation_name ==' + ' "pypy" and extra == "bar"' + ), + "implementation_name", + 'python_version >= "3.6" and extra == "foo" or extra == "bar"', + ), + ( + ( + 'python_version >= "3.6" or extra == "foo" and implementation_name ==' + ' "pypy" or extra == "bar"' + ), + "implementation_name", + 'python_version >= "3.6" or extra == "foo" or extra == "bar"', + ), + ( + 'extra == "foo" and python_version >= "3.6" or python_version >= "3.6"', + "extra", + 'python_version >= "3.6"', + ), + ], +) +def test_exclude(marker: str, excluded: str, expected: str) -> None: + m = parse_marker(marker) + + if expected == "*": + assert m.exclude(excluded).is_any() + else: + assert str(m.exclude(excluded)) == expected + + +@pytest.mark.parametrize( + "marker, only, expected", + [ + ('python_version >= "3.6"', ["python_version"], 'python_version >= "3.6"'), + ('python_version >= "3.6"', ["sys_platform"], ""), + ( + 'python_version >= "3.6" and extra == "foo"', + ["python_version"], + 'python_version >= "3.6"', + ), + ('python_version >= "3.6" and extra == "foo"', ["sys_platform"], ""), + ('python_version >= "3.6" or extra == "foo"', ["sys_platform"], ""), + ('python_version >= "3.6" or extra == "foo"', ["python_version"], ""), + ( + 'python_version >= "3.6" and (extra == "foo" or extra == "bar")', + ["extra"], + 'extra == "foo" or extra == "bar"', + ), + ( + ( + 'python_version >= "3.6" and (extra == "foo" or extra == "bar") or' + ' implementation_name == "pypy"' + ), + ["implementation_name"], + "", + ), + ( + ( + 'python_version >= "3.6" and (extra == "foo" or extra == "bar") or' + ' implementation_name == "pypy"' + ), + ["implementation_name", "extra"], + 'extra == "foo" or extra == "bar" or implementation_name == "pypy"', + ), + ( + ( + 'python_version >= "3.6" and (extra == "foo" or extra == "bar") or' + ' implementation_name == "pypy"' + ), + ["implementation_name", "python_version"], + 'python_version >= "3.6" or implementation_name == "pypy"', + ), + ( + ( + 'python_version >= "3.6" and extra == "foo" or implementation_name ==' + ' "pypy" and extra == "bar"' + ), + ["implementation_name", "extra"], + 'extra == "foo" or implementation_name == "pypy" and extra == "bar"', + ), + ], +) +def test_only(marker: str, only: list[str], expected: str) -> None: + m = parse_marker(marker) + + assert str(m.only(*only)) == expected diff --git a/tests/marker/test_compound.py b/tests/marker/test_compound.py new file mode 100644 index 0000000..c4191b4 --- /dev/null +++ b/tests/marker/test_compound.py @@ -0,0 +1,781 @@ +import pytest + +from dep_logic.markers import MarkerUnion, MultiMarker, parse_marker +from dep_logic.markers.empty import EmptyMarker +from dep_logic.utils import union + +EMPTY = "" + + +def test_multi_marker() -> None: + m = parse_marker('sys_platform == "darwin" and implementation_name == "cpython"') + + assert isinstance(m, MultiMarker) + assert m.markers == ( + parse_marker('sys_platform == "darwin"'), + parse_marker('implementation_name == "cpython"'), + ) + + +def test_multi_marker_is_empty_is_contradictory() -> None: + m = parse_marker( + 'sys_platform == "linux" and python_version >= "3.5" and python_version < "2.8"' + ) + + assert m.is_empty() + + m = parse_marker('sys_platform == "linux" and sys_platform == "win32"') + + assert m.is_empty() + + +def test_multi_complex_multi_marker_is_empty() -> None: + m1 = parse_marker( + 'python_full_version >= "3.0.0" and python_full_version < "3.4.0"' + ) + m2 = parse_marker( + 'python_version >= "3.6" and python_full_version < "3.0.0" and python_version <' + ' "3.7"' + ) + m3 = parse_marker( + 'python_version >= "3.6" and python_version < "3.7" and python_full_version >=' + ' "3.5.0"' + ) + + m = m1 & (m2 | m3) + + assert m.is_empty() + + +def test_multi_marker_is_any() -> None: + m1 = parse_marker('python_version != "3.6" or python_version == "3.6"') + m2 = parse_marker('python_version != "3.7" or python_version == "3.7"') + + assert m1 & m2.is_any() + assert m2 & m1.is_any() + + +def test_multi_marker_intersect_multi() -> None: + m = parse_marker('sys_platform == "darwin" and implementation_name == "cpython"') + + intersection = m & ( + parse_marker('python_version >= "3.6" and os_name == "Windows"') + ) + assert ( + str(intersection) + == 'sys_platform == "darwin" and implementation_name == "cpython" ' + 'and python_version >= "3.6" and os_name == "Windows"' + ) + + +def test_multi_marker_intersect_multi_with_overlapping_constraints() -> None: + m = parse_marker('sys_platform == "darwin" and python_version < "3.6"') + + intersection = m & ( + parse_marker( + 'python_version <= "3.4" and os_name == "Windows" and sys_platform ==' + ' "darwin"' + ) + ) + assert ( + str(intersection) + == 'sys_platform == "darwin" and python_version <= "3.4" and os_name ==' + ' "Windows"' + ) + + +def test_multi_marker_intersect_with_union_drops_union() -> None: + m = parse_marker('python_version >= "3" and python_version < "4"') + m2 = parse_marker('python_version < "2" or python_version >= "3"') + assert str(m & m2) == str(m) + assert str(m2 & m) == str(m) + + +def test_multi_marker_intersect_with_multi_union_leads_to_empty_in_one_step() -> None: + # empty marker in one step + # py == 2 and (py < 2 or py >= 3) -> empty + m = parse_marker('sys_platform == "darwin" and python_version == "2"') + m2 = parse_marker( + 'sys_platform == "darwin" and (python_version < "2" or python_version >= "3")' + ) + assert (m & m2).is_empty() + assert (m2 & m).is_empty() + + +def test_multi_marker_intersect_with_multi_union_leads_to_empty_in_two_steps() -> None: + # empty marker in two steps + # py >= 2 and (py < 2 or py >= 3) -> py >= 3 + # py < 3 and py >= 3 -> empty + m = parse_marker('python_version >= "2" and python_version < "3"') + m2 = parse_marker( + 'sys_platform == "darwin" and (python_version < "2" or python_version >= "3")' + ) + assert (m & m2).is_empty() + assert (m2 & m).is_empty() + + +def test_multi_marker_union_multi() -> None: + m = parse_marker('sys_platform == "darwin" and implementation_name == "cpython"') + + union = m | parse_marker('python_version >= "3.6" and os_name == "Windows"') + assert ( + str(union) == 'sys_platform == "darwin" and implementation_name == "cpython" ' + 'or python_version >= "3.6" and os_name == "Windows"' + ) + + +def test_multi_marker_union_multi_is_single_marker() -> None: + m = parse_marker('python_version >= "3" and sys_platform == "win32"') + m2 = parse_marker('sys_platform != "win32" and python_version >= "3"') + assert str(m | m2) == 'python_version >= "3"' + assert str(m2 | m) == 'python_version >= "3"' + + +@pytest.mark.parametrize( + "marker1, marker2, expected", + [ + ( + 'python_version >= "3" and sys_platform == "win32"', + ( + 'python_version >= "3" and sys_platform != "win32" and sys_platform !=' + ' "linux"' + ), + 'python_version >= "3" and sys_platform != "linux"', + ), + ( + ( + 'python_version >= "3.8" and python_version < "4.0" and sys_platform ==' + ' "win32"' + ), + 'python_version >= "3.8" and python_version < "4.0"', + 'python_version ~= "3.8"', + ), + ], +) +def test_multi_marker_union_multi_is_multi( + marker1: str, marker2: str, expected: str +) -> None: + m1 = parse_marker(marker1) + m2 = parse_marker(marker2) + assert str(m1 | m2) == expected + assert str(m2 | m1) == expected + + +@pytest.mark.parametrize( + "marker1, marker2, expected", + [ + # Ranges with same start + ( + 'python_version >= "3.6" and python_full_version < "3.6.2"', + 'python_version >= "3.6" and python_version < "3.7"', + 'python_version >= "3.6" and python_version < "3.7"', + ), + ( + 'python_version > "3.6" and python_full_version < "3.6.2"', + 'python_version > "3.6" and python_version < "3.7"', + 'python_version > "3.6" and python_version < "3.7"', + ), + # Ranges meet exactly + ( + 'python_version >= "3.6" and python_full_version < "3.6.2"', + 'python_full_version >= "3.6.2" and python_version < "3.7"', + 'python_version >= "3.6" and python_full_version < "3.7.0"', + ), + ( + 'python_version >= "3.6" and python_full_version <= "3.6.2"', + 'python_full_version > "3.6.2" and python_version < "3.7"', + 'python_version >= "3.6" and python_version < "3.7"', + ), + # Ranges overlap + ( + 'python_version >= "3.6" and python_full_version <= "3.6.8"', + 'python_full_version >= "3.6.2" and python_version < "3.7"', + 'python_version >= "3.6" and python_full_version < "3.7.0"', + ), + # Ranges with same end. + ( + 'python_version >= "3.6" and python_version < "3.7"', + 'python_full_version >= "3.6.2" and python_version < "3.7"', + 'python_version >= "3.6" and python_version < "3.7"', + ), + ( + 'python_version >= "3.6" and python_version <= "3.7"', + 'python_full_version >= "3.6.2" and python_version <= "3.7"', + 'python_version >= "3.6" and python_version <= "3.7"', + ), + # A range covers an exact marker. + ( + 'python_version >= "3.6" and python_version <= "3.7"', + 'python_version == "3.6"', + 'python_version >= "3.6" and python_version <= "3.7"', + ), + ( + 'python_version >= "3.6" and python_version <= "3.7"', + 'python_version == "3.6" and implementation_name == "cpython"', + 'python_version >= "3.6" and python_version <= "3.7"', + ), + ( + 'python_version >= "3.6" and python_version <= "3.7"', + 'python_full_version == "3.6.2"', + 'python_version >= "3.6" and python_version <= "3.7"', + ), + ( + 'python_version >= "3.6" and python_version <= "3.7"', + 'python_full_version == "3.6.2" and implementation_name == "cpython"', + 'python_version >= "3.6" and python_version <= "3.7"', + ), + ( + 'python_version >= "3.6" and python_version <= "3.7"', + 'python_version == "3.7"', + 'python_version >= "3.6" and python_version <= "3.7"', + ), + ( + 'python_version >= "3.6" and python_version <= "3.7"', + 'python_version == "3.7" and implementation_name == "cpython"', + 'python_version >= "3.6" and python_version <= "3.7"', + ), + ], +) +def test_version_ranges_collapse_on_union( + marker1: str, marker2: str, expected: str +) -> None: + m1 = parse_marker(marker1) + m2 = parse_marker(marker2) + assert str(m1 | m2) == expected + assert str(m2 | m1) == expected + + +def test_multi_marker_union_with_union() -> None: + m1 = parse_marker('sys_platform == "darwin" and implementation_name == "cpython"') + m2 = parse_marker('python_version >= "3.6" or os_name == "Windows"') + + # Union isn't _quite_ symmetrical. + expected1 = ( + 'sys_platform == "darwin" and implementation_name == "cpython" or' + ' python_version >= "3.6" or os_name == "Windows"' + ) + assert str(m1 | m2) == expected1 + + expected2 = ( + 'python_version >= "3.6" or os_name == "Windows" or' + ' sys_platform == "darwin" and implementation_name == "cpython"' + ) + assert str(m2 | m1) == expected2 + + +def test_multi_marker_union_with_multi_union_is_single_marker() -> None: + m = parse_marker('sys_platform == "darwin" and python_version == "3"') + m2 = parse_marker( + 'sys_platform == "darwin" and python_version < "3" or sys_platform == "darwin"' + ' and python_version > "3"' + ) + assert str(m | m2) == 'sys_platform == "darwin"' + assert str(m2 | m) == 'sys_platform == "darwin"' + + +def test_multi_marker_union_with_union_multi_is_single_marker() -> None: + m = parse_marker('sys_platform == "darwin" and python_version == "3"') + m2 = parse_marker( + 'sys_platform == "darwin" and (python_version < "3" or python_version > "3")' + ) + assert str(m | m2) == 'sys_platform == "darwin"' + assert str(m2 | m) == 'sys_platform == "darwin"' + + +def test_marker_union() -> None: + m = parse_marker('sys_platform == "darwin" or implementation_name == "cpython"') + + assert isinstance(m, MarkerUnion) + assert m.markers == ( + parse_marker('sys_platform == "darwin"'), + parse_marker('implementation_name == "cpython"'), + ) + + +def test_marker_union_deduplicate() -> None: + m = parse_marker( + 'sys_platform == "darwin" or implementation_name == "cpython" or sys_platform' + ' == "darwin"' + ) + + assert str(m) == 'sys_platform == "darwin" or implementation_name == "cpython"' + + +def test_marker_union_intersect_single_marker() -> None: + m = parse_marker('sys_platform == "darwin" or python_version < "3.4"') + + intersection = m & parse_marker('implementation_name == "cpython"') + assert ( + str(intersection) + == 'sys_platform == "darwin" and implementation_name == "cpython" ' + 'or python_version < "3.4" and implementation_name == "cpython"' + ) + + +def test_marker_union_intersect_single_with_overlapping_constraints() -> None: + m = parse_marker('sys_platform == "darwin" or python_version < "3.4"') + + intersection = m & parse_marker('python_version <= "3.6"') + assert ( + str(intersection) + == 'sys_platform == "darwin" and python_version <= "3.6" or python_version <' + ' "3.4"' + ) + + m = parse_marker('sys_platform == "darwin" or python_version < "3.4"') + intersection = m & parse_marker('sys_platform == "darwin"') + assert str(intersection) == 'sys_platform == "darwin"' + + +def test_marker_union_intersect_marker_union() -> None: + m = parse_marker('sys_platform == "darwin" or python_version < "3.4"') + + intersection = m & ( + parse_marker('implementation_name == "cpython" or os_name == "Windows"') + ) + assert ( + str(intersection) + == 'sys_platform == "darwin" and implementation_name == "cpython" ' + 'or sys_platform == "darwin" and os_name == "Windows" or ' + 'python_version < "3.4" and implementation_name == "cpython" or ' + 'python_version < "3.4" and os_name == "Windows"' + ) + + +def test_marker_union_intersect_marker_union_drops_unnecessary_markers() -> None: + m = parse_marker( + 'python_version >= "2.7" and python_version < "2.8" ' + 'or python_version >= "3.4" and python_version < "4.0"' + ) + m2 = parse_marker( + 'python_version >= "2.7" and python_version < "2.8" ' + 'or python_version >= "3.4" and python_version < "4.0"' + ) + + intersection = m & m2 + expected = ( + 'python_version >= "2.7" and python_version < "2.8" ' + 'or python_version ~= "3.4"' + ) + assert str(intersection) == expected + + +def test_marker_union_intersect_multi_marker() -> None: + m1 = parse_marker('sys_platform == "darwin" or python_version < "3.4"') + m2 = parse_marker('implementation_name == "cpython" and os_name == "Windows"') + + # Intersection isn't _quite_ symmetrical. + expected1 = ( + 'sys_platform == "darwin" and implementation_name == "cpython" and os_name ==' + ' "Windows" or python_version < "3.4" and implementation_name == "cpython" and' + ' os_name == "Windows"' + ) + + intersection = m1 & m2 + assert str(intersection) == expected1 + + expected2 = ( + 'implementation_name == "cpython" and os_name == "Windows" and sys_platform' + ' == "darwin" or implementation_name == "cpython" and os_name == "Windows"' + ' and python_version < "3.4"' + ) + + intersection = m2 & m1 + assert str(intersection) == expected2 + + +def test_marker_union_union_with_union() -> None: + m = parse_marker('sys_platform == "darwin" or python_version < "3.4"') + + union = m | ( + parse_marker('implementation_name == "cpython" or os_name == "Windows"') + ) + assert ( + str(union) == 'sys_platform == "darwin" or python_version < "3.4" ' + 'or implementation_name == "cpython" or os_name == "Windows"' + ) + + +def test_marker_union_union_duplicates() -> None: + m = parse_marker('sys_platform == "darwin" or python_version < "3.4"') + + union = m | parse_marker('sys_platform == "darwin" or os_name == "Windows"') + assert ( + str(union) + == 'sys_platform == "darwin" or python_version < "3.4" or os_name == "Windows"' + ) + + m = parse_marker('sys_platform == "darwin" or python_version < "3.4"') + + union = m | ( + parse_marker( + 'sys_platform == "darwin" or os_name == "Windows" or python_version <=' + ' "3.6"' + ) + ) + assert ( + str(union) + == 'sys_platform == "darwin" or python_version <= "3.6" or os_name == "Windows"' + ) + + +def test_marker_union_all_any() -> None: + union = MarkerUnion.of(parse_marker(""), parse_marker("")) + + assert union.is_any() + + +def test_marker_union_not_all_any() -> None: + union = MarkerUnion.of(parse_marker(""), parse_marker(""), EmptyMarker()) + + assert union.is_any() + + +def test_marker_union_all_empty() -> None: + union = MarkerUnion.of(EmptyMarker(), EmptyMarker()) + + assert union.is_empty() + + +def test_marker_union_not_all_empty() -> None: + union = MarkerUnion.of(EmptyMarker(), EmptyMarker(), parse_marker("")) + + assert not union.is_empty() + + +def test_intersect_compacts_constraints() -> None: + m = parse_marker('python_version < "4.0"') + + intersection = m & parse_marker('python_version < "5.0"') + assert str(intersection) == 'python_version < "4.0"' + + +def test_multi_marker_removes_duplicates() -> None: + m = parse_marker('sys_platform == "win32" and sys_platform == "win32"') + + assert str(m) == 'sys_platform == "win32"' + + m = parse_marker( + 'sys_platform == "darwin" and implementation_name == "cpython" ' + 'and sys_platform == "darwin" and implementation_name == "cpython"' + ) + + assert str(m) == 'sys_platform == "darwin" and implementation_name == "cpython"' + + +def test_union_of_a_single_marker_is_the_single_marker() -> None: + union = MarkerUnion.of(m := parse_marker("python_version>= '2.7'")) + + assert m == union + + +def test_union_of_multi_with_a_containing_single() -> None: + single = parse_marker('python_version >= "2.7"') + multi = parse_marker('python_version >= "2.7" and extra == "foo"') + union = multi | single + + assert union == single + + +def test_single_markers_are_found_in_complex_intersection() -> None: + m1 = parse_marker('implementation_name != "pypy" and python_version <= "3.6"') + m2 = parse_marker( + 'python_version >= "3.6" and python_version < "4.0" and implementation_name ==' + ' "cpython"' + ) + intersection = m1 & m2 + assert ( + str(intersection) + == 'implementation_name == "cpython" and python_version == "3.6"' + ) + + +@pytest.mark.parametrize( + "marker1, marker2", + [ + ( + ( + '(platform_system != "Windows" or platform_machine != "x86") and' + ' python_version == "3.8"' + ), + 'platform_system == "Windows" and platform_machine == "x86"', + ), + # Following example via + # https://github.com/python-poetry/poetry-plugin-export/issues/163 + ( + ( + 'python_version >= "3.8" and python_version < "3.11" and' + ' (python_version > "3.9" or platform_system != "Windows" or' + ' platform_machine != "x86") or python_version >= "3.11" and' + ' python_version < "3.12"' + ), + ( + 'python_version == "3.8" and platform_system == "Windows" and' + ' platform_machine == "x86" or python_version == "3.9" and' + ' platform_system == "Windows" and platform_machine == "x86"' + ), + ), + ], +) +def test_empty_marker_is_found_in_complex_intersection( + marker1: str, marker2: str +) -> None: + m1 = parse_marker(marker1) + m2 = parse_marker(marker2) + assert (m1 & m2).is_empty() + assert (m2 & m1).is_empty() + + +def test_empty_marker_is_found_in_complex_parse() -> None: + marker = parse_marker( + '(python_implementation != "pypy" or python_version != "3.6") and ' + '((python_implementation != "pypy" and python_version != "3.6") or' + ' (python_implementation == "pypy" and python_version == "3.6")) and ' + '(python_implementation == "pypy" or python_version == "3.6")' + ) + assert marker.is_empty() + + +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/ + """ + markers = [ + parse_marker(m) + for m in [ + ( + 'python_version < "3.7" and python_version >= "3.6"' + ' and platform_system == "Darwin" and platform_machine == "arm64"' + ), + ( + 'python_version >= "3.10" or python_version >= "3.9"' + ' and platform_system == "Darwin" and platform_machine == "arm64"' + ), + ( + 'python_version >= "3.8" and platform_system == "Darwin"' + ' and platform_machine == "arm64" and python_version < "3.9"' + ), + ( + 'python_version >= "3.7" and platform_system == "Darwin"' + ' and platform_machine == "arm64" and python_version < "3.8"' + ), + ] + ] + assert ( + str(union(*markers)) + == 'platform_system == "Darwin" and platform_machine == "arm64"' + ' and python_version >= "3.6" or python_version >= "3.10"' + ) + + +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 in "WIN32,win32,AMD64,amd64,x86_64,aarch64,ppc64le"' + ) + 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 | m2) == expected + assert str(m2 | 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 & 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 & m1) + == '(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 >= "3.11.0" ' + 'and python_full_version < "4.0.0"' + ) + + +@pytest.mark.parametrize( + "python_version, python_full_version, " + "expected_intersection_version, expected_union_version", + [ + # python_version > 3.6 (equal to python_full_version >= 3.7.0) + ('> "3.6"', '> "3.5.2"', '> "3.6"', '> "3.5.2"'), + ('> "3.6"', '>= "3.5.2"', '> "3.6"', '>= "3.5.2"'), + ('> "3.6"', '> "3.6.2"', '> "3.6"', '> "3.6.2"'), + ('> "3.6"', '>= "3.6.2"', '> "3.6"', '>= "3.6.2"'), + ('> "3.6"', '> "3.7.0"', '> "3.7.0"', '> "3.6"'), + ('> "3.6"', '>= "3.7.0"', '> "3.6"', '> "3.6"'), + ('> "3.6"', '> "3.7.1"', '> "3.7.1"', '> "3.6"'), + ('> "3.6"', '>= "3.7.1"', '>= "3.7.1"', '> "3.6"'), + ('> "3.6"', '== "3.6.2"', EMPTY, None), + ('> "3.6"', '== "3.7.0"', '== "3.7.0"', '> "3.6"'), + ('> "3.6"', '== "3.7.1"', '== "3.7.1"', '> "3.6"'), + ('> "3.6"', '!= "3.6.2"', '> "3.6"', '!= "3.6.2"'), + ('> "3.6"', '!= "3.7.0"', '> "3.7.0"', ""), + ('> "3.6"', '!= "3.7.1"', None, ""), + ('> "3.6"', '< "3.7.0"', EMPTY, ""), + ('> "3.6"', '<= "3.7.0"', '== "3.7.0"', ""), + ('> "3.6"', '< "3.7.1"', None, ""), + ('> "3.6"', '<= "3.7.1"', None, ""), + # python_version >= 3.6 (equal to python_full_version >= 3.6.0) + ('>= "3.6"', '> "3.5.2"', '>= "3.6"', '> "3.5.2"'), + ('>= "3.6"', '>= "3.5.2"', '>= "3.6"', '>= "3.5.2"'), + ('>= "3.6"', '> "3.6.0"', '> "3.6.0"', '>= "3.6"'), + ('>= "3.6"', '>= "3.6.0"', '>= "3.6"', '>= "3.6"'), + ('>= "3.6"', '> "3.6.1"', '> "3.6.1"', '>= "3.6"'), + ('>= "3.6"', '>= "3.6.1"', '>= "3.6.1"', '>= "3.6"'), + ('>= "3.6"', '== "3.5.2"', EMPTY, None), + ('>= "3.6"', '== "3.6.0"', '== "3.6.0"', '>= "3.6"'), + ('>= "3.6"', '!= "3.5.2"', '>= "3.6"', '!= "3.5.2"'), + ('>= "3.6"', '!= "3.6.0"', '> "3.6.0"', ""), + ('>= "3.6"', '!= "3.6.1"', None, ""), + ('>= "3.6"', '!= "3.7.1"', None, ""), + ('>= "3.6"', '< "3.6.0"', EMPTY, ""), + ('>= "3.6"', '<= "3.6.0"', '== "3.6.0"', ""), + ('>= "3.6"', '< "3.6.1"', None, ""), # '== "3.6.0"' + ('>= "3.6"', '<= "3.6.1"', None, ""), + # python_version < 3.6 (equal to python_full_version < 3.6.0) + ('< "3.6"', '< "3.5.2"', '< "3.5.2"', '< "3.6"'), + ('< "3.6"', '<= "3.5.2"', '<= "3.5.2"', '< "3.6"'), + ('< "3.6"', '< "3.6.0"', '< "3.6"', '< "3.6"'), + ('< "3.6"', '<= "3.6.0"', '< "3.6"', '<= "3.6.0"'), + ('< "3.6"', '< "3.6.1"', '< "3.6"', '< "3.6.1"'), + ('< "3.6"', '<= "3.6.1"', '< "3.6"', '<= "3.6.1"'), + ('< "3.6"', '== "3.5.2"', '== "3.5.2"', '< "3.6"'), + ('< "3.6"', '== "3.6.0"', EMPTY, '<= "3.6.0"'), + ('< "3.6"', '!= "3.5.2"', None, ""), + ('< "3.6"', '!= "3.6.0"', '< "3.6"', '!= "3.6.0"'), + ('< "3.6"', '> "3.6.0"', EMPTY, '!= "3.6.0"'), + ('< "3.6"', '>= "3.6.0"', EMPTY, ""), + ('< "3.6"', '> "3.5.2"', None, ""), + ('< "3.6"', '>= "3.5.2"', '~= "3.5.2"', ""), + # python_version <= 3.6 (equal to python_full_version < 3.7.0) + ('<= "3.6"', '< "3.6.1"', '< "3.6.1"', '<= "3.6"'), + ('<= "3.6"', '<= "3.6.1"', '<= "3.6.1"', '<= "3.6"'), + ('<= "3.6"', '< "3.7.0"', '<= "3.6"', '<= "3.6"'), + ('<= "3.6"', '<= "3.7.0"', '<= "3.6"', '<= "3.7.0"'), + ('<= "3.6"', '== "3.6.1"', '== "3.6.1"', '<= "3.6"'), + ('<= "3.6"', '== "3.7.0"', EMPTY, '<= "3.7.0"'), + ('<= "3.6"', '!= "3.6.1"', None, ""), + ('<= "3.6"', '!= "3.7.0"', '<= "3.6"', '!= "3.7.0"'), + ('<= "3.6"', '> "3.7.0"', EMPTY, '!= "3.7.0"'), + ('<= "3.6"', '>= "3.7.0"', EMPTY, ""), + ('<= "3.6"', '> "3.6.2"', None, ""), + ('<= "3.6"', '>= "3.6.2"', '~= "3.6.2"', ""), + # python_version == 3.6 + # (equal to python_full_version >= 3.6.0 and python_full_version < 3.7.0) + ('== "3.6"', '< "3.5.2"', EMPTY, None), + ('== "3.6"', '<= "3.5.2"', EMPTY, None), + ('== "3.6"', '> "3.5.2"', '== "3.6"', '> "3.5.2"'), + ('== "3.6"', '>= "3.5.2"', '== "3.6"', '>= "3.5.2"'), + ('== "3.6"', '!= "3.5.2"', '== "3.6"', '!= "3.5.2"'), + ('== "3.6"', '< "3.6.0"', EMPTY, '< "3.7.0"'), + ('== "3.6"', '<= "3.6.0"', '== "3.6.0"', '< "3.7.0"'), + ('== "3.6"', '> "3.6.0"', None, '>= "3.6.0"'), + ('== "3.6"', '>= "3.6.0"', '== "3.6"', '>= "3.6.0"'), + ('== "3.6"', '!= "3.6.0"', None, ""), + ('== "3.6"', '< "3.6.1"', None, '< "3.7.0"'), + ('== "3.6"', '<= "3.6.1"', None, '< "3.7.0"'), + ('== "3.6"', '> "3.6.1"', None, '>= "3.6.0"'), + ('== "3.6"', '>= "3.6.1"', '~= "3.6.1"', '>= "3.6.0"'), + ('== "3.6"', '!= "3.6.1"', None, ""), + ('== "3.6"', '< "3.7.0"', '== "3.6"', '< "3.7.0"'), + ('== "3.6"', '<= "3.7.0"', '== "3.6"', '<= "3.7.0"'), + ('== "3.6"', '> "3.7.0"', EMPTY, None), + ('== "3.6"', '>= "3.7.0"', EMPTY, '>= "3.6.0"'), + ('== "3.6"', '!= "3.7.0"', '== "3.6"', '!= "3.7.0"'), + ('== "3.6"', '<= "3.7.1"', '== "3.6"', '<= "3.7.1"'), + ('== "3.6"', '< "3.7.1"', '== "3.6"', '< "3.7.1"'), + ('== "3.6"', '> "3.7.1"', EMPTY, None), + ('== "3.6"', '>= "3.7.1"', EMPTY, None), + ('== "3.6"', '!= "3.7.1"', '== "3.6"', '!= "3.7.1"'), + # python_version != 3.6 + # (equal to python_full_version < 3.6.0 or python_full_version >= 3.7.0) + ('!= "3.6"', '< "3.5.2"', '< "3.5.2"', '!= "3.6"'), + ('!= "3.6"', '<= "3.5.2"', '<= "3.5.2"', '!= "3.6"'), + ('!= "3.6"', '> "3.5.2"', None, ""), + ('!= "3.6"', '>= "3.5.2"', None, ""), + ('!= "3.6"', '!= "3.5.2"', None, ""), + ('!= "3.6"', '< "3.6.0"', '< "3.6.0"', '!= "3.6"'), + ('!= "3.6"', '<= "3.6.0"', '< "3.6.0"', None), + ('!= "3.6"', '> "3.6.0"', '>= "3.7.0"', '!= "3.6.0"'), + ('!= "3.6"', '>= "3.6.0"', '>= "3.7.0"', ""), + ('!= "3.6"', '!= "3.6.0"', '!= "3.6"', '!= "3.6.0"'), + ('!= "3.6"', '< "3.6.1"', '< "3.6.0"', None), + ('!= "3.6"', '<= "3.6.1"', '< "3.6.0"', None), + ('!= "3.6"', '> "3.6.1"', '>= "3.7.0"', None), + ('!= "3.6"', '>= "3.6.1"', '>= "3.7.0"', None), + ('!= "3.6"', '!= "3.6.1"', '!= "3.6"', '!= "3.6.1"'), + ('!= "3.6"', '< "3.7.0"', '< "3.6.0"', ""), + ('!= "3.6"', '<= "3.7.0"', None, ""), + ('!= "3.6"', '> "3.7.0"', '> "3.7.0"', '!= "3.6"'), + ('!= "3.6"', '>= "3.7.0"', '>= "3.7.0"', '!= "3.6"'), + ('!= "3.6"', '!= "3.7.0"', None, ""), + ('!= "3.6"', '<= "3.7.1"', None, ""), + ('!= "3.6"', '< "3.7.1"', None, ""), + ('!= "3.6"', '> "3.7.1"', '> "3.7.1"', '!= "3.6"'), + ('!= "3.6"', '>= "3.7.1"', '>= "3.7.1"', '!= "3.6"'), + ('!= "3.6"', '!= "3.7.1"', None, ""), + ], +) +def test_merging_python_version_and_python_full_version( + python_version: str, + python_full_version: str, + expected_intersection_version: str, + expected_union_version: str, +) -> None: + m = f"python_version {python_version}" + m2 = f"python_full_version {python_full_version}" + + def get_expected_marker(expected_version: str, op: str) -> str: + if expected_version is None: + expected = f"{m} {op} {m2}" + elif expected_version in ("", EMPTY): + expected = expected_version + else: + expected_marker_name = ( + "python_version" + if expected_version.count(".") < 2 + else "python_full_version" + ) + expected = f"{expected_marker_name} {expected_version}" + return expected + + expected_intersection = get_expected_marker(expected_intersection_version, "and") + expected_union = get_expected_marker(expected_union_version, "or") + + intersection = parse_marker(m) & parse_marker(m2) + assert str(intersection) == expected_intersection + + union = parse_marker(m) | parse_marker(m2) + assert str(union) == expected_union diff --git a/tests/marker/test_evaluation.py b/tests/marker/test_evaluation.py new file mode 100644 index 0000000..4b869d7 --- /dev/null +++ b/tests/marker/test_evaluation.py @@ -0,0 +1,162 @@ +import os + +import pytest + +from dep_logic.markers import parse_marker + + +@pytest.mark.parametrize( + ("marker_string", "environment", "expected"), + [ + (f"os_name == '{os.name}'", None, True), + ("os_name == 'foo'", {"os_name": "foo"}, True), + ("os_name == 'foo'", {"os_name": "bar"}, False), + ("'2.7' in python_version", {"python_version": "2.7.5"}, True), + ("'2.7' not in python_version", {"python_version": "2.7"}, False), + ( + "os_name == 'foo' and python_version ~= '2.7.0'", + {"os_name": "foo", "python_version": "2.7.6"}, + True, + ), + ( + "python_version ~= '2.7.0' and (os_name == 'foo' or " "os_name == 'bar')", + {"os_name": "foo", "python_version": "2.7.4"}, + True, + ), + ( + "python_version ~= '2.7.0' and (os_name == 'foo' or " "os_name == 'bar')", + {"os_name": "bar", "python_version": "2.7.4"}, + True, + ), + ( + "python_version ~= '2.7.0' and (os_name == 'foo' or " "os_name == 'bar')", + {"os_name": "other", "python_version": "2.7.4"}, + False, + ), + ("extra == 'security'", {"extra": "quux"}, False), + ("extra == 'security'", {"extra": "security"}, True), + ("extra == 'SECURITY'", {"extra": "security"}, True), + ("extra == 'security'", {"extra": "SECURITY"}, True), + ("extra == 'pep-685-norm'", {"extra": "PEP_685...norm"}, True), + ( + "extra == 'Different.punctuation..is...equal'", + {"extra": "different__punctuation_is_EQUAL"}, + True, + ), + ], +) +def test_evaluates( + marker_string: str, environment: dict[str, str], expected: bool +) -> None: + args = [] if environment is None else [environment] + assert parse_marker(marker_string).evaluate(*args) == expected + + +@pytest.mark.parametrize( + ("marker_string", "environment", "expected"), + [ + (f"os.name == '{os.name}'", None, True), + ("sys.platform == 'win32'", {"sys_platform": "linux2"}, False), + ("platform.version in 'Ubuntu'", {"platform_version": "#39"}, False), + ("platform.machine=='x86_64'", {"platform_machine": "x86_64"}, True), + ( + "platform.python_implementation=='Jython'", + {"platform_python_implementation": "CPython"}, + False, + ), + ( + "python_version == '2.5' and platform.python_implementation!= 'Jython'", + {"python_version": "2.7"}, + False, + ), + ( + ( + "platform_machine in 'x86_64 X86_64 aarch64 AARCH64 ppc64le PPC64LE" + " amd64 AMD64 win32 WIN32'" + ), + {"platform_machine": "foo"}, + False, + ), + ( + ( + "platform_machine in 'x86_64 X86_64 aarch64 AARCH64 ppc64le PPC64LE" + " amd64 AMD64 win32 WIN32'" + ), + {"platform_machine": "x86_64"}, + True, + ), + ( + ( + "platform_machine not in 'x86_64 X86_64 aarch64 AARCH64 ppc64le PPC64LE" + " amd64 AMD64 win32 WIN32'" + ), + {"platform_machine": "foo"}, + True, + ), + ( + ( + "platform_machine not in 'x86_64 X86_64 aarch64 AARCH64 ppc64le PPC64LE" + " amd64 AMD64 win32 WIN32'" + ), + {"platform_machine": "x86_64"}, + False, + ), + # extras + # single extra + ("extra != 'security'", {"extra": "quux"}, True), + ("extra != 'security'", {"extra": "security"}, False), + # normalization + ("extra == 'Security.1'", {"extra": "security-1"}, True), + ("extra == 'a'", {}, False), + ("extra != 'a'", {}, True), + ("extra == 'a' and extra == 'b'", {}, False), + ("extra == 'a' or extra == 'b'", {}, False), + ("extra != 'a' and extra != 'b'", {}, True), + ("extra != 'a' or extra != 'b'", {}, True), + ("extra != 'a' and extra == 'b'", {}, False), + ("extra != 'a' or extra == 'b'", {}, True), + # multiple extras + ("extra == 'a'", {"extra": ("a", "b")}, True), + ("extra == 'a'", {"extra": ("b", "c")}, False), + ("extra != 'a'", {"extra": ("a", "b")}, False), + ("extra != 'a'", {"extra": ("b", "c")}, True), + ("extra == 'a' and extra == 'b'", {"extra": ("a", "b", "c")}, True), + ("extra == 'a' and extra == 'b'", {"extra": ("a", "c")}, False), + ("extra == 'a' or extra == 'b'", {"extra": ("a", "c")}, True), + ("extra == 'a' or extra == 'b'", {"extra": ("b", "c")}, True), + ("extra == 'a' or extra == 'b'", {"extra": ("c", "d")}, False), + ("extra != 'a' and extra != 'b'", {"extra": ("a", "c")}, False), + ("extra != 'a' and extra != 'b'", {"extra": ("b", "c")}, False), + ("extra != 'a' and extra != 'b'", {"extra": ("c", "d")}, True), + ("extra != 'a' or extra != 'b'", {"extra": ("a", "b", "c")}, False), + ("extra != 'a' or extra != 'b'", {"extra": ("a", "c")}, True), + ("extra != 'a' or extra != 'b'", {"extra": ("b", "c")}, True), + ("extra != 'a' and extra == 'b'", {"extra": ("a", "b")}, False), + ("extra != 'a' and extra == 'b'", {"extra": ("b", "c")}, True), + ("extra != 'a' and extra == 'b'", {"extra": ("c", "d")}, False), + ("extra != 'a' or extra == 'b'", {"extra": ("a", "b")}, True), + ("extra != 'a' or extra == 'b'", {"extra": ("c", "d")}, True), + ("extra != 'a' or extra == 'b'", {"extra": ("a", "c")}, False), + ], +) +def test_evaluate_extra( + marker_string: str, environment: dict[str, str] | None, expected: bool +) -> None: + m = parse_marker(marker_string) + + assert m.evaluate(environment) is expected + + +@pytest.mark.parametrize( + "marker, env", + [ + ( + 'platform_release >= "9.0" and platform_release < "11.0"', + {"platform_release": "10.0"}, + ) + ], +) +def test_parse_version_like_markers(marker: str, env: dict[str, str]) -> None: + m = parse_marker(marker) + + assert m.evaluate(env) diff --git a/tests/marker/test_expression.py b/tests/marker/test_expression.py new file mode 100644 index 0000000..6476c02 --- /dev/null +++ b/tests/marker/test_expression.py @@ -0,0 +1,301 @@ +import pytest + +from dep_logic.markers import parse_marker + + +def test_single_marker_normalisation() -> None: + m1 = parse_marker("python_version>='3.6'") + m2 = parse_marker("python_version >= '3.6'") + assert m1 == m2 + assert hash(m1) == hash(m2) + + +def test_single_marker_intersect() -> None: + m = parse_marker('sys_platform == "darwin"') + + intersection = m & parse_marker('implementation_name == "cpython"') + assert ( + str(intersection) + == 'sys_platform == "darwin" and implementation_name == "cpython"' + ) + + m = parse_marker('python_version >= "3.4"') + + intersection = m & parse_marker('python_version < "3.6"') + assert str(intersection) == 'python_version >= "3.4" and python_version < "3.6"' + + +def test_single_marker_intersect_compacts_constraints() -> None: + m = parse_marker('python_version < "3.6"') + + intersection = m & parse_marker('python_version < "3.4"') + assert str(intersection) == 'python_version < "3.4"' + + +def test_single_marker_intersect_with_multi() -> None: + m = parse_marker('sys_platform == "darwin"') + + intersection = m & ( + parse_marker('implementation_name == "cpython" and python_version >= "3.6"') + ) + assert ( + str(intersection) + == 'implementation_name == "cpython" and python_version >= "3.6" and' + ' sys_platform == "darwin"' + ) + + +def test_single_marker_intersect_with_multi_with_duplicate() -> None: + m = parse_marker('python_version < "4.0"') + + intersection = m & ( + parse_marker('sys_platform == "darwin" and python_version < "4.0"') + ) + assert str(intersection) == 'sys_platform == "darwin" and python_version < "4.0"' + + +def test_single_marker_intersect_with_multi_compacts_constraint() -> None: + m = parse_marker('python_version < "3.6"') + + intersection = m & ( + parse_marker('implementation_name == "cpython" and python_version < "3.4"') + ) + assert ( + str(intersection) + == 'implementation_name == "cpython" and python_version < "3.4"' + ) + + +def test_single_marker_intersect_with_union_leads_to_single_marker() -> None: + m = parse_marker('python_version >= "3.6"') + + intersection = m & ( + parse_marker('python_version < "3.6" or python_version >= "3.7"') + ) + assert str(intersection) == 'python_version >= "3.7"' + + +def test_single_marker_intersect_with_union_leads_to_empty() -> None: + m = parse_marker('python_version == "3.7"') + + intersection = m & ( + parse_marker('python_version < "3.7" or python_version >= "3.8"') + ) + assert intersection.is_empty() + + +def test_single_marker_not_in_python_intersection() -> None: + m = parse_marker('python_version not in "2.7, 3.0, 3.1"') + + intersection = m & (parse_marker('python_version not in "2.7, 3.0, 3.1, 3.2"')) + assert str(intersection) == 'python_version not in "2.7, 3.0, 3.1, 3.2"' + + +@pytest.mark.parametrize( + ("marker1", "marker2", "expected"), + [ + # same value + ('extra == "a"', 'extra == "a"', 'extra == "a"'), + ('extra == "a"', 'extra != "a"', ""), + ('extra != "a"', 'extra == "a"', ""), + ('extra != "a"', 'extra != "a"', 'extra != "a"'), + # different values + ('extra == "a"', 'extra == "b"', 'extra == "a" and extra == "b"'), + ('extra == "a"', 'extra != "b"', 'extra == "a" and extra != "b"'), + ('extra != "a"', 'extra == "b"', 'extra != "a" and extra == "b"'), + ('extra != "a"', 'extra != "b"', 'extra != "a" and extra != "b"'), + ], +) +def test_single_marker_intersect_extras( + marker1: str, marker2: str, expected: str +) -> None: + assert str(parse_marker(marker1) & parse_marker(marker2)) == expected + + +def test_single_marker_union() -> None: + m = parse_marker('sys_platform == "darwin"') + + union = m | (parse_marker('implementation_name == "cpython"')) + assert str(union) == 'sys_platform == "darwin" or implementation_name == "cpython"' + + +def test_single_marker_union_is_any() -> None: + m = parse_marker('python_version >= "3.4"') + + union = m | (parse_marker('python_version < "3.6"')) + assert union.is_any() + + +@pytest.mark.parametrize( + ("marker1", "marker2", "expected"), + [ + ( + 'python_version < "3.6"', + 'python_version < "3.4"', + 'python_version < "3.6"', + ), + ( + 'sys_platform == "linux"', + 'sys_platform != "win32"', + 'sys_platform != "win32"', + ), + ( + 'python_version == "3.6"', + 'python_version > "3.6"', + 'python_version >= "3.6"', + ), + ( + 'python_version == "3.6"', + 'python_version < "3.6"', + 'python_version <= "3.6"', + ), + ( + 'python_version < "3.6"', + 'python_version > "3.6"', + 'python_version != "3.6"', + ), + ], +) +def test_single_marker_union_is_single_marker( + marker1: str, marker2: str, expected: str +) -> None: + m = parse_marker(marker1) + + union = m | (parse_marker(marker2)) + assert str(union) == expected + + +def test_single_marker_union_with_multi() -> None: + m = parse_marker('sys_platform == "darwin"') + + union = m | ( + parse_marker('implementation_name == "cpython" and python_version >= "3.6"') + ) + assert ( + str(union) == 'implementation_name == "cpython" and python_version >= "3.6" or' + ' sys_platform == "darwin"' + ) + + +def test_single_marker_union_with_multi_duplicate() -> None: + m = parse_marker('sys_platform == "darwin" and python_version >= "3.6"') + + union = m | (parse_marker('sys_platform == "darwin" and python_version >= "3.6"')) + assert str(union) == 'sys_platform == "darwin" and python_version >= "3.6"' + + +@pytest.mark.parametrize( + ("single_marker", "multi_marker", "expected"), + [ + ( + 'python_version >= "3.6"', + 'python_version >= "3.7" and sys_platform == "win32"', + 'python_version >= "3.6"', + ), + ( + 'sys_platform == "linux"', + 'sys_platform != "linux" and sys_platform != "win32"', + 'sys_platform != "win32"', + ), + ], +) +def test_single_marker_union_with_multi_is_single_marker( + single_marker: str, multi_marker: str, expected: str +) -> None: + m1 = parse_marker(single_marker) + m2 = parse_marker(multi_marker) + assert str(m1 | (m2)) == expected + assert str(m2 | (m1)) == expected + + +def test_single_marker_union_with_multi_cannot_be_simplified() -> None: + m = parse_marker('python_version >= "3.7"') + union = m | (parse_marker('python_version >= "3.6" and sys_platform == "win32"')) + assert ( + str(union) + == 'python_version >= "3.6" and sys_platform == "win32" or python_version >=' + ' "3.7"' + ) + + +def test_single_marker_union_with_multi_is_union_of_single_markers() -> None: + m = parse_marker('python_version >= "3.6"') + union = m | (parse_marker('python_version < "3.6" and sys_platform == "win32"')) + assert str(union) == 'sys_platform == "win32" or python_version >= "3.6"' + + +def test_single_marker_union_with_multi_union_is_union_of_single_markers() -> None: + m = parse_marker('python_version >= "3.6"') + union = m | ( + parse_marker( + 'python_version < "3.6" and sys_platform == "win32" or python_version <' + ' "3.6" and sys_platform == "linux"' + ) + ) + assert ( + str(union) + == 'sys_platform == "win32" or sys_platform == "linux" or python_version >=' + ' "3.6"' + ) + + +def test_single_marker_union_with_union() -> None: + m = parse_marker('sys_platform == "darwin"') + + union = m | ( + parse_marker('implementation_name == "cpython" or python_version >= "3.6"') + ) + assert ( + str(union) + == 'implementation_name == "cpython" or python_version >= "3.6" or sys_platform' + ' == "darwin"' + ) + + +def test_single_marker_not_in_python_union() -> None: + m = parse_marker('python_version not in "2.7, 3.0, 3.1"') + + union = m | parse_marker('python_version not in "2.7, 3.0, 3.1, 3.2"') + assert str(union) == 'python_version not in "2.7, 3.0, 3.1"' + + +def test_single_marker_union_with_union_duplicate() -> None: + m = parse_marker('sys_platform == "darwin"') + + union = m | (parse_marker('sys_platform == "darwin" or python_version >= "3.6"')) + assert str(union) == 'sys_platform == "darwin" or python_version >= "3.6"' + + m = parse_marker('python_version >= "3.7"') + + union = m | (parse_marker('sys_platform == "darwin" or python_version >= "3.6"')) + assert str(union) == 'sys_platform == "darwin" or python_version >= "3.6"' + + m = parse_marker('python_version <= "3.6"') + + union = m | (parse_marker('sys_platform == "darwin" or python_version < "3.4"')) + assert str(union) == 'sys_platform == "darwin" or python_version <= "3.6"' + + +def test_single_marker_union_with_inverse() -> None: + m = parse_marker('sys_platform == "darwin"') + union = m | (parse_marker('sys_platform != "darwin"')) + assert union.is_any() + + +@pytest.mark.parametrize( + ("marker1", "marker2", "expected"), + [ + # same value + ('extra == "a"', 'extra == "a"', 'extra == "a"'), + ('extra == "a"', 'extra != "a"', ""), + ('extra != "a"', 'extra == "a"', ""), + ('extra != "a"', 'extra != "a"', 'extra != "a"'), + # different values + ('extra == "a"', 'extra == "b"', 'extra == "a" or extra == "b"'), + ('extra == "a"', 'extra != "b"', 'extra == "a" or extra != "b"'), + ('extra != "a"', 'extra == "b"', 'extra != "a" or extra == "b"'), + ('extra != "a"', 'extra != "b"', 'extra != "a" or extra != "b"'), + ], +) +def test_single_marker_union_extras(marker1: str, marker2: str, expected: str) -> None: + assert str(parse_marker(marker1) | (parse_marker(marker2))) == expected diff --git a/tests/marker/test_parsing.py b/tests/marker/test_parsing.py new file mode 100644 index 0000000..aa93f2c --- /dev/null +++ b/tests/marker/test_parsing.py @@ -0,0 +1,83 @@ +import itertools + +import pytest + +from dep_logic.markers import InvalidMarker, parse_marker + +VARIABLES = [ + "extra", + "implementation_name", + "implementation_version", + "os_name", + "platform_machine", + "platform_release", + "platform_system", + "platform_version", + "python_full_version", + "python_version", + "platform_python_implementation", + "sys_platform", +] + +PEP_345_VARIABLES = [ + "os.name", + "sys.platform", + "platform.version", + "platform.machine", + "platform.python_implementation", +] + + +OPERATORS = ["===", "==", ">=", "<=", "!=", "~=", ">", "<", "in", "not in"] + +VALUES = [ + "1.0", + "5.6a0", + "dog", + "freebsd", + "literally any string can go here", + "things @#4 dsfd (((", +] + + +@pytest.mark.parametrize( + "marker_string", + ["{} {} {!r}".format(*i) for i in itertools.product(VARIABLES, OPERATORS, VALUES)] + + [ + "{2!r} {1} {0}".format(*i) + for i in itertools.product(VARIABLES, OPERATORS, VALUES) + ], +) +def test_parses_valid(marker_string: str): + parse_marker(marker_string) + + +@pytest.mark.parametrize( + "marker_string", + [ + "this_isnt_a_real_variable >= '1.0'", + "python_version", + "(python_version)", + "python_version >= 1.0 and (python_version)", + '(python_version == "2.7" and os_name == "linux"', + '(python_version == "2.7") with random text', + ], +) +def test_parses_invalid(marker_string: str): + with pytest.raises(InvalidMarker): + parse_marker(marker_string) + + +@pytest.mark.parametrize( + "marker_string", + [ + "{} {} {!r}".format(*i) + for i in itertools.product(PEP_345_VARIABLES, OPERATORS, VALUES) + ] + + [ + "{2!r} {1} {0}".format(*i) + for i in itertools.product(PEP_345_VARIABLES, OPERATORS, VALUES) + ], +) +def test_parses_pep345_valid(marker_string: str) -> None: + parse_marker(marker_string) diff --git a/tests/specifier/test_range.py b/tests/specifier/test_range.py index 1883c87..fb0c9c1 100644 --- a/tests/specifier/test_range.py +++ b/tests/specifier/test_range.py @@ -1,7 +1,7 @@ import pytest from packaging.version import Version -from pkg_logical.specifiers import RangeSpecifier, parse +from dep_logic.specifiers import RangeSpecifier, parse_version_specifier @pytest.mark.parametrize( @@ -60,7 +60,7 @@ ], ) def test_parse_simple_range(value: str, parsed: RangeSpecifier) -> None: - spec = parse(value) + spec = parse_version_specifier(value) assert spec == parsed assert str(spec) == value assert spec.is_simple() @@ -80,7 +80,9 @@ def test_parse_simple_range(value: str, parsed: RangeSpecifier) -> None: ], ) def test_range_compare_lower(a: str, b: str, expected: bool) -> None: - assert parse(a).allows_lower(parse(b)) is expected + assert ( + parse_version_specifier(a).allows_lower(parse_version_specifier(b)) is expected + ) @pytest.mark.parametrize( @@ -175,7 +177,7 @@ def test_range_str_normalization(value: RangeSpecifier, expected: str) -> None: ], ) def test_range_intersection(a: str, b: str, expected: str) -> None: - assert str(parse(a) & parse(b)) == expected + assert str(parse_version_specifier(a) & parse_version_specifier(b)) == expected @pytest.mark.parametrize( @@ -192,8 +194,8 @@ def test_range_intersection(a: str, b: str, expected: str) -> None: ], ) def test_range_invert(value: str, inverted: str) -> None: - assert str(~parse(value)) == inverted - assert str(~parse(inverted)) == value + assert str(~parse_version_specifier(value)) == inverted + assert str(~parse_version_specifier(inverted)) == value @pytest.mark.parametrize( @@ -213,4 +215,4 @@ def test_range_invert(value: str, inverted: str) -> None: ], ) def test_range_union(a: str, b: str, expected: str) -> None: - assert str(parse(a) | parse(b)) == expected + assert str(parse_version_specifier(a) | parse_version_specifier(b)) == expected diff --git a/tests/specifier/test_union.py b/tests/specifier/test_union.py index 93273c4..20d4e6e 100644 --- a/tests/specifier/test_union.py +++ b/tests/specifier/test_union.py @@ -1,7 +1,11 @@ import pytest from packaging.version import Version -from pkg_logical.specifiers import RangeSpecifier, UnionSpecifier, parse +from dep_logic.specifiers import ( + RangeSpecifier, + UnionSpecifier, + parse_version_specifier, +) @pytest.mark.parametrize( @@ -28,7 +32,7 @@ ], ) def test_parse_simple_union_specifier(spec: str, parsed: UnionSpecifier) -> None: - value = parse(spec) + value = parse_version_specifier(spec) assert value.is_simple() assert value == parsed assert str(value) == spec @@ -60,7 +64,7 @@ def test_parse_simple_union_specifier(spec: str, parsed: UnionSpecifier) -> None ], ) def test_parse_union_specifier(spec: str, parsed: UnionSpecifier) -> None: - value = parse(spec) + value = parse_version_specifier(spec) assert not value.is_simple() assert value == parsed @@ -76,7 +80,7 @@ def test_parse_union_specifier(spec: str, parsed: UnionSpecifier) -> None: ], ) def test_union_intesection(a: str, b: str, expected: str) -> None: - assert str(parse(a) & parse(b)) == expected + assert str(parse_version_specifier(a) & parse_version_specifier(b)) == expected @pytest.mark.parametrize( @@ -91,4 +95,4 @@ def test_union_intesection(a: str, b: str, expected: str) -> None: ], ) def test_union_union(a: str, b: str, expected: str) -> None: - assert str(parse(a) | parse(b)) == expected + assert str(parse_version_specifier(a) | parse_version_specifier(b)) == expected