From c6eed43d70d2a8b15c47d32d7c7566f18f79e0db Mon Sep 17 00:00:00 2001 From: Brian Bjarke Jensen Date: Tue, 30 Jun 2026 15:47:04 +0200 Subject: [PATCH] Fix optional dependency handling for Redis and MinIO adapters. Lazy-load adapters at package boundaries and fail fast with install hints when extras are missing. Co-authored-by: Cursor --- README.md | 2 + python_repositories/__init__.py | 33 ++++++- python_repositories/adapters/__init__.py | 27 +++++- python_repositories/adapters/minio_adapter.py | 11 ++- python_repositories/adapters/redis_adapter.py | 11 ++- tests/unit/optional_dependencies_test.py | 95 +++++++++++++++++++ 6 files changed, 168 insertions(+), 11 deletions(-) create mode 100644 tests/unit/optional_dependencies_test.py 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..6730e45 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 + +import importlib +from typing import TYPE_CHECKING + +# Interfaces are always available; they have no optional backend dependencies. from .interfaces import ( ConnectionAwareInterface, ContextAwareInterface, @@ -8,6 +13,18 @@ from .interfaces import ( ObjectRepositoryInterface, ) +# Adapters are imported only for static type checkers; runtime loading is deferred below. +if TYPE_CHECKING: + from .adapters.minio_adapter import MinioAdapter + from .adapters.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. +_LAZY_EXPORTS = { + "RedisAdapter": (".adapters.redis_adapter", "RedisAdapter"), + "MinioAdapter": (".adapters.minio_adapter", "MinioAdapter"), +} + __all__ = [ "ConnectionAwareInterface", "ContextAwareInterface", @@ -16,3 +33,17 @@ __all__ = [ "RedisAdapter", "MinioAdapter", ] + + +def __getattr__(name: str) -> object: + """Load adapters on first access so the base package installs without redis/minio.""" + 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/__init__.py b/python_repositories/adapters/__init__.py index 1d38944..c7c7aad 100644 --- a/python_repositories/adapters/__init__.py +++ b/python_repositories/adapters/__init__.py @@ -4,10 +4,33 @@ 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 + +if TYPE_CHECKING: + from .minio_adapter import MinioAdapter + from .redis_adapter import RedisAdapter + +_LAZY_EXPORTS = { + "RedisAdapter": (".redis_adapter", "RedisAdapter"), + "MinioAdapter": (".minio_adapter", "MinioAdapter"), +} __all__ = [ "RedisAdapter", "MinioAdapter", ] + + +def __getattr__(name: str) -> object: + 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]: + 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..0af4acc --- /dev/null +++ b/tests/unit/optional_dependencies_test.py @@ -0,0 +1,95 @@ +"""Tests for optional dependency import behavior.""" + +from __future__ import annotations + +import builtins +import importlib +import sys +from collections.abc import Callable, Mapping, Sequence +from types import ModuleType +from unittest.mock import patch + +import pytest + +import python_repositories +from python_repositories import JsonRepositoryInterface + + +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: + """Interfaces are available without loading adapter modules.""" + 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 + + +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