Add config and client injection with test reorganization.
Code Quality Pipeline / code-quality (pull_request) Successful in 52s
PR Title Check / check-title (pull_request) Successful in 6s
Test Python Package / test (pull_request) Successful in 1m3s

Introduce typed config objects, optional adapter injection, and .env loading to simplify testing while preserving env-based defaults for production usage.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Brian Bjarke Jensen
2026-07-05 21:34:14 +02:00
co-authored by Cursor
parent 7ed1b34233
commit 5e32787b90
28 changed files with 645 additions and 377 deletions
+4
View File
@@ -6,6 +6,7 @@ from typing import TYPE_CHECKING
# Interfaces are always available; they have no optional backend dependencies.
from . import adapters
from .config import MinioConfig, RedisConfig, load_dotenv
from .interfaces import (
ConnectionAwareInterface,
ContextAwareInterface,
@@ -22,7 +23,10 @@ __all__ = [
"ConnectionAwareInterface",
"ContextAwareInterface",
"JsonRepositoryInterface",
"MinioConfig",
"ObjectRepositoryInterface",
"RedisConfig",
"load_dotenv",
*adapters.__all__,
]
+35 -23
View File
@@ -1,14 +1,12 @@
"""Definition of MinioAdapter class."""
from __future__ import annotations
import os
from io import BytesIO
from python_utils import check_env
from python_repositories.adapters.connection_aware_adapter import (
ConnectionAwareAdapter,
)
from python_repositories.config import MinioConfig
from python_repositories.interfaces import ObjectRepositoryInterface
try:
@@ -30,60 +28,74 @@ class MinioAdapter(ObjectRepositoryInterface, ConnectionAwareAdapter):
chunk_size: int = 5 * 2**20 # 5 MiB
connection_name: str = "Minio"
def __init__(self) -> None:
def __init__(
self,
*,
config: MinioConfig | None = None,
client: minio.Minio | None = None,
) -> None:
super().__init__()
check_env(
{
if client is not None and config is None:
raise ValueError("config is required when client is provided")
if config is None and client is None:
config = MinioConfig.from_env(
self.endpoint_env_var_name,
self.access_key_env_var_name,
self.secret_key_env_var_name,
self.bucket_env_var_name,
},
)
self._client: minio.Minio | None = None
self._bucket_name: str | None = None
)
assert config is not None
self._config = config
self._client_injected = client is not None
self._client: minio.Minio | None = client
self._bucket_name: str | None = config.bucket if client is not None else None
if self._client_injected:
self._invalidate_health_cache()
def _is_client_ready(self) -> bool:
return self._client is not None and self._bucket_name is not None
def connect(self) -> None:
"""Connect to the Minio server."""
if self._client_injected:
if self._client is not None:
try:
_ = self._client.list_buckets()
except Exception as exc: # pylint: disable=broad-except
raise ConnectionError(
f"Could not connect to Minio at {self._config.endpoint}"
) from exc
self._invalidate_health_cache()
return
if self._client is not None and self.is_connected():
self.logger.info("Already connected to Minio")
return
if self._client is not None:
self.disconnect()
# Prepare arguments
endpoint = str(os.getenv(self.endpoint_env_var_name))
access_key = str(os.getenv(self.access_key_env_var_name))
secret_key = str(os.getenv(self.secret_key_env_var_name))
bucket = str(os.getenv(self.bucket_env_var_name))
# Connect client
endpoint = self._config.endpoint
access_key = self._config.access_key
secret_key = self._config.secret_key
bucket = self._config.bucket
client = minio.Minio(
endpoint=endpoint,
access_key=access_key,
secret_key=secret_key,
secure=False,
secure=self._config.secure,
)
# Test the connection by listing buckets (will raise if connection fails)
try:
_ = client.list_buckets()
except Exception as exc: # pylint: disable=broad-except
raise ConnectionError(f"Could not connect to Minio at {endpoint}") from exc
# Ensure bucket exists
if not client.bucket_exists(bucket):
self.logger.info(f"Creating bucket '{bucket}'")
client.make_bucket(bucket)
# Persist information
self._client = client
self._bucket_name = bucket
self._invalidate_health_cache()
def disconnect(self) -> None:
"""Disconnect from the Minio server."""
# Close connection
# N.B. Minio client does not have a close method, but we include this for symmetry with other adapters
# Reset client
# N.B. Minio client does not have a close method, but we include this for symmetry
self._client = None
self._bucket_name = None
self._invalidate_health_cache()
+32 -13
View File
@@ -2,13 +2,11 @@
from __future__ import annotations
from typing import cast
import os
from python_utils import check_env
from python_repositories.adapters.connection_aware_adapter import (
ConnectionAwareAdapter,
)
from python_repositories.config import RedisConfig
from python_repositories.interfaces import JsonRepositoryInterface
try:
@@ -29,24 +27,48 @@ class RedisAdapter(JsonRepositoryInterface, ConnectionAwareAdapter):
encoding: str = "UTF-8"
connection_name: str = "Redis"
def __init__(self) -> None:
def __init__(
self,
*,
config: RedisConfig | None = None,
client: redis.Redis | None = None,
) -> None:
super().__init__()
check_env(self.uri_env_var_name)
self._client: redis.Redis | None = None
if client is not None and config is None:
raise ValueError("config is required when client is provided")
if config is None and client is None:
config = RedisConfig.from_env(self.uri_env_var_name)
assert config is not None
self._config = config
self._client_injected = client is not None
self._client: redis.Redis | None = client
self.path: str = RedisPath.root_path()
if self._client_injected:
self._invalidate_health_cache()
def _is_client_ready(self) -> bool:
return self._client is not None
def connect(self) -> None:
"""Connect to the Redis server."""
if self._client_injected:
if self._client is not None:
try:
if not self._client.ping():
raise ConnectionError(
f"Could not connect to Redis at {self._config.uri}"
)
except (redis.ConnectionError, redis.TimeoutError) as exc:
raise ConnectionError(
f"Could not connect to Redis at {self._config.uri}"
) from exc
self._invalidate_health_cache()
return
if self._client is not None:
self._client.close()
self._client = None
self._invalidate_health_cache()
# Prepare arguments
uri = str(os.getenv(self.uri_env_var_name))
# Connect client
uri = self._config.uri
try:
client = redis.Redis.from_url(
url=uri,
@@ -56,16 +78,13 @@ class RedisAdapter(JsonRepositoryInterface, ConnectionAwareAdapter):
raise ConnectionError(f"Could not connect to Redis at {uri}")
except (redis.ConnectionError, redis.TimeoutError) as exc:
raise ConnectionError(f"Could not connect to Redis at {uri}") from exc
# Persist client
self._client = client
self._invalidate_health_cache()
def disconnect(self) -> None:
"""Disconnect from the Redis server."""
# Close connection
if self._client is not None:
if self._client is not None and not self._client_injected:
self._client.close()
# Reset client
self._client = None
self._invalidate_health_cache()
+9
View File
@@ -0,0 +1,9 @@
from .dotenv_loader import load_dotenv as load_dotenv
from .minio_config import MinioConfig as MinioConfig
from .redis_config import RedisConfig as RedisConfig
__all__ = [
"MinioConfig",
"RedisConfig",
"load_dotenv",
]
@@ -0,0 +1,14 @@
"""Load environment variables from a .env file."""
from __future__ import annotations
from pathlib import Path
from dotenv import load_dotenv as _load_dotenv
def load_dotenv(path: str | Path | None = None) -> bool:
"""Load .env into os.environ. Idempotent; returns True if a file was loaded."""
if path is None:
return bool(_load_dotenv())
return bool(_load_dotenv(path))
@@ -0,0 +1,48 @@
"""MinIO connection configuration."""
from __future__ import annotations
import os
from dataclasses import dataclass
from python_utils import check_env
from python_repositories.config.dotenv_loader import load_dotenv
@dataclass(frozen=True)
class MinioConfig:
"""Configuration for connecting to MinIO."""
endpoint: str
access_key: str
secret_key: str
bucket: str
secure: bool = False
@classmethod
def from_env(
cls,
endpoint_env_var_name: str = "MINIO_ENDPOINT",
access_key_env_var_name: str = "MINIO_ACCESS_KEY",
secret_key_env_var_name: str = "MINIO_SECRET_KEY",
bucket_env_var_name: str = "MINIO_BUCKET",
*,
use_dotenv: bool = True,
) -> MinioConfig:
"""Load configuration from environment variables."""
if use_dotenv:
load_dotenv()
env_var_names = {
endpoint_env_var_name,
access_key_env_var_name,
secret_key_env_var_name,
bucket_env_var_name,
}
check_env(env_var_names)
return cls(
endpoint=str(os.getenv(endpoint_env_var_name)),
access_key=str(os.getenv(access_key_env_var_name)),
secret_key=str(os.getenv(secret_key_env_var_name)),
bucket=str(os.getenv(bucket_env_var_name)),
)
@@ -0,0 +1,30 @@
"""Redis connection configuration."""
from __future__ import annotations
import os
from dataclasses import dataclass
from python_utils import check_env
from python_repositories.config.dotenv_loader import load_dotenv
@dataclass(frozen=True)
class RedisConfig:
"""Configuration for connecting to Redis."""
uri: str
@classmethod
def from_env(
cls,
uri_env_var_name: str = "REDIS_URI",
*,
use_dotenv: bool = True,
) -> RedisConfig:
"""Load configuration from environment variables."""
if use_dotenv:
load_dotenv()
check_env(uri_env_var_name)
return cls(uri=str(os.getenv(uri_env_var_name)))