diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index aa9d52c..f80dcc5 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -87,4 +87,3 @@ repos: hooks: - id: check-dependabot - id: check-github-workflows - - id: check-readthedocs diff --git a/.readthedocs.yaml b/.readthedocs.yaml deleted file mode 100644 index 7e49657..0000000 --- a/.readthedocs.yaml +++ /dev/null @@ -1,18 +0,0 @@ -# Read the Docs configuration file -# See https://docs.readthedocs.io/en/stable/config-file/v2.html for details - -version: 2 - -build: - os: ubuntu-22.04 - tools: - python: "3.11" -sphinx: - configuration: docs/conf.py - -python: - install: - - method: pip - path: . - extra_requirements: - - docs diff --git a/conftest.py b/conftest.py new file mode 100644 index 0000000..a34a56c --- /dev/null +++ b/conftest.py @@ -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() diff --git a/pyproject.toml b/pyproject.toml index b2c82a2..c765391 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -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" @@ -58,7 +55,7 @@ version.source = "vcs" [tool.hatch.envs.default] - features = ["test"] + features = ["src", "test"] scripts.test = "pytest {args}" @@ -67,7 +64,7 @@ filterwarnings = ["error"] log_cli_level = "INFO" minversion = "6.0" - testpaths = ["tests"] + testpaths = ["src", "tests"] xfail_strict = true @@ -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", diff --git a/src/optional_dependencies/_core.py b/src/optional_dependencies/_core.py index 7ce6396..a55b8b8 100644 --- a/src/optional_dependencies/_core.py +++ b/src/optional_dependencies/_core.py @@ -4,8 +4,11 @@ __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 @@ -13,6 +16,64 @@ 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.""" @@ -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 @@ -61,7 +122,7 @@ def is_installed(self) -> bool: >>> class OptDeps(OptionalDependencyEnum): ... PACKAGING = auto() - >>> OptDeps.PACKAGING.is_installed + >>> OptDeps.PACKAGING.installed True """ @@ -88,11 +149,43 @@ def version(self) -> Version: ... PACKAGING = auto() >>> OptDeps.PACKAGING.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 diff --git a/src/optional_dependencies/utils.py b/src/optional_dependencies/utils.py index 3b5bd1e..bd817aa 100644 --- a/src/optional_dependencies/utils.py +++ b/src/optional_dependencies/utils.py @@ -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 @@ -72,7 +72,7 @@ def get_version(pkg_name: str, /) -> Version | Literal[InstalledState.NOT_INSTAL Examples -------- >>> get_version("packaging") - + """ try: @@ -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")) >>> chain_checks(version, version > Version("2.0")) - NOT_INSTALLED + - >>> chain_checks(NotInstalled("packaging"), False) - NOT_INSTALLED + >>> chain_checks(NOT_INSTALLED, True) + + + >>> chain_checks(NOT_INSTALLED, False) + """ if version is NOT_INSTALLED: diff --git a/tests/test_misc.py b/tests/test_misc.py index f3383b3..1086334 100644 --- a/tests/test_misc.py +++ b/tests/test_misc.py @@ -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): @@ -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(): @@ -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)