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 -- 2.54.0 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. -- 2.54.0 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" -- 2.54.0 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: -- 2.54.0