Skip to content

Commit

Permalink
feat: rich comparison (#11)
Browse files Browse the repository at this point in the history
* refactor: rename is_installed to installed
* feat: comparison
* feat: add sybil

Signed-off-by: nstarman <[email protected]>
  • Loading branch information
nstarman authored Sep 10, 2024
1 parent 4371dda commit 1a79408
Show file tree
Hide file tree
Showing 7 changed files with 206 additions and 48 deletions.
1 change: 0 additions & 1 deletion .pre-commit-config.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -87,4 +87,3 @@ repos:
hooks:
- id: check-dependabot
- id: check-github-workflows
- id: check-readthedocs
18 changes: 0 additions & 18 deletions .readthedocs.yaml

This file was deleted.

15 changes: 15 additions & 0 deletions conftest.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
"""Doctest configuration."""

from doctest import ELLIPSIS, NORMALIZE_WHITESPACE

from sybil import Sybil
from sybil.parsers.rest import DocTestParser, PythonCodeBlockParser, SkipParser

pytest_collect_file = Sybil(
parsers=[
DocTestParser(optionflags=NORMALIZE_WHITESPACE | ELLIPSIS),
PythonCodeBlockParser(),
SkipParser(),
],
patterns=["*.rst", "*.py"],
).pytest()
21 changes: 7 additions & 14 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -36,15 +36,12 @@
requires-python = ">=3.8"

[project.optional-dependencies]
dev = ["pytest >=6", "pytest-cov >=3"]
docs = [
"furo>=2023.08.17",
"myst_parser>=0.13",
"sphinx>=7.0",
"sphinx_autodoc_typehints",
"sphinx_copybutton",
test = [
"pytest >=6",
"pytest-cov >=3",
"sybil",
]
test = ["pytest >=6", "pytest-cov >=3"]
dev = ["dataclassish[test]"]

[project.urls]
"Bug Tracker" = "https://github.com/GalacticDynamics/optional_dependencies/issues"
Expand All @@ -58,7 +55,7 @@
version.source = "vcs"

[tool.hatch.envs.default]
features = ["test"]
features = ["src", "test"]
scripts.test = "pytest {args}"


Expand All @@ -67,7 +64,7 @@
filterwarnings = ["error"]
log_cli_level = "INFO"
minversion = "6.0"
testpaths = ["tests"]
testpaths = ["src", "tests"]
xfail_strict = true


Expand Down Expand Up @@ -112,10 +109,6 @@
]

[tool.ruff.lint.per-file-ignores]
"docs/conf.py" = [
"A001", # Variable `copyright` is shadowing a Python builtin
"INP001", # implicit namespace package
]
"noxfile.py" = ["T20"]
"tests/**" = [
"ANN",
Expand Down
103 changes: 98 additions & 5 deletions src/optional_dependencies/_core.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,15 +4,76 @@

__all__: list[str] = []

import operator
from dataclasses import dataclass
from enum import Enum
from typing import Literal, cast
from types import MethodType
from typing import Callable, Literal, cast

from packaging.utils import canonicalize_name
from packaging.version import Version

from .utils import InstalledState, get_version


@dataclass(frozen=True)
class Comparator:
"""A comparison operator for versions."""

operator: Callable[[Version, Version], bool]
"""The comparison operator to use."""

def __get__(
self,
instance: OptionalDependencyEnum | None,
owner: type[OptionalDependencyEnum] | None,
) -> Comparator | MethodType:
"""Get the descriptor.
Parameters
----------
instance : OptionalDependencyEnum
The instance of the descriptor.
owner : OptionalDependencyEnum
The owner of the descriptor.
Returns
-------
Comparator
The descriptor.
"""
# Access the descriptor on the class
if instance is None:
return self
# Bind the descriptor to the instance
return MethodType(self.__call__, instance)

def __call__(self, enum: OptionalDependencyEnum, other: object) -> bool:
"""Compare two versions.
Returns
-------
bool
True if the comparison is successful, False otherwise.
"""
# Defer to the other object if it is not a Version
if not isinstance(other, Version):
return NotImplemented

# If the optional dependency is not installed, it is not greater than
# the other version.
if not enum.installed:
return False

# Compare the versions
return self.operator(enum.version, other)


# ===================================================================


class OptionalDependencyEnum(Enum):
"""An enumeration of optional dependencies."""

Expand Down Expand Up @@ -47,7 +108,7 @@ def _generate_next_value_(
return get_version(name)

@property
def is_installed(self) -> bool:
def installed(self) -> bool:
"""Check if the optional dependency is installed.
Returns
Expand All @@ -61,7 +122,7 @@ def is_installed(self) -> bool:
>>> class OptDeps(OptionalDependencyEnum):
... PACKAGING = auto()
>>> OptDeps.PACKAGING.is_installed
>>> OptDeps.PACKAGING.installed
True
"""
Expand All @@ -88,11 +149,43 @@ def version(self) -> Version:
... PACKAGING = auto()
>>> OptDeps.PACKAGING.version
<Version('20.9')>
<Version('...')>
"""
if not self.is_installed:
if not self.installed:
msg = f"{self.name} is not installed"
raise ImportError(msg)

return cast(Version, self.value)

# ===============================================================

__lt__ = Comparator(operator.__lt__)
__le__ = Comparator(operator.__le__)
__ge__ = Comparator(operator.__ge__)
__gt__ = Comparator(operator.__gt__)

def __eq__(self, other: object) -> bool:
"""Check if two optional dependencies are equal.
Returns
-------
bool
True if the optional dependencies are equal, False otherwise.
"""
# First support comparison with other OptionalDependencyEnum instances
if isinstance(other, OptionalDependencyEnum):
return super().__eq__(other)

# Defer to the other object if it is not a Version
if not isinstance(other, Version):
return NotImplemented

# If the optional dependency is not installed, it is not greater than
# the other version.
if not self.installed:
return False

# Compare the versions
return self.version == other
15 changes: 9 additions & 6 deletions src/optional_dependencies/utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -50,7 +50,7 @@ def is_installed(pkg_name: str, /) -> bool:
"""
try:
spec = importlib.util.find_spec(pkg_name)
except ModuleNotFoundError:
except ModuleNotFoundError: # pragma: no cover
return False
return spec is not None

Expand All @@ -72,7 +72,7 @@ def get_version(pkg_name: str, /) -> Version | Literal[InstalledState.NOT_INSTAL
Examples
--------
>>> get_version("packaging")
<Version('20.9')>
<Version('...')>
"""
try:
Expand Down Expand Up @@ -125,17 +125,20 @@ def chain_checks(
Examples
--------
>>> from packaging.version import Version
>>> from optional_dependencies import NOT_INSTALLED
>>> from optional_dependencies.utils import NOT_INSTALLED
>>> version = Version("1.0")
>>> chain_checks(version, version < Version("2.0"))
<Version('1.0')>
>>> chain_checks(version, version > Version("2.0"))
NOT_INSTALLED
<InstalledState.NOT_INSTALLED: False>
>>> chain_checks(NotInstalled("packaging"), False)
NOT_INSTALLED
>>> chain_checks(NOT_INSTALLED, True)
<InstalledState.NOT_INSTALLED: False>
>>> chain_checks(NOT_INSTALLED, False)
<InstalledState.NOT_INSTALLED: False>
"""
if version is NOT_INSTALLED:
Expand Down
81 changes: 77 additions & 4 deletions tests/test_misc.py
Original file line number Diff line number Diff line change
@@ -1,9 +1,12 @@
"""Test the package."""

import re

import pytest
from packaging.version import Version

from optional_dependencies import OptionalDependencyEnum, auto
from optional_dependencies._core import Comparator


class OptDeps(OptionalDependencyEnum):
Expand All @@ -20,10 +23,10 @@ def test_enum_member_exists():
), "NOTINSTALLED member should exist in OptDeps"


def test_is_installed():
assert OptDeps.PACKAGING.is_installed, "PACKAGING should be installed"
assert OptDeps.PYTEST.is_installed, "PYTEST should be installed"
assert not OptDeps.NOTINSTALLED.is_installed, "NOTINSTALLED should not be installed"
def test_installed():
assert OptDeps.PACKAGING.installed, "PACKAGING should be installed"
assert OptDeps.PYTEST.installed, "PYTEST should be installed"
assert not OptDeps.NOTINSTALLED.installed, "NOTINSTALLED should not be installed"


def test_version():
Expand All @@ -32,3 +35,73 @@ def test_version():

with pytest.raises(ImportError):
_ = OptDeps.NOTINSTALLED.version


def test_lt():
# Compare with an unsupported type
with pytest.raises(TypeError, match=re.escape("'<' not supported")):
_ = OptDeps.PACKAGING < 1

# Compare with a Version
assert not OptDeps.PACKAGING < Version("1.0") # noqa: SIM300

# Something not installed
assert not OptDeps.NOTINSTALLED <= Version("1.0") # noqa: SIM300


def test_le():
# Compare with an unsupported type
with pytest.raises(TypeError, match=re.escape("'<=' not supported")):
_ = OptDeps.PACKAGING <= 1

# Compare with a Version
assert not OptDeps.PACKAGING <= Version("0.1") # noqa: SIM300

# Something not installed
assert not OptDeps.NOTINSTALLED <= Version("1.0") # noqa: SIM300


def test_ge():
# Compare with an unsupported type
with pytest.raises(TypeError, match=re.escape("'>=' not supported")):
assert OptDeps.PACKAGING >= 1

# Compare with a Version
assert OptDeps.PACKAGING >= Version("0.1") # noqa: SIM300

# Something not installed
assert not OptDeps.NOTINSTALLED == Version("1.0") # noqa: SIM201, SIM300


def test_gt():
# Compare with an unsupported type
with pytest.raises(TypeError, match=re.escape("'>' not supported")):
_ = OptDeps.PACKAGING > 1

# Compare with a Version
assert OptDeps.PACKAGING > Version("0.1") # noqa: SIM300

# Something not installed
assert not OptDeps.NOTINSTALLED > Version("1.0") # noqa: SIM300


def test_eq():
# Compare with other OptionalDependencyEnum instances
assert OptDeps.PACKAGING == OptDeps.PACKAGING
assert not OptDeps.PACKAGING == OptDeps.PYTEST # noqa: SIM201

# Compare with an unsupported type
assert OptDeps.PACKAGING != 1

# Other Versions
assert not OptDeps.PACKAGING == Version("1.0") # noqa: SIM201, SIM300

# Something not installed
assert not OptDeps.NOTINSTALLED == Version("1.0") # noqa: SIM201, SIM300


def test_comparator():
assert isinstance(type(OptDeps.PACKAGING).__lt__, Comparator)
assert isinstance(type(OptDeps.PACKAGING).__le__, Comparator)
assert isinstance(type(OptDeps.PACKAGING).__ge__, Comparator)
assert isinstance(type(OptDeps.PACKAGING).__gt__, Comparator)

0 comments on commit 1a79408

Please sign in to comment.