From c6eed43d70d2a8b15c47d32d7c7566f18f79e0db Mon Sep 17 00:00:00 2001 From: Brian Bjarke Jensen Date: Tue, 30 Jun 2026 15:47:04 +0200 Subject: [PATCH 1/4] 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 From f092ca2022d7fd1b31ab971d828102eb1f08e15b Mon Sep 17 00:00:00 2001 From: Brian Bjarke Jensen Date: Tue, 30 Jun 2026 15:48:01 +0200 Subject: [PATCH 2/4] Fix trailing whitespace in LICENSE and .gitignore. Co-authored-by: Cursor --- .gitignore | 1 - LICENSE | 18 +++++++++--------- 2 files changed, 9 insertions(+), 10 deletions(-) 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. From 1f02195c275c2214b5af46143cd75237db8e113a Mon Sep 17 00:00:00 2001 From: Brian Bjarke Jensen Date: Tue, 30 Jun 2026 15:57:52 +0200 Subject: [PATCH 3/4] Delegate top-level adapter imports to adapters package. Keep lazy loading in adapters/__init__.py as the single place to register new adapters. Co-authored-by: Cursor --- python_repositories/__init__.py | 26 ++++++++---------------- python_repositories/adapters/__init__.py | 6 ++++++ tests/unit/optional_dependencies_test.py | 7 +++++++ 3 files changed, 21 insertions(+), 18 deletions(-) diff --git a/python_repositories/__init__.py b/python_repositories/__init__.py index 6730e45..dfdd8d4 100644 --- a/python_repositories/__init__.py +++ b/python_repositories/__init__.py @@ -2,10 +2,10 @@ from __future__ import annotations -import importlib from typing import TYPE_CHECKING # Interfaces are always available; they have no optional backend dependencies. +from . import adapters from .interfaces import ( ConnectionAwareInterface, ContextAwareInterface, @@ -13,34 +13,24 @@ from .interfaces import ( ObjectRepositoryInterface, ) -# Adapters are imported only for static type checkers; runtime loading is deferred below. +# Adapters are imported only for static type checkers; runtime loading is delegated 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"), -} + 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: - """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) + """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}") diff --git a/python_repositories/adapters/__init__.py b/python_repositories/adapters/__init__.py index c7c7aad..eb0ef57 100644 --- a/python_repositories/adapters/__init__.py +++ b/python_repositories/adapters/__init__.py @@ -9,10 +9,14 @@ 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"), @@ -25,6 +29,7 @@ __all__ = [ 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__) @@ -33,4 +38,5 @@ def __getattr__(name: str) -> object: def __dir__() -> list[str]: + """Expose lazy adapter names in tab completion and dir().""" return sorted(__all__) diff --git a/tests/unit/optional_dependencies_test.py b/tests/unit/optional_dependencies_test.py index 0af4acc..92f36d3 100644 --- a/tests/unit/optional_dependencies_test.py +++ b/tests/unit/optional_dependencies_test.py @@ -93,3 +93,10 @@ def test_top_level_lazy_import_propagates_minio_import_error() -> None: ): 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" From f5953906c1e504dc29f3147682757c9edc33901a Mon Sep 17 00:00:00 2001 From: Brian Bjarke Jensen Date: Tue, 30 Jun 2026 16:05:41 +0200 Subject: [PATCH 4/4] Fix optional-deps import test for full-suite execution. Run the base-import assertion in a subprocess so integration tests do not pollute sys.modules. Co-authored-by: Cursor --- tests/unit/optional_dependencies_test.py | 27 +++++++++++++++++++----- 1 file changed, 22 insertions(+), 5 deletions(-) diff --git a/tests/unit/optional_dependencies_test.py b/tests/unit/optional_dependencies_test.py index 92f36d3..f67d64b 100644 --- a/tests/unit/optional_dependencies_test.py +++ b/tests/unit/optional_dependencies_test.py @@ -4,15 +4,19 @@ 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 -from python_repositories import JsonRepositoryInterface + +_ROOT = Path(__file__).resolve().parents[2] def _block_backend_import(blocked_prefix: str) -> Callable[..., ModuleType]: @@ -33,10 +37,23 @@ def _block_backend_import(blocked_prefix: str) -> Callable[..., ModuleType]: 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 + """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: