diff --git a/.gitignore b/.gitignore index 36b13f1..69169fd 100644 --- a/.gitignore +++ b/.gitignore @@ -173,4 +173,3 @@ cython_debug/ # PyPI configuration file .pypirc - diff --git a/LICENSE b/LICENSE index f4e7ad7..d6a6398 100644 --- a/LICENSE +++ b/LICENSE @@ -2,17 +2,17 @@ MIT License Copyright (c) 2025 brian -Permission is hereby granted, free of charge, to any person obtaining a copy of this software and -associated documentation files (the "Software"), to deal in the Software without restriction, including -without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the +Permission is hereby granted, free of charge, to any person obtaining a copy of this software and +associated documentation files (the "Software"), to deal in the Software without restriction, including +without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: -The above copyright notice and this permission notice shall be included in all copies or substantial +The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. -THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT -LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO -EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER -IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT +LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO +EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER +IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. diff --git a/README.md b/README.md index 9785a67..5dcfb34 100644 --- a/README.md +++ b/README.md @@ -14,6 +14,8 @@ Subclass an adapter in your own repository to add domain-specific methods while ## Optional dependencies +Repository **interfaces** import with the base package. **Adapters** require the matching extra; importing an adapter without its extra raises `ImportError` with install instructions. + Install with the extras you need: ```bash diff --git a/python_repositories/__init__.py b/python_repositories/__init__.py index bb5154d..dfdd8d4 100644 --- a/python_repositories/__init__.py +++ b/python_repositories/__init__.py @@ -1,6 +1,11 @@ """python_repositories: Unified repository interfaces and adapters.""" -from .adapters import MinioAdapter, RedisAdapter +from __future__ import annotations + +from typing import TYPE_CHECKING + +# Interfaces are always available; they have no optional backend dependencies. +from . import adapters from .interfaces import ( ConnectionAwareInterface, ContextAwareInterface, @@ -8,11 +13,27 @@ from .interfaces import ( ObjectRepositoryInterface, ) +# Adapters are imported only for static type checkers; runtime loading is delegated below. +if TYPE_CHECKING: + from .adapters.minio_adapter import MinioAdapter as MinioAdapter + from .adapters.redis_adapter import RedisAdapter as RedisAdapter + __all__ = [ "ConnectionAwareInterface", "ContextAwareInterface", "JsonRepositoryInterface", "ObjectRepositoryInterface", - "RedisAdapter", - "MinioAdapter", + *adapters.__all__, ] + + +def __getattr__(name: str) -> object: + """Delegate adapter lookups to adapters; lazy loading is defined there.""" + if name in adapters.__all__: + return getattr(adapters, name) + raise AttributeError(f"module {__name__!r} has no attribute {name!r}") + + +def __dir__() -> list[str]: + """Expose lazy adapter names in tab completion and dir().""" + return sorted(__all__) diff --git a/python_repositories/adapters/__init__.py b/python_repositories/adapters/__init__.py index 1d38944..eb0ef57 100644 --- a/python_repositories/adapters/__init__.py +++ b/python_repositories/adapters/__init__.py @@ -4,10 +4,39 @@ Adapters for various backend repositories (e.g., Redis, Minio). This module exposes concrete implementations for repository interfaces. """ -from .redis_adapter import RedisAdapter -from .minio_adapter import MinioAdapter +from __future__ import annotations + +import importlib +from typing import TYPE_CHECKING + +# Adapters are imported only for static type checkers; runtime loading is deferred below. +if TYPE_CHECKING: + from .minio_adapter import MinioAdapter + from .redis_adapter import RedisAdapter + +# Map public adapter names to their defining module and class. +# Each adapter module fails fast with an install hint if its extra is missing. +# When adding a new adapter, update this dict and __all__ only. +_LAZY_EXPORTS = { + "RedisAdapter": (".redis_adapter", "RedisAdapter"), + "MinioAdapter": (".minio_adapter", "MinioAdapter"), +} __all__ = [ "RedisAdapter", "MinioAdapter", ] + + +def __getattr__(name: str) -> object: + """Load an adapter on first access so the base package installs without backend clients.""" + if name in _LAZY_EXPORTS: + module_path, attr = _LAZY_EXPORTS[name] + module = importlib.import_module(module_path, __package__) + return getattr(module, attr) + raise AttributeError(f"module {__name__!r} has no attribute {name!r}") + + +def __dir__() -> list[str]: + """Expose lazy adapter names in tab completion and dir().""" + return sorted(__all__) diff --git a/python_repositories/adapters/minio_adapter.py b/python_repositories/adapters/minio_adapter.py index c255769..e659c13 100644 --- a/python_repositories/adapters/minio_adapter.py +++ b/python_repositories/adapters/minio_adapter.py @@ -3,7 +3,6 @@ from __future__ import annotations import os from io import BytesIO -from importlib.util import find_spec from typing import Self import structlog from python_utils import check_env @@ -14,9 +13,13 @@ from python_repositories.interfaces import ( ObjectRepositoryInterface, ) -# Handle optional dependencies -if find_spec("minio") is not None: +try: import minio +except ImportError as exc: + raise ImportError( + "MinIO support requires the minio extra. " + "Install with: pip install python-repositories[minio]" + ) from exc class MinioAdapter( @@ -112,7 +115,7 @@ class MinioAdapter( @property def is_connected(self) -> bool: """Check if connected to Minio server.""" - res = bool(isinstance(self._client, minio.Minio)) + res = self._client is not None self.logger.debug(res) return res diff --git a/python_repositories/adapters/redis_adapter.py b/python_repositories/adapters/redis_adapter.py index 56fa459..3dc5daa 100644 --- a/python_repositories/adapters/redis_adapter.py +++ b/python_repositories/adapters/redis_adapter.py @@ -2,7 +2,6 @@ from __future__ import annotations from typing import Self, cast -from importlib.util import find_spec import os import structlog @@ -14,10 +13,14 @@ from python_repositories.interfaces import ( JsonRepositoryInterface, ) -# Handle optional dependencies -if find_spec("redis") is not None: +try: import redis from redis.commands.json.path import Path as RedisPath +except ImportError as exc: + raise ImportError( + "Redis support requires the redis extra. " + "Install with: pip install python-repositories[redis]" + ) from exc class RedisAdapter( @@ -90,7 +93,7 @@ class RedisAdapter( @property def is_connected(self) -> bool: """Check if connected to Redis server.""" - res = bool(isinstance(self._client, redis.Redis)) + res = self._client is not None self.logger.debug(res) return res diff --git a/tests/unit/optional_dependencies_test.py b/tests/unit/optional_dependencies_test.py new file mode 100644 index 0000000..f67d64b --- /dev/null +++ b/tests/unit/optional_dependencies_test.py @@ -0,0 +1,119 @@ +"""Tests for optional dependency import behavior.""" + +from __future__ import annotations + +import builtins +import importlib +import os +import subprocess +import sys +from collections.abc import Callable, Mapping, Sequence +from pathlib import Path +from types import ModuleType +from unittest.mock import patch + +import pytest + +import python_repositories + +_ROOT = Path(__file__).resolve().parents[2] + + +def _block_backend_import(blocked_prefix: str) -> Callable[..., ModuleType]: + real_import = builtins.__import__ + + def fake_import( + name: str, + globals: Mapping[str, object] | None = None, + locals: Mapping[str, object] | None = None, + fromlist: Sequence[str] = (), + level: int = 0, + ) -> ModuleType: + if name == blocked_prefix or name.startswith(f"{blocked_prefix}."): + raise ImportError(f"No module named '{name}'") + return real_import(name, globals, locals, fromlist, level) + + return fake_import + + +def test_base_import_does_not_load_adapters() -> None: + """Base package import does not eagerly load backend adapter modules.""" + script = """ +import sys +from python_repositories import JsonRepositoryInterface + +assert JsonRepositoryInterface is not None +assert "python_repositories.adapters.redis_adapter" not in sys.modules +assert "python_repositories.adapters.minio_adapter" not in sys.modules +""" + result = subprocess.run( + [sys.executable, "-c", script], + cwd=_ROOT, + env={**os.environ, "PYTHONPATH": str(_ROOT)}, + capture_output=True, + text=True, + ) + assert result.returncode == 0, result.stderr or result.stdout + + +def test_lazy_adapter_load_succeeds_when_extra_present() -> None: + """Adapters load when their optional dependencies are installed.""" + from python_repositories import MinioAdapter, RedisAdapter + + assert RedisAdapter.__name__ == "RedisAdapter" + assert MinioAdapter.__name__ == "MinioAdapter" + + +def test_redis_adapter_import_error_without_extra() -> None: + """Missing redis extra raises ImportError with install hint.""" + import python_repositories.adapters.redis_adapter as redis_adapter_module + + with patch.object(builtins, "__import__", new=_block_backend_import("redis")): + with pytest.raises(ImportError, match=r"python-repositories\[redis\]"): + importlib.reload(redis_adapter_module) + + importlib.reload(redis_adapter_module) + + +def test_minio_adapter_import_error_without_extra() -> None: + """Missing minio extra raises ImportError with install hint.""" + import python_repositories.adapters.minio_adapter as minio_adapter_module + + with patch.object(builtins, "__import__", new=_block_backend_import("minio")): + with pytest.raises(ImportError, match=r"python-repositories\[minio\]"): + importlib.reload(minio_adapter_module) + + importlib.reload(minio_adapter_module) + + +def test_top_level_lazy_import_propagates_redis_import_error() -> None: + """Top-level RedisAdapter access surfaces adapter import errors.""" + with patch( + "importlib.import_module", + side_effect=ImportError( + "Redis support requires the redis extra. " + "Install with: pip install python-repositories[redis]" + ), + ): + with pytest.raises(ImportError, match=r"python-repositories\[redis\]"): + _ = python_repositories.RedisAdapter + + +def test_top_level_lazy_import_propagates_minio_import_error() -> None: + """Top-level MinioAdapter access surfaces adapter import errors.""" + with patch( + "importlib.import_module", + side_effect=ImportError( + "MinIO support requires the minio extra. " + "Install with: pip install python-repositories[minio]" + ), + ): + with pytest.raises(ImportError, match=r"python-repositories\[minio\]"): + _ = python_repositories.MinioAdapter + + +def test_adapters_subpackage_lazy_import_succeeds() -> None: + """Adapter subpackage imports delegate to the same lazy loader.""" + from python_repositories.adapters import RedisAdapter + + assert RedisAdapter.__name__ == "RedisAdapter"