diff --git a/src/dep_logic/tags/tags.py b/src/dep_logic/tags/tags.py index fd3f417..e2463b7 100644 --- a/src/dep_logic/tags/tags.py +++ b/src/dep_logic/tags/tags.py @@ -1,7 +1,9 @@ from __future__ import annotations +import sys from dataclasses import dataclass -from platform import python_implementation, python_version +from enum import IntEnum, auto +from platform import python_implementation from typing import TYPE_CHECKING from ..specifiers import InvalidSpecifier, VersionSpecifier, parse_version_specifier @@ -84,37 +86,48 @@ def parse(cls, name: str, gil_disabled: bool = False) -> Self: ) +class EnvCompatibility(IntEnum): + INCOMPATIBLE = auto() + LOWER_OR_EQUAL = auto() + HIGHER = auto() + + @dataclass(frozen=True) class EnvSpec: requires_python: VersionSpecifier - platform: Platform - implementation: Implementation + platform: Platform | None = None + implementation: Implementation | None = None def as_dict(self) -> dict[str, str | bool]: - return { - "requires_python": str(self.requires_python), - "platform": str(self.platform), - "implementation": self.implementation.name, - "gil_disabled": self.implementation.gil_disabled, - } + result: dict[str, str | bool] = {"requires_python": str(self.requires_python)} + if self.platform is not None: + result["platform"] = str(self.platform) + if self.implementation is not None: + result["implementation"] = self.implementation.name + result["gil_disabled"] = self.implementation.gil_disabled + return result @classmethod def from_spec( cls, requires_python: str, - platform: str, - implementation: str = "cpython", + platform: str | None = None, + implementation: str | None = None, gil_disabled: bool = False, ) -> Self: return cls( _ensure_version_specifier(requires_python), - Platform.parse(platform), - Implementation.parse(implementation, gil_disabled=gil_disabled), + Platform.parse(platform) if platform else None, + Implementation.parse(implementation, gil_disabled=gil_disabled) + if implementation + else None, ) @classmethod def current(cls) -> Self: - requires_python = _ensure_version_specifier(f"=={python_version()}") + # XXX: Strip pre-release and post-release tags + python_version = f"{sys.version_info.major}.{sys.version_info.minor}.{sys.version_info.micro}" + requires_python = _ensure_version_specifier(f"=={python_version}") platform = Platform.current() implementation = Implementation.current() return cls(requires_python, platform, implementation) @@ -122,8 +135,12 @@ def current(cls) -> Self: def _evaluate_python( self, python_tag: str, abi_tag: str ) -> tuple[int, int, int] | None: + """Return a tuple of (major, minor, abi) if the wheel is compatible with the environment, or None otherwise.""" impl, major, minor = python_tag[:2], python_tag[2], python_tag[3:] - if impl not in [self.implementation.short, "py"]: + if self.implementation is not None and impl not in [ + self.implementation.short, + "py", + ]: return None abi_impl = ( abi_tag.split("_", 1)[0] @@ -152,6 +169,8 @@ def _evaluate_python( return (int(major), int(minor or 0), 0 if abi_impl == "none" else 2) def _evaluate_platform(self, platform_tag: str) -> int | None: + if self.platform is None: + return -1 platform_tags = [*self.platform.compatible_tags, "any"] if platform_tag not in platform_tags: return None @@ -195,7 +214,37 @@ def wheel_compatibility( ) def markers(self) -> dict[str, str]: - return { - "implementation_name": self.implementation.name, - **self.platform.markers(), - } + result = {} + if self.platform is not None: + result.update(self.platform.markers()) + if self.implementation is not None: + result["implementation_name"] = self.implementation.name + return result + + def compare(self, target: EnvSpec) -> EnvCompatibility: + if self == target: + return EnvCompatibility.LOWER_OR_EQUAL + if (self.requires_python & target.requires_python).is_empty(): + return EnvCompatibility.INCOMPATIBLE + if ( + self.implementation is not None + and target.implementation is not None + and self.implementation != target.implementation + ): + return EnvCompatibility.INCOMPATIBLE + if self.platform is None or target.platform is None: + return EnvCompatibility.LOWER_OR_EQUAL + if self.platform.arch != target.platform.arch: + return EnvCompatibility.INCOMPATIBLE + if type(self.platform.os) is not type(target.platform.os): + return EnvCompatibility.INCOMPATIBLE + + if hasattr(self.platform.os, "major") and hasattr(self.platform.os, "minor"): + if (self.platform.os.major, self.platform.os.minor) <= ( # type: ignore[attr-defined] + target.platform.os.major, # type: ignore[attr-defined] + target.platform.os.minor, # type: ignore[attr-defined] + ): + return EnvCompatibility.LOWER_OR_EQUAL + else: + return EnvCompatibility.HIGHER + return EnvCompatibility.LOWER_OR_EQUAL diff --git a/tests/tags/test_tags.py b/tests/tags/test_tags.py index 402f48f..cc4691b 100644 --- a/tests/tags/test_tags.py +++ b/tests/tags/test_tags.py @@ -1,4 +1,7 @@ +import pytest + from dep_logic.tags import EnvSpec +from dep_logic.tags.tags import EnvCompatibility def test_check_wheel_tags(): @@ -53,3 +56,81 @@ def test_check_wheel_tags(): "protobuf-5.27.2-cp38-abi3-macosx_10_9_universal2.whl", "protobuf-5.27.2-py3-none-any.whl", ] + + python_env = EnvSpec.from_spec(">=3.9") + wheel_compats = { + f: c + for f, c in {f: python_env.wheel_compatibility(f) for f in wheels}.items() + if c is not None + } + filtered_wheels = sorted(wheel_compats, key=wheel_compats.__getitem__, reverse=True) + assert filtered_wheels == [ + "protobuf-5.27.2-cp310-cp310-macosx_12_0_arm64.whl", + "protobuf-5.27.2-cp310-abi3-win32.whl", + "protobuf-5.27.2-cp310-abi3-win_amd64.whl", + "protobuf-5.27.2-cp39-cp39-win32.whl", + "protobuf-5.27.2-cp39-cp39-win_amd64.whl", + "protobuf-5.27.2-cp38-abi3-macosx_10_9_universal2.whl", + "protobuf-5.27.2-cp38-abi3-manylinux2014_aarch64.whl", + "protobuf-5.27.2-cp38-abi3-manylinux2014_x86_64.whl", + "protobuf-5.27.2-py3-none-any.whl", + ] + + +@pytest.mark.parametrize( + "left,right,expected", + [ + ( + EnvSpec.from_spec(">=3.9", "macos", "cpython"), + EnvSpec.from_spec(">=3.9", "macos", "cpython"), + EnvCompatibility.LOWER_OR_EQUAL, + ), + ( + EnvSpec.from_spec(">=3.9", "macos", "cpython"), + EnvSpec.from_spec(">=3.9", "macos"), + EnvCompatibility.LOWER_OR_EQUAL, + ), + ( + EnvSpec.from_spec(">=3.9", "macos"), + EnvSpec.from_spec(">=3.9", "macos", "cpython"), + EnvCompatibility.LOWER_OR_EQUAL, + ), + ( + EnvSpec.from_spec(">=3.9", "macos", "cpython"), + EnvSpec.from_spec(">=3.7,<3.10"), + EnvCompatibility.LOWER_OR_EQUAL, + ), + ( + EnvSpec.from_spec(">=3.7,<3.10"), + EnvSpec.from_spec(">=3.9", "macos", "cpython"), + EnvCompatibility.LOWER_OR_EQUAL, + ), + ( + EnvSpec.from_spec(">=3.9", "macos", "cpython"), + EnvSpec.from_spec("<3.8", "macos", "cpython"), + EnvCompatibility.INCOMPATIBLE, + ), + ( + EnvSpec.from_spec(">=3.9", "macos", "cpython"), + EnvSpec.from_spec("<3.10", "linux", "cpython"), + EnvCompatibility.INCOMPATIBLE, + ), + ( + EnvSpec.from_spec(">=3.9", "macos", "cpython"), + EnvSpec.from_spec("<3.10", "macos", "pypy"), + EnvCompatibility.INCOMPATIBLE, + ), + ( + EnvSpec.from_spec(">=3.9", "macos_x86_64", "cpython"), + EnvSpec.from_spec("<3.10", "macos_10_9_x86_64", "cpython"), + EnvCompatibility.HIGHER, + ), + ( + EnvSpec.from_spec("<3.10", "macos_10_9_x86_64", "cpython"), + EnvSpec.from_spec(">=3.9", "macos_x86_64", "cpython"), + EnvCompatibility.LOWER_OR_EQUAL, + ), + ], +) +def test_env_spec_comparison(left, right, expected): + assert left.compare(right) == expected