├── tests ├── __init__.py ├── specifier │ ├── test_arbitrary.py │ ├── test_union.py │ └── test_range.py ├── marker │ ├── test_parsing.py │ ├── test_common.py │ ├── test_evaluation.py │ └── test_expression.py └── tags │ ├── test_tags.py │ └── test_platform.py ├── src └── dep_logic │ ├── py.typed │ ├── __init__.py │ ├── tags │ ├── __init__.py │ ├── os.py │ ├── tags.py │ └── platform.py │ ├── markers │ ├── any.py │ ├── empty.py │ ├── base.py │ ├── utils.py │ ├── __init__.py │ ├── union.py │ ├── multi.py │ └── single.py │ ├── specifiers │ ├── base.py │ ├── special.py │ ├── arbitrary.py │ ├── generic.py │ ├── __init__.py │ ├── union.py │ └── range.py │ └── utils.py ├── .pre-commit-config.yaml ├── .github └── workflows │ ├── ci.yml │ └── release.yml ├── pyproject.toml ├── README.md ├── pdm.lock ├── .gitignore └── LICENSE /tests/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/dep_logic/py.typed: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/dep_logic/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/dep_logic/tags/__init__.py: -------------------------------------------------------------------------------- 1 | from .platform import Platform, PlatformError 2 | from .tags import ( 3 | EnvCompatibility, 4 | EnvSpec, 5 | Implementation, 6 | InvalidWheelFilename, 7 | TagsError, 8 | UnsupportedImplementation, 9 | ) 10 | 11 | __all__ = [ 12 | "Platform", 13 | "PlatformError", 14 | "TagsError", 15 | "UnsupportedImplementation", 16 | "InvalidWheelFilename", 17 | "EnvSpec", 18 | "Implementation", 19 | "EnvCompatibility", 20 | ] 21 | -------------------------------------------------------------------------------- /.pre-commit-config.yaml: -------------------------------------------------------------------------------- 1 | ci: 2 | autoupdate_schedule: monthly 3 | repos: 4 | - repo: https://github.com/asottile/pyupgrade 5 | rev: v3.17.0 6 | hooks: 7 | - id: pyupgrade 8 | args: [--py38-plus] 9 | exclude: ^(src/pdm/models/in_process/.*\.py|install-pdm\.py)$ 10 | 11 | - repo: https://github.com/astral-sh/ruff-pre-commit 12 | rev: 'v0.6.3' 13 | hooks: 14 | - id: ruff 15 | args: [--fix, --exit-non-zero-on-fix, --show-fixes] 16 | - id: ruff-format 17 | 18 | - repo: https://github.com/RobertCraigie/pyright-python 19 | rev: v1.1.378 20 | hooks: 21 | - id: pyright 22 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: Tests 2 | 3 | on: 4 | pull_request: 5 | paths-ignore: 6 | - "*.md" 7 | push: 8 | branches: 9 | - main 10 | paths-ignore: 11 | - "*.md" 12 | 13 | jobs: 14 | Testing: 15 | runs-on: ubuntu-latest 16 | strategy: 17 | matrix: 18 | python-version: ["3.8", "3.9", "3.10", "3.11", "3.12", "3.13", "pypy3.9", "pypy3.10"] 19 | 20 | steps: 21 | - uses: actions/checkout@v4 22 | - name: Set up PDM 23 | uses: pdm-project/setup-pdm@v4 24 | with: 25 | python-version: ${{ matrix.python-version }} 26 | cache: "true" 27 | 28 | - name: Install packages 29 | run: pdm install 30 | 31 | - name: Run Tests 32 | run: pdm run pytest 33 | -------------------------------------------------------------------------------- /src/dep_logic/markers/any.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from dep_logic.markers.base import BaseMarker, EvaluationContext 4 | 5 | 6 | class AnyMarker(BaseMarker): 7 | def __and__(self, other: BaseMarker) -> BaseMarker: 8 | return other 9 | 10 | __rand__ = __and__ 11 | 12 | def __or__(self, other: BaseMarker) -> BaseMarker: 13 | return self 14 | 15 | __ror__ = __or__ 16 | 17 | def is_any(self) -> bool: 18 | return True 19 | 20 | def evaluate( 21 | self, 22 | environment: dict[str, str | set[str]] | None = None, 23 | context: EvaluationContext = "metadata", 24 | ): 25 | return True 26 | 27 | def without_extras(self) -> BaseMarker: 28 | return self 29 | 30 | def exclude(self, marker_name: str) -> BaseMarker: 31 | return self 32 | 33 | def only(self, *marker_names: str) -> BaseMarker: 34 | return self 35 | 36 | def __str__(self) -> str: 37 | return "" 38 | 39 | def __repr__(self) -> str: 40 | return "" 41 | 42 | def __hash__(self) -> int: 43 | return hash("any") 44 | 45 | def __eq__(self, other: object) -> bool: 46 | if not isinstance(other, BaseMarker): 47 | return NotImplemented 48 | 49 | return isinstance(other, AnyMarker) 50 | -------------------------------------------------------------------------------- /src/dep_logic/markers/empty.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from dep_logic.markers.base import BaseMarker, EvaluationContext 4 | 5 | 6 | class EmptyMarker(BaseMarker): 7 | def __and__(self, other: BaseMarker) -> BaseMarker: 8 | return self 9 | 10 | __rand__ = __and__ 11 | 12 | def __or__(self, other: BaseMarker) -> BaseMarker: 13 | return other 14 | 15 | __ror__ = __or__ 16 | 17 | def is_empty(self) -> bool: 18 | return True 19 | 20 | def evaluate( 21 | self, 22 | environment: dict[str, str | set[str]] | None = None, 23 | context: EvaluationContext = "metadata", 24 | ) -> bool: 25 | return False 26 | 27 | def without_extras(self) -> BaseMarker: 28 | return self 29 | 30 | def exclude(self, marker_name: str) -> BaseMarker: 31 | return self 32 | 33 | def only(self, *marker_names: str) -> BaseMarker: 34 | return self 35 | 36 | def __str__(self) -> str: 37 | return "" 38 | 39 | def __repr__(self) -> str: 40 | return "" 41 | 42 | def __hash__(self) -> int: 43 | return hash("empty") 44 | 45 | def __eq__(self, other: object) -> bool: 46 | if not isinstance(other, BaseMarker): 47 | return NotImplemented 48 | 49 | return isinstance(other, EmptyMarker) 50 | -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | name: Release 2 | 3 | on: 4 | push: 5 | tags: 6 | - "*" 7 | 8 | jobs: 9 | build: 10 | name: release-pypi 11 | runs-on: ubuntu-latest 12 | 13 | steps: 14 | - uses: actions/checkout@v4 15 | with: 16 | fetch-depth: 0 17 | - uses: actions/setup-node@v4 18 | with: 19 | node-version: 'lts/*' 20 | 21 | - run: npx changelogithub 22 | continue-on-error: true 23 | env: 24 | GITHUB_TOKEN: ${{secrets.GITHUB_TOKEN}} 25 | - uses: actions/setup-python@v4 26 | with: 27 | python-version: "3.11" 28 | - name: Build artifacts 29 | run: | 30 | pipx run build 31 | - name: Store the distribution packages 32 | uses: actions/upload-artifact@v4 33 | with: 34 | name: python-package-distributions 35 | path: dist/ 36 | 37 | pypi-publish: 38 | name: Upload release to PyPI 39 | runs-on: ubuntu-latest 40 | needs: build 41 | environment: 42 | name: pypi 43 | url: https://pypi.org/project/dep-logic 44 | permissions: 45 | id-token: write 46 | steps: 47 | - name: Download all the dists 48 | uses: actions/download-artifact@v4 49 | with: 50 | name: python-package-distributions 51 | path: dist/ 52 | - name: Publish package distributions to PyPI 53 | uses: pypa/gh-action-pypi-publish@release/v1 54 | -------------------------------------------------------------------------------- /tests/specifier/test_arbitrary.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | 3 | from dep_logic.specifiers import parse_version_specifier 4 | 5 | 6 | @pytest.mark.parametrize( 7 | "a, b, expected", 8 | [ 9 | ("===abc", "", "===abc"), 10 | ("", "===abc", "===abc"), 11 | ("===abc", "===abc", "===abc"), 12 | ("===abc", "===def", ""), 13 | ("===abc", "", ""), 14 | ("", "===abc", ""), 15 | ("===1.0.0", ">=1", "===1.0.0"), 16 | ("===1.0.0", "<1", ""), 17 | ], 18 | ) 19 | def test_arbitrary_intersection(a: str, b: str, expected: str) -> None: 20 | assert str(parse_version_specifier(a) & parse_version_specifier(b)) == expected 21 | 22 | 23 | @pytest.mark.parametrize( 24 | "a, b, expected", 25 | [ 26 | ("===abc", "", ""), 27 | ("", "===abc", ""), 28 | ("===abc", "===abc", "===abc"), 29 | ("===abc", "", "===abc"), 30 | ("", "===abc", "===abc"), 31 | ("===1.0.0", ">=1", ">=1"), 32 | ], 33 | ) 34 | def test_arbitrary_union(a: str, b: str, expected: str) -> None: 35 | assert str(parse_version_specifier(a) | parse_version_specifier(b)) == expected 36 | 37 | 38 | @pytest.mark.parametrize( 39 | "a, b, operand", 40 | [("===abc", ">=1", "and"), ("===1.0.0", "<1", "or"), ("===abc", "==1.*", "or")], 41 | ) 42 | def test_arbitrary_unsupported(a: str, b: str, operand: str) -> None: 43 | with pytest.raises(ValueError): 44 | if operand == "and": 45 | _ = parse_version_specifier(a) & parse_version_specifier(b) 46 | else: 47 | _ = parse_version_specifier(a) | parse_version_specifier(b) 48 | -------------------------------------------------------------------------------- /src/dep_logic/tags/os.py: -------------------------------------------------------------------------------- 1 | from dataclasses import dataclass 2 | 3 | 4 | class Os: 5 | def __str__(self) -> str: 6 | return self.__class__.__name__.lower() 7 | 8 | 9 | @dataclass(frozen=True) 10 | class Manylinux(Os): 11 | major: int 12 | minor: int 13 | 14 | def __str__(self) -> str: 15 | return f"manylinux_{self.major}_{self.minor}" 16 | 17 | 18 | @dataclass(frozen=True) 19 | class Musllinux(Os): 20 | major: int 21 | minor: int 22 | 23 | def __str__(self) -> str: 24 | return f"musllinux_{self.major}_{self.minor}" 25 | 26 | 27 | @dataclass(frozen=True) 28 | class Windows(Os): 29 | pass 30 | 31 | 32 | @dataclass(frozen=True) 33 | class Macos(Os): 34 | major: int 35 | minor: int 36 | 37 | def __str__(self) -> str: 38 | return f"macos_{self.major}_{self.minor}" 39 | 40 | 41 | @dataclass(frozen=True) 42 | class FreeBsd(Os): 43 | release: str 44 | 45 | def __str__(self) -> str: 46 | return f"freebsd_{self.release}" 47 | 48 | 49 | @dataclass(frozen=True) 50 | class NetBsd(Os): 51 | release: str 52 | 53 | def __str__(self) -> str: 54 | return f"netbsd_{self.release}" 55 | 56 | 57 | @dataclass(frozen=True) 58 | class OpenBsd(Os): 59 | release: str 60 | 61 | 62 | @dataclass(frozen=True) 63 | class Dragonfly(Os): 64 | release: str 65 | 66 | def __str__(self) -> str: 67 | return f"dragonfly_{self.release}" 68 | 69 | 70 | @dataclass(frozen=True) 71 | class Illumos(Os): 72 | release: str 73 | arch: str 74 | 75 | def __str__(self) -> str: 76 | return f"illumos_{self.release}_{self.arch}" 77 | 78 | 79 | @dataclass(frozen=True) 80 | class Haiku(Os): 81 | release: str 82 | 83 | def __str__(self) -> str: 84 | return f"haiku_{self.release}" 85 | 86 | 87 | @dataclass(frozen=True) 88 | class Generic(Os): 89 | name: str 90 | 91 | def __str__(self) -> str: 92 | return self.name.lower() 93 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [project] 2 | name = "dep-logic" 3 | description = "Python dependency specifications supporting logical operations" 4 | authors = [ 5 | {name = "Frost Ming", email = "me@frostming.com"}, 6 | ] 7 | dependencies = [ 8 | "packaging>=22", 9 | ] 10 | requires-python = ">=3.8" 11 | readme = "README.md" 12 | license = {text = "Apache-2.0"} 13 | dynamic = ["version"] 14 | 15 | keywords = ["dependency", "specification", "logic", "packaging"] 16 | classifiers = [ 17 | "Intended Audience :: Developers", 18 | "Programming Language :: Python :: 3", 19 | "Programming Language :: Python :: 3.8", 20 | "Programming Language :: Python :: 3.9", 21 | "Programming Language :: Python :: 3.10", 22 | "Programming Language :: Python :: 3.11", 23 | "Programming Language :: Python :: 3.12", 24 | "Programming Language :: Python :: 3.13", 25 | "License :: OSI Approved :: Apache Software License", 26 | ] 27 | 28 | [build-system] 29 | requires = ["pdm-backend"] 30 | build-backend = "pdm.backend" 31 | 32 | [tool.ruff] 33 | line-length = 88 34 | src = ["src"] 35 | exclude = ["tests/fixtures"] 36 | target-version = "py310" 37 | 38 | [tool.ruff.lint] 39 | extend-select = [ 40 | "I", # isort 41 | "B", # flake8-bugbear 42 | "C4", # flake8-comprehensions 43 | "PGH", # pygrep-hooks 44 | "RUF", # ruff 45 | "W", # pycodestyle 46 | "YTT", # flake8-2020 47 | ] 48 | extend-ignore = ["B018", "B019", "B905"] 49 | 50 | [tool.ruff.lint.mccabe] 51 | max-complexity = 10 52 | 53 | [tool.ruff.lint.isort] 54 | known-first-party = ["dep_logic"] 55 | 56 | [tool.pdm.version] 57 | source = "scm" 58 | 59 | [tool.pdm.dev-dependencies] 60 | dev = [ 61 | "pytest>=7.4.3", 62 | ] 63 | 64 | [tool.pdm.scripts] 65 | test = "pytest" 66 | 67 | [tool.pytest.ini_options] 68 | addopts = "-ra" 69 | testpaths = [ 70 | "src/", 71 | "tests/", 72 | ] 73 | 74 | [tool.pyright] 75 | venvPath = "." 76 | venv = ".venv" 77 | pythonVersion = "3.11" 78 | reportPrivateImportUsage = "none" 79 | -------------------------------------------------------------------------------- /src/dep_logic/specifiers/base.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import abc 4 | import typing as t 5 | 6 | from packaging.specifiers import SpecifierSet 7 | from packaging.version import Version 8 | 9 | UnparsedVersion = t.Union[Version, str] 10 | 11 | 12 | class InvalidSpecifier(ValueError): 13 | pass 14 | 15 | 16 | class BaseSpecifier(metaclass=abc.ABCMeta): 17 | @abc.abstractmethod 18 | def __str__(self) -> str: 19 | """ 20 | Returns the str representation of this Specifier-like object. This 21 | should be representative of the Specifier itself. 22 | """ 23 | 24 | @abc.abstractmethod 25 | def __and__(self, other: t.Any) -> BaseSpecifier: 26 | raise NotImplementedError 27 | 28 | @abc.abstractmethod 29 | def __or__(self, other: t.Any) -> BaseSpecifier: 30 | raise NotImplementedError 31 | 32 | @abc.abstractmethod 33 | def __invert__(self) -> BaseSpecifier: 34 | raise NotImplementedError 35 | 36 | def is_simple(self) -> bool: 37 | return False 38 | 39 | def __repr__(self) -> str: 40 | return f"<{self.__class__.__name__} {self}>" 41 | 42 | def is_empty(self) -> bool: 43 | return False 44 | 45 | def is_any(self) -> bool: 46 | return False 47 | 48 | @abc.abstractmethod 49 | def __contains__(self, value: str) -> bool: 50 | raise NotImplementedError 51 | 52 | 53 | class VersionSpecifier(BaseSpecifier): 54 | @abc.abstractmethod 55 | def contains( 56 | self, version: UnparsedVersion, prereleases: bool | None = None 57 | ) -> bool: 58 | raise NotImplementedError 59 | 60 | @property 61 | @abc.abstractmethod 62 | def num_parts(self) -> int: 63 | raise NotImplementedError 64 | 65 | def __contains__(self, version: UnparsedVersion) -> bool: 66 | return self.contains(version) 67 | 68 | @abc.abstractmethod 69 | def to_specifierset(self) -> SpecifierSet: 70 | """Convert to a packaging.specifiers.SpecifierSet object.""" 71 | raise NotImplementedError 72 | -------------------------------------------------------------------------------- /src/dep_logic/specifiers/special.py: -------------------------------------------------------------------------------- 1 | import typing as t 2 | 3 | from dep_logic.specifiers.base import BaseSpecifier 4 | 5 | 6 | class EmptySpecifier(BaseSpecifier): 7 | def __invert__(self) -> BaseSpecifier: 8 | return AnySpecifier() 9 | 10 | def __and__(self, other: t.Any) -> BaseSpecifier: 11 | if not isinstance(other, BaseSpecifier): 12 | return NotImplemented 13 | return self 14 | 15 | __rand__ = __and__ 16 | 17 | def __or__(self, other: t.Any) -> BaseSpecifier: 18 | if not isinstance(other, BaseSpecifier): 19 | return NotImplemented 20 | return other 21 | 22 | __ror__ = __or__ 23 | 24 | def __str__(self) -> str: 25 | return "" 26 | 27 | def __hash__(self) -> int: 28 | return hash(str(self)) 29 | 30 | def __eq__(self, other: object) -> bool: 31 | if not isinstance(other, BaseSpecifier): 32 | return NotImplemented 33 | return isinstance(other, EmptySpecifier) 34 | 35 | def is_empty(self) -> bool: 36 | return True 37 | 38 | def __contains__(self, value: str) -> bool: 39 | return False 40 | 41 | 42 | class AnySpecifier(BaseSpecifier): 43 | def __invert__(self) -> BaseSpecifier: 44 | return EmptySpecifier() 45 | 46 | def __and__(self, other: t.Any) -> BaseSpecifier: 47 | if not isinstance(other, BaseSpecifier): 48 | return NotImplemented 49 | return other 50 | 51 | __rand__ = __and__ 52 | 53 | def __or__(self, other: t.Any) -> BaseSpecifier: 54 | if not isinstance(other, BaseSpecifier): 55 | return NotImplemented 56 | return self 57 | 58 | __ror__ = __or__ 59 | 60 | def __str__(self) -> str: 61 | return "" 62 | 63 | def __hash__(self) -> int: 64 | return hash(str(self)) 65 | 66 | def __eq__(self, other: object) -> bool: 67 | if not isinstance(other, BaseSpecifier): 68 | return NotImplemented 69 | return other.is_any() 70 | 71 | def is_any(self) -> bool: 72 | return True 73 | 74 | def __contains__(self, value: str) -> bool: 75 | return True 76 | -------------------------------------------------------------------------------- /src/dep_logic/specifiers/arbitrary.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from dataclasses import dataclass 4 | from typing import Any 5 | 6 | from packaging.specifiers import SpecifierSet 7 | 8 | from dep_logic.specifiers.base import BaseSpecifier, UnparsedVersion, VersionSpecifier 9 | from dep_logic.specifiers.special import EmptySpecifier 10 | from dep_logic.utils import DATACLASS_ARGS 11 | 12 | 13 | @dataclass(frozen=True, unsafe_hash=True, **DATACLASS_ARGS) 14 | class ArbitrarySpecifier(VersionSpecifier): 15 | """`===` specifier.""" 16 | 17 | target: str 18 | 19 | def to_specifierset(self) -> SpecifierSet: 20 | return SpecifierSet(f"==={self.target}") 21 | 22 | def __str__(self) -> str: 23 | return f"==={self.target}" 24 | 25 | def contains( 26 | self, version: UnparsedVersion, prereleases: bool | None = None 27 | ) -> bool: 28 | return str(version) == self.target 29 | 30 | @property 31 | def num_parts(self) -> int: 32 | return 1 33 | 34 | def is_simple(self) -> bool: 35 | return True 36 | 37 | def __invert__(self) -> BaseSpecifier: 38 | raise ValueError("Cannot invert an ArbitrarySpecifier") 39 | 40 | def __and__(self, other: Any) -> BaseSpecifier: 41 | if not isinstance(other, VersionSpecifier): 42 | return NotImplemented 43 | if other.is_empty(): 44 | return other 45 | try: 46 | if other.is_any() or self.target in other: 47 | return self 48 | return EmptySpecifier() 49 | except ValueError: 50 | raise ValueError( 51 | f"Unsupported intersection of '{self}' and '{other}'" 52 | ) from None 53 | 54 | __rand__ = __and__ 55 | 56 | def __or__(self, other: Any) -> BaseSpecifier: 57 | if not isinstance(other, VersionSpecifier): 58 | return NotImplemented 59 | if other.is_empty(): 60 | return self 61 | try: 62 | if other.is_any() or self.target in other: 63 | return other 64 | except ValueError: 65 | pass 66 | raise ValueError(f"Unsupported union of '{self}' and '{other}'") from None 67 | 68 | __ror__ = __or__ 69 | -------------------------------------------------------------------------------- /tests/marker/test_parsing.py: -------------------------------------------------------------------------------- 1 | import itertools 2 | 3 | import pytest 4 | 5 | from dep_logic.markers import InvalidMarker, parse_marker 6 | 7 | VARIABLES = [ 8 | "extra", 9 | "implementation_name", 10 | "implementation_version", 11 | "os_name", 12 | "platform_machine", 13 | "platform_release", 14 | "platform_system", 15 | "platform_version", 16 | "python_full_version", 17 | "python_version", 18 | "platform_python_implementation", 19 | "sys_platform", 20 | ] 21 | 22 | PEP_345_VARIABLES = [ 23 | "os.name", 24 | "sys.platform", 25 | "platform.version", 26 | "platform.machine", 27 | "platform.python_implementation", 28 | ] 29 | 30 | 31 | OPERATORS = ["===", "==", ">=", "<=", "!=", "~=", ">", "<", "in", "not in"] 32 | 33 | VALUES = [ 34 | "1.0", 35 | "5.6a0", 36 | "dog", 37 | "freebsd", 38 | "literally any string can go here", 39 | "things @#4 dsfd (((", 40 | ] 41 | 42 | 43 | @pytest.mark.parametrize( 44 | "marker_string", 45 | ["{} {} {!r}".format(*i) for i in itertools.product(VARIABLES, OPERATORS, VALUES)] 46 | + [ 47 | "{2!r} {1} {0}".format(*i) 48 | for i in itertools.product(VARIABLES, OPERATORS, VALUES) 49 | ], 50 | ) 51 | def test_parses_valid(marker_string: str): 52 | parse_marker(marker_string) 53 | 54 | 55 | @pytest.mark.parametrize( 56 | "marker_string", 57 | [ 58 | "this_isnt_a_real_variable >= '1.0'", 59 | "python_version", 60 | "(python_version)", 61 | "python_version >= 1.0 and (python_version)", 62 | '(python_version == "2.7" and os_name == "linux"', 63 | '(python_version == "2.7") with random text', 64 | ], 65 | ) 66 | def test_parses_invalid(marker_string: str): 67 | with pytest.raises(InvalidMarker): 68 | parse_marker(marker_string) 69 | 70 | 71 | @pytest.mark.parametrize( 72 | "marker_string", 73 | [ 74 | "{} {} {!r}".format(*i) 75 | for i in itertools.product(PEP_345_VARIABLES, OPERATORS, VALUES) 76 | ] 77 | + [ 78 | "{2!r} {1} {0}".format(*i) 79 | for i in itertools.product(PEP_345_VARIABLES, OPERATORS, VALUES) 80 | ], 81 | ) 82 | def test_parses_pep345_valid(marker_string: str) -> None: 83 | parse_marker(marker_string) 84 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Dep-Logic 2 | 3 | ![PyPI - Version](https://img.shields.io/pypi/v/dep-logic) 4 | ![Python Version from PEP 621 TOML](https://img.shields.io/python/required-version-toml?tomlFilePath=https%3A%2F%2Fraw.githubusercontent.com%2Fpdm-project%2Fdep-logic%2Fmain%2Fpyproject.toml) 5 | ![GitHub License](https://img.shields.io/github/license/pdm-project/dep-logic) 6 | 7 | 8 | Python dependency specifications supporting logical operations 9 | 10 | ## Installation 11 | 12 | ```bash 13 | pip install dep-logic 14 | ``` 15 | 16 | This library requires Python 3.8 or later. 17 | 18 | Currently, it contains two sub-modules: 19 | 20 | - `dep_logic.specifier` - a module for parsing and calculating PEP 440 version specifiers. 21 | - `dep_logic.markers` - a module for parsing and calculating PEP 508 environment markers. 22 | 23 | ## What does it do? 24 | 25 | This library allows logic operations on version specifiers and environment markers. 26 | 27 | For example: 28 | 29 | ```pycon 30 | >>> from dep_logic.specifiers import parse_version_specifier 31 | >>> 32 | >>> a = parse_version_specifier(">=1.0.0") 33 | >>> b = parse_version_specifier("<2.0.0") 34 | >>> print(a & b) 35 | >=1.0.0,<2.0.0 36 | >>> a = parse_version_specifier(">=1.0.0,<2.0.0") 37 | >>> b = parse_version_specifier(">1.5") 38 | >>> print(a | b) 39 | >=1.0.0 40 | ``` 41 | 42 | For markers: 43 | 44 | ```pycon 45 | >>> from dep_logic.markers import parse_marker 46 | >>> m1 = parse_marker("python_version < '3.8'") 47 | >>> m2 = parse_marker("python_version >= '3.6'") 48 | >>> print(m1 & m2) 49 | python_version < "3.8" and python_version >= "3.6" 50 | ``` 51 | 52 | ## About the project 53 | 54 | This project is based on @sdispater's [poetry-core](https://github.com/python-poetry/poetry-core) code, but it includes additional packages and a lark parser, which increases the package size and makes it less reusable. 55 | 56 | Furthermore, `poetry-core` does not always comply with PEP-508. As a result, this project aims to offer a lightweight utility for dependency specification logic using [PyPA's packaging](https://github.com/pypa/packaging). 57 | 58 | Submodules: 59 | 60 | - `dep_logic.specifiers` - PEP 440 version specifiers 61 | - `dep_logic.markers` - PEP 508 environment markers 62 | - `dep_logic.tags` - PEP 425 platform tags 63 | 64 | ## Caveats 65 | 66 | Logic operations with `===` specifiers is partially supported. 67 | -------------------------------------------------------------------------------- /src/dep_logic/markers/base.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from abc import ABCMeta, abstractmethod 4 | from typing import Any, Literal 5 | 6 | EvaluationContext = Literal["lock_file", "metadata", "requirement"] 7 | 8 | 9 | class BaseMarker(metaclass=ABCMeta): 10 | @property 11 | def complexity(self) -> tuple[int, ...]: 12 | """ 13 | The first number is the number of marker expressions, 14 | and the second number is 1 if the marker is single-like. 15 | """ 16 | return 1, 1 17 | 18 | @abstractmethod 19 | def __and__(self, other: Any) -> BaseMarker: 20 | raise NotImplementedError 21 | 22 | @abstractmethod 23 | def __or__(self, other: Any) -> BaseMarker: 24 | raise NotImplementedError 25 | 26 | def is_any(self) -> bool: 27 | """Returns True if the marker allows any environment.""" 28 | return False 29 | 30 | def is_empty(self) -> bool: 31 | """Returns True if the marker disallows any environment.""" 32 | return False 33 | 34 | @abstractmethod 35 | def evaluate( 36 | self, 37 | environment: dict[str, str | set[str]] | None = None, 38 | context: EvaluationContext = "metadata", 39 | ) -> bool: 40 | """Evaluates the marker against the given environment. 41 | 42 | Args: 43 | environment: The environment to evaluate against. 44 | context: The context in which the evaluation is performed, 45 | can be "lock_file", "metadata", or "requirement". 46 | """ 47 | raise NotImplementedError 48 | 49 | @abstractmethod 50 | def without_extras(self) -> BaseMarker: 51 | """Generate a new marker from the current marker but without "extra" markers.""" 52 | raise NotImplementedError 53 | 54 | @abstractmethod 55 | def exclude(self, marker_name: str) -> BaseMarker: 56 | """Generate a new marker from the current marker but without the given marker.""" 57 | raise NotImplementedError 58 | 59 | @abstractmethod 60 | def only(self, *marker_names: str) -> BaseMarker: 61 | """Generate a new marker from the current marker but only with the given markers.""" 62 | raise NotImplementedError 63 | 64 | def __repr__(self) -> str: 65 | return f"<{self.__class__.__name__} {self}>" 66 | 67 | @abstractmethod 68 | def __str__(self) -> str: 69 | raise NotImplementedError 70 | -------------------------------------------------------------------------------- /tests/specifier/test_union.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | from packaging.version import Version 3 | 4 | from dep_logic.specifiers import ( 5 | RangeSpecifier, 6 | UnionSpecifier, 7 | parse_version_specifier, 8 | ) 9 | 10 | 11 | @pytest.mark.parametrize( 12 | "spec,parsed", 13 | [ 14 | ( 15 | "!=1.2.3", 16 | UnionSpecifier( 17 | ( 18 | RangeSpecifier(max=Version("1.2.3")), 19 | RangeSpecifier(min=Version("1.2.3")), 20 | ) 21 | ), 22 | ), 23 | ( 24 | "!=1.2.*", 25 | UnionSpecifier( 26 | ( 27 | RangeSpecifier(max=Version("1.2.0")), 28 | RangeSpecifier(min=Version("1.3.0"), include_min=True), 29 | ) 30 | ), 31 | ), 32 | ], 33 | ) 34 | def test_parse_simple_union_specifier(spec: str, parsed: UnionSpecifier) -> None: 35 | value = parse_version_specifier(spec) 36 | assert value.is_simple() 37 | assert value == parsed 38 | assert str(value) == spec 39 | 40 | 41 | @pytest.mark.parametrize( 42 | "spec,parsed", 43 | [ 44 | ( 45 | "<3.0||>=3.6", 46 | UnionSpecifier( 47 | ( 48 | RangeSpecifier(max=Version("3.0.0")), 49 | RangeSpecifier(min=Version("3.6.0"), include_min=True), 50 | ) 51 | ), 52 | ), 53 | ( 54 | ">=2.7,!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,!=3.5.*,!=3.6.*", 55 | UnionSpecifier( 56 | ( 57 | RangeSpecifier( 58 | min=Version("2.7.0"), max=Version("3.0.0"), include_min=True 59 | ), 60 | RangeSpecifier(min=Version("3.7.0"), include_min=True), 61 | ) 62 | ), 63 | ), 64 | ], 65 | ) 66 | def test_parse_union_specifier(spec: str, parsed: UnionSpecifier) -> None: 67 | value = parse_version_specifier(spec) 68 | assert not value.is_simple() 69 | assert value == parsed 70 | 71 | 72 | @pytest.mark.parametrize( 73 | "a,b,expected", 74 | [ 75 | ("!=2.0", ">=1.0", "~=1.0||>2.0"), 76 | ("!=2.0", ">=2.0", ">2.0"), 77 | ("~=2.7||>=3.6", "==3.3", ""), 78 | ("~=2.7||>=3.6", "<3.0", "~=2.7"), 79 | ("~=2.7||==3.7.*", "<2.8||>=3.6", ">=2.7,<2.8||==3.7.*"), 80 | ], 81 | ) 82 | def test_union_intesection(a: str, b: str, expected: str) -> None: 83 | assert str(parse_version_specifier(a) & parse_version_specifier(b)) == expected 84 | 85 | 86 | @pytest.mark.parametrize( 87 | "a,b,expected", 88 | [ 89 | ("!=2.0", ">=1.0", ""), 90 | ("~=2.7||>=3.6", ">=3.0,<3.3", ">=2.7,<3.3||>=3.6"), 91 | ("~=2.7||>=3.6", ">=3.1,<3.3", "~=2.7||>=3.1,<3.3||>=3.6"), 92 | ("~=2.7||>=3.6", ">=3.0,<3.3||==3.4.*", ">=2.7,<3.3||==3.4.*||>=3.6"), 93 | ("~=2.7||>=3.6", "", "~=2.7||>=3.6"), 94 | ("~=2.7||>=3.6", "", ""), 95 | ], 96 | ) 97 | def test_union_union(a: str, b: str, expected: str) -> None: 98 | assert str(parse_version_specifier(a) | parse_version_specifier(b)) == expected 99 | -------------------------------------------------------------------------------- /pdm.lock: -------------------------------------------------------------------------------- 1 | # This file is @generated by PDM. 2 | # It is not intended for manual editing. 3 | 4 | [metadata] 5 | groups = ["default", "dev"] 6 | strategy = ["cross_platform", "inherit_metadata"] 7 | lock_version = "4.4.2" 8 | content_hash = "sha256:7fcb03ae06fee244b9af7cf9efb8e986ad48b8608e7a918f6ca9783f48470b23" 9 | 10 | [[package]] 11 | name = "colorama" 12 | version = "0.4.6" 13 | requires_python = "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,!=3.5.*,!=3.6.*,>=2.7" 14 | summary = "Cross-platform colored terminal text." 15 | groups = ["dev"] 16 | marker = "sys_platform == \"win32\"" 17 | files = [ 18 | {file = "colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6"}, 19 | {file = "colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44"}, 20 | ] 21 | 22 | [[package]] 23 | name = "exceptiongroup" 24 | version = "1.1.3" 25 | requires_python = ">=3.7" 26 | summary = "Backport of PEP 654 (exception groups)" 27 | groups = ["dev"] 28 | marker = "python_version < \"3.11\"" 29 | files = [ 30 | {file = "exceptiongroup-1.1.3-py3-none-any.whl", hash = "sha256:343280667a4585d195ca1cf9cef84a4e178c4b6cf2274caef9859782b567d5e3"}, 31 | {file = "exceptiongroup-1.1.3.tar.gz", hash = "sha256:097acd85d473d75af5bb98e41b61ff7fe35efe6675e4f9370ec6ec5126d160e9"}, 32 | ] 33 | 34 | [[package]] 35 | name = "iniconfig" 36 | version = "2.0.0" 37 | requires_python = ">=3.7" 38 | summary = "brain-dead simple config-ini parsing" 39 | groups = ["dev"] 40 | files = [ 41 | {file = "iniconfig-2.0.0-py3-none-any.whl", hash = "sha256:b6a85871a79d2e3b22d2d1b94ac2824226a63c6b741c88f7ae975f18b6778374"}, 42 | {file = "iniconfig-2.0.0.tar.gz", hash = "sha256:2d91e135bf72d31a410b17c16da610a82cb55f6b0477d1a902134b24a455b8b3"}, 43 | ] 44 | 45 | [[package]] 46 | name = "packaging" 47 | version = "23.2" 48 | requires_python = ">=3.7" 49 | summary = "Core utilities for Python packages" 50 | groups = ["default", "dev"] 51 | files = [ 52 | {file = "packaging-23.2-py3-none-any.whl", hash = "sha256:8c491190033a9af7e1d931d0b5dacc2ef47509b34dd0de67ed209b5203fc88c7"}, 53 | {file = "packaging-23.2.tar.gz", hash = "sha256:048fb0e9405036518eaaf48a55953c750c11e1a1b68e0dd1a9d62ed0c092cfc5"}, 54 | ] 55 | 56 | [[package]] 57 | name = "pluggy" 58 | version = "1.3.0" 59 | requires_python = ">=3.8" 60 | summary = "plugin and hook calling mechanisms for python" 61 | groups = ["dev"] 62 | files = [ 63 | {file = "pluggy-1.3.0-py3-none-any.whl", hash = "sha256:d89c696a773f8bd377d18e5ecda92b7a3793cbe66c87060a6fb58c7b6e1061f7"}, 64 | {file = "pluggy-1.3.0.tar.gz", hash = "sha256:cf61ae8f126ac6f7c451172cf30e3e43d3ca77615509771b3a984a0730651e12"}, 65 | ] 66 | 67 | [[package]] 68 | name = "pytest" 69 | version = "7.4.3" 70 | requires_python = ">=3.7" 71 | summary = "pytest: simple powerful testing with Python" 72 | groups = ["dev"] 73 | dependencies = [ 74 | "colorama; sys_platform == \"win32\"", 75 | "exceptiongroup>=1.0.0rc8; python_version < \"3.11\"", 76 | "iniconfig", 77 | "packaging", 78 | "pluggy<2.0,>=0.12", 79 | "tomli>=1.0.0; python_version < \"3.11\"", 80 | ] 81 | files = [ 82 | {file = "pytest-7.4.3-py3-none-any.whl", hash = "sha256:0d009c083ea859a71b76adf7c1d502e4bc170b80a8ef002da5806527b9591fac"}, 83 | {file = "pytest-7.4.3.tar.gz", hash = "sha256:d989d136982de4e3b29dabcc838ad581c64e8ed52c11fbe86ddebd9da0818cd5"}, 84 | ] 85 | 86 | [[package]] 87 | name = "tomli" 88 | version = "2.0.1" 89 | requires_python = ">=3.7" 90 | summary = "A lil' TOML parser" 91 | groups = ["dev"] 92 | marker = "python_version < \"3.11\"" 93 | files = [ 94 | {file = "tomli-2.0.1-py3-none-any.whl", hash = "sha256:939de3e7a6161af0c887ef91b7d41a53e7c5a1ca976325f429cb46ea9bc30ecc"}, 95 | {file = "tomli-2.0.1.tar.gz", hash = "sha256:de526c12914f0c550d15924c62d72abc48d6fe7364aa87328337a31007fe8a4f"}, 96 | ] 97 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Byte-compiled / optimized / DLL files 2 | __pycache__/ 3 | *.py[cod] 4 | *$py.class 5 | 6 | # C extensions 7 | *.so 8 | 9 | # Distribution / packaging 10 | .Python 11 | build/ 12 | develop-eggs/ 13 | dist/ 14 | downloads/ 15 | eggs/ 16 | .eggs/ 17 | lib/ 18 | lib64/ 19 | parts/ 20 | sdist/ 21 | var/ 22 | wheels/ 23 | share/python-wheels/ 24 | *.egg-info/ 25 | .installed.cfg 26 | *.egg 27 | MANIFEST 28 | 29 | # PyInstaller 30 | # Usually these files are written by a python script from a template 31 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 32 | *.manifest 33 | *.spec 34 | 35 | # Installer logs 36 | pip-log.txt 37 | pip-delete-this-directory.txt 38 | 39 | # Unit test / coverage reports 40 | htmlcov/ 41 | .tox/ 42 | .nox/ 43 | .coverage 44 | .coverage.* 45 | .cache 46 | nosetests.xml 47 | coverage.xml 48 | *.cover 49 | *.py,cover 50 | .hypothesis/ 51 | .pytest_cache/ 52 | cover/ 53 | 54 | # Translations 55 | *.mo 56 | *.pot 57 | 58 | # Django stuff: 59 | *.log 60 | local_settings.py 61 | db.sqlite3 62 | db.sqlite3-journal 63 | 64 | # Flask stuff: 65 | instance/ 66 | .webassets-cache 67 | 68 | # Scrapy stuff: 69 | .scrapy 70 | 71 | # Sphinx documentation 72 | docs/_build/ 73 | 74 | # PyBuilder 75 | .pybuilder/ 76 | target/ 77 | 78 | # Jupyter Notebook 79 | .ipynb_checkpoints 80 | 81 | # IPython 82 | profile_default/ 83 | ipython_config.py 84 | 85 | # pyenv 86 | # For a library or package, you might want to ignore these files since the code is 87 | # intended to run in multiple environments; otherwise, check them in: 88 | # .python-version 89 | 90 | # pipenv 91 | # According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. 92 | # However, in case of collaboration, if having platform-specific dependencies or dependencies 93 | # having no cross-platform support, pipenv may install dependencies that don't work, or not 94 | # install all needed dependencies. 95 | #Pipfile.lock 96 | 97 | # poetry 98 | # Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control. 99 | # This is especially recommended for binary packages to ensure reproducibility, and is more 100 | # commonly ignored for libraries. 101 | # https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control 102 | #poetry.lock 103 | 104 | # pdm 105 | # Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control. 106 | #pdm.lock 107 | # pdm stores project-wide configurations in .pdm.toml, but it is recommended to not include it 108 | # in version control. 109 | # https://pdm-project.org/#use-with-ide 110 | .pdm.toml 111 | .pdm-python 112 | .pdm-build/ 113 | 114 | # PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm 115 | __pypackages__/ 116 | 117 | # Celery stuff 118 | celerybeat-schedule 119 | celerybeat.pid 120 | 121 | # SageMath parsed files 122 | *.sage.py 123 | 124 | # Environments 125 | .env 126 | .venv 127 | env/ 128 | venv/ 129 | ENV/ 130 | env.bak/ 131 | venv.bak/ 132 | 133 | # Spyder project settings 134 | .spyderproject 135 | .spyproject 136 | 137 | # Rope project settings 138 | .ropeproject 139 | 140 | # mkdocs documentation 141 | /site 142 | 143 | # mypy 144 | .mypy_cache/ 145 | .dmypy.json 146 | dmypy.json 147 | 148 | # Pyre type checker 149 | .pyre/ 150 | 151 | # pytype static type analyzer 152 | .pytype/ 153 | 154 | # Cython debug symbols 155 | cython_debug/ 156 | 157 | # PyCharm 158 | # JetBrains specific template is maintained in a separate JetBrains.gitignore that can 159 | # be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore 160 | # and can be added to the global gitignore or merged into this file. For a more nuclear 161 | # option (not recommended) you can uncomment the following to ignore the entire idea folder. 162 | #.idea/ 163 | -------------------------------------------------------------------------------- /src/dep_logic/markers/utils.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import functools 4 | import itertools 5 | from typing import AbstractSet, Iterable, Iterator, TypeVar 6 | 7 | from dep_logic.markers.base import BaseMarker 8 | 9 | T = TypeVar("T") 10 | 11 | 12 | @functools.lru_cache(maxsize=None) 13 | def cnf(marker: BaseMarker) -> BaseMarker: 14 | from dep_logic.markers.multi import MultiMarker 15 | from dep_logic.markers.union import MarkerUnion 16 | 17 | """Transforms the marker into CNF (conjunctive normal form).""" 18 | if isinstance(marker, MarkerUnion): 19 | cnf_markers = [cnf(m) for m in marker.markers] 20 | sub_marker_lists = [ 21 | m.markers if isinstance(m, MultiMarker) else [m] for m in cnf_markers 22 | ] 23 | return MultiMarker.of( 24 | *[MarkerUnion.of(*c) for c in itertools.product(*sub_marker_lists)] 25 | ) 26 | 27 | if isinstance(marker, MultiMarker): 28 | return MultiMarker.of(*[cnf(m) for m in marker.markers]) 29 | 30 | return marker 31 | 32 | 33 | @functools.lru_cache(maxsize=None) 34 | def dnf(marker: BaseMarker) -> BaseMarker: 35 | """Transforms the marker into DNF (disjunctive normal form).""" 36 | from dep_logic.markers.multi import MultiMarker 37 | from dep_logic.markers.union import MarkerUnion 38 | 39 | if isinstance(marker, MultiMarker): 40 | dnf_markers = [dnf(m) for m in marker.markers] 41 | sub_marker_lists = [ 42 | m.markers if isinstance(m, MarkerUnion) else [m] for m in dnf_markers 43 | ] 44 | return MarkerUnion.of( 45 | *[MultiMarker.of(*c) for c in itertools.product(*sub_marker_lists)] 46 | ) 47 | 48 | if isinstance(marker, MarkerUnion): 49 | return MarkerUnion.of(*[dnf(m) for m in marker.markers]) 50 | 51 | return marker 52 | 53 | 54 | def intersection(*markers: BaseMarker) -> BaseMarker: 55 | from dep_logic.markers.multi import MultiMarker 56 | 57 | return dnf(MultiMarker(*markers)) 58 | 59 | 60 | def union(*markers: BaseMarker) -> BaseMarker: 61 | from dep_logic.markers.multi import MultiMarker 62 | from dep_logic.markers.union import MarkerUnion 63 | 64 | # Sometimes normalization makes it more complicate instead of simple 65 | # -> choose candidate with the least complexity 66 | unnormalized: BaseMarker = MarkerUnion(*markers) 67 | while ( 68 | isinstance(unnormalized, (MultiMarker, MarkerUnion)) 69 | and len(unnormalized.markers) == 1 70 | ): 71 | unnormalized = unnormalized.markers[0] 72 | 73 | conjunction = cnf(unnormalized) 74 | if not isinstance(conjunction, MultiMarker): 75 | return conjunction 76 | 77 | disjunction = dnf(conjunction) 78 | if not isinstance(disjunction, MarkerUnion): 79 | return disjunction 80 | 81 | return min(disjunction, conjunction, unnormalized, key=lambda x: x.complexity) 82 | 83 | 84 | _op_reflect_map = { 85 | "<": ">", 86 | "<=": ">=", 87 | ">": "<", 88 | ">=": "<=", 89 | "==": "==", 90 | "!=": "!=", 91 | "===": "===", 92 | "~=": "~=", 93 | "in": "in", 94 | "not in": "not in", 95 | } 96 | 97 | 98 | def get_reflect_op(op: str) -> str: 99 | return _op_reflect_map[op] 100 | 101 | 102 | class OrderedSet(AbstractSet[T]): 103 | def __init__(self, iterable: Iterable[T]) -> None: 104 | self._data: list[T] = [] 105 | for item in iterable: 106 | if item in self._data: 107 | continue 108 | self._data.append(item) 109 | 110 | def __hash__(self) -> int: 111 | return self._hash() 112 | 113 | def __contains__(self, obj: object) -> bool: 114 | return obj in self._data 115 | 116 | def __iter__(self) -> Iterator[T]: 117 | return iter(self._data) 118 | 119 | def __len__(self) -> int: 120 | return len(self._data) 121 | 122 | def peek(self) -> T: 123 | return self._data[0] 124 | -------------------------------------------------------------------------------- /src/dep_logic/markers/__init__.py: -------------------------------------------------------------------------------- 1 | # Adapted from poetry/core/version/markers.py 2 | # The original work is published under the MIT license. 3 | # Copyright (c) 2020 Sébastien Eustace 4 | # Adapted by Frost Ming (c) 2023 5 | 6 | from __future__ import annotations 7 | 8 | import functools 9 | from typing import TYPE_CHECKING 10 | 11 | from packaging.markers import InvalidMarker as _InvalidMarker 12 | from packaging.markers import Marker as _Marker 13 | 14 | from dep_logic.markers.any import AnyMarker 15 | from dep_logic.markers.base import BaseMarker 16 | from dep_logic.markers.empty import EmptyMarker 17 | from dep_logic.markers.multi import MultiMarker 18 | from dep_logic.markers.single import MarkerExpression 19 | from dep_logic.markers.union import MarkerUnion 20 | from dep_logic.utils import get_reflect_op 21 | 22 | if TYPE_CHECKING: 23 | from typing import List, Literal, Tuple, Union 24 | 25 | from packaging.markers import Op, Value, Variable 26 | 27 | _ParsedMarker = Tuple[Variable, Op, Value] 28 | _ParsedMarkers = Union[ 29 | _ParsedMarker, List[Union["_ParsedMarkers", Literal["or", "and"]]] 30 | ] 31 | 32 | 33 | __all__ = [ 34 | "AnyMarker", 35 | "BaseMarker", 36 | "EmptyMarker", 37 | "InvalidMarker", 38 | "MarkerExpression", 39 | "MarkerUnion", 40 | "MultiMarker", 41 | "from_pkg_marker", 42 | "parse_marker", 43 | ] 44 | 45 | 46 | class InvalidMarker(ValueError): 47 | """ 48 | An invalid marker was found, users should refer to PEP 508. 49 | """ 50 | 51 | 52 | @functools.lru_cache(maxsize=None) 53 | def parse_marker(marker: str) -> BaseMarker: 54 | if marker == "": 55 | return EmptyMarker() 56 | 57 | if not marker or marker == "*": 58 | return AnyMarker() 59 | try: 60 | parsed = _Marker(marker) 61 | except _InvalidMarker as e: 62 | raise InvalidMarker(str(e)) from e 63 | 64 | markers = _build_markers(parsed._markers) 65 | 66 | return markers 67 | 68 | 69 | def from_pkg_marker(marker: _Marker) -> BaseMarker: 70 | return _build_markers(marker._markers) 71 | 72 | 73 | def _build_markers(markers: _ParsedMarkers) -> BaseMarker: 74 | from packaging.markers import Variable 75 | 76 | if isinstance(markers, tuple): 77 | if isinstance(markers[0], Variable): 78 | name, op, value, reversed = ( 79 | str(markers[0]), 80 | str(markers[1]), 81 | str(markers[2]), 82 | False, 83 | ) 84 | else: 85 | # in reverse order 86 | name, op, value, reversed = ( 87 | str(markers[2]), 88 | get_reflect_op(str(markers[1])), 89 | str(markers[0]), 90 | True, 91 | ) 92 | return MarkerExpression(name, op, value, reversed) 93 | or_groups: list[BaseMarker] = [AnyMarker()] 94 | for item in markers: 95 | if item == "or": 96 | or_groups.append(AnyMarker()) 97 | elif item == "and": 98 | continue 99 | else: 100 | or_groups[-1] &= _build_markers(item) 101 | return MarkerUnion.of(*or_groups) 102 | 103 | 104 | def _patch_marker_parser() -> None: 105 | import re 106 | 107 | try: 108 | from packaging._tokenizer import DEFAULT_RULES 109 | except (ModuleNotFoundError, AttributeError): 110 | return 111 | 112 | DEFAULT_RULES["VARIABLE"] = re.compile( 113 | r""" 114 | \b( 115 | python_version 116 | |python_full_version 117 | |os[._]name 118 | |sys[._]platform 119 | |platform_(release|system) 120 | |platform[._](version|machine|python_implementation) 121 | |python_implementation 122 | |implementation_(name|version) 123 | |extras? 124 | |dependency_groups 125 | )\b 126 | """, 127 | re.VERBOSE, 128 | ) 129 | 130 | 131 | _patch_marker_parser() 132 | del _patch_marker_parser 133 | -------------------------------------------------------------------------------- /src/dep_logic/specifiers/generic.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import operator 4 | import typing as t 5 | from dataclasses import dataclass 6 | 7 | from dep_logic.specifiers.base import BaseSpecifier, InvalidSpecifier 8 | from dep_logic.specifiers.special import AnySpecifier, EmptySpecifier 9 | from dep_logic.utils import DATACLASS_ARGS 10 | 11 | Operator = t.Callable[[str, str], bool] 12 | 13 | 14 | @dataclass(frozen=True, unsafe_hash=True, **DATACLASS_ARGS) 15 | class GenericSpecifier(BaseSpecifier): 16 | op: str 17 | value: str 18 | op_order: t.ClassVar[dict[str, int]] = {"==": 0, "!=": 1, "in": 2, "not in": 3} 19 | _op_map: t.ClassVar[dict[str, Operator]] = { 20 | "==": operator.eq, 21 | "!=": operator.ne, 22 | "in": lambda lhs, rhs: lhs in rhs, 23 | "not in": lambda lhs, rhs: lhs not in rhs, 24 | ">": operator.gt, 25 | ">=": operator.ge, 26 | "<": operator.lt, 27 | "<=": operator.le, 28 | } 29 | 30 | def __post_init__(self) -> None: 31 | if self.op not in self._op_map: 32 | raise InvalidSpecifier(f"Invalid operator: {self.op!r}") 33 | 34 | def __str__(self) -> str: 35 | return f'{self.op} "{self.value}"' 36 | 37 | def __invert__(self) -> BaseSpecifier: 38 | invert_map = { 39 | "==": "!=", 40 | "!=": "==", 41 | "not in": "in", 42 | "in": "not in", 43 | "<": ">=", 44 | "<=": ">", 45 | ">": "<=", 46 | ">=": "<", 47 | } 48 | op = invert_map[self.op] 49 | return GenericSpecifier(op, self.value) 50 | 51 | def __and__(self, other: t.Any) -> BaseSpecifier: 52 | if not isinstance(other, GenericSpecifier): 53 | return NotImplemented 54 | if self == other: 55 | return self 56 | this, that = sorted( 57 | (self, other), key=lambda x: self.op_order.get(x.op, len(self.op_order)) 58 | ) 59 | if this.op == that.op == "==": 60 | # left must be different from right 61 | return EmptySpecifier() 62 | elif (this.op, that.op) == ("==", "!="): 63 | if this.value == that.value: 64 | return EmptySpecifier() 65 | return this 66 | elif (this.op, that.op) == ("in", "not in") and this.value == that.value: 67 | return EmptySpecifier() 68 | elif (this.op, that.op) == ("==", "in"): 69 | if this.value in that.value: 70 | return this 71 | return EmptySpecifier() 72 | elif (this.op, that.op) == ("!=", "not in") and this.value in that.value: 73 | return that 74 | else: 75 | raise NotImplementedError 76 | 77 | def __or__(self, other: t.Any) -> BaseSpecifier: 78 | if not isinstance(other, GenericSpecifier): 79 | return NotImplemented 80 | if self == other: 81 | return self 82 | this, that = sorted( 83 | (self, other), key=lambda x: self.op_order.get(x.op, len(self.op_order)) 84 | ) 85 | if this.op == "==" and that.op == "!=": 86 | if this.value == that.value: 87 | return AnySpecifier() 88 | return that 89 | elif this.op == "!=" and that.op == "!=": 90 | return AnySpecifier() 91 | elif this.op == "in" and that.op == "not in" and this.value == that.value: 92 | return AnySpecifier() 93 | elif this.op == "!=" and that.op == "in" and this.value in that.value: 94 | return AnySpecifier() 95 | elif this.op == "!=" and that.op == "not in": 96 | if this.value in that.value: 97 | return this 98 | return AnySpecifier() 99 | elif this.op == "==" and that.op == "in" and this.value in that.value: 100 | return that 101 | else: 102 | raise NotImplementedError 103 | 104 | def __contains__(self, value: str) -> bool: 105 | return self._op_map[self.op](value, self.value) 106 | -------------------------------------------------------------------------------- /src/dep_logic/specifiers/__init__.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import functools 4 | import itertools 5 | import operator 6 | 7 | from packaging.specifiers import InvalidSpecifier as PkgInvalidSpecifier 8 | from packaging.specifiers import Specifier, SpecifierSet 9 | from packaging.version import Version 10 | 11 | from dep_logic.specifiers.arbitrary import ArbitrarySpecifier 12 | from dep_logic.specifiers.base import ( 13 | BaseSpecifier, 14 | InvalidSpecifier, 15 | VersionSpecifier, 16 | ) 17 | from dep_logic.specifiers.generic import GenericSpecifier 18 | from dep_logic.specifiers.range import RangeSpecifier 19 | from dep_logic.specifiers.special import AnySpecifier, EmptySpecifier 20 | from dep_logic.specifiers.union import UnionSpecifier 21 | from dep_logic.utils import is_not_suffix, version_split 22 | 23 | 24 | def from_specifierset(spec: SpecifierSet) -> VersionSpecifier: 25 | """Convert from a packaging.specifiers.SpecifierSet object.""" 26 | 27 | return functools.reduce( 28 | operator.and_, map(_from_pkg_specifier, spec), RangeSpecifier() 29 | ) 30 | 31 | 32 | def _from_pkg_specifier(spec: Specifier) -> VersionSpecifier: 33 | version = spec.version 34 | min: Version | None = None 35 | max: Version | None = None 36 | include_min = False 37 | include_max = False 38 | if (op := spec.operator) in (">", ">="): 39 | min = Version(version) 40 | include_min = spec.operator == ">=" 41 | elif op in ("<", "<="): 42 | max = Version(version) 43 | include_max = spec.operator == "<=" 44 | elif op == "==": 45 | if "*" not in version: 46 | min = Version(version) 47 | max = Version(version) 48 | include_min = True 49 | include_max = True 50 | else: 51 | version_parts = list( 52 | itertools.takewhile(lambda x: x != "*", version_split(version)) 53 | ) 54 | min = Version(".".join([*version_parts, "0"])) 55 | version_parts[-1] = str(int(version_parts[-1]) + 1) 56 | max = Version(".".join([*version_parts, "0"])) 57 | include_min = True 58 | include_max = False 59 | elif op == "~=": 60 | min = Version(version) 61 | version_parts = list( 62 | itertools.takewhile(is_not_suffix, version_split(version)) 63 | )[:-1] 64 | version_parts[-1] = str(int(version_parts[-1]) + 1) 65 | max = Version(".".join([*version_parts, "0"])) 66 | include_min = True 67 | include_max = False 68 | elif op == "!=": 69 | if "*" not in version: 70 | v = Version(version) 71 | return UnionSpecifier( 72 | ( 73 | RangeSpecifier(max=v, include_max=False), 74 | RangeSpecifier(min=v, include_min=False), 75 | ), 76 | simplified=str(spec), 77 | ) 78 | else: 79 | version_parts = list( 80 | itertools.takewhile(lambda x: x != "*", version_split(version)) 81 | ) 82 | left = Version(".".join([*version_parts, "0"])) 83 | version_parts[-1] = str(int(version_parts[-1]) + 1) 84 | right = Version(".".join([*version_parts, "0"])) 85 | return UnionSpecifier( 86 | ( 87 | RangeSpecifier(max=left, include_max=False), 88 | RangeSpecifier(min=right, include_min=True), 89 | ), 90 | simplified=str(spec), 91 | ) 92 | elif op == "===": 93 | return ArbitrarySpecifier(target=version) 94 | else: 95 | raise InvalidSpecifier(f'Unsupported operator "{op}" in specifier "{spec}"') 96 | return RangeSpecifier( 97 | min=min, 98 | max=max, 99 | include_min=include_min, 100 | include_max=include_max, 101 | simplified=str(spec), 102 | ) 103 | 104 | 105 | def parse_version_specifier(spec: str) -> BaseSpecifier: 106 | """Parse a specifier string.""" 107 | if spec == "": 108 | return EmptySpecifier() 109 | if "||" in spec: 110 | return functools.reduce( 111 | operator.or_, map(parse_version_specifier, spec.split("||")) 112 | ) 113 | try: 114 | pkg_spec = SpecifierSet(spec) 115 | except PkgInvalidSpecifier as e: 116 | raise InvalidSpecifier(str(e)) from e 117 | else: 118 | return from_specifierset(pkg_spec) 119 | 120 | 121 | __all__ = [ 122 | "from_specifierset", 123 | "parse_version_specifier", 124 | "VersionSpecifier", 125 | "EmptySpecifier", 126 | "AnySpecifier", 127 | "RangeSpecifier", 128 | "UnionSpecifier", 129 | "BaseSpecifier", 130 | "GenericSpecifier", 131 | "ArbitrarySpecifier", 132 | "InvalidSpecifier", 133 | ] 134 | -------------------------------------------------------------------------------- /tests/marker/test_common.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import pytest 4 | 5 | from dep_logic.markers import parse_marker 6 | 7 | 8 | @pytest.mark.parametrize( 9 | "marker, expected", 10 | [ 11 | ('python_version >= "3.6"', 'python_version >= "3.6"'), 12 | ('python_version >= "3.6" and extra == "foo"', 'python_version >= "3.6"'), 13 | ( 14 | 'python_version >= "3.6" and (extra == "foo" or extra == "bar")', 15 | 'python_version >= "3.6"', 16 | ), 17 | ( 18 | ( 19 | 'python_version >= "3.6" and (extra == "foo" or extra == "bar") or' 20 | ' implementation_name == "pypy"' 21 | ), 22 | 'python_version >= "3.6" or implementation_name == "pypy"', 23 | ), 24 | ( 25 | ( 26 | 'python_version >= "3.6" and extra == "foo" or implementation_name ==' 27 | ' "pypy" and extra == "bar"' 28 | ), 29 | 'python_version >= "3.6" or implementation_name == "pypy"', 30 | ), 31 | ( 32 | ( 33 | 'python_version >= "3.6" or extra == "foo" and implementation_name ==' 34 | ' "pypy" or extra == "bar"' 35 | ), 36 | 'python_version >= "3.6" or implementation_name == "pypy"', 37 | ), 38 | ('extra == "foo"', ""), 39 | ('extra == "foo" or extra == "bar"', ""), 40 | ], 41 | ) 42 | def test_without_extras(marker: str, expected: str) -> None: 43 | m = parse_marker(marker) 44 | 45 | assert str(m.without_extras()) == expected 46 | 47 | 48 | @pytest.mark.parametrize( 49 | "marker, excluded, expected", 50 | [ 51 | ('python_version >= "3.6"', "implementation_name", 'python_version >= "3.6"'), 52 | ('python_version >= "3.6"', "python_version", "*"), 53 | ('python_version >= "3.6" and python_version < "3.11"', "python_version", "*"), 54 | ( 55 | 'python_version >= "3.6" and extra == "foo"', 56 | "extra", 57 | 'python_version >= "3.6"', 58 | ), 59 | ( 60 | 'python_version >= "3.6" and (extra == "foo" or extra == "bar")', 61 | "python_version", 62 | 'extra == "foo" or extra == "bar"', 63 | ), 64 | ( 65 | ( 66 | 'python_version >= "3.6" and (extra == "foo" or extra == "bar") or' 67 | ' implementation_name == "pypy"' 68 | ), 69 | "python_version", 70 | 'extra == "foo" or extra == "bar" or implementation_name == "pypy"', 71 | ), 72 | ( 73 | ( 74 | 'python_version >= "3.6" and extra == "foo" or implementation_name ==' 75 | ' "pypy" and extra == "bar"' 76 | ), 77 | "implementation_name", 78 | 'python_version >= "3.6" and extra == "foo" or extra == "bar"', 79 | ), 80 | ( 81 | ( 82 | 'python_version >= "3.6" or extra == "foo" and implementation_name ==' 83 | ' "pypy" or extra == "bar"' 84 | ), 85 | "implementation_name", 86 | 'python_version >= "3.6" or extra == "foo" or extra == "bar"', 87 | ), 88 | ( 89 | 'extra == "foo" and python_version >= "3.6" or python_version >= "3.6"', 90 | "extra", 91 | 'python_version >= "3.6"', 92 | ), 93 | ], 94 | ) 95 | def test_exclude(marker: str, excluded: str, expected: str) -> None: 96 | m = parse_marker(marker) 97 | 98 | if expected == "*": 99 | assert m.exclude(excluded).is_any() 100 | else: 101 | assert str(m.exclude(excluded)) == expected 102 | 103 | 104 | @pytest.mark.parametrize( 105 | "marker, only, expected", 106 | [ 107 | ('python_version >= "3.6"', ["python_version"], 'python_version >= "3.6"'), 108 | ('python_version >= "3.6"', ["sys_platform"], ""), 109 | ( 110 | 'python_version >= "3.6" and extra == "foo"', 111 | ["python_version"], 112 | 'python_version >= "3.6"', 113 | ), 114 | ('python_version >= "3.6" and extra == "foo"', ["sys_platform"], ""), 115 | ('python_version >= "3.6" or extra == "foo"', ["sys_platform"], ""), 116 | ('python_version >= "3.6" or extra == "foo"', ["python_version"], ""), 117 | ( 118 | 'python_version >= "3.6" and (extra == "foo" or extra == "bar")', 119 | ["extra"], 120 | 'extra == "foo" or extra == "bar"', 121 | ), 122 | ( 123 | ( 124 | 'python_version >= "3.6" and (extra == "foo" or extra == "bar") or' 125 | ' implementation_name == "pypy"' 126 | ), 127 | ["implementation_name"], 128 | "", 129 | ), 130 | ( 131 | ( 132 | 'python_version >= "3.6" and (extra == "foo" or extra == "bar") or' 133 | ' implementation_name == "pypy"' 134 | ), 135 | ["implementation_name", "extra"], 136 | 'extra == "foo" or extra == "bar" or implementation_name == "pypy"', 137 | ), 138 | ( 139 | ( 140 | 'python_version >= "3.6" and (extra == "foo" or extra == "bar") or' 141 | ' implementation_name == "pypy"' 142 | ), 143 | ["implementation_name", "python_version"], 144 | 'python_version >= "3.6" or implementation_name == "pypy"', 145 | ), 146 | ( 147 | ( 148 | 'python_version >= "3.6" and extra == "foo" or implementation_name ==' 149 | ' "pypy" and extra == "bar"' 150 | ), 151 | ["implementation_name", "extra"], 152 | 'extra == "foo" or implementation_name == "pypy" and extra == "bar"', 153 | ), 154 | ], 155 | ) 156 | def test_only(marker: str, only: list[str], expected: str) -> None: 157 | m = parse_marker(marker) 158 | 159 | assert str(m.only(*only)) == expected 160 | -------------------------------------------------------------------------------- /src/dep_logic/utils.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import functools 4 | import itertools 5 | import re 6 | import sys 7 | from typing import TYPE_CHECKING, AbstractSet, Iterable, Iterator, Protocol, TypeVar 8 | 9 | _prefix_regex = re.compile(r"^([0-9]+)((?:a|b|c|rc)[0-9]+)$") 10 | 11 | if TYPE_CHECKING: 12 | from typing import TypedDict 13 | 14 | from dep_logic.markers.base import BaseMarker 15 | 16 | class _DataClassArgs(TypedDict, total=False): 17 | slots: bool 18 | repr: bool 19 | 20 | 21 | if sys.version_info >= (3, 10): 22 | DATACLASS_ARGS: _DataClassArgs = {"slots": True, "repr": False} 23 | else: 24 | DATACLASS_ARGS = {"repr": False} 25 | 26 | 27 | class Ident(Protocol): 28 | def __hash__(self) -> int: ... 29 | 30 | def __eq__(self, __value: object) -> bool: ... 31 | 32 | 33 | T = TypeVar("T", bound=Ident) 34 | V = TypeVar("V") 35 | 36 | 37 | def version_split(version: str) -> list[str]: 38 | result: list[str] = [] 39 | for item in version.split("."): 40 | match = _prefix_regex.search(item) 41 | if match: 42 | result.extend(match.groups()) 43 | else: 44 | result.append(item) 45 | return result 46 | 47 | 48 | def is_not_suffix(segment: str) -> bool: 49 | return not any( 50 | segment.startswith(prefix) for prefix in ("dev", "a", "b", "rc", "post") 51 | ) 52 | 53 | 54 | def flatten_items(items: Iterable[T], flatten_cls: type[Iterable[T]]) -> list[T]: 55 | flattened: list[T] = [] 56 | for item in items: 57 | if isinstance(item, flatten_cls): 58 | for subitem in flatten_items(item, flatten_cls): 59 | if subitem not in flattened: 60 | flattened.append(subitem) 61 | elif item not in flattened: 62 | flattened.append(item) 63 | return flattened 64 | 65 | 66 | def first_different_index( 67 | iterable1: Iterable[object], iterable2: Iterable[object] 68 | ) -> int: 69 | index = 0 70 | for index, (item1, item2) in enumerate(zip(iterable1, iterable2)): 71 | if item1 != item2: 72 | return index 73 | return index + 1 74 | 75 | 76 | def pad_zeros(parts: list[int], to_length: int) -> list[int]: 77 | if len(parts) >= to_length: 78 | return parts 79 | return parts + [0] * (to_length - len(parts)) 80 | 81 | 82 | @functools.lru_cache(maxsize=None) 83 | def cnf(marker: BaseMarker) -> BaseMarker: 84 | from dep_logic.markers.multi import MultiMarker 85 | from dep_logic.markers.union import MarkerUnion 86 | 87 | """Transforms the marker into CNF (conjunctive normal form).""" 88 | if isinstance(marker, MarkerUnion): 89 | cnf_markers = [cnf(m) for m in marker.markers] 90 | sub_marker_lists = [ 91 | m.markers if isinstance(m, MultiMarker) else [m] for m in cnf_markers 92 | ] 93 | return MultiMarker.of( 94 | *[MarkerUnion.of(*c) for c in itertools.product(*sub_marker_lists)] 95 | ) 96 | 97 | if isinstance(marker, MultiMarker): 98 | return MultiMarker.of(*[cnf(m) for m in marker.markers]) 99 | 100 | return marker 101 | 102 | 103 | @functools.lru_cache(maxsize=None) 104 | def dnf(marker: BaseMarker) -> BaseMarker: 105 | """Transforms the marker into DNF (disjunctive normal form).""" 106 | from dep_logic.markers.multi import MultiMarker 107 | from dep_logic.markers.union import MarkerUnion 108 | 109 | if isinstance(marker, MultiMarker): 110 | dnf_markers = [dnf(m) for m in marker.markers] 111 | sub_marker_lists = [ 112 | m.markers if isinstance(m, MarkerUnion) else [m] for m in dnf_markers 113 | ] 114 | return MarkerUnion.of( 115 | *[MultiMarker.of(*c) for c in itertools.product(*sub_marker_lists)] 116 | ) 117 | 118 | if isinstance(marker, MarkerUnion): 119 | return MarkerUnion.of(*[dnf(m) for m in marker.markers]) 120 | 121 | return marker 122 | 123 | 124 | def intersection(*markers: BaseMarker) -> BaseMarker: 125 | from dep_logic.markers.multi import MultiMarker 126 | 127 | return dnf(MultiMarker(*markers)) 128 | 129 | 130 | def union(*markers: BaseMarker) -> BaseMarker: 131 | from dep_logic.markers.multi import MultiMarker 132 | from dep_logic.markers.union import MarkerUnion 133 | 134 | # Sometimes normalization makes it more complicate instead of simple 135 | # -> choose candidate with the least complexity 136 | unnormalized: BaseMarker = MarkerUnion(*markers) 137 | while ( 138 | isinstance(unnormalized, (MultiMarker, MarkerUnion)) 139 | and len(unnormalized.markers) == 1 140 | ): 141 | unnormalized = unnormalized.markers[0] 142 | 143 | conjunction = cnf(unnormalized) 144 | if not isinstance(conjunction, MultiMarker): 145 | return conjunction 146 | 147 | disjunction = dnf(conjunction) 148 | if not isinstance(disjunction, MarkerUnion): 149 | return disjunction 150 | 151 | return min(disjunction, conjunction, unnormalized, key=lambda x: x.complexity) 152 | 153 | 154 | _op_reflect_map = { 155 | "<": ">", 156 | "<=": ">=", 157 | ">": "<", 158 | ">=": "<=", 159 | "==": "==", 160 | "!=": "!=", 161 | "===": "===", 162 | "~=": "~=", 163 | "in": "in", 164 | "not in": "not in", 165 | } 166 | 167 | 168 | def get_reflect_op(op: str) -> str: 169 | return _op_reflect_map[op] 170 | 171 | 172 | class OrderedSet(AbstractSet[T]): 173 | def __init__(self, iterable: Iterable[T]) -> None: 174 | self._data: list[T] = [] 175 | for item in iterable: 176 | if item in self._data: 177 | continue 178 | self._data.append(item) 179 | 180 | def __hash__(self) -> int: 181 | return self._hash() 182 | 183 | def __contains__(self, obj: object) -> bool: 184 | return obj in self._data 185 | 186 | def __iter__(self) -> Iterator[T]: 187 | return iter(self._data) 188 | 189 | def __len__(self) -> int: 190 | return len(self._data) 191 | 192 | def peek(self) -> T: 193 | return self._data[0] 194 | 195 | 196 | def normalize_name(name: str) -> str: 197 | return re.sub(r"[-_.]+", "-", name).lower() 198 | -------------------------------------------------------------------------------- /tests/tags/test_tags.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | 3 | from dep_logic.tags import EnvSpec 4 | from dep_logic.tags.tags import EnvCompatibility 5 | 6 | 7 | def test_check_wheel_tags(): 8 | wheels = [ 9 | "protobuf-5.27.2-cp313-cp313t-macosx_14_0_arm64.whl", 10 | "protobuf-5.27.2-cp313-cp313-macosx_14_0_arm64.whl", 11 | "protobuf-5.27.2-cp310-abi3-win32.whl", 12 | "protobuf-5.27.2-cp310-abi3-win_amd64.whl", 13 | "protobuf-5.27.2-cp310-cp310-macosx_12_0_arm64.whl", 14 | "protobuf-5.27.2-cp38-abi3-macosx_10_9_universal2.whl", 15 | "protobuf-5.27.2-cp38-abi3-manylinux2014_aarch64.whl", 16 | "protobuf-5.27.2-cp38-abi3-manylinux2014_x86_64.whl", 17 | "protobuf-5.27.2-cp38-cp38-win32.whl", 18 | "protobuf-5.27.2-cp38-cp38-win_amd64.whl", 19 | "protobuf-5.27.2-cp39-cp39-win32.whl", 20 | "protobuf-5.27.2-cp39-cp39-win_amd64.whl", 21 | "protobuf-5.27.2-py3-none-any.whl", 22 | ] 23 | 24 | linux_env = EnvSpec.from_spec(">=3.9", "linux", "cpython") 25 | wheel_compats = { 26 | f: c 27 | for f, c in {f: linux_env.wheel_compatibility(f) for f in wheels}.items() 28 | if c is not None 29 | } 30 | filtered_wheels = sorted(wheel_compats, key=wheel_compats.__getitem__, reverse=True) 31 | assert filtered_wheels == [ 32 | "protobuf-5.27.2-cp38-abi3-manylinux2014_x86_64.whl", 33 | "protobuf-5.27.2-py3-none-any.whl", 34 | ] 35 | 36 | windows_env = EnvSpec.from_spec(">=3.9", "windows", "cpython") 37 | wheel_compats = { 38 | f: c 39 | for f, c in {f: windows_env.wheel_compatibility(f) for f in wheels}.items() 40 | if c is not None 41 | } 42 | filtered_wheels = sorted(wheel_compats, key=wheel_compats.__getitem__, reverse=True) 43 | assert filtered_wheels == [ 44 | "protobuf-5.27.2-cp310-abi3-win_amd64.whl", 45 | "protobuf-5.27.2-cp39-cp39-win_amd64.whl", 46 | "protobuf-5.27.2-py3-none-any.whl", 47 | ] 48 | 49 | macos_env = EnvSpec.from_spec(">=3.9", "macos", "cpython") 50 | wheel_compats = { 51 | f: c 52 | for f, c in {f: macos_env.wheel_compatibility(f) for f in wheels}.items() 53 | if c is not None 54 | } 55 | filtered_wheels = sorted(wheel_compats, key=wheel_compats.__getitem__, reverse=True) 56 | assert filtered_wheels == [ 57 | "protobuf-5.27.2-cp313-cp313-macosx_14_0_arm64.whl", 58 | "protobuf-5.27.2-cp310-cp310-macosx_12_0_arm64.whl", 59 | "protobuf-5.27.2-cp38-abi3-macosx_10_9_universal2.whl", 60 | "protobuf-5.27.2-py3-none-any.whl", 61 | ] 62 | 63 | macos_free_threaded_env = EnvSpec.from_spec(">=3.9", "macos", "cpython", True) 64 | wheel_compats = { 65 | f: c 66 | for f, c in { 67 | f: macos_free_threaded_env.wheel_compatibility(f) for f in wheels 68 | }.items() 69 | if c is not None 70 | } 71 | filtered_wheels = sorted(wheel_compats, key=wheel_compats.__getitem__, reverse=True) 72 | assert filtered_wheels == [ 73 | "protobuf-5.27.2-cp313-cp313t-macosx_14_0_arm64.whl", 74 | "protobuf-5.27.2-py3-none-any.whl", 75 | ] 76 | 77 | python_env = EnvSpec.from_spec(">=3.9") 78 | wheel_compats = { 79 | f: c 80 | for f, c in {f: python_env.wheel_compatibility(f) for f in wheels}.items() 81 | if c is not None 82 | } 83 | filtered_wheels = sorted(wheel_compats, key=wheel_compats.__getitem__, reverse=True) 84 | assert filtered_wheels == [ 85 | "protobuf-5.27.2-cp313-cp313t-macosx_14_0_arm64.whl", 86 | "protobuf-5.27.2-cp313-cp313-macosx_14_0_arm64.whl", 87 | "protobuf-5.27.2-cp310-cp310-macosx_12_0_arm64.whl", 88 | "protobuf-5.27.2-cp310-abi3-win32.whl", 89 | "protobuf-5.27.2-cp310-abi3-win_amd64.whl", 90 | "protobuf-5.27.2-cp39-cp39-win32.whl", 91 | "protobuf-5.27.2-cp39-cp39-win_amd64.whl", 92 | "protobuf-5.27.2-cp38-abi3-macosx_10_9_universal2.whl", 93 | "protobuf-5.27.2-cp38-abi3-manylinux2014_aarch64.whl", 94 | "protobuf-5.27.2-cp38-abi3-manylinux2014_x86_64.whl", 95 | "protobuf-5.27.2-py3-none-any.whl", 96 | ] 97 | 98 | 99 | @pytest.mark.parametrize( 100 | "left,right,expected", 101 | [ 102 | ( 103 | EnvSpec.from_spec(">=3.9", "macos", "cpython"), 104 | EnvSpec.from_spec(">=3.9", "macos", "cpython"), 105 | EnvCompatibility.LOWER_OR_EQUAL, 106 | ), 107 | ( 108 | EnvSpec.from_spec(">=3.9", "macos", "cpython"), 109 | EnvSpec.from_spec(">=3.9", "macos"), 110 | EnvCompatibility.LOWER_OR_EQUAL, 111 | ), 112 | ( 113 | EnvSpec.from_spec(">=3.9", "macos"), 114 | EnvSpec.from_spec(">=3.9", "macos", "cpython"), 115 | EnvCompatibility.LOWER_OR_EQUAL, 116 | ), 117 | ( 118 | EnvSpec.from_spec(">=3.9", "macos", "cpython"), 119 | EnvSpec.from_spec(">=3.7,<3.10"), 120 | EnvCompatibility.LOWER_OR_EQUAL, 121 | ), 122 | ( 123 | EnvSpec.from_spec(">=3.7,<3.10"), 124 | EnvSpec.from_spec(">=3.9", "macos", "cpython"), 125 | EnvCompatibility.LOWER_OR_EQUAL, 126 | ), 127 | ( 128 | EnvSpec.from_spec(">=3.9", "macos", "cpython"), 129 | EnvSpec.from_spec("<3.8", "macos", "cpython"), 130 | EnvCompatibility.INCOMPATIBLE, 131 | ), 132 | ( 133 | EnvSpec.from_spec(">=3.9", "macos", "cpython"), 134 | EnvSpec.from_spec("<3.10", "linux", "cpython"), 135 | EnvCompatibility.INCOMPATIBLE, 136 | ), 137 | ( 138 | EnvSpec.from_spec(">=3.9", "macos", "cpython"), 139 | EnvSpec.from_spec("<3.10", "macos", "pypy"), 140 | EnvCompatibility.INCOMPATIBLE, 141 | ), 142 | ( 143 | EnvSpec.from_spec(">=3.9", "macos_x86_64", "cpython"), 144 | EnvSpec.from_spec("<3.10", "macos_10_9_x86_64", "cpython"), 145 | EnvCompatibility.HIGHER, 146 | ), 147 | ( 148 | EnvSpec.from_spec("<3.10", "macos_10_9_x86_64", "cpython"), 149 | EnvSpec.from_spec(">=3.9", "macos_x86_64", "cpython"), 150 | EnvCompatibility.LOWER_OR_EQUAL, 151 | ), 152 | ], 153 | ) 154 | def test_env_spec_comparison(left, right, expected): 155 | assert left.compare(right) == expected 156 | -------------------------------------------------------------------------------- /src/dep_logic/markers/union.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from dataclasses import dataclass 4 | from typing import Iterator 5 | 6 | from dep_logic.markers.any import AnyMarker 7 | from dep_logic.markers.base import BaseMarker, EvaluationContext 8 | from dep_logic.markers.empty import EmptyMarker 9 | from dep_logic.markers.multi import MultiMarker 10 | from dep_logic.markers.single import SingleMarker 11 | from dep_logic.utils import DATACLASS_ARGS, flatten_items, intersection, union 12 | 13 | 14 | @dataclass(init=False, frozen=True, unsafe_hash=True, **DATACLASS_ARGS) 15 | class MarkerUnion(BaseMarker): 16 | markers: tuple[BaseMarker, ...] 17 | 18 | def __init__(self, *markers: BaseMarker) -> None: 19 | object.__setattr__(self, "markers", tuple(flatten_items(markers, MarkerUnion))) 20 | 21 | def __iter__(self) -> Iterator[BaseMarker]: 22 | return iter(self.markers) 23 | 24 | @property 25 | def complexity(self) -> tuple[int, ...]: 26 | return tuple(sum(c) for c in zip(*(m.complexity for m in self.markers))) 27 | 28 | @classmethod 29 | def of(cls, *markers: BaseMarker) -> BaseMarker: 30 | new_markers = flatten_items(markers, MarkerUnion) 31 | old_markers: list[BaseMarker] = [] 32 | 33 | while old_markers != new_markers: 34 | old_markers = new_markers 35 | new_markers = [] 36 | for marker in old_markers: 37 | if marker in new_markers: 38 | continue 39 | 40 | if marker.is_empty(): 41 | continue 42 | 43 | included = False 44 | for i, mark in enumerate(new_markers): 45 | # If we have a SingleMarker then with any luck after union it'll 46 | # become another SingleMarker. 47 | if isinstance(mark, SingleMarker): 48 | new_marker = mark | marker 49 | if new_marker.is_any(): 50 | return AnyMarker() 51 | 52 | if isinstance(new_marker, SingleMarker): 53 | new_markers[i] = new_marker 54 | included = True 55 | break 56 | 57 | # If we have a MultiMarker then we can look for the simplifications 58 | # implemented in union_simplify(). 59 | elif isinstance(mark, MultiMarker): 60 | union = mark.union_simplify(marker) 61 | if union is not None: 62 | new_markers[i] = union 63 | included = True 64 | break 65 | 66 | if included: 67 | # flatten again because union_simplify may return a union 68 | new_markers = flatten_items(new_markers, MarkerUnion) 69 | continue 70 | 71 | new_markers.append(marker) 72 | 73 | if any(m.is_any() for m in new_markers): 74 | return AnyMarker() 75 | 76 | if not new_markers: 77 | return EmptyMarker() 78 | 79 | if len(new_markers) == 1: 80 | return new_markers[0] 81 | 82 | return MarkerUnion(*new_markers) 83 | 84 | def __and__(self, other: BaseMarker) -> BaseMarker: 85 | return intersection(self, other) 86 | 87 | def __or__(self, other: BaseMarker) -> BaseMarker: 88 | return union(self, other) 89 | 90 | __rand__ = __and__ 91 | __ror__ = __or__ 92 | 93 | def intersect_simplify(self, other: BaseMarker) -> BaseMarker | None: 94 | """ 95 | Finds a couple of easy simplifications for intersection on MarkerUnions: 96 | 97 | - intersection with any marker that appears as part of the union is just 98 | that marker 99 | 100 | - intersection between two markerunions where one is contained by the other 101 | is just the smaller of the two 102 | 103 | - intersection between two markerunions where there are some common markers 104 | and the intersection of unique markers is not a single marker 105 | """ 106 | if other in self.markers: 107 | return other 108 | 109 | if isinstance(other, MarkerUnion): 110 | our_markers = set(self.markers) 111 | their_markers = set(other.markers) 112 | 113 | if our_markers.issubset(their_markers): 114 | return self 115 | 116 | if their_markers.issubset(our_markers): 117 | return other 118 | 119 | shared_markers = our_markers.intersection(their_markers) 120 | if not shared_markers: 121 | return None 122 | 123 | unique_markers = our_markers - their_markers 124 | other_unique_markers = their_markers - our_markers 125 | unique_intersection = MarkerUnion(*unique_markers) & MarkerUnion( 126 | *other_unique_markers 127 | ) 128 | 129 | if isinstance(unique_intersection, (SingleMarker, EmptyMarker)): 130 | # Use list instead of set for deterministic order. 131 | common_markers = [ 132 | marker for marker in self.markers if marker in shared_markers 133 | ] 134 | return unique_intersection | MarkerUnion(*common_markers) 135 | 136 | return None 137 | 138 | def evaluate( 139 | self, 140 | environment: dict[str, str | set[str]] | None = None, 141 | context: EvaluationContext = "metadata", 142 | ) -> bool: 143 | return any(m.evaluate(environment, context) for m in self.markers) 144 | 145 | def without_extras(self) -> BaseMarker: 146 | return self.exclude("extra") 147 | 148 | def exclude(self, marker_name: str) -> BaseMarker: 149 | new_markers = [] 150 | 151 | for m in self.markers: 152 | if isinstance(m, SingleMarker) and m.name == marker_name: 153 | # The marker is not relevant since it must be excluded 154 | continue 155 | 156 | marker = m.exclude(marker_name) 157 | new_markers.append(marker) 158 | 159 | if not new_markers: 160 | # All markers were the excluded marker. 161 | return AnyMarker() 162 | 163 | return self.of(*new_markers) 164 | 165 | def only(self, *marker_names: str) -> BaseMarker: 166 | return self.of(*(m.only(*marker_names) for m in self.markers)) 167 | 168 | def __str__(self) -> str: 169 | return " or ".join(str(m) for m in self.markers) 170 | -------------------------------------------------------------------------------- /src/dep_logic/markers/multi.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from dataclasses import dataclass 4 | from typing import Iterator 5 | 6 | from dep_logic.markers.any import AnyMarker 7 | from dep_logic.markers.base import BaseMarker, EvaluationContext 8 | from dep_logic.markers.empty import EmptyMarker 9 | from dep_logic.markers.single import MarkerExpression, SingleMarker 10 | from dep_logic.utils import DATACLASS_ARGS, flatten_items, intersection, union 11 | 12 | 13 | @dataclass(init=False, frozen=True, unsafe_hash=True, **DATACLASS_ARGS) 14 | class MultiMarker(BaseMarker): 15 | markers: tuple[BaseMarker, ...] 16 | 17 | def __init__(self, *markers: BaseMarker) -> None: 18 | object.__setattr__(self, "markers", tuple(flatten_items(markers, MultiMarker))) 19 | 20 | def __iter__(self) -> Iterator[BaseMarker]: 21 | return iter(self.markers) 22 | 23 | @property 24 | def complexity(self) -> tuple[int, ...]: 25 | return tuple(sum(c) for c in zip(*(m.complexity for m in self.markers))) 26 | 27 | @classmethod 28 | def of(cls, *markers: BaseMarker) -> BaseMarker: 29 | from dep_logic.markers.union import MarkerUnion 30 | 31 | new_markers = flatten_items(markers, MultiMarker) 32 | old_markers: list[BaseMarker] = [] 33 | 34 | while old_markers != new_markers: 35 | old_markers = new_markers 36 | new_markers = [] 37 | for marker in old_markers: 38 | if marker in new_markers: 39 | continue 40 | 41 | if marker.is_any(): 42 | continue 43 | 44 | intersected = False 45 | for i, mark in enumerate(new_markers): 46 | # If we have a SingleMarker then with any luck after intersection 47 | # it'll become another SingleMarker. 48 | if isinstance(mark, SingleMarker): 49 | new_marker = mark & marker 50 | if new_marker.is_empty(): 51 | return EmptyMarker() 52 | 53 | if isinstance(new_marker, SingleMarker): 54 | new_markers[i] = new_marker 55 | intersected = True 56 | break 57 | 58 | # If we have a MarkerUnion then we can look for the simplifications 59 | # implemented in intersect_simplify(). 60 | elif isinstance(mark, MarkerUnion): 61 | intersection = mark.intersect_simplify(marker) 62 | if intersection is not None: 63 | new_markers[i] = intersection 64 | intersected = True 65 | break 66 | 67 | if intersected: 68 | # flatten again because intersect_simplify may return a multi 69 | new_markers = flatten_items(new_markers, MultiMarker) 70 | continue 71 | 72 | new_markers.append(marker) 73 | 74 | if any(m.is_empty() for m in new_markers): 75 | return EmptyMarker() 76 | 77 | if not new_markers: 78 | return AnyMarker() 79 | 80 | if len(new_markers) == 1: 81 | return new_markers[0] 82 | 83 | return MultiMarker(*new_markers) 84 | 85 | def __and__(self, other: BaseMarker) -> BaseMarker: 86 | return intersection(self, other) 87 | 88 | def __or__(self, other: BaseMarker) -> BaseMarker: 89 | return union(self, other) 90 | 91 | __rand__ = __and__ 92 | __ror__ = __or__ 93 | 94 | def union_simplify(self, other: BaseMarker) -> BaseMarker | None: 95 | """ 96 | Finds a couple of easy simplifications for union on MultiMarkers: 97 | 98 | - union with any marker that appears as part of the multi is just that 99 | marker 100 | 101 | - union between two multimarkers where one is contained by the other is just 102 | the larger of the two 103 | 104 | - union between two multimarkers where there are some common markers 105 | and the union of unique markers is a single marker 106 | """ 107 | if other in self.markers: 108 | return other 109 | 110 | if isinstance(other, MultiMarker): 111 | our_markers = set(self.markers) 112 | their_markers = set(other.markers) 113 | 114 | if our_markers.issubset(their_markers): 115 | return self 116 | 117 | if their_markers.issubset(our_markers): 118 | return other 119 | 120 | shared_markers = our_markers.intersection(their_markers) 121 | if not shared_markers: 122 | return None 123 | 124 | unique_markers = our_markers - their_markers 125 | other_unique_markers = their_markers - our_markers 126 | unique_union = MultiMarker(*unique_markers) | ( 127 | MultiMarker(*other_unique_markers) 128 | ) 129 | if isinstance(unique_union, (SingleMarker, AnyMarker)): 130 | # Use list instead of set for deterministic order. 131 | common_markers = [ 132 | marker for marker in self.markers if marker in shared_markers 133 | ] 134 | return unique_union & MultiMarker(*common_markers) 135 | 136 | return None 137 | 138 | def evaluate( 139 | self, 140 | environment: dict[str, str | set[str]] | None = None, 141 | context: EvaluationContext = "metadata", 142 | ) -> bool: 143 | return all(m.evaluate(environment, context) for m in self.markers) 144 | 145 | def without_extras(self) -> BaseMarker: 146 | return self.exclude("extra") 147 | 148 | def exclude(self, marker_name: str) -> BaseMarker: 149 | new_markers = [] 150 | 151 | for m in self.markers: 152 | if isinstance(m, SingleMarker) and m.name == marker_name: 153 | # The marker is not relevant since it must be excluded 154 | continue 155 | 156 | marker = m.exclude(marker_name) 157 | 158 | if not marker.is_empty(): 159 | new_markers.append(marker) 160 | 161 | return self.of(*new_markers) 162 | 163 | def only(self, *marker_names: str) -> BaseMarker: 164 | return self.of(*(m.only(*marker_names) for m in self.markers)) 165 | 166 | def __str__(self) -> str: 167 | elements = [] 168 | for m in self.markers: 169 | if isinstance(m, (MarkerExpression, MultiMarker)): 170 | elements.append(str(m)) 171 | else: 172 | elements.append(f"({m})") 173 | 174 | return " and ".join(elements) 175 | -------------------------------------------------------------------------------- /tests/specifier/test_range.py: -------------------------------------------------------------------------------- 1 | from typing import cast 2 | 3 | import pytest 4 | from packaging.version import Version 5 | 6 | from dep_logic.specifiers import RangeSpecifier, parse_version_specifier 7 | 8 | 9 | @pytest.mark.parametrize( 10 | "value,parsed", 11 | [ 12 | ("", RangeSpecifier()), 13 | (">2.0.0", RangeSpecifier(min=Version("2.0.0"), include_min=False)), 14 | (">=2.0.0", RangeSpecifier(min=Version("2.0.0"), include_min=True)), 15 | ("<2.0.0", RangeSpecifier(max=Version("2.0.0"), include_max=False)), 16 | ("<=2.0.0", RangeSpecifier(max=Version("2.0.0"), include_max=True)), 17 | ( 18 | "==2.0.0", 19 | RangeSpecifier( 20 | min=Version("2.0.0"), 21 | max=Version("2.0.0"), 22 | include_min=True, 23 | include_max=True, 24 | ), 25 | ), 26 | ( 27 | "==2.0.0a1", 28 | RangeSpecifier( 29 | min=Version("2.0.0a1"), 30 | max=Version("2.0.0a1"), 31 | include_min=True, 32 | include_max=True, 33 | ), 34 | ), 35 | ( 36 | "==2.0.*", 37 | RangeSpecifier( 38 | min=Version("2.0.0"), 39 | max=Version("2.1.0"), 40 | include_min=True, 41 | include_max=False, 42 | ), 43 | ), 44 | ( 45 | "~=2.0.1", 46 | RangeSpecifier( 47 | min=Version("2.0.1"), 48 | max=Version("2.1.0"), 49 | include_min=True, 50 | include_max=False, 51 | ), 52 | ), 53 | ( 54 | "~=2.0.1dev2", 55 | RangeSpecifier( 56 | min=Version("2.0.1.dev2"), 57 | max=Version("2.1.0"), 58 | include_min=True, 59 | include_max=False, 60 | ), 61 | ), 62 | ], 63 | ) 64 | def test_parse_simple_range(value: str, parsed: RangeSpecifier) -> None: 65 | spec = parse_version_specifier(value) 66 | assert spec == parsed 67 | assert str(spec) == value 68 | assert spec.is_simple() 69 | 70 | 71 | @pytest.mark.parametrize( 72 | "a,b,expected", 73 | [ 74 | (">2.0.0", ">2.0.0", False), 75 | (">1.0.0", ">=1.0.1", True), 76 | ("", ">=2.0.0", True), 77 | (">=1.0.0", "", False), 78 | ("<1.0", ">=1.0", True), 79 | (">=1.0.0", ">1.0.0", True), 80 | (">1.0.0", ">=1.0.0", False), 81 | (">=1.0.0", ">=1.0.0", False), 82 | ], 83 | ) 84 | def test_range_compare_lower(a: str, b: str, expected: bool) -> None: 85 | assert ( 86 | cast(RangeSpecifier, parse_version_specifier(a)).allows_lower( 87 | cast(RangeSpecifier, parse_version_specifier(b)) 88 | ) 89 | is expected 90 | ) 91 | 92 | 93 | @pytest.mark.parametrize( 94 | "value,expected", 95 | [ 96 | (RangeSpecifier(), ""), 97 | (RangeSpecifier(min=Version("1.0")), ">1.0"), 98 | (RangeSpecifier(min=Version("1.0"), include_min=True), ">=1.0"), 99 | (RangeSpecifier(max=Version("1.0")), "<1.0"), 100 | (RangeSpecifier(max=Version("1.0")), "<1.0"), 101 | ( 102 | RangeSpecifier( 103 | min=Version("1.0"), 104 | max=Version("1.0"), 105 | include_min=True, 106 | include_max=True, 107 | ), 108 | "==1.0", 109 | ), 110 | (RangeSpecifier(min=Version("1.2"), max=Version("1.4")), ">1.2,<1.4"), 111 | ( 112 | RangeSpecifier(min=Version("1.2a2"), max=Version("1.4"), include_min=True), 113 | ">=1.2a2,<1.4", 114 | ), 115 | ( 116 | RangeSpecifier( 117 | min=Version("1.2"), 118 | max=Version("2"), 119 | include_min=True, 120 | ), 121 | "~=1.2", 122 | ), 123 | ( 124 | RangeSpecifier( 125 | min=Version("1.2r3"), 126 | max=Version("2"), 127 | include_min=True, 128 | ), 129 | "~=1.2.post3", 130 | ), 131 | ( 132 | RangeSpecifier( 133 | min=Version("1.2"), 134 | max=Version("2.0post1"), 135 | include_min=True, 136 | ), 137 | "~=1.2", 138 | ), 139 | ( 140 | RangeSpecifier( 141 | min=Version("1.2"), 142 | max=Version("1!2"), 143 | include_min=True, 144 | ), 145 | ">=1.2,<1!2", 146 | ), 147 | ( 148 | RangeSpecifier( 149 | min=Version("1.2"), 150 | max=Version("2"), 151 | ), 152 | ">1.2,<2", 153 | ), 154 | ( 155 | RangeSpecifier( 156 | min=Version("1.2"), 157 | max=Version("2"), 158 | include_min=True, 159 | include_max=True, 160 | ), 161 | ">=1.2,<=2", 162 | ), 163 | ], 164 | ) 165 | def test_range_str_normalization(value: RangeSpecifier, expected: str) -> None: 166 | assert str(value) == expected 167 | 168 | 169 | @pytest.mark.parametrize( 170 | "a,b,expected", 171 | [ 172 | ("", ">=1.0", ""), 173 | ("<1.0", "", ""), 174 | ("<1.0", ">=1.0", ""), 175 | (">=1.0", "<0.5", ""), 176 | (">=1.0,<1.5", ">1.5,<2", ""), 177 | (">=1.0", ">1.0", ">1.0"), 178 | (">=1.0,<2", ">=1.0", "~=1.0"), 179 | ("~=1.2", ">=1.3", "~=1.3"), 180 | (">=1.2,<1.8", "~=1.3", ">=1.3,<1.8"), 181 | (">=1.2", "<=1.2", "==1.2"), 182 | ], 183 | ) 184 | def test_range_intersection(a: str, b: str, expected: str) -> None: 185 | assert str(parse_version_specifier(a) & parse_version_specifier(b)) == expected 186 | 187 | 188 | @pytest.mark.parametrize( 189 | "value,inverted", 190 | [ 191 | ("", ""), 192 | (">1.0", "<=1.0"), 193 | (">=1.0", "<1.0"), 194 | ("~=1.2", "<1.2||>=2.0"), 195 | ("==1.2", "!=1.2"), 196 | ("~=1.2.0", "!=1.2.*"), 197 | ("<2||>=2.2", ">=2,<2.2"), 198 | ("<2||>=2.2,<2.4||>=3.0", ">=2,<2.2||~=2.4"), 199 | ], 200 | ) 201 | def test_range_invert(value: str, inverted: str) -> None: 202 | assert str(~parse_version_specifier(value)) == inverted 203 | assert str(~parse_version_specifier(inverted)) == value 204 | 205 | 206 | @pytest.mark.parametrize( 207 | "a,b,expected", 208 | [ 209 | ("", ">=1.0", ">=1.0"), 210 | (">1.0", "", ">1.0"), 211 | ("", "==1.0", ""), 212 | (">=1.0", "<0.6", "<0.6||>=1.0"), 213 | ("<2.0", "<=1.4", "<2.0"), 214 | ("==1.4", ">=1,<2", ">=1,<2"), 215 | (">=1.0,<2", ">=1.8,<2.2", ">=1.0,<2.2"), 216 | (">=1.0,<2.2", "==2.2", ">=1.0,<=2.2"), 217 | ("==1.2.*", "==1.4.4", "==1.2.*||==1.4.4"), 218 | (">=1.2.3", "<1.3", ""), 219 | ("<1.0", ">1.0", "!=1.0"), 220 | ], 221 | ) 222 | def test_range_union(a: str, b: str, expected: str) -> None: 223 | assert str(parse_version_specifier(a) | parse_version_specifier(b)) == expected 224 | -------------------------------------------------------------------------------- /src/dep_logic/specifiers/union.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import itertools 4 | import typing as t 5 | from dataclasses import dataclass, field 6 | from functools import cached_property 7 | 8 | from packaging.specifiers import SpecifierSet 9 | 10 | from dep_logic.specifiers.base import BaseSpecifier, UnparsedVersion, VersionSpecifier 11 | from dep_logic.specifiers.range import RangeSpecifier 12 | from dep_logic.specifiers.special import EmptySpecifier 13 | from dep_logic.utils import DATACLASS_ARGS, first_different_index, pad_zeros 14 | 15 | 16 | @dataclass(frozen=True, unsafe_hash=True, **DATACLASS_ARGS) 17 | class UnionSpecifier(VersionSpecifier): 18 | ranges: tuple[RangeSpecifier, ...] 19 | simplified: str | None = field(default=None, compare=False, hash=False) 20 | 21 | def to_specifierset(self) -> SpecifierSet: 22 | if (simplified := self._simplified_form) is None: 23 | raise ValueError("Cannot convert UnionSpecifier to SpecifierSet") 24 | return SpecifierSet(simplified) 25 | 26 | @property 27 | def num_parts(self) -> int: 28 | return sum(range.num_parts for range in self.ranges) 29 | 30 | @cached_property 31 | def _simplified_form(self) -> str | None: 32 | if self.simplified is not None: 33 | return self.simplified 34 | # try to get a not-equals form(!=) if possible 35 | left, right, *rest = self.ranges 36 | if rest: 37 | return None 38 | if ( 39 | left.min is None 40 | and right.max is None 41 | and left.max == right.min 42 | and left.max is not None 43 | ): 44 | # (-inf, version) | (version, inf) => != version 45 | return f"!={left.max}" 46 | 47 | if ( 48 | left.min is None 49 | and right.max is None 50 | and not left.include_max 51 | and right.include_min 52 | and left.max is not None 53 | and right.min is not None 54 | ): 55 | # (-inf, X.Y.0) | [X.Y+1.0, inf) => != X.Y.* 56 | if left.max.is_prerelease or right.min.is_prerelease: 57 | return None 58 | left_stable = [left.max.epoch, *left.max.release] 59 | right_stable = [right.min.epoch, *right.min.release] 60 | max_length = max(len(left_stable), len(right_stable)) 61 | left_stable = pad_zeros(left_stable, max_length) 62 | right_stable = pad_zeros(right_stable, max_length) 63 | first_different = first_different_index(left_stable, right_stable) 64 | if ( 65 | first_different > 0 66 | and right_stable[first_different] - left_stable[first_different] == 1 67 | and set( 68 | left_stable[first_different + 1 :] 69 | + right_stable[first_different + 1 :] 70 | ) 71 | == {0} 72 | ): 73 | epoch = "" if left.max.epoch == 0 else f"{left.max.epoch}!" 74 | version = ".".join(map(str, left.max.release[:first_different])) + ".*" 75 | return f"!={epoch}{version}" 76 | 77 | return None 78 | 79 | def __str__(self) -> str: 80 | if self._simplified_form is not None: 81 | return self._simplified_form 82 | return "||".join(map(str, self.ranges)) 83 | 84 | @staticmethod 85 | def _from_ranges(ranges: t.Sequence[RangeSpecifier]) -> BaseSpecifier: 86 | if (ranges_number := len(ranges)) == 0: 87 | return EmptySpecifier() 88 | elif ranges_number == 1: 89 | return ranges[0] 90 | else: 91 | return UnionSpecifier(tuple(ranges)) 92 | 93 | def is_simple(self) -> bool: 94 | return self._simplified_form is not None 95 | 96 | def contains( 97 | self, version: UnparsedVersion, prereleases: bool | None = None 98 | ) -> bool: 99 | return any( 100 | specifier.contains(version, prereleases) for specifier in self.ranges 101 | ) 102 | 103 | def __invert__(self) -> BaseSpecifier: 104 | to_union: list[RangeSpecifier] = [] 105 | if (first := self.ranges[0]).min is not None: 106 | to_union.append( 107 | RangeSpecifier(max=first.min, include_max=not first.include_min) 108 | ) 109 | for a, b in zip(self.ranges, self.ranges[1:]): 110 | to_union.append( 111 | RangeSpecifier( 112 | min=a.max, 113 | include_min=not a.include_max, 114 | max=b.min, 115 | include_max=not b.include_min, 116 | ) 117 | ) 118 | if (last := self.ranges[-1]).max is not None: 119 | to_union.append( 120 | RangeSpecifier(min=last.max, include_min=not last.include_max) 121 | ) 122 | return self._from_ranges(to_union) 123 | 124 | def __and__(self, other: t.Any) -> BaseSpecifier: 125 | if isinstance(other, RangeSpecifier): 126 | if other.is_any(): 127 | return self 128 | to_intersect: list[RangeSpecifier] = [other] 129 | elif isinstance(other, UnionSpecifier): 130 | to_intersect = list(other.ranges) 131 | else: 132 | return NotImplemented 133 | # Expand the ranges to be intersected, and discard the empty ones 134 | # (a | b) & (c | d) = (a & c) | (a & d) | (b & c) | (b & d) 135 | # Since each subrange doesn't overlap with each other and intersection 136 | # only makes it smaller, so the result is also guaranteed to be a set 137 | # of non-overlapping ranges, just build a new union from them. 138 | new_ranges = [ 139 | range 140 | for (a, b) in itertools.product(self.ranges, to_intersect) 141 | if not isinstance(range := a & b, EmptySpecifier) 142 | ] 143 | return self._from_ranges(new_ranges) 144 | 145 | __rand__ = __and__ 146 | 147 | def __or__(self, other: t.Any) -> BaseSpecifier: 148 | if isinstance(other, RangeSpecifier): 149 | if other.is_any(): 150 | return other 151 | new_ranges: list[RangeSpecifier] = [] 152 | ranges = iter(self.ranges) 153 | for range in ranges: 154 | if range.can_combine(other): 155 | other = t.cast(RangeSpecifier, other | range) 156 | elif other.allows_lower(range): 157 | # all following ranges are higher than the input, quit the loop 158 | # and copy the rest ranges. 159 | new_ranges.extend([other, range, *ranges]) 160 | break 161 | else: 162 | # range is strictly lower than other, nothing to do here 163 | new_ranges.append(range) 164 | else: 165 | # we have consumed all ranges or no range is merged, 166 | # just append to the last. 167 | new_ranges.append(other) 168 | return self._from_ranges(new_ranges) 169 | elif isinstance(other, UnionSpecifier): 170 | result = self 171 | for range in other.ranges: 172 | result = result | range 173 | return result 174 | else: 175 | return NotImplemented 176 | 177 | __ror__ = __or__ 178 | -------------------------------------------------------------------------------- /tests/marker/test_evaluation.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import os 4 | 5 | import pytest 6 | 7 | from dep_logic.markers import parse_marker 8 | 9 | 10 | @pytest.mark.parametrize( 11 | ("marker_string", "environment", "expected"), 12 | [ 13 | (f"os_name == '{os.name}'", None, True), 14 | ("os_name == 'foo'", {"os_name": "foo"}, True), 15 | ("os_name == 'foo'", {"os_name": "bar"}, False), 16 | ("'2.7' in python_version", {"python_version": "2.7.5"}, True), 17 | ("'2.7' not in python_version", {"python_version": "2.7"}, False), 18 | ( 19 | "os_name == 'foo' and python_version ~= '2.7.0'", 20 | {"os_name": "foo", "python_version": "2.7.6"}, 21 | True, 22 | ), 23 | ( 24 | "python_version ~= '2.7.0' and (os_name == 'foo' or os_name == 'bar')", 25 | {"os_name": "foo", "python_version": "2.7.4"}, 26 | True, 27 | ), 28 | ( 29 | "python_version ~= '2.7.0' and (os_name == 'foo' or os_name == 'bar')", 30 | {"os_name": "bar", "python_version": "2.7.4"}, 31 | True, 32 | ), 33 | ( 34 | "python_version ~= '2.7.0' and (os_name == 'foo' or os_name == 'bar')", 35 | {"os_name": "other", "python_version": "2.7.4"}, 36 | False, 37 | ), 38 | ("extra == 'security'", {"extra": "quux"}, False), 39 | ("extra == 'security'", {"extra": "security"}, True), 40 | ("extra == 'SECURITY'", {"extra": "security"}, True), 41 | ("extra == 'security'", {"extra": "SECURITY"}, True), 42 | ("extra == 'pep-685-norm'", {"extra": "PEP_685...norm"}, True), 43 | ( 44 | "extra == 'Different.punctuation..is...equal'", 45 | {"extra": "different__punctuation_is_EQUAL"}, 46 | True, 47 | ), 48 | ], 49 | ) 50 | def test_evaluates( 51 | marker_string: str, environment: dict[str, str | set[str]], expected: bool 52 | ) -> None: 53 | assert parse_marker(marker_string).evaluate(environment) == expected 54 | 55 | 56 | @pytest.mark.parametrize( 57 | ("marker_string", "environment", "expected"), 58 | [ 59 | (f"os.name == '{os.name}'", None, True), 60 | ("sys.platform == 'win32'", {"sys_platform": "linux2"}, False), 61 | ("platform.version in 'Ubuntu'", {"platform_version": "#39"}, False), 62 | ("platform.machine=='x86_64'", {"platform_machine": "x86_64"}, True), 63 | ( 64 | "platform.python_implementation=='Jython'", 65 | {"platform_python_implementation": "CPython"}, 66 | False, 67 | ), 68 | ( 69 | "python_version == '2.5' and platform.python_implementation!= 'Jython'", 70 | {"python_version": "2.7"}, 71 | False, 72 | ), 73 | ( 74 | ( 75 | "platform_machine in 'x86_64 X86_64 aarch64 AARCH64 ppc64le PPC64LE" 76 | " amd64 AMD64 win32 WIN32'" 77 | ), 78 | {"platform_machine": "foo"}, 79 | False, 80 | ), 81 | ( 82 | ( 83 | "platform_machine in 'x86_64 X86_64 aarch64 AARCH64 ppc64le PPC64LE" 84 | " amd64 AMD64 win32 WIN32'" 85 | ), 86 | {"platform_machine": "x86_64"}, 87 | True, 88 | ), 89 | ( 90 | ( 91 | "platform_machine not in 'x86_64 X86_64 aarch64 AARCH64 ppc64le PPC64LE" 92 | " amd64 AMD64 win32 WIN32'" 93 | ), 94 | {"platform_machine": "foo"}, 95 | True, 96 | ), 97 | ( 98 | ( 99 | "platform_machine not in 'x86_64 X86_64 aarch64 AARCH64 ppc64le PPC64LE" 100 | " amd64 AMD64 win32 WIN32'" 101 | ), 102 | {"platform_machine": "x86_64"}, 103 | False, 104 | ), 105 | ("platform_release >= '6'", {"platform_release": "6.1-foobar"}, True), 106 | # extras 107 | # single extra 108 | ("extra != 'security'", {"extra": "quux"}, True), 109 | ("extra != 'security'", {"extra": "security"}, False), 110 | ("extra != 'security'", {}, True), 111 | ("extra != 'security'", {"platform_machine": "x86_64"}, True), 112 | # normalization 113 | ("extra == 'Security.1'", {"extra": "security-1"}, True), 114 | ("extra == 'a'", {}, False), 115 | ("extra != 'a'", {}, True), 116 | ("extra == 'a' and extra == 'b'", {}, False), 117 | ("extra == 'a' or extra == 'b'", {}, False), 118 | ("extra != 'a' and extra != 'b'", {}, True), 119 | ("extra != 'a' or extra != 'b'", {}, True), 120 | ("extra != 'a' and extra == 'b'", {}, False), 121 | ("extra != 'a' or extra == 'b'", {}, True), 122 | # multiple extras 123 | ("extra == 'a'", {"extra": ("a", "b")}, True), 124 | ("extra == 'a'", {"extra": ("b", "c")}, False), 125 | ("extra != 'a'", {"extra": ("a", "b")}, False), 126 | ("extra != 'a'", {"extra": ("b", "c")}, True), 127 | ("extra == 'a' and extra == 'b'", {"extra": ("a", "b", "c")}, True), 128 | ("extra == 'a' and extra == 'b'", {"extra": ("a", "c")}, False), 129 | ("extra == 'a' or extra == 'b'", {"extra": ("a", "c")}, True), 130 | ("extra == 'a' or extra == 'b'", {"extra": ("b", "c")}, True), 131 | ("extra == 'a' or extra == 'b'", {"extra": ("c", "d")}, False), 132 | ("extra != 'a' and extra != 'b'", {"extra": ("a", "c")}, False), 133 | ("extra != 'a' and extra != 'b'", {"extra": ("b", "c")}, False), 134 | ("extra != 'a' and extra != 'b'", {"extra": ("c", "d")}, True), 135 | ("extra != 'a' or extra != 'b'", {"extra": ("a", "b", "c")}, False), 136 | ("extra != 'a' or extra != 'b'", {"extra": ("a", "c")}, True), 137 | ("extra != 'a' or extra != 'b'", {"extra": ("b", "c")}, True), 138 | ("extra != 'a' and extra == 'b'", {"extra": ("a", "b")}, False), 139 | ("extra != 'a' and extra == 'b'", {"extra": ("b", "c")}, True), 140 | ("extra != 'a' and extra == 'b'", {"extra": ("c", "d")}, False), 141 | ("extra != 'a' or extra == 'b'", {"extra": ("a", "b")}, True), 142 | ("extra != 'a' or extra == 'b'", {"extra": ("c", "d")}, True), 143 | ("extra != 'a' or extra == 'b'", {"extra": ("a", "c")}, False), 144 | ], 145 | ) 146 | def test_evaluate_extra( 147 | marker_string: str, environment: dict[str, str | set[str]] | None, expected: bool 148 | ) -> None: 149 | m = parse_marker(marker_string) 150 | 151 | assert m.evaluate(environment) is expected 152 | 153 | 154 | @pytest.mark.parametrize( 155 | "marker, env", 156 | [ 157 | ( 158 | 'platform_release >= "9.0" and platform_release < "11.0"', 159 | {"platform_release": "10.0"}, 160 | ) 161 | ], 162 | ) 163 | def test_parse_version_like_markers( 164 | marker: str, env: dict[str, str | set[str]] 165 | ) -> None: 166 | m = parse_marker(marker) 167 | 168 | assert m.evaluate(env) 169 | 170 | 171 | @pytest.mark.parametrize("variable", ["extras", "dependency_groups"]) 172 | @pytest.mark.parametrize( 173 | "expression,result", 174 | [ 175 | pytest.param('"foo" in {0}', True, id="value-in-foo"), 176 | pytest.param('"bar" in {0}', True, id="value-in-bar"), 177 | pytest.param('"baz" in {0}', False, id="value-not-in"), 178 | pytest.param('"baz" not in {0}', True, id="value-not-in-negated"), 179 | pytest.param('"foo" in {0} and "bar" in {0}', True, id="and-in"), 180 | pytest.param('"foo" in {0} or "bar" in {0}', True, id="or-in"), 181 | pytest.param('"baz" in {0} and "foo" in {0}', False, id="short-circuit-and"), 182 | pytest.param('"foo" in {0} or "baz" in {0}', True, id="short-circuit-or"), 183 | pytest.param('"Foo" in {0}', True, id="case-sensitive"), 184 | ], 185 | ) 186 | def test_extras_and_dependency_groups( 187 | variable: str, expression: str, result: bool 188 | ) -> None: 189 | environment: dict[str, str | set[str]] = {variable: {"foo", "bar"}} 190 | assert parse_marker(expression.format(variable)).evaluate(environment) == result 191 | 192 | 193 | @pytest.mark.parametrize("variable", ["extras", "dependency_groups"]) 194 | def test_extras_and_dependency_groups_disallowed(variable: str) -> None: 195 | marker = parse_marker(f'"foo" in {variable}') 196 | assert not marker.evaluate(context="lock_file") 197 | 198 | with pytest.raises(KeyError): 199 | marker.evaluate() 200 | 201 | with pytest.raises(KeyError): 202 | marker.evaluate(context="requirement") 203 | -------------------------------------------------------------------------------- /src/dep_logic/specifiers/range.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from dataclasses import dataclass, field 4 | from functools import cached_property 5 | from typing import Any 6 | 7 | from packaging.specifiers import SpecifierSet 8 | from packaging.version import Version 9 | 10 | from dep_logic.specifiers.base import ( 11 | BaseSpecifier, 12 | InvalidSpecifier, 13 | UnparsedVersion, 14 | VersionSpecifier, 15 | ) 16 | from dep_logic.specifiers.special import EmptySpecifier 17 | from dep_logic.utils import DATACLASS_ARGS, first_different_index, pad_zeros 18 | 19 | 20 | @dataclass(frozen=True, unsafe_hash=True, **DATACLASS_ARGS) 21 | class RangeSpecifier(VersionSpecifier): 22 | min: Version | None = None 23 | max: Version | None = None 24 | include_min: bool = False 25 | include_max: bool = False 26 | simplified: str | None = field(default=None, compare=False, hash=False) 27 | 28 | def __post_init__(self) -> None: 29 | if self.min is None and self.include_min: 30 | raise InvalidSpecifier("Cannot include min when min is None") 31 | if self.max is None and self.include_max: 32 | raise InvalidSpecifier("Cannot include max when max is None") 33 | 34 | def to_specifierset(self) -> SpecifierSet: 35 | return SpecifierSet(str(self)) 36 | 37 | @property 38 | def num_parts(self) -> int: 39 | return len(self.to_specifierset()) 40 | 41 | @cached_property 42 | def _simplified_form(self) -> str | None: 43 | if self.simplified is not None: 44 | return self.simplified 45 | if self.min is None and self.max is None: 46 | return "" 47 | elif self.min is None: 48 | return f"{'<=' if self.include_max else '<'}{self.max}" 49 | elif self.max is None: 50 | return f"{'>=' if self.include_min else '>'}{self.min}" 51 | else: 52 | # First, try to make a equality specifier 53 | if self.min == self.max: 54 | # include_min and include_max are always True here 55 | return f"=={self.min}" 56 | if not self.include_min or self.include_max: 57 | return None 58 | # Next, try to make a ~= specifier 59 | min_stable = [self.min.epoch, *self.min.release] 60 | max_stable = [self.max.epoch, *self.max.release] 61 | max_length = max(len(min_stable), len(max_stable)) 62 | min_stable = pad_zeros(min_stable, max_length) 63 | max_stable = pad_zeros(max_stable, max_length) 64 | first_different = first_different_index(min_stable, max_stable) 65 | if first_different >= len(min_stable) - 1 or first_different == 0: 66 | # either they are all equal or the last one is different(>=2.3.1,<2.3.3) 67 | return None 68 | if max_stable[first_different] - min_stable[first_different] != 1: 69 | # The different part must be larger than 1 70 | return None 71 | # What's more, we need the max version to be a stable with a suffix of 0 72 | if ( 73 | all(p == 0 for p in max_stable[first_different + 1 :]) 74 | and not self.max.is_prerelease 75 | and len(self.min.release) == first_different + 1 76 | ): 77 | return f"~={self.min}" 78 | return None 79 | 80 | def __str__(self) -> str: 81 | simplified = self._simplified_form 82 | if simplified is not None: 83 | return simplified 84 | return f"{'>=' if self.include_min else '>'}{self.min},{'<=' if self.include_max else '<'}{self.max}" 85 | 86 | def contains( 87 | self, version: UnparsedVersion, prereleases: bool | None = None 88 | ) -> bool: 89 | return self.to_specifierset().contains(version, prereleases) 90 | 91 | def __invert__(self) -> BaseSpecifier: 92 | from dep_logic.specifiers.union import UnionSpecifier 93 | 94 | if self.min is None and self.max is None: 95 | return EmptySpecifier() 96 | 97 | specs: list[RangeSpecifier] = [] 98 | if self.min is not None: 99 | specs.append(RangeSpecifier(max=self.min, include_max=not self.include_min)) 100 | if self.max is not None: 101 | specs.append(RangeSpecifier(min=self.max, include_min=not self.include_max)) 102 | if len(specs) == 1: 103 | return specs[0] 104 | return UnionSpecifier(tuple(specs)) 105 | 106 | def is_simple(self) -> bool: 107 | return self._simplified_form is not None 108 | 109 | def is_any(self) -> bool: 110 | return self.min is None and self.max is None 111 | 112 | def allows_lower(self, other: RangeSpecifier) -> bool: 113 | if other.min is None: 114 | return False 115 | if self.min is None: 116 | return True 117 | 118 | return self.min < other.min or ( 119 | self.min == other.min and self.include_min and not other.include_min 120 | ) 121 | 122 | def allows_higher(self, other: RangeSpecifier) -> bool: 123 | if other.max is None: 124 | return False 125 | if self.max is None: 126 | return True 127 | 128 | return self.max > other.max or ( 129 | self.max == other.max and self.include_max and not other.include_max 130 | ) 131 | 132 | def is_strictly_lower(self, other: RangeSpecifier) -> bool: 133 | """Return True if this range is lower than the other range 134 | and they have no overlap. 135 | """ 136 | if self.max is None or other.min is None: 137 | return False 138 | 139 | return self.max < other.min or ( 140 | self.max == other.min and False in (self.include_max, other.include_min) 141 | ) 142 | 143 | def is_adjacent_to(self, other: RangeSpecifier) -> bool: 144 | if self.max is None or other.min is None: 145 | return False 146 | return ( 147 | self.max == other.min 148 | and [self.include_max, other.include_min].count(True) == 1 149 | ) 150 | 151 | def __lt__(self, other: Any) -> bool: 152 | if not isinstance(other, RangeSpecifier): 153 | return NotImplemented 154 | return self.allows_lower(other) 155 | 156 | def is_superset(self, other: RangeSpecifier) -> bool: 157 | min_lower = self.min is None or ( 158 | other.min is not None 159 | and ( 160 | self.min < other.min 161 | or ( 162 | self.min == other.min 163 | and not (not self.include_min and other.include_min) 164 | ) 165 | ) 166 | ) 167 | max_higher = self.max is None or ( 168 | other.max is not None 169 | and ( 170 | self.max > other.max 171 | or ( 172 | self.max == other.max 173 | and not (not self.include_max and other.include_max) 174 | ) 175 | ) 176 | ) 177 | return min_lower and max_higher 178 | 179 | def is_subset(self, other: RangeSpecifier) -> bool: 180 | return other.is_superset(self) 181 | 182 | def can_combine(self, other: RangeSpecifier) -> bool: 183 | """Return True if the two ranges can be combined into one range.""" 184 | if self.allows_lower(other): 185 | return not self.is_strictly_lower(other) or self.is_adjacent_to(other) 186 | else: 187 | return not other.is_strictly_lower(self) or other.is_adjacent_to(self) 188 | 189 | def __and__(self, other: Any) -> RangeSpecifier | EmptySpecifier: 190 | if not isinstance(other, RangeSpecifier): 191 | return NotImplemented 192 | if self.is_superset(other): 193 | return other 194 | 195 | if other.is_superset(self): 196 | return self 197 | 198 | if self.allows_lower(other): 199 | if self.is_strictly_lower(other): 200 | return EmptySpecifier() 201 | intersect_min = other.min 202 | intersect_include_min = other.include_min 203 | else: 204 | if other.is_strictly_lower(self): 205 | return EmptySpecifier() 206 | intersect_min = self.min 207 | intersect_include_min = self.include_min 208 | 209 | if self.allows_higher(other): 210 | intersect_max = other.max 211 | intersect_include_max = other.include_max 212 | else: 213 | intersect_max = self.max 214 | intersect_include_max = self.include_max 215 | 216 | return type(self)( 217 | min=intersect_min, 218 | max=intersect_max, 219 | include_min=intersect_include_min, 220 | include_max=intersect_include_max, 221 | ) 222 | 223 | def __or__(self, other: Any) -> VersionSpecifier: 224 | from dep_logic.specifiers.union import UnionSpecifier 225 | 226 | if not isinstance(other, RangeSpecifier): 227 | return NotImplemented 228 | 229 | if self.is_superset(other): 230 | return self 231 | if other.is_superset(self): 232 | return other 233 | 234 | if self.allows_lower(other): 235 | if self.is_strictly_lower(other) and not self.is_adjacent_to(other): 236 | return UnionSpecifier((self, other)) 237 | union_min = self.min 238 | union_include_min = self.include_min 239 | else: 240 | if other.is_strictly_lower(self) and not other.is_adjacent_to(self): 241 | return UnionSpecifier((other, self)) 242 | union_min = other.min 243 | union_include_min = other.include_min 244 | 245 | if self.allows_higher(other): 246 | union_max = self.max 247 | union_include_max = self.include_max 248 | else: 249 | union_max = other.max 250 | union_include_max = other.include_max 251 | 252 | return type(self)( 253 | min=union_min, 254 | max=union_max, 255 | include_min=union_include_min, 256 | include_max=union_include_max, 257 | ) 258 | -------------------------------------------------------------------------------- /tests/marker/test_expression.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | 3 | from dep_logic.markers import parse_marker 4 | 5 | 6 | def test_single_marker_normalisation() -> None: 7 | m1 = parse_marker("python_version>='3.6'") 8 | m2 = parse_marker("python_version >= '3.6'") 9 | assert m1 == m2 10 | assert hash(m1) == hash(m2) 11 | 12 | 13 | def test_single_marker_intersect() -> None: 14 | m = parse_marker('sys_platform == "darwin"') 15 | 16 | intersection = m & parse_marker('implementation_name == "cpython"') 17 | assert ( 18 | str(intersection) 19 | == 'sys_platform == "darwin" and implementation_name == "cpython"' 20 | ) 21 | 22 | m = parse_marker('python_version >= "3.4"') 23 | 24 | intersection = m & parse_marker('python_version < "3.6"') 25 | assert str(intersection) == 'python_version >= "3.4" and python_version < "3.6"' 26 | 27 | 28 | def test_single_marker_intersect_compacts_constraints() -> None: 29 | m = parse_marker('python_version < "3.6"') 30 | 31 | intersection = m & parse_marker('python_version < "3.4"') 32 | assert str(intersection) == 'python_version < "3.4"' 33 | 34 | 35 | def test_single_marker_intersect_with_multi() -> None: 36 | m = parse_marker('sys_platform == "darwin"') 37 | 38 | intersection = m & ( 39 | parse_marker('implementation_name == "cpython" and python_version >= "3.6"') 40 | ) 41 | assert ( 42 | str(intersection) 43 | == 'implementation_name == "cpython" and python_version >= "3.6" and' 44 | ' sys_platform == "darwin"' 45 | ) 46 | 47 | 48 | def test_single_marker_intersect_with_multi_with_duplicate() -> None: 49 | m = parse_marker('python_version < "4.0"') 50 | 51 | intersection = m & ( 52 | parse_marker('sys_platform == "darwin" and python_version < "4.0"') 53 | ) 54 | assert str(intersection) == 'sys_platform == "darwin" and python_version < "4.0"' 55 | 56 | 57 | def test_single_marker_intersect_with_multi_compacts_constraint() -> None: 58 | m = parse_marker('python_version < "3.6"') 59 | 60 | intersection = m & ( 61 | parse_marker('implementation_name == "cpython" and python_version < "3.4"') 62 | ) 63 | assert ( 64 | str(intersection) 65 | == 'implementation_name == "cpython" and python_version < "3.4"' 66 | ) 67 | 68 | 69 | def test_single_marker_intersect_with_union_leads_to_single_marker() -> None: 70 | m = parse_marker('python_version >= "3.6"') 71 | 72 | intersection = m & ( 73 | parse_marker('python_version < "3.6" or python_version >= "3.7"') 74 | ) 75 | assert str(intersection) == 'python_version >= "3.7"' 76 | 77 | 78 | def test_single_marker_intersect_with_union_leads_to_empty() -> None: 79 | m = parse_marker('python_version == "3.7"') 80 | 81 | intersection = m & ( 82 | parse_marker('python_version < "3.7" or python_version >= "3.8"') 83 | ) 84 | assert intersection.is_empty() 85 | 86 | 87 | def test_single_marker_not_in_python_intersection() -> None: 88 | m = parse_marker('python_version not in "2.7, 3.0, 3.1"') 89 | 90 | intersection = m & (parse_marker('python_version not in "2.7, 3.0, 3.1, 3.2"')) 91 | assert str(intersection) == 'python_version not in "2.7, 3.0, 3.1, 3.2"' 92 | 93 | 94 | @pytest.mark.parametrize( 95 | ("marker1", "marker2", "expected"), 96 | [ 97 | # same value 98 | ('extra == "a"', 'extra == "a"', 'extra == "a"'), 99 | ('extra == "a"', 'extra != "a"', ""), 100 | ('extra != "a"', 'extra == "a"', ""), 101 | ('extra != "a"', 'extra != "a"', 'extra != "a"'), 102 | # different values 103 | ('extra == "a"', 'extra == "b"', 'extra == "a" and extra == "b"'), 104 | ('extra == "a"', 'extra != "b"', 'extra == "a" and extra != "b"'), 105 | ('extra != "a"', 'extra == "b"', 'extra != "a" and extra == "b"'), 106 | ('extra != "a"', 'extra != "b"', 'extra != "a" and extra != "b"'), 107 | ], 108 | ) 109 | def test_single_marker_intersect_extras( 110 | marker1: str, marker2: str, expected: str 111 | ) -> None: 112 | assert str(parse_marker(marker1) & parse_marker(marker2)) == expected 113 | 114 | 115 | def test_single_marker_union() -> None: 116 | m = parse_marker('sys_platform == "darwin"') 117 | 118 | union = m | (parse_marker('implementation_name == "cpython"')) 119 | assert str(union) == 'sys_platform == "darwin" or implementation_name == "cpython"' 120 | 121 | 122 | def test_single_marker_union_is_any() -> None: 123 | m = parse_marker('python_version >= "3.4"') 124 | 125 | union = m | (parse_marker('python_version < "3.6"')) 126 | assert union.is_any() 127 | 128 | 129 | @pytest.mark.parametrize( 130 | ("marker1", "marker2", "expected"), 131 | [ 132 | ( 133 | 'python_version < "3.6"', 134 | 'python_version < "3.4"', 135 | 'python_version < "3.6"', 136 | ), 137 | ( 138 | 'sys_platform == "linux"', 139 | 'sys_platform != "win32"', 140 | 'sys_platform != "win32"', 141 | ), 142 | ( 143 | 'python_version == "3.6"', 144 | 'python_version > "3.6"', 145 | 'python_version >= "3.6"', 146 | ), 147 | ( 148 | 'python_version == "3.6"', 149 | 'python_version < "3.6"', 150 | 'python_version <= "3.6"', 151 | ), 152 | ( 153 | 'python_version < "3.6"', 154 | 'python_version > "3.6"', 155 | 'python_version != "3.6"', 156 | ), 157 | ], 158 | ) 159 | def test_single_marker_union_is_single_marker( 160 | marker1: str, marker2: str, expected: str 161 | ) -> None: 162 | m = parse_marker(marker1) 163 | 164 | union = m | (parse_marker(marker2)) 165 | assert str(union) == expected 166 | 167 | 168 | def test_single_marker_union_with_multi() -> None: 169 | m = parse_marker('sys_platform == "darwin"') 170 | 171 | union = m | ( 172 | parse_marker('implementation_name == "cpython" and python_version >= "3.6"') 173 | ) 174 | assert ( 175 | str(union) == 'implementation_name == "cpython" and python_version >= "3.6" or' 176 | ' sys_platform == "darwin"' 177 | ) 178 | 179 | 180 | def test_single_marker_union_with_multi_duplicate() -> None: 181 | m = parse_marker('sys_platform == "darwin" and python_version >= "3.6"') 182 | 183 | union = m | (parse_marker('sys_platform == "darwin" and python_version >= "3.6"')) 184 | assert str(union) == 'sys_platform == "darwin" and python_version >= "3.6"' 185 | 186 | 187 | @pytest.mark.parametrize( 188 | ("single_marker", "multi_marker", "expected"), 189 | [ 190 | ( 191 | 'python_version >= "3.6"', 192 | 'python_version >= "3.7" and sys_platform == "win32"', 193 | 'python_version >= "3.6"', 194 | ), 195 | ( 196 | 'sys_platform == "linux"', 197 | 'sys_platform != "linux" and sys_platform != "win32"', 198 | 'sys_platform != "win32"', 199 | ), 200 | ], 201 | ) 202 | def test_single_marker_union_with_multi_is_single_marker( 203 | single_marker: str, multi_marker: str, expected: str 204 | ) -> None: 205 | m1 = parse_marker(single_marker) 206 | m2 = parse_marker(multi_marker) 207 | assert str(m1 | (m2)) == expected 208 | assert str(m2 | (m1)) == expected 209 | 210 | 211 | def test_single_marker_union_with_multi_cannot_be_simplified() -> None: 212 | m = parse_marker('python_version >= "3.7"') 213 | union = m | (parse_marker('python_version >= "3.6" and sys_platform == "win32"')) 214 | assert ( 215 | str(union) 216 | == 'python_version >= "3.6" and sys_platform == "win32" or python_version >=' 217 | ' "3.7"' 218 | ) 219 | 220 | 221 | def test_single_marker_union_with_multi_is_union_of_single_markers() -> None: 222 | m = parse_marker('python_version >= "3.6"') 223 | union = m | (parse_marker('python_version < "3.6" and sys_platform == "win32"')) 224 | assert str(union) == 'sys_platform == "win32" or python_version >= "3.6"' 225 | 226 | 227 | def test_single_marker_union_with_multi_union_is_union_of_single_markers() -> None: 228 | m = parse_marker('python_version >= "3.6"') 229 | union = m | ( 230 | parse_marker( 231 | 'python_version < "3.6" and sys_platform == "win32" or python_version <' 232 | ' "3.6" and sys_platform == "linux"' 233 | ) 234 | ) 235 | assert ( 236 | str(union) 237 | == 'sys_platform == "win32" or sys_platform == "linux" or python_version >=' 238 | ' "3.6"' 239 | ) 240 | 241 | 242 | def test_single_marker_union_with_union() -> None: 243 | m = parse_marker('sys_platform == "darwin"') 244 | 245 | union = m | ( 246 | parse_marker('implementation_name == "cpython" or python_version >= "3.6"') 247 | ) 248 | assert ( 249 | str(union) 250 | == 'implementation_name == "cpython" or python_version >= "3.6" or sys_platform' 251 | ' == "darwin"' 252 | ) 253 | 254 | 255 | def test_single_marker_not_in_python_union() -> None: 256 | m = parse_marker('python_version not in "2.7, 3.0, 3.1"') 257 | 258 | union = m | parse_marker('python_version not in "2.7, 3.0, 3.1, 3.2"') 259 | assert str(union) == 'python_version not in "2.7, 3.0, 3.1"' 260 | 261 | 262 | def test_single_marker_union_with_union_duplicate() -> None: 263 | m = parse_marker('sys_platform == "darwin"') 264 | 265 | union = m | (parse_marker('sys_platform == "darwin" or python_version >= "3.6"')) 266 | assert str(union) == 'sys_platform == "darwin" or python_version >= "3.6"' 267 | 268 | m = parse_marker('python_version >= "3.7"') 269 | 270 | union = m | (parse_marker('sys_platform == "darwin" or python_version >= "3.6"')) 271 | assert str(union) == 'sys_platform == "darwin" or python_version >= "3.6"' 272 | 273 | m = parse_marker('python_version <= "3.6"') 274 | 275 | union = m | (parse_marker('sys_platform == "darwin" or python_version < "3.4"')) 276 | assert str(union) == 'sys_platform == "darwin" or python_version <= "3.6"' 277 | 278 | 279 | def test_single_marker_union_with_inverse() -> None: 280 | m = parse_marker('sys_platform == "darwin"') 281 | union = m | (parse_marker('sys_platform != "darwin"')) 282 | assert union.is_any() 283 | 284 | 285 | @pytest.mark.parametrize( 286 | ("marker1", "marker2", "expected"), 287 | [ 288 | # same value 289 | ('extra == "a"', 'extra == "a"', 'extra == "a"'), 290 | ('extra == "a"', 'extra != "a"', ""), 291 | ('extra != "a"', 'extra == "a"', ""), 292 | ('extra != "a"', 'extra != "a"', 'extra != "a"'), 293 | # different values 294 | ('extra == "a"', 'extra == "b"', 'extra == "a" or extra == "b"'), 295 | ('extra == "a"', 'extra != "b"', 'extra == "a" or extra != "b"'), 296 | ('extra != "a"', 'extra == "b"', 'extra != "a" or extra == "b"'), 297 | ('extra != "a"', 'extra != "b"', 'extra != "a" or extra != "b"'), 298 | ], 299 | ) 300 | def test_single_marker_union_extras(marker1: str, marker2: str, expected: str) -> None: 301 | assert str(parse_marker(marker1) | (parse_marker(marker2))) == expected 302 | -------------------------------------------------------------------------------- /src/dep_logic/tags/tags.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import sys 4 | from dataclasses import dataclass 5 | from enum import IntEnum, auto 6 | from platform import python_implementation 7 | from typing import TYPE_CHECKING 8 | 9 | from dep_logic.specifiers.range import RangeSpecifier 10 | 11 | from ..specifiers import InvalidSpecifier, VersionSpecifier, parse_version_specifier 12 | from .platform import Platform 13 | 14 | if TYPE_CHECKING: 15 | from typing import Literal, Self 16 | 17 | 18 | def parse_wheel_tags(filename: str) -> tuple[list[str], list[str], list[str]]: 19 | if not filename.endswith(".whl"): 20 | raise InvalidWheelFilename( 21 | f"Invalid wheel filename (extension must be '.whl'): {filename}" 22 | ) 23 | 24 | filename = filename[:-4] 25 | dashes = filename.count("-") 26 | if dashes not in (4, 5): 27 | raise InvalidWheelFilename( 28 | f"Invalid wheel filename (wrong number of parts): {filename}" 29 | ) 30 | 31 | parts = filename.split("-") 32 | python, abi, platform = parts[-3:] 33 | return python.split("."), abi.split("."), platform.split(".") 34 | 35 | 36 | def _ensure_version_specifier(spec: str) -> VersionSpecifier: 37 | parsed = parse_version_specifier(spec) 38 | if not isinstance(parsed, VersionSpecifier): 39 | raise InvalidSpecifier(f"Invalid version specifier {spec}") 40 | return parsed 41 | 42 | 43 | class TagsError(Exception): 44 | pass 45 | 46 | 47 | class InvalidWheelFilename(TagsError, ValueError): 48 | pass 49 | 50 | 51 | class UnsupportedImplementation(TagsError, ValueError): 52 | pass 53 | 54 | 55 | @dataclass(frozen=True) 56 | class Implementation: 57 | name: Literal["cpython", "pypy", "pyston"] 58 | gil_disabled: bool = False 59 | 60 | @property 61 | def short(self) -> str: 62 | if self.name == "cpython": 63 | return "cp" 64 | elif self.name == "pypy": 65 | return "pp" 66 | else: 67 | return "pt" 68 | 69 | @property 70 | def capitalized(self) -> str: 71 | if self.name == "pypy": 72 | return "PyPy" 73 | elif self.name == "pyston": 74 | return "Pyston" 75 | else: 76 | return "CPython" 77 | 78 | @classmethod 79 | def current(cls) -> Self: 80 | import sysconfig 81 | 82 | implementation = python_implementation() 83 | 84 | return cls.parse( 85 | implementation.lower(), sysconfig.get_config_var("Py_GIL_DISABLED") or False 86 | ) 87 | 88 | @classmethod 89 | def parse(cls, name: str, gil_disabled: bool = False) -> Self: 90 | if gil_disabled and name != "cpython": 91 | raise UnsupportedImplementation("Only CPython supports GIL disabled mode") 92 | if name in ("cpython", "pypy", "pyston"): 93 | return cls(name, gil_disabled) 94 | else: 95 | raise UnsupportedImplementation( 96 | f"Unsupported implementation: {name}, expected cpython, pypy, or pyston" 97 | ) 98 | 99 | 100 | class EnvCompatibility(IntEnum): 101 | INCOMPATIBLE = auto() 102 | LOWER_OR_EQUAL = auto() 103 | HIGHER = auto() 104 | 105 | 106 | @dataclass(frozen=True) 107 | class EnvSpec: 108 | requires_python: VersionSpecifier 109 | platform: Platform | None = None 110 | implementation: Implementation | None = None 111 | 112 | def __str__(self) -> str: 113 | parts = [str(self.requires_python)] 114 | if self.platform is not None: 115 | parts.append(str(self.platform)) 116 | if self.implementation is not None: 117 | parts.append(self.implementation.name) 118 | return f"({', '.join(parts)})" 119 | 120 | def as_dict(self) -> dict[str, str | bool]: 121 | result: dict[str, str | bool] = {"requires_python": str(self.requires_python)} 122 | if self.platform is not None: 123 | result["platform"] = str(self.platform) 124 | if self.implementation is not None: 125 | result["implementation"] = self.implementation.name 126 | result["gil_disabled"] = self.implementation.gil_disabled 127 | return result 128 | 129 | @classmethod 130 | def from_spec( 131 | cls, 132 | requires_python: str, 133 | platform: str | None = None, 134 | implementation: str | None = None, 135 | gil_disabled: bool = False, 136 | ) -> Self: 137 | return cls( 138 | _ensure_version_specifier(requires_python), 139 | Platform.parse(platform) if platform else None, 140 | Implementation.parse(implementation, gil_disabled=gil_disabled) 141 | if implementation 142 | else None, 143 | ) 144 | 145 | @classmethod 146 | def current(cls) -> Self: 147 | # XXX: Strip pre-release and post-release tags 148 | python_version = f"{sys.version_info.major}.{sys.version_info.minor}.{sys.version_info.micro}" 149 | requires_python = _ensure_version_specifier(f"=={python_version}") 150 | platform = Platform.current() 151 | implementation = Implementation.current() 152 | return cls(requires_python, platform, implementation) 153 | 154 | def _evaluate_python( 155 | self, python_tag: str, abi_tag: str 156 | ) -> tuple[int, int, int] | None: 157 | """Return a tuple of (major, minor, abi) if the wheel is compatible with the environment, or None otherwise.""" 158 | impl, major, minor = python_tag[:2], python_tag[2:3], python_tag[3:] 159 | if self.implementation is not None and impl not in [ 160 | self.implementation.short, 161 | "py", 162 | ]: 163 | return None 164 | abi_impl = ( 165 | abi_tag.split("_", 1)[0] 166 | .replace("pypy", "pp") 167 | .replace("pyston", "pt") 168 | .lower() 169 | ) 170 | allow_abi3 = impl == "cp" and ( 171 | self.implementation is None or not self.implementation.gil_disabled 172 | ) 173 | free_threaded: bool | None = None 174 | if self.implementation is not None: 175 | free_threaded = self.implementation.gil_disabled 176 | try: 177 | if abi_impl == "abi3": 178 | if not allow_abi3: 179 | return None 180 | if ( 181 | parse_version_specifier(f">={major}.{minor or 0}") 182 | & self.requires_python 183 | ).is_empty(): 184 | return None 185 | return (int(major), int(minor or 0), 1) # 1 for abi3 186 | # cp36-cp36m-* 187 | # cp312-cp312m-* 188 | # pp310-pypy310_pp75-* 189 | if abi_impl != "none": 190 | if not abi_impl.startswith(python_tag.lower()): 191 | return None 192 | if ( 193 | free_threaded is not None 194 | and abi_impl.endswith("t") is not free_threaded 195 | ): 196 | return None 197 | if major and minor: 198 | wheel_range = parse_version_specifier(f"=={major}.{minor}.*") 199 | else: 200 | wheel_range = parse_version_specifier(f"=={major}.*") 201 | except InvalidSpecifier: 202 | return None 203 | if (wheel_range & self.requires_python).is_empty(): 204 | return None 205 | return (int(major), int(minor or 0), 0 if abi_impl == "none" else 2) 206 | 207 | def _evaluate_platform(self, platform_tag: str) -> int | None: 208 | if self.platform is None: 209 | return -1 210 | platform_tags = [*self.platform.compatible_tags, "any"] 211 | if platform_tag not in platform_tags: 212 | return None 213 | return len(platform_tags) - platform_tags.index(platform_tag) 214 | 215 | def compatibility( 216 | self, 217 | wheel_python_tags: list[str], 218 | wheel_abi_tags: list[str], 219 | wheel_platform_tags: list[str], 220 | ) -> tuple[int, int, int, int] | None: 221 | python_abi_combinations = ( 222 | (python_tag, abi_tag) 223 | for python_tag in wheel_python_tags 224 | for abi_tag in wheel_abi_tags 225 | ) 226 | python_compat = max( 227 | filter( 228 | None, (self._evaluate_python(*comb) for comb in python_abi_combinations) 229 | ), 230 | default=None, 231 | ) 232 | if python_compat is None: 233 | return None 234 | platform_compat = max( 235 | filter(None, map(self._evaluate_platform, wheel_platform_tags)), 236 | default=None, 237 | ) 238 | if platform_compat is None: 239 | return None 240 | return (*python_compat, platform_compat) 241 | 242 | def wheel_compatibility( 243 | self, wheel_filename: str 244 | ) -> tuple[int, int, int, int] | None: 245 | wheel_python_tags, wheel_abi_tags, wheel_platform_tags = parse_wheel_tags( 246 | wheel_filename 247 | ) 248 | return self.compatibility( 249 | wheel_python_tags, wheel_abi_tags, wheel_platform_tags 250 | ) 251 | 252 | def markers(self) -> dict[str, str]: 253 | result = {} 254 | if ( 255 | isinstance(self.requires_python, RangeSpecifier) 256 | and (version := self.requires_python.min) is not None 257 | and version == self.requires_python.max 258 | ): 259 | result.update( 260 | python_version=f"{version.major}.{version.minor}", 261 | python_full_version=str(version), 262 | ) 263 | if self.platform is not None: 264 | result.update(self.platform.markers()) 265 | if self.implementation is not None: 266 | result.update( 267 | implementation_name=self.implementation.name, 268 | platform_python_implementation=self.implementation.capitalized, 269 | ) 270 | 271 | return result 272 | 273 | def compare(self, target: EnvSpec) -> EnvCompatibility: 274 | if self == target: 275 | return EnvCompatibility.LOWER_OR_EQUAL 276 | if (self.requires_python & target.requires_python).is_empty(): 277 | return EnvCompatibility.INCOMPATIBLE 278 | if ( 279 | self.implementation is not None 280 | and target.implementation is not None 281 | and self.implementation != target.implementation 282 | ): 283 | return EnvCompatibility.INCOMPATIBLE 284 | if self.platform is None or target.platform is None: 285 | return EnvCompatibility.LOWER_OR_EQUAL 286 | if self.platform.arch != target.platform.arch: 287 | return EnvCompatibility.INCOMPATIBLE 288 | if type(self.platform.os) is not type(target.platform.os): 289 | return EnvCompatibility.INCOMPATIBLE 290 | 291 | if hasattr(self.platform.os, "major") and hasattr(self.platform.os, "minor"): 292 | if (self.platform.os.major, self.platform.os.minor) <= ( # type: ignore[attr-defined] 293 | target.platform.os.major, # type: ignore[attr-defined] 294 | target.platform.os.minor, # type: ignore[attr-defined] 295 | ): 296 | return EnvCompatibility.LOWER_OR_EQUAL 297 | else: 298 | return EnvCompatibility.HIGHER 299 | return EnvCompatibility.LOWER_OR_EQUAL 300 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Apache License 2 | Version 2.0, January 2004 3 | http://www.apache.org/licenses/ 4 | 5 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 6 | 7 | 1. Definitions. 8 | 9 | "License" shall mean the terms and conditions for use, reproduction, 10 | and distribution as defined by Sections 1 through 9 of this document. 11 | 12 | "Licensor" shall mean the copyright owner or entity authorized by 13 | the copyright owner that is granting the License. 14 | 15 | "Legal Entity" shall mean the union of the acting entity and all 16 | other entities that control, are controlled by, or are under common 17 | control with that entity. For the purposes of this definition, 18 | "control" means (i) the power, direct or indirect, to cause the 19 | direction or management of such entity, whether by contract or 20 | otherwise, or (ii) ownership of fifty percent (50%) or more of the 21 | outstanding shares, or (iii) beneficial ownership of such entity. 22 | 23 | "You" (or "Your") shall mean an individual or Legal Entity 24 | exercising permissions granted by this License. 25 | 26 | "Source" form shall mean the preferred form for making modifications, 27 | including but not limited to software source code, documentation 28 | source, and configuration files. 29 | 30 | "Object" form shall mean any form resulting from mechanical 31 | transformation or translation of a Source form, including but 32 | not limited to compiled object code, generated documentation, 33 | and conversions to other media types. 34 | 35 | "Work" shall mean the work of authorship, whether in Source or 36 | Object form, made available under the License, as indicated by a 37 | copyright notice that is included in or attached to the work 38 | (an example is provided in the Appendix below). 39 | 40 | "Derivative Works" shall mean any work, whether in Source or Object 41 | form, that is based on (or derived from) the Work and for which the 42 | editorial revisions, annotations, elaborations, or other modifications 43 | represent, as a whole, an original work of authorship. For the purposes 44 | of this License, Derivative Works shall not include works that remain 45 | separable from, or merely link (or bind by name) to the interfaces of, 46 | the Work and Derivative Works thereof. 47 | 48 | "Contribution" shall mean any work of authorship, including 49 | the original version of the Work and any modifications or additions 50 | to that Work or Derivative Works thereof, that is intentionally 51 | submitted to Licensor for inclusion in the Work by the copyright owner 52 | or by an individual or Legal Entity authorized to submit on behalf of 53 | the copyright owner. For the purposes of this definition, "submitted" 54 | means any form of electronic, verbal, or written communication sent 55 | to the Licensor or its representatives, including but not limited to 56 | communication on electronic mailing lists, source code control systems, 57 | and issue tracking systems that are managed by, or on behalf of, the 58 | Licensor for the purpose of discussing and improving the Work, but 59 | excluding communication that is conspicuously marked or otherwise 60 | designated in writing by the copyright owner as "Not a Contribution." 61 | 62 | "Contributor" shall mean Licensor and any individual or Legal Entity 63 | on behalf of whom a Contribution has been received by Licensor and 64 | subsequently incorporated within the Work. 65 | 66 | 2. Grant of Copyright License. Subject to the terms and conditions of 67 | this License, each Contributor hereby grants to You a perpetual, 68 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 69 | copyright license to reproduce, prepare Derivative Works of, 70 | publicly display, publicly perform, sublicense, and distribute the 71 | Work and such Derivative Works in Source or Object form. 72 | 73 | 3. Grant of Patent License. Subject to the terms and conditions of 74 | this License, each Contributor hereby grants to You a perpetual, 75 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 76 | (except as stated in this section) patent license to make, have made, 77 | use, offer to sell, sell, import, and otherwise transfer the Work, 78 | where such license applies only to those patent claims licensable 79 | by such Contributor that are necessarily infringed by their 80 | Contribution(s) alone or by combination of their Contribution(s) 81 | with the Work to which such Contribution(s) was submitted. If You 82 | institute patent litigation against any entity (including a 83 | cross-claim or counterclaim in a lawsuit) alleging that the Work 84 | or a Contribution incorporated within the Work constitutes direct 85 | or contributory patent infringement, then any patent licenses 86 | granted to You under this License for that Work shall terminate 87 | as of the date such litigation is filed. 88 | 89 | 4. Redistribution. You may reproduce and distribute copies of the 90 | Work or Derivative Works thereof in any medium, with or without 91 | modifications, and in Source or Object form, provided that You 92 | meet the following conditions: 93 | 94 | (a) You must give any other recipients of the Work or 95 | Derivative Works a copy of this License; and 96 | 97 | (b) You must cause any modified files to carry prominent notices 98 | stating that You changed the files; and 99 | 100 | (c) You must retain, in the Source form of any Derivative Works 101 | that You distribute, all copyright, patent, trademark, and 102 | attribution notices from the Source form of the Work, 103 | excluding those notices that do not pertain to any part of 104 | the Derivative Works; and 105 | 106 | (d) If the Work includes a "NOTICE" text file as part of its 107 | distribution, then any Derivative Works that You distribute must 108 | include a readable copy of the attribution notices contained 109 | within such NOTICE file, excluding those notices that do not 110 | pertain to any part of the Derivative Works, in at least one 111 | of the following places: within a NOTICE text file distributed 112 | as part of the Derivative Works; within the Source form or 113 | documentation, if provided along with the Derivative Works; or, 114 | within a display generated by the Derivative Works, if and 115 | wherever such third-party notices normally appear. The contents 116 | of the NOTICE file are for informational purposes only and 117 | do not modify the License. You may add Your own attribution 118 | notices within Derivative Works that You distribute, alongside 119 | or as an addendum to the NOTICE text from the Work, provided 120 | that such additional attribution notices cannot be construed 121 | as modifying the License. 122 | 123 | You may add Your own copyright statement to Your modifications and 124 | may provide additional or different license terms and conditions 125 | for use, reproduction, or distribution of Your modifications, or 126 | for any such Derivative Works as a whole, provided Your use, 127 | reproduction, and distribution of the Work otherwise complies with 128 | the conditions stated in this License. 129 | 130 | 5. Submission of Contributions. Unless You explicitly state otherwise, 131 | any Contribution intentionally submitted for inclusion in the Work 132 | by You to the Licensor shall be under the terms and conditions of 133 | this License, without any additional terms or conditions. 134 | Notwithstanding the above, nothing herein shall supersede or modify 135 | the terms of any separate license agreement you may have executed 136 | with Licensor regarding such Contributions. 137 | 138 | 6. Trademarks. This License does not grant permission to use the trade 139 | names, trademarks, service marks, or product names of the Licensor, 140 | except as required for reasonable and customary use in describing the 141 | origin of the Work and reproducing the content of the NOTICE file. 142 | 143 | 7. Disclaimer of Warranty. Unless required by applicable law or 144 | agreed to in writing, Licensor provides the Work (and each 145 | Contributor provides its Contributions) on an "AS IS" BASIS, 146 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or 147 | implied, including, without limitation, any warranties or conditions 148 | of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A 149 | PARTICULAR PURPOSE. You are solely responsible for determining the 150 | appropriateness of using or redistributing the Work and assume any 151 | risks associated with Your exercise of permissions under this License. 152 | 153 | 8. Limitation of Liability. In no event and under no legal theory, 154 | whether in tort (including negligence), contract, or otherwise, 155 | unless required by applicable law (such as deliberate and grossly 156 | negligent acts) or agreed to in writing, shall any Contributor be 157 | liable to You for damages, including any direct, indirect, special, 158 | incidental, or consequential damages of any character arising as a 159 | result of this License or out of the use or inability to use the 160 | Work (including but not limited to damages for loss of goodwill, 161 | work stoppage, computer failure or malfunction, or any and all 162 | other commercial damages or losses), even if such Contributor 163 | has been advised of the possibility of such damages. 164 | 165 | 9. Accepting Warranty or Additional Liability. While redistributing 166 | the Work or Derivative Works thereof, You may choose to offer, 167 | and charge a fee for, acceptance of support, warranty, indemnity, 168 | or other liability obligations and/or rights consistent with this 169 | License. However, in accepting such obligations, You may act only 170 | on Your own behalf and on Your sole responsibility, not on behalf 171 | of any other Contributor, and only if You agree to indemnify, 172 | defend, and hold each Contributor harmless for any liability 173 | incurred by, or claims asserted against, such Contributor by reason 174 | of your accepting any such warranty or additional liability. 175 | 176 | END OF TERMS AND CONDITIONS 177 | 178 | APPENDIX: How to apply the Apache License to your work. 179 | 180 | To apply the Apache License to your work, attach the following 181 | boilerplate notice, with the fields enclosed by brackets "[]" 182 | replaced with your own identifying information. (Don't include 183 | the brackets!) The text should be enclosed in the appropriate 184 | comment syntax for the file format. We also recommend that a 185 | file or class name and description of purpose be included on the 186 | same "printed page" as the copyright notice for easier 187 | identification within third-party archives. 188 | 189 | Copyright 2023 Frost Ming 190 | 191 | Licensed under the Apache License, Version 2.0 (the "License"); 192 | you may not use this file except in compliance with the License. 193 | You may obtain a copy of the License at 194 | 195 | http://www.apache.org/licenses/LICENSE-2.0 196 | 197 | Unless required by applicable law or agreed to in writing, software 198 | distributed under the License is distributed on an "AS IS" BASIS, 199 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 200 | See the License for the specific language governing permissions and 201 | limitations under the License. 202 | -------------------------------------------------------------------------------- /tests/tags/test_platform.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | 3 | from dep_logic.tags import os 4 | from dep_logic.tags.platform import Arch, Platform 5 | 6 | 7 | def test_platform_tags_manylinux(): 8 | tags = Platform(os.Manylinux(2, 20), Arch.X86_64).compatible_tags 9 | assert tags == [ 10 | "manylinux_2_20_x86_64", 11 | "manylinux_2_19_x86_64", 12 | "manylinux_2_18_x86_64", 13 | "manylinux_2_17_x86_64", 14 | "manylinux2014_x86_64", 15 | "manylinux_2_16_x86_64", 16 | "manylinux_2_15_x86_64", 17 | "manylinux_2_14_x86_64", 18 | "manylinux_2_13_x86_64", 19 | "manylinux_2_12_x86_64", 20 | "manylinux2010_x86_64", 21 | "manylinux_2_11_x86_64", 22 | "manylinux_2_10_x86_64", 23 | "manylinux_2_9_x86_64", 24 | "manylinux_2_8_x86_64", 25 | "manylinux_2_7_x86_64", 26 | "manylinux_2_6_x86_64", 27 | "manylinux_2_5_x86_64", 28 | "manylinux1_x86_64", 29 | "linux_x86_64", 30 | ] 31 | 32 | 33 | def test_platform_tags_macos(): 34 | tags = Platform(os.Macos(21, 6), Arch.X86_64).compatible_tags 35 | assert tags == [ 36 | "macosx_21_0_x86_64", 37 | "macosx_21_0_intel", 38 | "macosx_21_0_fat64", 39 | "macosx_21_0_fat32", 40 | "macosx_21_0_universal2", 41 | "macosx_21_0_universal", 42 | "macosx_20_0_x86_64", 43 | "macosx_20_0_intel", 44 | "macosx_20_0_fat64", 45 | "macosx_20_0_fat32", 46 | "macosx_20_0_universal2", 47 | "macosx_20_0_universal", 48 | "macosx_19_0_x86_64", 49 | "macosx_19_0_intel", 50 | "macosx_19_0_fat64", 51 | "macosx_19_0_fat32", 52 | "macosx_19_0_universal2", 53 | "macosx_19_0_universal", 54 | "macosx_18_0_x86_64", 55 | "macosx_18_0_intel", 56 | "macosx_18_0_fat64", 57 | "macosx_18_0_fat32", 58 | "macosx_18_0_universal2", 59 | "macosx_18_0_universal", 60 | "macosx_17_0_x86_64", 61 | "macosx_17_0_intel", 62 | "macosx_17_0_fat64", 63 | "macosx_17_0_fat32", 64 | "macosx_17_0_universal2", 65 | "macosx_17_0_universal", 66 | "macosx_16_0_x86_64", 67 | "macosx_16_0_intel", 68 | "macosx_16_0_fat64", 69 | "macosx_16_0_fat32", 70 | "macosx_16_0_universal2", 71 | "macosx_16_0_universal", 72 | "macosx_15_0_x86_64", 73 | "macosx_15_0_intel", 74 | "macosx_15_0_fat64", 75 | "macosx_15_0_fat32", 76 | "macosx_15_0_universal2", 77 | "macosx_15_0_universal", 78 | "macosx_14_0_x86_64", 79 | "macosx_14_0_intel", 80 | "macosx_14_0_fat64", 81 | "macosx_14_0_fat32", 82 | "macosx_14_0_universal2", 83 | "macosx_14_0_universal", 84 | "macosx_13_0_x86_64", 85 | "macosx_13_0_intel", 86 | "macosx_13_0_fat64", 87 | "macosx_13_0_fat32", 88 | "macosx_13_0_universal2", 89 | "macosx_13_0_universal", 90 | "macosx_12_0_x86_64", 91 | "macosx_12_0_intel", 92 | "macosx_12_0_fat64", 93 | "macosx_12_0_fat32", 94 | "macosx_12_0_universal2", 95 | "macosx_12_0_universal", 96 | "macosx_11_0_x86_64", 97 | "macosx_11_0_intel", 98 | "macosx_11_0_fat64", 99 | "macosx_11_0_fat32", 100 | "macosx_11_0_universal2", 101 | "macosx_11_0_universal", 102 | "macosx_10_16_x86_64", 103 | "macosx_10_16_intel", 104 | "macosx_10_16_fat64", 105 | "macosx_10_16_fat32", 106 | "macosx_10_16_universal2", 107 | "macosx_10_16_universal", 108 | "macosx_10_15_x86_64", 109 | "macosx_10_15_intel", 110 | "macosx_10_15_fat64", 111 | "macosx_10_15_fat32", 112 | "macosx_10_15_universal2", 113 | "macosx_10_15_universal", 114 | "macosx_10_14_x86_64", 115 | "macosx_10_14_intel", 116 | "macosx_10_14_fat64", 117 | "macosx_10_14_fat32", 118 | "macosx_10_14_universal2", 119 | "macosx_10_14_universal", 120 | "macosx_10_13_x86_64", 121 | "macosx_10_13_intel", 122 | "macosx_10_13_fat64", 123 | "macosx_10_13_fat32", 124 | "macosx_10_13_universal2", 125 | "macosx_10_13_universal", 126 | "macosx_10_12_x86_64", 127 | "macosx_10_12_intel", 128 | "macosx_10_12_fat64", 129 | "macosx_10_12_fat32", 130 | "macosx_10_12_universal2", 131 | "macosx_10_12_universal", 132 | "macosx_10_11_x86_64", 133 | "macosx_10_11_intel", 134 | "macosx_10_11_fat64", 135 | "macosx_10_11_fat32", 136 | "macosx_10_11_universal2", 137 | "macosx_10_11_universal", 138 | "macosx_10_10_x86_64", 139 | "macosx_10_10_intel", 140 | "macosx_10_10_fat64", 141 | "macosx_10_10_fat32", 142 | "macosx_10_10_universal2", 143 | "macosx_10_10_universal", 144 | "macosx_10_9_x86_64", 145 | "macosx_10_9_intel", 146 | "macosx_10_9_fat64", 147 | "macosx_10_9_fat32", 148 | "macosx_10_9_universal2", 149 | "macosx_10_9_universal", 150 | "macosx_10_8_x86_64", 151 | "macosx_10_8_intel", 152 | "macosx_10_8_fat64", 153 | "macosx_10_8_fat32", 154 | "macosx_10_8_universal2", 155 | "macosx_10_8_universal", 156 | "macosx_10_7_x86_64", 157 | "macosx_10_7_intel", 158 | "macosx_10_7_fat64", 159 | "macosx_10_7_fat32", 160 | "macosx_10_7_universal2", 161 | "macosx_10_7_universal", 162 | "macosx_10_6_x86_64", 163 | "macosx_10_6_intel", 164 | "macosx_10_6_fat64", 165 | "macosx_10_6_fat32", 166 | "macosx_10_6_universal2", 167 | "macosx_10_6_universal", 168 | "macosx_10_5_x86_64", 169 | "macosx_10_5_intel", 170 | "macosx_10_5_fat64", 171 | "macosx_10_5_fat32", 172 | "macosx_10_5_universal2", 173 | "macosx_10_5_universal", 174 | "macosx_10_4_x86_64", 175 | "macosx_10_4_intel", 176 | "macosx_10_4_fat64", 177 | "macosx_10_4_fat32", 178 | "macosx_10_4_universal2", 179 | "macosx_10_4_universal", 180 | ] 181 | 182 | tags = Platform(os.Macos(14, 0), Arch.X86_64).compatible_tags 183 | assert tags == [ 184 | "macosx_14_0_x86_64", 185 | "macosx_14_0_intel", 186 | "macosx_14_0_fat64", 187 | "macosx_14_0_fat32", 188 | "macosx_14_0_universal2", 189 | "macosx_14_0_universal", 190 | "macosx_13_0_x86_64", 191 | "macosx_13_0_intel", 192 | "macosx_13_0_fat64", 193 | "macosx_13_0_fat32", 194 | "macosx_13_0_universal2", 195 | "macosx_13_0_universal", 196 | "macosx_12_0_x86_64", 197 | "macosx_12_0_intel", 198 | "macosx_12_0_fat64", 199 | "macosx_12_0_fat32", 200 | "macosx_12_0_universal2", 201 | "macosx_12_0_universal", 202 | "macosx_11_0_x86_64", 203 | "macosx_11_0_intel", 204 | "macosx_11_0_fat64", 205 | "macosx_11_0_fat32", 206 | "macosx_11_0_universal2", 207 | "macosx_11_0_universal", 208 | "macosx_10_16_x86_64", 209 | "macosx_10_16_intel", 210 | "macosx_10_16_fat64", 211 | "macosx_10_16_fat32", 212 | "macosx_10_16_universal2", 213 | "macosx_10_16_universal", 214 | "macosx_10_15_x86_64", 215 | "macosx_10_15_intel", 216 | "macosx_10_15_fat64", 217 | "macosx_10_15_fat32", 218 | "macosx_10_15_universal2", 219 | "macosx_10_15_universal", 220 | "macosx_10_14_x86_64", 221 | "macosx_10_14_intel", 222 | "macosx_10_14_fat64", 223 | "macosx_10_14_fat32", 224 | "macosx_10_14_universal2", 225 | "macosx_10_14_universal", 226 | "macosx_10_13_x86_64", 227 | "macosx_10_13_intel", 228 | "macosx_10_13_fat64", 229 | "macosx_10_13_fat32", 230 | "macosx_10_13_universal2", 231 | "macosx_10_13_universal", 232 | "macosx_10_12_x86_64", 233 | "macosx_10_12_intel", 234 | "macosx_10_12_fat64", 235 | "macosx_10_12_fat32", 236 | "macosx_10_12_universal2", 237 | "macosx_10_12_universal", 238 | "macosx_10_11_x86_64", 239 | "macosx_10_11_intel", 240 | "macosx_10_11_fat64", 241 | "macosx_10_11_fat32", 242 | "macosx_10_11_universal2", 243 | "macosx_10_11_universal", 244 | "macosx_10_10_x86_64", 245 | "macosx_10_10_intel", 246 | "macosx_10_10_fat64", 247 | "macosx_10_10_fat32", 248 | "macosx_10_10_universal2", 249 | "macosx_10_10_universal", 250 | "macosx_10_9_x86_64", 251 | "macosx_10_9_intel", 252 | "macosx_10_9_fat64", 253 | "macosx_10_9_fat32", 254 | "macosx_10_9_universal2", 255 | "macosx_10_9_universal", 256 | "macosx_10_8_x86_64", 257 | "macosx_10_8_intel", 258 | "macosx_10_8_fat64", 259 | "macosx_10_8_fat32", 260 | "macosx_10_8_universal2", 261 | "macosx_10_8_universal", 262 | "macosx_10_7_x86_64", 263 | "macosx_10_7_intel", 264 | "macosx_10_7_fat64", 265 | "macosx_10_7_fat32", 266 | "macosx_10_7_universal2", 267 | "macosx_10_7_universal", 268 | "macosx_10_6_x86_64", 269 | "macosx_10_6_intel", 270 | "macosx_10_6_fat64", 271 | "macosx_10_6_fat32", 272 | "macosx_10_6_universal2", 273 | "macosx_10_6_universal", 274 | "macosx_10_5_x86_64", 275 | "macosx_10_5_intel", 276 | "macosx_10_5_fat64", 277 | "macosx_10_5_fat32", 278 | "macosx_10_5_universal2", 279 | "macosx_10_5_universal", 280 | "macosx_10_4_x86_64", 281 | "macosx_10_4_intel", 282 | "macosx_10_4_fat64", 283 | "macosx_10_4_fat32", 284 | "macosx_10_4_universal2", 285 | "macosx_10_4_universal", 286 | ] 287 | 288 | tags = Platform(os.Macos(10, 6), Arch.X86_64).compatible_tags 289 | assert tags == [ 290 | "macosx_10_6_x86_64", 291 | "macosx_10_6_intel", 292 | "macosx_10_6_fat64", 293 | "macosx_10_6_fat32", 294 | "macosx_10_6_universal2", 295 | "macosx_10_6_universal", 296 | "macosx_10_5_x86_64", 297 | "macosx_10_5_intel", 298 | "macosx_10_5_fat64", 299 | "macosx_10_5_fat32", 300 | "macosx_10_5_universal2", 301 | "macosx_10_5_universal", 302 | "macosx_10_4_x86_64", 303 | "macosx_10_4_intel", 304 | "macosx_10_4_fat64", 305 | "macosx_10_4_fat32", 306 | "macosx_10_4_universal2", 307 | "macosx_10_4_universal", 308 | ] 309 | 310 | 311 | def test_platform_tags_windows(): 312 | tags = Platform(os.Windows(), Arch.X86_64).compatible_tags 313 | assert tags == ["win_amd64"] 314 | 315 | 316 | def test_platform_tags_musl(): 317 | tags = Platform(os.Musllinux(1, 2), Arch.Aarch64).compatible_tags 318 | assert tags == ["linux_aarch64", "musllinux_1_1_aarch64", "musllinux_1_2_aarch64"] 319 | 320 | 321 | @pytest.mark.parametrize( 322 | "text,expected,normalized", 323 | [ 324 | ("linux", Platform(os.Manylinux(2, 40), Arch.X86_64), "manylinux_2_40_x86_64"), 325 | ("macos", Platform(os.Macos(14, 0), Arch.Aarch64), "macos_14_0_arm64"), 326 | ("windows", Platform(os.Windows(), Arch.X86_64), "windows_amd64"), 327 | ("alpine", Platform(os.Musllinux(1, 2), Arch.X86_64), "musllinux_1_2_x86_64"), 328 | ( 329 | "manylinux_2_20_aarch64", 330 | Platform(os.Manylinux(2, 20), Arch.Aarch64), 331 | "manylinux_2_20_aarch64", 332 | ), 333 | ( 334 | "macos_14_0_arm64", 335 | Platform(os.Macos(14, 0), Arch.Aarch64), 336 | "macos_14_0_arm64", 337 | ), 338 | ("windows_amd64", Platform(os.Windows(), Arch.X86_64), "windows_amd64"), 339 | ("windows_arm64", Platform(os.Windows(), Arch.Aarch64), "windows_arm64"), 340 | ( 341 | "macos_12_0_x86_64", 342 | Platform(os.Macos(12, 0), Arch.X86_64), 343 | "macos_12_0_x86_64", 344 | ), 345 | ( 346 | "mingw_x86_64", 347 | Platform(os.Generic("mingw"), Arch.X86_64), 348 | "mingw_x86_64", 349 | ), 350 | ], 351 | ) 352 | def test_parse_platform(text, expected, normalized): 353 | platform = Platform.parse(text) 354 | assert platform == expected 355 | assert str(platform) == normalized 356 | -------------------------------------------------------------------------------- /src/dep_logic/tags/platform.py: -------------------------------------------------------------------------------- 1 | # Abstractions for understanding the current platform (operating system and architecture). 2 | from __future__ import annotations 3 | 4 | import re 5 | import struct 6 | import sys 7 | from dataclasses import dataclass 8 | from enum import Enum 9 | from functools import cached_property 10 | from typing import TYPE_CHECKING 11 | 12 | from . import os 13 | 14 | if TYPE_CHECKING: 15 | from typing import Self 16 | 17 | 18 | class PlatformError(Exception): 19 | pass 20 | 21 | 22 | _platform_major_minor_re = re.compile( 23 | r"(?Pmanylinux|macos|musllinux)_(?P\d+?)_(?P\d+?)_(?P[a-z0-9_]+)$" 24 | ) 25 | 26 | _os_mapping = { 27 | "freebsd": os.FreeBsd, 28 | "netbsd": os.NetBsd, 29 | "openbsd": os.OpenBsd, 30 | "dragonfly": os.Dragonfly, 31 | "illumos": os.Illumos, 32 | "haiku": os.Haiku, 33 | } 34 | 35 | 36 | @dataclass(frozen=True) 37 | class Platform: 38 | os: os.Os 39 | arch: Arch 40 | 41 | @classmethod 42 | def parse(cls, platform: str) -> Self: 43 | """Parse a platform string (e.g., `linux_x86_64`, `macosx_10_9_x86_64`, or `win_amd64`) 44 | 45 | Available operating systems: 46 | - `linux`: an alias for `manylinux_2_17_x86_64` 47 | - `windows`: an alias for `win_amd64` 48 | - `macos`: an alias for `macos_14_0_arm64` 49 | - `alpine`: an alias for `musllinux_1_2_x86_64` 50 | - `windows_amd64` 51 | - `windows_x86` 52 | - `windows_arm64` 53 | - `macos_arm64`: an alias for `macos_14_0_arm64` 54 | - `macos_x86_64`: an alias for `macos_14_0_x86_64` 55 | - `macos_X_Y_arm64` 56 | - `macos_X_Y_x86_64` 57 | - `manylinux_X_Y_x86_64` 58 | - `manylinux_X_Y_aarch64` 59 | - `musllinux_X_Y_x86_64` 60 | - `musllinux_X_Y_aarch64` 61 | """ 62 | if platform == "linux": 63 | return cls(os.Manylinux(2, 40), Arch.X86_64) 64 | elif platform == "windows": 65 | return cls(os.Windows(), Arch.X86_64) 66 | elif platform == "macos": 67 | return cls(os.Macos(14, 0), Arch.Aarch64) 68 | elif platform == "alpine": 69 | return cls(os.Musllinux(1, 2), Arch.X86_64) 70 | elif platform.startswith("windows_"): 71 | return cls(os.Windows(), Arch.parse(platform.split("_", 1)[1])) 72 | elif platform == "macos_arm64": 73 | return cls(os.Macos(14, 0), Arch.Aarch64) 74 | elif platform == "macos_x86_64": 75 | return cls(os.Macos(14, 0), Arch.X86_64) 76 | elif (m := _platform_major_minor_re.match(platform)) is not None: 77 | os_name, major, minor, arch = m.groups() 78 | if os_name == "manylinux": 79 | return cls(os.Manylinux(int(major), int(minor)), Arch.parse(arch)) 80 | elif os_name == "macos": 81 | return cls(os.Macos(int(major), int(minor)), Arch.parse(arch)) 82 | else: # os_name == "musllinux" 83 | return cls(os.Musllinux(int(major), int(minor)), Arch.parse(arch)) 84 | else: 85 | os_, arch = platform.split("_", 1) 86 | if os_ in _os_mapping: 87 | release, _, arch = arch.partition("_") 88 | return cls(_os_mapping[os_](release), Arch.parse(arch)) 89 | try: 90 | return cls(os.Generic(os_), Arch.parse(arch)) 91 | except ValueError as e: 92 | raise PlatformError(f"Unsupported platform {platform}") from e 93 | 94 | def __str__(self) -> str: 95 | if isinstance(self.os, os.Windows) and self.arch == Arch.X86_64: 96 | return "windows_amd64" 97 | if isinstance(self.os, (os.Macos, os.Windows)) and self.arch == Arch.Aarch64: 98 | return f"{self.os}_arm64" 99 | return f"{self.os}_{self.arch}" 100 | 101 | @classmethod 102 | def current(cls) -> Self: 103 | """Return the current platform.""" 104 | import platform 105 | import sysconfig 106 | 107 | platform_ = sysconfig.get_platform() 108 | platform_info = platform_.split("-", 1) 109 | if len(platform_info) == 1: 110 | if platform_info[0] == "win32": 111 | return cls(os.Windows(), Arch.X86) 112 | operating_system, _, version_arch = ( 113 | platform_.replace(".", "_").replace(" ", "_").partition("_") 114 | ) 115 | else: 116 | operating_system, version_arch = platform_info 117 | if "-" in version_arch: 118 | # Ex: macosx-11.2-arm64 119 | version, architecture = version_arch.rsplit("-", 1) 120 | else: 121 | # Ex: linux-x86_64 or x86_64_msvcrt_gnu 122 | version = None 123 | architecture = version_arch 124 | if version_arch.startswith("x86_64"): 125 | architecture = "x86_64" 126 | 127 | if operating_system == "linux": 128 | from packaging._manylinux import _get_glibc_version 129 | from packaging._musllinux import _get_musl_version 130 | 131 | musl_version = _get_musl_version(sys.executable) 132 | glibc_version = _get_glibc_version() 133 | if musl_version: 134 | os_ = os.Musllinux(musl_version[0], musl_version[1]) 135 | else: 136 | os_ = os.Manylinux(glibc_version[0], glibc_version[1]) 137 | elif operating_system == "win": 138 | os_ = os.Windows() 139 | elif operating_system == "macosx": 140 | # Apparently, Mac OS is reporting i386 sometimes in sysconfig.get_platform even 141 | # though that's not a thing anymore. 142 | # https://github.com/astral-sh/uv/issues/2450 143 | version, _, architecture = platform.mac_ver() 144 | 145 | # https://github.com/pypa/packaging/blob/cc938f984bbbe43c5734b9656c9837ab3a28191f/src/packaging/tags.py#L356-L363 146 | is_32bit = struct.calcsize("P") == 4 147 | if is_32bit: 148 | if architecture.startswith("ppc"): 149 | architecture = "ppc" 150 | else: 151 | architecture = "i386" 152 | 153 | version = version.split(".") 154 | os_ = os.Macos(int(version[0]), int(version[1])) 155 | elif operating_system in _os_mapping: 156 | os_ = _os_mapping[operating_system](version) 157 | else: 158 | os_ = os.Generic(operating_system) 159 | 160 | return cls(os_, Arch.parse(architecture)) 161 | 162 | @classmethod 163 | def choices(cls) -> list[str]: 164 | return [ 165 | "linux", 166 | "windows", 167 | "macos", 168 | "alpine", 169 | "windows_amd64", 170 | "windows_x86", 171 | "windows_arm64", 172 | "macos_arm64", 173 | "macos_x86_64", 174 | "macos_X_Y_arm64", 175 | "macos_X_Y_x86_64", 176 | "manylinux_X_Y_x86_64", 177 | "manylinux_X_Y_aarch64", 178 | "musllinux_X_Y_x86_64", 179 | "musllinux_X_Y_aarch64", 180 | ] 181 | 182 | @cached_property 183 | def compatible_tags(self) -> list[str]: 184 | """Returns the compatible tags for the current [`Platform`] (e.g., `manylinux_2_17`, 185 | `macosx_11_0_arm64`, or `win_amd64`). 186 | 187 | We have two cases: Actual platform specific tags (including "merged" tags such as universal2) 188 | and "any". 189 | 190 | Bit of a mess, needs to be cleaned up. 191 | """ 192 | os_ = self.os 193 | arch = self.arch 194 | platform_tags: list[str] = [] 195 | if isinstance(os_, os.Manylinux): 196 | if (min_minor := arch.get_minimum_manylinux_minor()) is not None: 197 | for minor in range(os_.minor, min_minor - 1, -1): 198 | platform_tags.append(f"manylinux_{os_.major}_{minor}_{arch}") 199 | # Support legacy manylinux tags with lower priority 200 | # 201 | if minor == 12: 202 | platform_tags.append(f"manylinux2010_{arch}") 203 | if minor == 17: 204 | platform_tags.append(f"manylinux2014_{arch}") 205 | if minor == 5: 206 | platform_tags.append(f"manylinux1_{arch}") 207 | # Non-manylinux is lowest priority 208 | # 209 | platform_tags.append(f"linux_{arch}") 210 | elif isinstance(os_, os.Musllinux): 211 | platform_tags.append(f"linux_{arch}") 212 | for minor in range(1, os_.minor + 1): 213 | # musl 1.1 is the lowest supported version in musllinux 214 | platform_tags.append(f"musllinux_{os_.major}_{minor}_{arch}") 215 | elif isinstance(os_, os.Macos) and arch == Arch.X86_64: 216 | if os_.major == 10: 217 | for minor in range(os_.minor, 3, -1): 218 | for binary_format in arch.get_mac_binary_formats(): 219 | platform_tags.append(f"macosx_10_{minor}_{binary_format}") 220 | elif isinstance(os_.major, int) and os_.major >= 11: 221 | # Starting with Mac OS 11, each yearly release bumps the major version number. 222 | # The minor versions are now the midyear updates. 223 | for major in range(os_.major, 10, -1): 224 | for binary_format in arch.get_mac_binary_formats(): 225 | platform_tags.append(f"macosx_{major}_0_{binary_format}") 226 | # The "universal2" binary format can have a macOS version earlier than 11.0 227 | # when the x86_64 part of the binary supports that version of macOS. 228 | for minor in range(16, 3, -1): 229 | for binary_format in arch.get_mac_binary_formats(): 230 | platform_tags.append(f"macosx_10_{minor}_{binary_format}") 231 | else: 232 | raise PlatformError(f"Unsupported macOS version {os_.major}") 233 | elif isinstance(os_, os.Macos) and arch == Arch.Aarch64: 234 | # Starting with Mac OS 11, each yearly release bumps the major version number. 235 | # The minor versions are now the midyear updates. 236 | for major in range(os_.major, 10, -1): 237 | for binary_format in arch.get_mac_binary_formats(): 238 | platform_tags.append(f"macosx_{major}_0_{binary_format}") 239 | # The "universal2" binary format can have a macOS version earlier than 11.0 240 | # when the x86_64 part of the binary supports that version of macOS. 241 | for minor in range(16, 3, -1): 242 | platform_tags.append(f"macosx_10_{minor}_universal2") 243 | elif isinstance(os_, os.Windows): 244 | if arch == Arch.X86: 245 | platform_tags.append("win32") 246 | elif arch == Arch.X86_64: 247 | platform_tags.append("win_amd64") 248 | elif arch == Arch.Aarch64: 249 | platform_tags.append("win_arm64") 250 | else: 251 | raise PlatformError(f"Unsupported Windows architecture {arch}") 252 | elif isinstance( 253 | os_, (os.FreeBsd, os.NetBsd, os.OpenBsd, os.Dragonfly, os.Haiku) 254 | ): 255 | release = os_.release.replace(".", "_").replace("-", "_") 256 | platform_tags.append(f"{str(os_).lower()}_{release}_{arch}") 257 | elif isinstance(os_, os.Illumos): 258 | # See https://github.com/python/cpython/blob/46c8d915715aa2bd4d697482aa051fe974d440e1/Lib/sysconfig.py#L722-L730 259 | try: 260 | major, other = os_.release.split("_", 1) 261 | except ValueError: 262 | platform_tags.append(f"{str(os_).lower()}_{os_.release}_{arch}") 263 | else: 264 | major_ver = int(major) 265 | if major_ver >= 5: 266 | # SunOS 5 == Solaris 2 267 | release = f"{major_ver - 3}_{other}" 268 | arch = f"{arch}_64bit" 269 | platform_tags.append(f"solaris_{release}_{arch}") 270 | elif isinstance(os_, os.Generic): 271 | platform_tags.append(f"{os_}_{arch}") 272 | else: 273 | raise PlatformError( 274 | f"Unsupported operating system and architecture combination: {os_} {arch}" 275 | ) 276 | return platform_tags 277 | 278 | @cached_property 279 | def os_name(self) -> str: 280 | return "nt" if isinstance(self.os, os.Windows) else "posix" 281 | 282 | @cached_property 283 | def sys_platform(self) -> str: 284 | if isinstance(self.os, os.Windows): 285 | return "win32" 286 | elif isinstance(self.os, (os.Macos, os.Illumos)): 287 | return "darwin" 288 | else: 289 | return "linux" 290 | 291 | @cached_property 292 | def platform_machine(self) -> str: 293 | if isinstance(self.os, (os.Windows, os.Macos)) and self.arch == Arch.Aarch64: 294 | return "arm64" 295 | if isinstance(self.os, os.Windows) and self.arch == Arch.X86_64: 296 | return "AMD64" 297 | return str(self.arch) 298 | 299 | @cached_property 300 | def platform_release(self) -> str: 301 | return "" 302 | 303 | @cached_property 304 | def platform_version(self) -> str: 305 | return "" 306 | 307 | @cached_property 308 | def platform_system(self) -> str: 309 | if isinstance(self.os, os.Macos): 310 | return "Darwin" 311 | if isinstance(self.os, os.Windows): 312 | return "Windows" 313 | return "Linux" 314 | 315 | def is_current(self) -> bool: 316 | current = self.current() 317 | return isinstance(self.os, type(current.os)) and self.arch == current.arch 318 | 319 | def markers(self) -> dict[str, str]: 320 | if self.is_current(): 321 | return {} 322 | return { 323 | "os_name": self.os_name, 324 | "platform_machine": self.platform_machine, 325 | "platform_release": self.platform_release, 326 | "platform_system": self.platform_system, 327 | "platform_version": self.platform_version, 328 | "sys_platform": self.sys_platform, 329 | } 330 | 331 | 332 | class Arch(Enum): 333 | Aarch64 = "aarch64" 334 | Armv6L = "armv6l" 335 | Armv7L = "armv7l" 336 | Powerpc64Le = "ppc64le" 337 | Powerpc64 = "ppc64" 338 | X86 = "x86" 339 | X86_64 = "x86_64" 340 | S390X = "s390x" 341 | RISCV64 = "riscv64" 342 | LoongArch64 = "loongarch64" 343 | 344 | def __str__(self) -> str: 345 | return self.value 346 | 347 | def get_minimum_manylinux_minor(self) -> int | None: 348 | if self in [ 349 | Arch.Aarch64, 350 | Arch.Armv7L, 351 | Arch.Powerpc64, 352 | Arch.Powerpc64Le, 353 | Arch.S390X, 354 | Arch.RISCV64, 355 | ]: 356 | return 17 357 | elif self in [Arch.X86, Arch.X86_64]: 358 | return 5 359 | else: 360 | return None 361 | 362 | def get_mac_binary_formats(self) -> list[str]: 363 | if self == Arch.Aarch64: 364 | formats = ["arm64"] 365 | else: 366 | formats = [self.value] 367 | 368 | if self == Arch.X86_64: 369 | formats.extend(["intel", "fat64", "fat32"]) 370 | 371 | if self in [Arch.X86_64, Arch.Aarch64]: 372 | formats.append("universal2") 373 | 374 | if self == Arch.X86_64: 375 | formats.append("universal") 376 | 377 | return formats 378 | 379 | @classmethod 380 | def parse(cls, arch: str) -> Arch: 381 | if arch in ("i386", "i686"): 382 | return cls.X86 383 | if arch == "amd64": 384 | return cls.X86_64 385 | if arch == "arm64": 386 | return cls.Aarch64 387 | return cls(arch) 388 | -------------------------------------------------------------------------------- /src/dep_logic/markers/single.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import functools 4 | import operator 5 | import typing as t 6 | from abc import abstractmethod 7 | from dataclasses import dataclass, field, replace 8 | 9 | from packaging.markers import default_environment 10 | from packaging.specifiers import InvalidSpecifier, Specifier 11 | from packaging.version import InvalidVersion 12 | 13 | from dep_logic.markers.any import AnyMarker 14 | from dep_logic.markers.base import BaseMarker, EvaluationContext 15 | from dep_logic.markers.empty import EmptyMarker 16 | from dep_logic.specifiers import BaseSpecifier 17 | from dep_logic.specifiers.base import VersionSpecifier 18 | from dep_logic.specifiers.generic import GenericSpecifier 19 | from dep_logic.utils import DATACLASS_ARGS, OrderedSet, get_reflect_op, normalize_name 20 | 21 | if t.TYPE_CHECKING: 22 | from dep_logic.markers.multi import MultiMarker 23 | from dep_logic.markers.union import MarkerUnion 24 | 25 | PYTHON_VERSION_MARKERS = {"python_version", "python_full_version"} 26 | MARKERS_ALLOWING_SET = {"extras", "dependency_groups"} 27 | Operator = t.Callable[[str, t.Union[str, t.Set[str]]], bool] 28 | _operators: dict[str, Operator] = { 29 | "in": lambda lhs, rhs: lhs in rhs, 30 | "not in": lambda lhs, rhs: lhs not in rhs, 31 | "<": operator.lt, 32 | "<=": operator.le, 33 | "==": operator.eq, 34 | "!=": operator.ne, 35 | ">=": operator.ge, 36 | ">": operator.gt, 37 | } 38 | 39 | 40 | class UndefinedComparison(ValueError): 41 | pass 42 | 43 | 44 | class SingleMarker(BaseMarker): 45 | name: str 46 | _VERSION_LIKE_MARKER_NAME: t.ClassVar[set[str]] = { 47 | "python_version", 48 | "python_full_version", 49 | "platform_release", 50 | } 51 | 52 | def without_extras(self) -> BaseMarker: 53 | return self.exclude("extra") 54 | 55 | def exclude(self, marker_name: str) -> BaseMarker: 56 | if self.name == marker_name: 57 | return AnyMarker() 58 | 59 | return self 60 | 61 | def only(self, *marker_names: str) -> BaseMarker: 62 | if self.name not in marker_names: 63 | return AnyMarker() 64 | 65 | return self 66 | 67 | def evaluate( 68 | self, 69 | environment: dict[str, str | set[str]] | None = None, 70 | context: EvaluationContext = "metadata", 71 | ) -> bool: 72 | current_environment = t.cast("dict[str, str|set[str]]", default_environment()) 73 | if context == "metadata": 74 | current_environment["extra"] = "" 75 | elif context == "lock_file": 76 | current_environment.update(extras=set(), dependency_groups=set()) 77 | if environment: 78 | current_environment.update(environment) 79 | if "extra" in current_environment and current_environment["extra"] is None: 80 | current_environment["extra"] = "" 81 | return self._evaluate(current_environment) 82 | 83 | @abstractmethod 84 | def _evaluate(self, environment: dict[str, str | set[str]]) -> bool: 85 | raise NotImplementedError 86 | 87 | 88 | @dataclass(unsafe_hash=True, **DATACLASS_ARGS) 89 | class MarkerExpression(SingleMarker): 90 | name: str 91 | op: str 92 | value: str 93 | reversed: bool = field(default=False, compare=False, hash=False) 94 | _specifier: BaseSpecifier | None = field(default=None, compare=False, hash=False) 95 | 96 | @property 97 | def specifier(self) -> BaseSpecifier: 98 | if self._specifier is None: 99 | self._specifier = self._get_specifier() 100 | return self._specifier 101 | 102 | @classmethod 103 | def from_specifier(cls, name: str, specifier: BaseSpecifier) -> BaseMarker | None: 104 | if specifier.is_any(): 105 | return AnyMarker() 106 | if specifier.is_empty(): 107 | return EmptyMarker() 108 | if isinstance(specifier, VersionSpecifier): 109 | if not specifier.is_simple(): 110 | return None 111 | pkg_spec = next(iter(specifier.to_specifierset())) 112 | pkg_version = pkg_spec.version 113 | if ( 114 | dot_num := pkg_version.count(".") 115 | ) < 2 and name == "python_full_version": 116 | for _ in range(2 - dot_num): 117 | pkg_version += ".0" 118 | return MarkerExpression( 119 | name, pkg_spec.operator, pkg_version, _specifier=specifier 120 | ) 121 | assert isinstance(specifier, GenericSpecifier) 122 | return MarkerExpression( 123 | name, specifier.op, specifier.value, _specifier=specifier 124 | ) 125 | 126 | def _get_specifier(self) -> BaseSpecifier: 127 | from dep_logic.specifiers import parse_version_specifier 128 | 129 | if self.name not in self._VERSION_LIKE_MARKER_NAME: 130 | return GenericSpecifier(self.op, self.value) 131 | if self.op in ("in", "not in"): 132 | versions: list[str] = [] 133 | op, glue = ("==", "||") if self.op == "in" else ("!=", ",") 134 | for part in self.value.split(","): 135 | splitted = part.strip().split(".") 136 | if part_num := len(splitted) < 3: 137 | if self.name == "python_version": 138 | splitted.append("*") 139 | else: 140 | splitted.extend(["0"] * (3 - part_num)) 141 | 142 | versions.append(op + ".".join(splitted)) 143 | return parse_version_specifier(glue.join(versions)) 144 | return parse_version_specifier(f"{self.op}{self.value}") 145 | 146 | def __str__(self) -> str: 147 | if self.reversed: 148 | return f'"{self.value}" {get_reflect_op(self.op)} {self.name}' 149 | return f'{self.name} {self.op} "{self.value}"' 150 | 151 | def __and__(self, other: t.Any) -> BaseMarker: 152 | from dep_logic.markers.multi import MultiMarker 153 | 154 | if not isinstance(other, MarkerExpression): 155 | return NotImplemented 156 | merged = _merge_single_markers(self, other, MultiMarker) 157 | if merged is not None: 158 | return merged 159 | 160 | return MultiMarker(self, other) 161 | 162 | def __or__(self, other: t.Any) -> BaseMarker: 163 | from dep_logic.markers.union import MarkerUnion 164 | 165 | if not isinstance(other, MarkerExpression): 166 | return NotImplemented 167 | merged = _merge_single_markers(self, other, MarkerUnion) 168 | if merged is not None: 169 | return merged 170 | 171 | return MarkerUnion(self, other) 172 | 173 | def _evaluate(self, environment: dict[str, str | set[str]]) -> bool: 174 | if self.name == "extra": 175 | # Support batch comparison for "extra" markers 176 | extra = environment["extra"] 177 | if isinstance(extra, str): 178 | extra = {extra} 179 | assert self.op in ("==", "!=") 180 | value = normalize_name(self.value) 181 | extra = {normalize_name(v) for v in extra} 182 | return value in extra if self.op == "==" else value not in extra 183 | 184 | target = environment[self.name] 185 | if self.reversed: 186 | lhs, rhs = self.value, target 187 | oper = _operators.get(get_reflect_op(self.op)) 188 | else: 189 | lhs, rhs = target, self.value 190 | assert isinstance(lhs, str) 191 | oper = _operators.get(self.op) 192 | if self.name in MARKERS_ALLOWING_SET: 193 | lhs = normalize_name(lhs) 194 | if isinstance(rhs, set): 195 | rhs = {normalize_name(v) for v in rhs} 196 | else: 197 | rhs = normalize_name(rhs) 198 | if isinstance(rhs, str): 199 | try: 200 | spec = Specifier(f"{self.op}{rhs}") 201 | except InvalidSpecifier: 202 | pass 203 | else: 204 | try: 205 | return spec.contains(lhs) 206 | except InvalidVersion: 207 | pass 208 | 209 | if oper is None: 210 | raise UndefinedComparison(f"Undefined comparison {self}") 211 | return oper(lhs, rhs) 212 | 213 | 214 | @dataclass(frozen=True, unsafe_hash=True, **DATACLASS_ARGS) 215 | class EqualityMarkerUnion(SingleMarker): 216 | name: str 217 | values: OrderedSet[str] 218 | 219 | def __str__(self) -> str: 220 | return " or ".join(f'{self.name} == "{value}"' for value in self.values) 221 | 222 | def replace(self, values: OrderedSet[str]) -> BaseMarker: 223 | if not values: 224 | return EmptyMarker() 225 | if len(values) == 1: 226 | return MarkerExpression(self.name, "==", values.peek()) 227 | return replace(self, values=values) 228 | 229 | @property 230 | def complexity(self) -> tuple[int, ...]: 231 | return len(self.values), 1 232 | 233 | def __and__(self, other: t.Any) -> BaseMarker: 234 | from dep_logic.markers.multi import MultiMarker 235 | 236 | if not isinstance(other, SingleMarker): 237 | return NotImplemented 238 | 239 | if self.name != other.name: 240 | return MultiMarker(self, other) 241 | if isinstance(other, MarkerExpression): 242 | new_values = OrderedSet([v for v in self.values if v in other.specifier]) 243 | return self.replace(new_values) 244 | elif isinstance(other, EqualityMarkerUnion): 245 | new_values = self.values & other.values 246 | return self.replace(t.cast(OrderedSet, new_values)) 247 | else: 248 | # intersection with InequalityMarkerUnion will be handled in the other class 249 | return NotImplemented 250 | 251 | def __or__(self, other: t.Any) -> BaseMarker: 252 | from dep_logic.markers.union import MarkerUnion 253 | 254 | if not isinstance(other, SingleMarker): 255 | return NotImplemented 256 | 257 | if self.name != other.name: 258 | return MarkerUnion(self, other) 259 | 260 | if isinstance(other, MarkerExpression): 261 | if other.op == "==": 262 | if other.value in self.values: 263 | return self 264 | return replace(self, values=self.values | {other.value}) 265 | if other.op == "!=": 266 | if other.value in self.values: 267 | AnyMarker() 268 | return other 269 | if all(v in other.specifier for v in self.values): 270 | return other 271 | else: 272 | return MarkerUnion(self, other) 273 | elif isinstance(other, EqualityMarkerUnion): 274 | return replace(self, values=self.values | other.values) 275 | else: 276 | # intersection with InequalityMarkerUnion will be handled in the other class 277 | return NotImplemented 278 | 279 | __rand__ = __and__ 280 | __ror__ = __or__ 281 | 282 | def _evaluate(self, environment: dict[str, str | set[str]]) -> bool: 283 | return environment[self.name] in self.values 284 | 285 | 286 | @dataclass(frozen=True, unsafe_hash=True, **DATACLASS_ARGS) 287 | class InequalityMultiMarker(SingleMarker): 288 | name: str 289 | values: OrderedSet[str] 290 | 291 | def __str__(self) -> str: 292 | return " and ".join(f'{self.name} != "{value}"' for value in self.values) 293 | 294 | def replace(self, values: OrderedSet[str]) -> BaseMarker: 295 | if not values: 296 | return AnyMarker() 297 | if len(values) == 1: 298 | return MarkerExpression(self.name, "!=", values.peek()) 299 | return replace(self, values=values) 300 | 301 | @property 302 | def complexity(self) -> tuple[int, ...]: 303 | return len(self.values), 1 304 | 305 | def __and__(self, other: t.Any) -> BaseMarker: 306 | from dep_logic.markers.multi import MultiMarker 307 | 308 | if not isinstance(other, SingleMarker): 309 | return NotImplemented 310 | if self.name != other.name: 311 | return MultiMarker(self, other) 312 | 313 | if isinstance(other, MarkerExpression): 314 | if other.op == "==": 315 | if other.value in self.values: 316 | return EmptyMarker() 317 | return other 318 | elif other.op == "!=": 319 | if other.value in self.values: 320 | return self 321 | return replace(self, values=self.values | {other.value}) 322 | elif not any(v in other.specifier for v in self.values): 323 | return other 324 | else: 325 | return MultiMarker(self, other) 326 | elif isinstance(other, EqualityMarkerUnion): 327 | new_values = other.values - self.values 328 | return other.replace(t.cast(OrderedSet, new_values)) 329 | else: 330 | assert isinstance(other, InequalityMultiMarker) 331 | return replace(self, values=self.values | other.values) 332 | 333 | def __or__(self, other: t.Any) -> BaseMarker: 334 | from dep_logic.markers.union import MarkerUnion 335 | 336 | if not isinstance(other, SingleMarker): 337 | return NotImplemented 338 | 339 | if self.name != other.name: 340 | return MarkerUnion(self, other) 341 | 342 | if isinstance(other, MarkerExpression): 343 | new_values = OrderedSet( 344 | [v for v in self.values if v not in other.specifier] 345 | ) 346 | return self.replace(new_values) 347 | elif isinstance(other, EqualityMarkerUnion): 348 | new_values = self.values - other.values 349 | return self.replace(t.cast(OrderedSet, new_values)) 350 | else: 351 | assert isinstance(other, InequalityMultiMarker) 352 | new_values = self.values & other.values 353 | return self.replace(t.cast(OrderedSet, new_values)) 354 | 355 | __rand__ = __and__ 356 | __ror__ = __or__ 357 | 358 | def _evaluate(self, environment: dict[str, str | set[str]]) -> bool: 359 | return environment[self.name] not in self.values 360 | 361 | 362 | @functools.lru_cache(maxsize=None) 363 | def _merge_single_markers( 364 | marker1: MarkerExpression, 365 | marker2: MarkerExpression, 366 | merge_class: type[MultiMarker | MarkerUnion], 367 | ) -> BaseMarker | None: 368 | from dep_logic.markers.multi import MultiMarker 369 | from dep_logic.markers.union import MarkerUnion 370 | 371 | if {marker1.name, marker2.name} == PYTHON_VERSION_MARKERS: 372 | return _merge_python_version_single_markers(marker1, marker2, merge_class) 373 | 374 | if marker1.name != marker2.name: 375 | return None 376 | 377 | # "extra" is special because it can have multiple values at the same time. 378 | # That's why we can only merge two "extra" markers if they have the same value. 379 | if marker1.name == "extra": 380 | if marker1.value != marker2.value: # type: ignore[attr-defined] 381 | return None 382 | try: 383 | if merge_class is MultiMarker: 384 | result_specifier = marker1.specifier & marker2.specifier 385 | else: 386 | result_specifier = marker1.specifier | marker2.specifier 387 | except NotImplementedError: 388 | if marker1.op == marker2.op == "==" and merge_class is MarkerUnion: 389 | return EqualityMarkerUnion( 390 | marker1.name, OrderedSet([marker1.value, marker2.value]) 391 | ) 392 | elif marker1.op == marker2.op == "!=" and merge_class is MultiMarker: 393 | return InequalityMultiMarker( 394 | marker1.name, OrderedSet([marker1.value, marker2.value]) 395 | ) 396 | return None 397 | else: 398 | if result_specifier == marker1.specifier: 399 | return marker1 400 | if result_specifier == marker2.specifier: 401 | return marker2 402 | return MarkerExpression.from_specifier(marker1.name, result_specifier) 403 | 404 | 405 | def _merge_python_version_single_markers( 406 | marker1: MarkerExpression, 407 | marker2: MarkerExpression, 408 | merge_class: type[MultiMarker | MarkerUnion], 409 | ) -> BaseMarker | None: 410 | from dep_logic.markers.multi import MultiMarker 411 | 412 | if marker1.name == "python_version": 413 | version_marker = marker1 414 | full_version_marker = marker2 415 | else: 416 | version_marker = marker2 417 | full_version_marker = marker1 418 | 419 | normalized_specifier = _normalize_python_version_specifier(version_marker) 420 | 421 | if merge_class is MultiMarker: 422 | merged = normalized_specifier & full_version_marker.specifier 423 | else: 424 | merged = normalized_specifier | full_version_marker.specifier 425 | if merged == normalized_specifier: 426 | # prefer original marker to avoid unnecessary changes 427 | return version_marker 428 | 429 | return MarkerExpression.from_specifier("python_full_version", merged) 430 | 431 | 432 | def _normalize_python_version_specifier(marker: MarkerExpression) -> BaseSpecifier: 433 | from dep_logic.specifiers import parse_version_specifier 434 | 435 | op, value = marker.op, marker.value 436 | if op in ("in", "not in"): 437 | # skip this case, so in the following code value must be a dotted version string 438 | return marker.specifier 439 | splitted = [p.strip() for p in value.split(".")] 440 | if len(splitted) > 2 or "*" in splitted: 441 | return marker.specifier 442 | if op in ("==", "!="): 443 | splitted.append("*") 444 | elif op == ">": 445 | # python_version > '3.7' is equal to python_full_version >= '3.8.0' 446 | splitted[-1] = str(int(splitted[-1]) + 1) 447 | op = ">=" 448 | elif op == "<=": 449 | # python_version <= '3.7' is equal to python_full_version < '3.8.0' 450 | splitted[-1] = str(int(splitted[-1]) + 1) 451 | op = "<" 452 | 453 | spec = parse_version_specifier(f"{op}{'.'.join(splitted)}") 454 | return spec 455 | --------------------------------------------------------------------------------