diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..f5427fc --- /dev/null +++ b/.env.example @@ -0,0 +1,9 @@ +# Redis (requires redis extra) +REDIS_URI=redis://localhost:6379 + +# MinIO (requires minio extra) +MINIO_ENDPOINT=localhost:9000 +MINIO_ACCESS_KEY=minioadmin +MINIO_SECRET_KEY=minioadmin +MINIO_BUCKET=my-bucket +MINIO_SECURE=false diff --git a/README.md b/README.md index 780473f..45593f9 100644 --- a/README.md +++ b/README.md @@ -42,6 +42,40 @@ Requires Redis with the RedisJSON module (e.g. redis-stack). | `MINIO_ACCESS_KEY` | Access key | | `MINIO_SECRET_KEY` | Secret key | | `MINIO_BUCKET` | Bucket name (created on connect if missing) | +| `MINIO_SECURE` | Use HTTPS (`true`/`false`; default: `true`) | + +Copy [`.env.example`](.env.example) to `.env` for local development. `RedisConfig.from_env()` and `MinioConfig.from_env()` load `.env` automatically when resolving configuration from the environment. + +## Configuration injection + +Adapters accept optional `config` and `client` keyword arguments for explicit setup and testing: + +```python +from python_repositories import RedisAdapter, RedisConfig, MinioAdapter, MinioConfig + +redis = RedisAdapter(config=RedisConfig(uri="redis://localhost:6379")) +minio = MinioAdapter( + config=MinioConfig( + endpoint="localhost:9000", + access_key="minioadmin", + secret_key="minioadmin", + bucket="my-bucket", + secure=False, + ) +) +``` + +When both `config` and `client` are provided, `connect()` skips client creation (the caller owns the client lifecycle). `config` is required whenever `client` is injected. + +Load a `.env` file explicitly: + +```python +from python_repositories import load_dotenv + +load_dotenv() # optional — from_env() also loads .env by default +``` + +Calling `RedisAdapter()` or `MinioAdapter()` with no arguments still loads configuration from environment variables (and `.env` if present). ## Quick start @@ -93,9 +127,12 @@ from python_repositories import ( ConnectionAwareInterface, ContextAwareInterface, JsonRepositoryInterface, + MinioAdapter, + MinioConfig, ObjectRepositoryInterface, RedisAdapter, - MinioAdapter, + RedisConfig, + load_dotenv, ) ``` @@ -104,9 +141,13 @@ from python_repositories import ( ```bash uv sync --all-extras uv run pre-commit install # once per clone — runs hooks on git commit -uv run pytest tests/integration/ -v +uv run pytest tests/unit/ -v # fast, no Docker +uv run pytest -m "not integration" -v # all non-Docker tests +uv run pytest -v # full suite (requires Docker) ``` +Integration tests are marked with `@pytest.mark.integration` and require Docker (testcontainers). Run unit tests alone for quick local feedback. + `pre-commit` is included in the dev dependency group. `uv sync` installs the CLI, but git does not run hooks until you install them with `pre-commit install` (one time per clone). After that, commits run the checks defined in [`.pre-commit-config.yaml`](.pre-commit-config.yaml) (ruff, mypy, pyupgrade, prettier, and general file hygiene). To run all hooks manually without committing: @@ -115,8 +156,6 @@ To run all hooks manually without committing: uv run pre-commit run --all-files ``` -Integration tests require Docker (testcontainers). - ## Releases Releases are automated when a pull request is merged to `main`. CI reads the **merged PR title** to decide whether and how to bump the version. diff --git a/pyproject.toml b/pyproject.toml index 4dd505e..df95b65 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -14,6 +14,7 @@ classifiers = [ "Operating System :: OS Independent", ] dependencies = [ + "python-dotenv>=1.0.0", "python-utils>=0.1.0", "structlog>=25.4.0", ] @@ -33,6 +34,10 @@ build-backend = "hatchling.build" [tool.pytest.ini_options] testpaths = ["tests"] pythonpath = ["."] +addopts = "--import-mode=importlib" +markers = [ + "integration: tests requiring Docker containers (deselect with '-m \"not integration\"')", +] [tool.uv.sources] python-utils = { index = "gitea" } diff --git a/python_repositories/__init__.py b/python_repositories/__init__.py index dfdd8d4..7828124 100644 --- a/python_repositories/__init__.py +++ b/python_repositories/__init__.py @@ -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__, ] diff --git a/python_repositories/adapters/minio_adapter.py b/python_repositories/adapters/minio_adapter.py index bf2d34e..02775af 100644 --- a/python_repositories/adapters/minio_adapter.py +++ b/python_repositories/adapters/minio_adapter.py @@ -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: @@ -27,63 +25,78 @@ class MinioAdapter(ObjectRepositoryInterface, ConnectionAwareAdapter): 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" + secure_env_var_name: str = "MINIO_SECURE" 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 config is None: + if client is not None: + raise ValueError("config is required when client is provided") + 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 + self.secure_env_var_name, + ) + 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() diff --git a/python_repositories/adapters/redis_adapter.py b/python_repositories/adapters/redis_adapter.py index 6920d97..47a88a4 100644 --- a/python_repositories/adapters/redis_adapter.py +++ b/python_repositories/adapters/redis_adapter.py @@ -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,47 @@ 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 config is None: + if client is not None: + raise ValueError("config is required when client is provided") + config = RedisConfig.from_env(self.uri_env_var_name) + 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 +77,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() diff --git a/python_repositories/config/__init__.py b/python_repositories/config/__init__.py new file mode 100644 index 0000000..8bd2d81 --- /dev/null +++ b/python_repositories/config/__init__.py @@ -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", +] diff --git a/python_repositories/config/dotenv_loader.py b/python_repositories/config/dotenv_loader.py new file mode 100644 index 0000000..ee6a91b --- /dev/null +++ b/python_repositories/config/dotenv_loader.py @@ -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)) diff --git a/python_repositories/config/minio_config.py b/python_repositories/config/minio_config.py new file mode 100644 index 0000000..0405ddb --- /dev/null +++ b/python_repositories/config/minio_config.py @@ -0,0 +1,65 @@ +"""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 + +_TRUTHY = frozenset({"1", "true", "yes", "on"}) +_FALSY = frozenset({"0", "false", "no", "off"}) + + +def _env_bool(name: str, default: bool) -> bool: + raw = os.getenv(name) + if raw is None: + return default + normalized = raw.strip().lower() + if normalized in _TRUTHY: + return True + if normalized in _FALSY: + return False + raise ValueError(f"Invalid boolean value for {name}: {raw!r}") + + +@dataclass(frozen=True) +class MinioConfig: + """Configuration for connecting to MinIO.""" + + endpoint: str + access_key: str + secret_key: str + bucket: str + secure: bool = True + + @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", + secure_env_var_name: str = "MINIO_SECURE", + *, + 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)), + secure=_env_bool(secure_env_var_name, default=True), + ) diff --git a/python_repositories/config/redis_config.py b/python_repositories/config/redis_config.py new file mode 100644 index 0000000..85a3050 --- /dev/null +++ b/python_repositories/config/redis_config.py @@ -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))) diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 0000000..09b99f1 --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,12 @@ +"""Shared test configuration constants.""" + +from python_repositories.config import MinioConfig, RedisConfig + +TEST_REDIS_CONFIG = RedisConfig(uri="redis://localhost:6379") +TEST_MINIO_CONFIG = MinioConfig( + endpoint="localhost:9000", + access_key="minioadmin", + secret_key="minioadmin", + bucket="test-bucket", + secure=False, +) diff --git a/tests/integration/conftest.py b/tests/integration/conftest.py index 933cd07..cf1e821 100644 --- a/tests/integration/conftest.py +++ b/tests/integration/conftest.py @@ -1,7 +1,6 @@ """Integration tests configuration.""" import logging -import os from collections.abc import Generator import pytest @@ -10,6 +9,7 @@ import structlog from minio import Minio from testcontainers.minio import MinioContainer +from python_repositories.config import MinioConfig, RedisConfig from tests.integration.redis_container_test import REDIS_PORT, RedisTestContainer collect_ignore = ["redis_container_test.py"] @@ -22,7 +22,6 @@ MINIO_BUCKET = "test-bucket" @pytest.fixture(scope="session", autouse=True) def configure_logging() -> None: """Configure logging for the test session.""" - # Configure structlog structlog.configure( processors=[ structlog.stdlib.filter_by_level, @@ -35,40 +34,34 @@ def configure_logging() -> None: wrapper_class=structlog.stdlib.BoundLogger, cache_logger_on_first_use=True, ) - # Set up basic logging configuration logging.basicConfig(level=logging.ERROR) @pytest.fixture(scope="session") def redis_container() -> Generator[str, None, None]: """Set up a Redis container for testing and yield the Redis URI.""" - # Start container container = RedisTestContainer( image="redis/redis-stack:7.2.0-v0", ) container.start() - # Set environment variable for Redis URI redis_host = container.get_container_host_ip() redis_port = container.get_exposed_port(REDIS_PORT) redis_uri = f"redis://{redis_host}:{redis_port}" yield redis_uri - # Stop container container.stop() @pytest.fixture(scope="session") def minio_container() -> Generator[dict[str, str], None, None]: - """Set up a Minio container for testing and yield the Minio URI.""" - # Start container + """Set up a Minio container for testing and yield connection settings.""" container = MinioContainer( image="minio/minio:latest", access_key=MINIO_ACCESS_KEY, secret_key=MINIO_SECRET_KEY, ) container.start() - # Build environment variables dictionary minio_host = container.get_container_host_ip() minio_port = container.get_exposed_port(9000) minio_endpoint = f"{minio_host}:{minio_port}" @@ -77,38 +70,35 @@ def minio_container() -> Generator[dict[str, str], None, None]: "MINIO_ACCESS_KEY": MINIO_ACCESS_KEY, "MINIO_SECRET_KEY": MINIO_SECRET_KEY, "MINIO_BUCKET": MINIO_BUCKET, + "MINIO_SECURE": "false", } yield env_vars - # Stop container container.stop() -@pytest.fixture(scope="session", autouse=True) -def set_environment_variables( - redis_container: str, - minio_container: dict[str, str], -) -> Generator[dict[str, str], None, None]: - """Set environment variables needed for tests.""" - # Build environment variables dictionary - env_vars = {"REDIS_URI": redis_container} - env_vars.update(minio_container) - # Set environment variables - for key, value in env_vars.items(): - os.environ[key] = value +@pytest.fixture(scope="session") +def redis_config(redis_container: str) -> RedisConfig: + """Provide RedisConfig built from the test container.""" + return RedisConfig(uri=redis_container) - yield env_vars - # Cleanup - for key in env_vars: - _ = os.environ.pop(key, default=None) +@pytest.fixture(scope="session") +def minio_config(minio_container: dict[str, str]) -> MinioConfig: + """Provide MinioConfig built from the test container.""" + return MinioConfig( + endpoint=minio_container["MINIO_ENDPOINT"], + access_key=minio_container["MINIO_ACCESS_KEY"], + secret_key=minio_container["MINIO_SECRET_KEY"], + bucket=minio_container["MINIO_BUCKET"], + secure=False, + ) @pytest.fixture(scope="session") def raw_redis_client(redis_container: str) -> Generator[redis.Redis, None, None]: """Provide a raw Redis client connected to the test Redis container.""" - # Connect client client = redis.Redis.from_url( url=redis_container, socket_connect_timeout=10, @@ -116,7 +106,6 @@ def raw_redis_client(redis_container: str) -> Generator[redis.Redis, None, None] yield client - # Cleanup client.flushall() client.close() @@ -124,21 +113,18 @@ def raw_redis_client(redis_container: str) -> Generator[redis.Redis, None, None] @pytest.fixture(scope="session") def raw_minio_client(minio_container: dict[str, str]) -> Generator[Minio, None, None]: """Provide a raw Minio client connected to the test Minio container.""" - # Connect client client = Minio( endpoint=minio_container["MINIO_ENDPOINT"], access_key=minio_container["MINIO_ACCESS_KEY"], secret_key=minio_container["MINIO_SECRET_KEY"], secure=False, ) - # Ensure bucket exists bucket_name = minio_container["MINIO_BUCKET"] if not client.bucket_exists(bucket_name): client.make_bucket(bucket_name) yield client - # Cleanup objects = client.list_objects(bucket_name, recursive=True) for obj in objects: client.remove_object(bucket_name, obj.object_name) diff --git a/tests/integration/examples_test.py b/tests/integration/examples_test.py index 4586bcd..3ad0053 100644 --- a/tests/integration/examples_test.py +++ b/tests/integration/examples_test.py @@ -2,29 +2,46 @@ from collections.abc import Generator from io import BytesIO +import os import random import pytest + from python_repositories.examples.artifact_object_repository import ( ArtifactObjectRepository, ) from python_repositories.examples.user_json_repository import UserJsonRepository +pytestmark = pytest.mark.integration + + +@pytest.fixture(scope="module", autouse=True) +def set_example_env( + redis_container: str, + minio_container: dict[str, str], +) -> Generator[None, None, None]: + """Set env vars so example repositories can use from_env() defaults.""" + env_vars = {"REDIS_URI": redis_container, **minio_container} + for key, value in env_vars.items(): + os.environ[key] = value + yield + for key in env_vars: + _ = os.environ.pop(key, default=None) + @pytest.fixture(scope="module") -def user_data() -> Generator[dict[str, str]]: +def user_data() -> Generator[dict[str, str], None, None]: """Provide sample user data for tests.""" yield {"name": "Alice", "email": "alice@example.com"} @pytest.fixture(scope="module") -def artifact_data() -> Generator[BytesIO]: +def artifact_data() -> Generator[BytesIO, None, None]: """Provide sample artifact data for tests.""" yield BytesIO(random.randbytes(2**20)) def test_user_json_repository_save_and_get( - redis_container: str, user_data: dict[str, str], ) -> None: """Test that UserJsonRepository can save and retrieve a user.""" @@ -34,7 +51,6 @@ def test_user_json_repository_save_and_get( def test_user_json_repository_delete( - redis_container: str, user_data: dict[str, str], ) -> None: """Test that UserJsonRepository can delete a user.""" @@ -45,7 +61,6 @@ def test_user_json_repository_delete( def test_artifact_object_repository_store_and_get( - minio_container: dict[str, str], artifact_data: BytesIO, ) -> None: """Test that ArtifactObjectRepository can store and retrieve an artifact.""" diff --git a/tests/integration/minio_adapter_test.py b/tests/integration/minio_adapter_test.py index e79e157..dd4b31b 100644 --- a/tests/integration/minio_adapter_test.py +++ b/tests/integration/minio_adapter_test.py @@ -1,17 +1,20 @@ """Integration tests for the MinioAdapter.""" from collections.abc import Generator -import pytest -from unittest.mock import MagicMock -from minio import Minio -from io import BytesIO -import random -import os import logging -from minio import S3Error +import random +from io import BytesIO +from unittest.mock import MagicMock + +import pytest +from minio import Minio, S3Error from urllib3.response import BaseHTTPResponse + from python_repositories.adapters.minio_adapter import MinioAdapter -from python_repositories.interfaces import ObjectRepositoryInterface +from python_repositories.config import MinioConfig +from tests.conftest import TEST_MINIO_CONFIG + +pytestmark = pytest.mark.integration def same_data( @@ -19,15 +22,10 @@ def same_data( data_b: BytesIO, ) -> bool: """Check if two BytesIO-objects contain the same data.""" - assert isinstance(data_a, BytesIO) - assert isinstance(data_b, BytesIO) - # prepare for being read data_a.seek(0) data_b.seek(0) - # convert to bytes data_a_bytes = data_a.read() data_b_bytes = data_b.read() - # compare size if len(data_a_bytes) != len(data_b_bytes): logging.error( "data has different length: %s and %s", @@ -35,7 +33,6 @@ def same_data( len(data_b_bytes), ) return False - # compare content if data_a_bytes != data_b_bytes: logging.error("data has different bytes") return False @@ -45,20 +42,19 @@ def same_data( @pytest.fixture(scope="module") def data() -> Generator[BytesIO, None, None]: """Provide a sample data bytes for tests.""" - # Generate random bytes - random_bytes = random.randbytes(2**21) # 2 MiB + random_bytes = random.randbytes(2**21) yield BytesIO(random_bytes) @pytest.fixture(scope="function") def data_in_minio( raw_minio_client: Minio, + minio_config: MinioConfig, data: BytesIO, ) -> Generator[tuple[str, BytesIO], None, None]: """Fixture to set up a known value in Minio before each test.""" object_name = "test_object" - bucket_name = str(os.getenv("MINIO_BUCKET")) - # Upload object + bucket_name = minio_config.bucket num_bytes = data.getbuffer().nbytes data.seek(0) raw_minio_client.put_object( @@ -68,19 +64,17 @@ def data_in_minio( length=num_bytes, part_size=MinioAdapter.chunk_size, ) - # Reset data for reading in tests data.seek(0) yield object_name, data - # Cleanup raw_minio_client.remove_object(bucket_name, object_name) @pytest.fixture(scope="module") -def minio_adapter() -> Generator[MinioAdapter, None, None]: +def minio_adapter(minio_config: MinioConfig) -> Generator[MinioAdapter, None, None]: """Fixture to provide a connected MinioAdapter instance.""" - adapter = MinioAdapter() + adapter = MinioAdapter(config=minio_config) adapter.connect() yield adapter adapter.disconnect() @@ -89,10 +83,10 @@ def minio_adapter() -> Generator[MinioAdapter, None, None]: @pytest.fixture(scope="function", autouse=True) def clear_minio( raw_minio_client: Minio, + minio_config: MinioConfig, ) -> None: """Fixture to clear all Minio objects before each test.""" - bucket_name = str(os.getenv("MINIO_BUCKET")) - # Clear all objects before each test + bucket_name = minio_config.bucket objects = raw_minio_client.list_objects(bucket_name, recursive=True) for obj in objects: if not obj.object_name: @@ -100,24 +94,6 @@ def clear_minio( raw_minio_client.remove_object(bucket_name, obj.object_name) -def test_should_adhere_to_interface() -> None: - """Test that the MinioAdapter adheres to the expected interface.""" - assert issubclass(MinioAdapter, ObjectRepositoryInterface) - _ = MinioAdapter() - - -def test_should_have_logger_when_instantiated() -> None: - """Test that the MinioAdapter has a logger when instantiated.""" - adapter = MinioAdapter() - assert hasattr(adapter, "logger") - - -def test_should_not_be_connected_when_instantiated() -> None: - """Test that the MinioAdapter is not connected when instantiated.""" - adapter = MinioAdapter() - assert not adapter.is_connected() - - def test_should_log_info_when_already_connected( minio_adapter: MinioAdapter, caplog: pytest.LogCaptureFixture, @@ -128,13 +104,15 @@ def test_should_log_info_when_already_connected( assert "Already connected to Minio" in caplog.text -def test_should_raise_connection_error_when_unable_to_connect( - monkeypatch: pytest.MonkeyPatch, -) -> None: +def test_should_raise_connection_error_when_unable_to_connect() -> None: """Test that the MinioAdapter raises a ConnectionError when unable to connect.""" - # Arrange - monkeypatch.setenv("MINIO_ENDPOINT", "invalid_uri") - adapter = MinioAdapter() + config = MinioConfig( + endpoint="invalid_uri", + access_key="minioadmin", + secret_key="minioadmin", + bucket="test-bucket", + ) + adapter = MinioAdapter(config=config) with pytest.raises(ConnectionError): adapter.connect() assert not adapter.is_connected() @@ -142,38 +120,35 @@ def test_should_raise_connection_error_when_unable_to_connect( def test_should_log_info_when_creating_expected_bucket( raw_minio_client: Minio, + minio_config: MinioConfig, caplog: pytest.LogCaptureFixture, ) -> None: """Test that the MinioAdapter logs info when creating the expected bucket.""" - # Arrange - bucket_name = str(os.getenv("MINIO_BUCKET")) + bucket_name = minio_config.bucket raw_minio_client.remove_bucket(bucket_name) - adapter = MinioAdapter() - # Act + adapter = MinioAdapter(config=minio_config) with caplog.at_level(logging.INFO): adapter.connect() - # Assert assert f"Creating bucket '{bucket_name}'" in caplog.text def test_should_log_error_on_exception_during_exit( - minio_container: dict[str, str], + minio_config: MinioConfig, caplog: pytest.LogCaptureFixture, ) -> None: """Test that the MinioAdapter logs an error if an exception occurs during __exit__.""" try: - with MinioAdapter() as adapter: + with MinioAdapter(config=minio_config) as adapter: assert adapter.is_connected() raise ValueError("Simulated error") except ValueError: - pass # Expected - # Assert error was logged + pass assert "Error while exiting context" in caplog.text -def test_should_have_context_manager() -> None: +def test_should_have_context_manager(minio_config: MinioConfig) -> None: """Test that the MinioAdapter can be used as a context manager.""" - with MinioAdapter() as adapter: + with MinioAdapter(config=minio_config) as adapter: assert adapter._client is not None assert adapter._client is None @@ -183,11 +158,8 @@ def test_should_get_data( minio_adapter: MinioAdapter, ) -> None: """Test that the MinioAdapter can get data from a bucket.""" - # Arrange object_name, expected_data = data_in_minio - # Act received_data = minio_adapter.get(object_name) - # Assert assert received_data is not None assert same_data(expected_data, received_data) @@ -196,11 +168,7 @@ def test_should_get_none_for_nonexistent_object( minio_adapter: MinioAdapter, ) -> None: """Test that the MinioAdapter returns None for a nonexistent object.""" - # Arrange - object_name = "nonexistent_object" - # Act - received_data = minio_adapter.get(object_name) - # Assert + received_data = minio_adapter.get("nonexistent_object") assert received_data is None @@ -208,21 +176,17 @@ def test_should_raise_value_error_on_invalid_get_object_name( minio_adapter: MinioAdapter, ) -> None: """Test that the MinioAdapter raises ValueError when getting with an invalid object name.""" - # Arrange invalid_object_names = ["", 123, None] - # Act & Assert for object_name in invalid_object_names: with pytest.raises(ValueError): minio_adapter.get(object_name) # type: ignore def test_should_raise_connection_error_on_get_when_not_connected( - minio_adapter: MinioAdapter, + minio_config: MinioConfig, ) -> None: """Test that the MinioAdapter raises ConnectionError when getting while not connected.""" - # Arrange - adapter = MinioAdapter() # not connected - # Act & Assert + adapter = MinioAdapter(config=minio_config) with pytest.raises(ConnectionError): adapter.get("some_object") @@ -231,10 +195,9 @@ def test_should_log_warning_when_getting_nonexistent_object( caplog: pytest.LogCaptureFixture, ) -> None: """Test that the MinioAdapter logs a warning when getting a nonexistent object.""" - # Arrange - adapter = MinioAdapter() - adapter._client = MagicMock(spec=Minio) - adapter._client.get_object.side_effect = S3Error( + mock_client = MagicMock(spec=Minio) + mock_client.bucket_exists.return_value = True + mock_client.get_object.side_effect = S3Error( MagicMock(spec=BaseHTTPResponse), "NoSuchKey", "", @@ -244,12 +207,10 @@ def test_should_log_warning_when_getting_nonexistent_object( bucket_name="test-bucket", object_name="missing-object", ) - adapter._bucket_name = "test-bucket" + adapter = MinioAdapter(config=TEST_MINIO_CONFIG, client=mock_client) object_name = "missing-object" - # Act with caplog.at_level("WARNING"): result = adapter.get(object_name) - # Assert assert result is None assert ( f"Object '{object_name}' not found in bucket '{adapter._bucket_name}'" @@ -260,10 +221,9 @@ def test_should_log_warning_when_getting_nonexistent_object( def test_should_log_error_when_getting_with_s3error_other_than_no_such_key( caplog: pytest.LogCaptureFixture, ) -> None: - """Test that the MinioAdapter logs an error when getting a nonexistent object.""" - # Arrange - adapter = MinioAdapter() - adapter._client = MagicMock(spec=Minio) + """Test that the MinioAdapter logs an error for unhandled S3 errors.""" + mock_client = MagicMock(spec=Minio) + mock_client.bucket_exists.return_value = True other_s3error = S3Error( MagicMock(spec=BaseHTTPResponse), "UnhandledError", @@ -274,13 +234,10 @@ def test_should_log_error_when_getting_with_s3error_other_than_no_such_key( bucket_name="test-bucket", object_name="missing-object", ) - adapter._client.get_object.side_effect = other_s3error - adapter._bucket_name = "test-bucket" - object_name = "missing-object" - # Act + mock_client.get_object.side_effect = other_s3error + adapter = MinioAdapter(config=TEST_MINIO_CONFIG, client=mock_client) with caplog.at_level("ERROR"): - result = adapter.get(object_name) - # Assert + result = adapter.get("missing-object") assert result is None assert repr(other_s3error) in caplog.text @@ -288,18 +245,14 @@ def test_should_log_error_when_getting_with_s3error_other_than_no_such_key( def test_should_log_error_when_getting_with_general_exception( caplog: pytest.LogCaptureFixture, ) -> None: - """Test that the MinioAdapter logs an error when getting a nonexistent object.""" - # Arrange - adapter = MinioAdapter() - adapter._client = MagicMock(spec=Minio) + """Test that the MinioAdapter logs an error on general exceptions during get.""" + mock_client = MagicMock(spec=Minio) + mock_client.bucket_exists.return_value = True general_exception = Exception("General failure") - adapter._client.get_object.side_effect = general_exception - adapter._bucket_name = "test-bucket" - object_name = "missing-object" - # Act + mock_client.get_object.side_effect = general_exception + adapter = MinioAdapter(config=TEST_MINIO_CONFIG, client=mock_client) with caplog.at_level("ERROR"): - result = adapter.get(object_name) - # Assert + result = adapter.get("missing-object") assert result is None assert repr(general_exception) in caplog.text @@ -307,21 +260,17 @@ def test_should_log_error_when_getting_with_general_exception( def test_should_put_data( data: BytesIO, minio_adapter: MinioAdapter, + minio_config: MinioConfig, ) -> None: """Test that the MinioAdapter can put data into a bucket.""" - # Arrange object_name = "new_test_object" received_data = minio_adapter.get(object_name) - assert received_data is None # ensure object does not exist yet - # Act + assert received_data is None minio_adapter.put(object_name, data) - # Assert received_data = minio_adapter.get(object_name) assert received_data is not None assert same_data(data, received_data) - # Cleanup - bucket_name = str(os.getenv("MINIO_BUCKET")) - minio_adapter._client.remove_object(bucket_name, object_name) # type: ignore + minio_adapter._client.remove_object(minio_config.bucket, object_name) # type: ignore[union-attr] def test_should_update_data( @@ -329,15 +278,12 @@ def test_should_update_data( minio_adapter: MinioAdapter, ) -> None: """Test that the MinioAdapter can update data in a bucket.""" - # Arrange object_name, _ = data_in_minio - new_data = BytesIO(random.randbytes(2**21)) # 2 MiB + new_data = BytesIO(random.randbytes(2**21)) received_data = minio_adapter.get(object_name) assert received_data is not None assert not same_data(received_data, new_data) - # Act minio_adapter.put(object_name, new_data) - # Assert received_data = minio_adapter.get(object_name) assert received_data is not None assert same_data(new_data, received_data) @@ -348,9 +294,7 @@ def test_should_raise_value_error_on_invalid_put_object_name( minio_adapter: MinioAdapter, ) -> None: """Test that the MinioAdapter raises ValueError when putting with an invalid object name.""" - # Arrange invalid_object_names = ["", 123, None] - # Act & Assert for object_name in invalid_object_names: with pytest.raises(ValueError): minio_adapter.put(object_name, data) # type: ignore @@ -360,13 +304,11 @@ def test_should_raise_value_error_on_invalid_put_data( minio_adapter: MinioAdapter, ) -> None: """Test that the MinioAdapter raises ValueError when putting with invalid data.""" - # Arrange object_name = "valid_object_name" invalid_data = ["not_bytesio", 123, None] - # Act & Assert - for data in invalid_data: + for invalid in invalid_data: with pytest.raises(ValueError): - minio_adapter.put(object_name, data) # type: ignore + minio_adapter.put(object_name, invalid) # type: ignore def test_should_raise_value_error_on_invalid_put_content_type( @@ -374,23 +316,19 @@ def test_should_raise_value_error_on_invalid_put_content_type( minio_adapter: MinioAdapter, ) -> None: """Test that the MinioAdapter raises ValueError when putting with an invalid content type.""" - # Arrange object_name = "valid_object_name" invalid_content_types = ["", 123, None] - # Act & Assert for content_type in invalid_content_types: with pytest.raises(ValueError): minio_adapter.put(object_name, data, content_type) # type: ignore def test_should_raise_connection_error_on_put_when_not_connected( + minio_config: MinioConfig, data: BytesIO, - minio_adapter: MinioAdapter, ) -> None: """Test that the MinioAdapter raises ConnectionError when putting while not connected.""" - # Arrange - adapter = MinioAdapter() # not connected - # Act & Assert + adapter = MinioAdapter(config=minio_config) with pytest.raises(ConnectionError): adapter.put("some_object", data) @@ -400,36 +338,28 @@ def test_should_delete_object( minio_adapter: MinioAdapter, ) -> None: """Test that the MinioAdapter can delete an object from a bucket.""" - # Arrange object_name, _ = data_in_minio received_data = minio_adapter.get(object_name) - assert received_data is not None # ensure object exists - # Act + assert received_data is not None minio_adapter.delete(object_name) - # Assert - received_data = minio_adapter.get(object_name) - assert received_data is None + assert minio_adapter.get(object_name) is None def test_should_raise_value_error_on_invalid_delete_object_name( minio_adapter: MinioAdapter, ) -> None: """Test that the MinioAdapter raises ValueError when deleting with an invalid object name.""" - # Arrange invalid_object_names = ["", 123, None] - # Act & Assert for object_name in invalid_object_names: with pytest.raises(ValueError): minio_adapter.delete(object_name) # type: ignore def test_should_raise_connection_error_on_delete_when_not_connected( - minio_adapter: MinioAdapter, + minio_config: MinioConfig, ) -> None: """Test that the MinioAdapter raises ConnectionError when deleting while not connected.""" - # Arrange - adapter = MinioAdapter() # not connected - # Act & Assert + adapter = MinioAdapter(config=minio_config) with pytest.raises(ConnectionError): adapter.delete("some_object") @@ -439,14 +369,11 @@ def test_should_list_objects( minio_adapter: MinioAdapter, ) -> None: """Test that the MinioAdapter can list objects in a bucket.""" - # Arrange object_name, _ = data_in_minio - new_data = BytesIO(random.randbytes(2**21)) # 2 MiB + new_data = BytesIO(random.randbytes(2**21)) new_data_name = "another_test_object" minio_adapter.put(new_data_name, new_data) - # Act objects = minio_adapter.list_objects() - # Assert assert isinstance(objects, list) assert len(objects) == 2 assert object_name in objects @@ -458,15 +385,12 @@ def test_should_list_objects_with_prefix( minio_adapter: MinioAdapter, ) -> None: """Test that the MinioAdapter can list objects in a bucket with a prefix.""" - # Arrange object_name, _ = data_in_minio - new_data = BytesIO(random.randbytes(2**21)) # 2 MiB + new_data = BytesIO(random.randbytes(2**21)) new_data_name = "prefix_test_object" minio_adapter.put(new_data_name, new_data) prefix = "prefix_" - # Act objects = minio_adapter.list_objects(prefix) - # Assert assert isinstance(objects, list) assert len(objects) == 1 assert new_data_name in objects @@ -477,25 +401,20 @@ def test_should_raise_value_error_on_invalid_list_objects_prefix( minio_adapter: MinioAdapter, ) -> None: """Test that the MinioAdapter raises ValueError when listing with an invalid prefix.""" - # Arrange invalid_prefixes = [123, None] - # Act & Assert for prefix in invalid_prefixes: with pytest.raises(ValueError): minio_adapter.list_objects(prefix) # type: ignore def test_should_raise_connection_error_on_list_objects_when_not_connected( - minio_adapter: MinioAdapter, + minio_config: MinioConfig, ) -> None: """Test that the MinioAdapter raises ConnectionError when listing while not connected.""" - # Arrange - adapter = MinioAdapter() # not connected - # Act & Assert + adapter = MinioAdapter(config=minio_config) with pytest.raises(ConnectionError): adapter.list_objects() -# allows local debugging by running file as script if __name__ == "__main__": pytest.main(["-s", "-v", __file__]) diff --git a/tests/integration/redis_adapter_test.py b/tests/integration/redis_adapter_test.py index 7cfe8fd..5185204 100644 --- a/tests/integration/redis_adapter_test.py +++ b/tests/integration/redis_adapter_test.py @@ -1,11 +1,15 @@ """Integration tests for the RedisAdapter.""" from collections.abc import Generator + import pytest import redis from redis.commands.json.path import Path as RedisPath + from python_repositories.adapters.redis_adapter import RedisAdapter -from python_repositories.interfaces import JsonRepositoryInterface +from python_repositories.config import RedisConfig + +pytestmark = pytest.mark.integration @pytest.fixture(scope="module") @@ -26,14 +30,13 @@ def data_in_redis( yield key, data - # Cleanup raw_redis_client.delete(key) @pytest.fixture(scope="module") -def redis_adapter(redis_container: str) -> Generator[RedisAdapter, None, None]: +def redis_adapter(redis_config: RedisConfig) -> Generator[RedisAdapter, None, None]: """Fixture to provide a connected RedisAdapter instance.""" - adapter = RedisAdapter() + adapter = RedisAdapter(config=redis_config) adapter.connect() yield adapter adapter.disconnect() @@ -42,38 +45,12 @@ def redis_adapter(redis_container: str) -> Generator[RedisAdapter, None, None]: @pytest.fixture(scope="function", autouse=True) def clear_redis(raw_redis_client: redis.Redis) -> None: """Fixture to clear all Redis keys before each test.""" - # Clear all keys before each test raw_redis_client.flushall() -def test_should_adhere_to_interface(redis_container: str) -> None: - """Test that the RedisAdapter adheres to the expected interface.""" - assert issubclass(RedisAdapter, JsonRepositoryInterface) - _ = RedisAdapter() - - -def test_should_have_logger_when_instantiated(redis_container: str) -> None: - """Test that the RedisAdapter has a logger when instantiated.""" - adapter = RedisAdapter() - assert hasattr(adapter, "logger") - assert adapter.logger is not None - - -def test_should_not_be_connected_when_instantiated(redis_container: str) -> None: - """Test that the RedisAdapter is not connected when instantiated.""" - adapter = RedisAdapter() - assert adapter._client is None - assert not adapter.is_connected() - - -def test_should_raise_connection_error_when_unable_to_connect( - monkeypatch: pytest.MonkeyPatch, -) -> None: +def test_should_raise_connection_error_when_unable_to_connect() -> None: """Test that the RedisAdapter raises ConnectionError when unable to connect.""" - # Arrange - monkeypatch.setenv("REDIS_URI", "redis://invalid:6379") - adapter = RedisAdapter() - # Act & Assert + adapter = RedisAdapter(config=RedisConfig(uri="redis://invalid:6379")) with pytest.raises(ConnectionError): adapter.connect() assert adapter._client is None @@ -84,42 +61,37 @@ def test_connect_raises_connection_error_when_unable_to_ping( monkeypatch: pytest.MonkeyPatch, ) -> None: """Test that the RedisAdapter raises ConnectionError when ping fails.""" - # Set an invalid URI - monkeypatch.setenv("REDIS_URI", "redis://invalid:6379") - # Monkeypatch redis.Redis.from_url to return a mock client class MockRedis: """A mock Redis client that simulates a failed ping.""" def ping(self) -> bool: - """Simulate a failed ping.""" - return False # Simulate failed ping + return False monkeypatch.setattr("redis.Redis.from_url", lambda *a, **kw: MockRedis()) - adapter = RedisAdapter() + adapter = RedisAdapter(config=RedisConfig(uri="redis://invalid:6379")) with pytest.raises(ConnectionError, match="Could not connect to Redis"): adapter.connect() def test_should_log_error_on_exception_during_exit( - redis_container: str, + redis_config: RedisConfig, caplog: pytest.LogCaptureFixture, ) -> None: """Test that the RedisAdapter logs an error when an exception occurs during context exit.""" try: - with RedisAdapter() as adapter: + with RedisAdapter(config=redis_config) as adapter: assert adapter.is_connected() raise ValueError("Simulated error") except ValueError: - pass # Expected - # Assert error was logged + pass assert "Error while exiting context" in caplog.text -def test_should_have_context_manager(redis_container: str) -> None: +def test_should_have_context_manager(redis_config: RedisConfig) -> None: """Test that the RedisAdapter can be used as a context manager.""" - with RedisAdapter() as adapter: + with RedisAdapter(config=redis_config) as adapter: assert adapter._client is not None assert adapter._client is None @@ -129,11 +101,8 @@ def test_should_get_value( redis_adapter: RedisAdapter, ) -> None: """Test that the RedisAdapter can get a value.""" - # Arrange key, data = data_in_redis - # Act value = redis_adapter.get(key) - # Assert assert value is not None assert value == data @@ -142,9 +111,7 @@ def test_should_get_none_for_missing_key( redis_adapter: RedisAdapter, ) -> None: """Test that getting a non-existent key returns None.""" - # Act value = redis_adapter.get("nonexistent_key") - # Assert assert value is None @@ -152,21 +119,17 @@ def test_should_raise_value_error_on_invalid_get_key( redis_adapter: RedisAdapter, ) -> None: """Test that the RedisAdapter raises ValueError when getting with an invalid key.""" - # Arrange invalid_keys = ["", 123, None] - # Act & Assert for key in invalid_keys: with pytest.raises(ValueError): redis_adapter.get(key) # type: ignore def test_should_raise_connection_error_on_get_when_not_connected( - redis_adapter: RedisAdapter, + redis_config: RedisConfig, ) -> None: """Test that the RedisAdapter raises ConnectionError when getting while not connected.""" - # Arrange - adapter = RedisAdapter() # not connected - # Act & Assert + adapter = RedisAdapter(config=redis_config) with pytest.raises(ConnectionError): adapter.get("some_key") @@ -176,13 +139,10 @@ def test_should_set_value( redis_adapter: RedisAdapter, ) -> None: """Test that the RedisAdapter can set a value.""" - # Arrange key = "test_key" received_data = redis_adapter.get(key) - assert received_data is None # Ensure key does not exist - # Act + assert received_data is None redis_adapter.set(key, data) - # Assert received_data = redis_adapter.get(key) assert received_data is not None assert received_data == data @@ -193,15 +153,12 @@ def test_should_update_value( redis_adapter: RedisAdapter, ) -> None: """Test that the RedisAdapter can update an existing value.""" - # Arrange key, _ = data_in_redis new_data = {"new_key": "new_value"} received_data = redis_adapter.get(key) assert received_data is not None assert received_data != new_data - # Act redis_adapter.set(key, new_data) - # Assert assert redis_adapter.get(key) == new_data @@ -210,9 +167,7 @@ def test_should_raise_value_error_on_invalid_set_key( data: dict[str, str], ) -> None: """Test that the RedisAdapter raises ValueError when setting with an invalid key.""" - # Arrange invalid_keys = ["", 123, None] - # Act & Assert for key in invalid_keys: with pytest.raises(ValueError): redis_adapter.set(key, data) # type: ignore @@ -222,26 +177,21 @@ def test_should_raise_value_error_on_invalid_set_data( redis_adapter: RedisAdapter, ) -> None: """Test that the RedisAdapter raises ValueError when setting with invalid data.""" - # Arrange key = "test_key" invalid_data = ["", 123, None, [], {}] - # Act & Assert for data in invalid_data: with pytest.raises(ValueError): redis_adapter.set(key, data) # type: ignore def test_should_raise_connection_error_on_set_when_not_connected( - redis_adapter: RedisAdapter, + redis_config: RedisConfig, data: dict[str, str], ) -> None: """Test that the RedisAdapter raises ConnectionError when setting while not connected.""" - # Arrange - adapter = RedisAdapter() - key = "test_key" - # Act & Assert + adapter = RedisAdapter(config=redis_config) with pytest.raises(ConnectionError): - adapter.set(key, data) + adapter.set("test_key", data) def test_should_delete_key( @@ -249,13 +199,10 @@ def test_should_delete_key( redis_adapter: RedisAdapter, ) -> None: """Test that deleting a key removes it from Redis.""" - # Arrange key, _ = data_in_redis received_data = redis_adapter.get(key) - assert received_data is not None # Ensure key exists - # Act + assert received_data is not None redis_adapter.delete(key) - # Assert assert redis_adapter.get(key) is None @@ -263,21 +210,17 @@ def test_should_raise_value_error_on_invalid_delete_key( redis_adapter: RedisAdapter, ) -> None: """Test that the RedisAdapter raises ValueError when deleting with an invalid key.""" - # Arrange invalid_keys = ["", 123, None] - # Act & Assert for key in invalid_keys: with pytest.raises(ValueError): redis_adapter.delete(key) # type: ignore def test_should_raise_connection_error_on_delete_when_not_connected( - redis_adapter: RedisAdapter, + redis_config: RedisConfig, ) -> None: """Test that the RedisAdapter raises ConnectionError when deleting while not connected.""" - # Arrange - adapter = RedisAdapter() - # Act & Assert + adapter = RedisAdapter(config=redis_config) with pytest.raises(ConnectionError): adapter.delete("some_key") @@ -286,12 +229,9 @@ def test_should_list_keys( redis_adapter: RedisAdapter, ) -> None: """Test listing keys matching a pattern returns correct keys.""" - # Arrange redis_adapter.set("key1", {"a": 1}) redis_adapter.set("key2", {"b": 2}) - # Act keys = redis_adapter.list_keys("key*") - # Assert assert set(keys) == {"key1", "key2"} @@ -299,25 +239,20 @@ def test_should_raise_value_error_on_invalid_list_keys_pattern( redis_adapter: RedisAdapter, ) -> None: """Test that the RedisAdapter raises ValueError when listing keys with an invalid pattern.""" - # Arrange invalid_patterns = ["", 123, None] - # Act & Assert for pattern in invalid_patterns: with pytest.raises(ValueError): redis_adapter.list_keys(pattern) # type: ignore def test_should_raise_connection_error_on_list_keys_when_not_connected( - redis_adapter: RedisAdapter, + redis_config: RedisConfig, ) -> None: """Test that the RedisAdapter raises ConnectionError when listing keys while not connected.""" - # Arrange - adapter = RedisAdapter() - # Act & Assert + adapter = RedisAdapter(config=redis_config) with pytest.raises(ConnectionError): adapter.list_keys("some_pattern") -# allows local debugging by running file as script if __name__ == "__main__": pytest.main(["-s", "-v", __file__]) diff --git a/tests/unit/optional_dependencies_test.py b/tests/unit/adapters_test.py similarity index 98% rename from tests/unit/optional_dependencies_test.py rename to tests/unit/adapters_test.py index f67d64b..ed3ac5f 100644 --- a/tests/unit/optional_dependencies_test.py +++ b/tests/unit/adapters_test.py @@ -1,4 +1,4 @@ -"""Tests for optional dependency import behavior.""" +"""Unit tests for lazy adapter loading in adapters subpackage.""" from __future__ import annotations diff --git a/tests/unit/conftest.py b/tests/unit/conftest.py new file mode 100644 index 0000000..92c7ff5 --- /dev/null +++ b/tests/unit/conftest.py @@ -0,0 +1,27 @@ +"""Unit test fixtures for mocked adapters.""" + +from __future__ import annotations + +from unittest.mock import MagicMock + +import pytest +import redis +from minio import Minio + +from python_repositories.adapters.minio_adapter import MinioAdapter +from python_repositories.adapters.redis_adapter import RedisAdapter +from tests.conftest import TEST_MINIO_CONFIG, TEST_REDIS_CONFIG + + +@pytest.fixture +def redis_adapter() -> RedisAdapter: + """Provide a RedisAdapter with an injected mock client.""" + mock_client = MagicMock(spec=redis.Redis) + return RedisAdapter(config=TEST_REDIS_CONFIG, client=mock_client) + + +@pytest.fixture +def minio_adapter() -> MinioAdapter: + """Provide a MinioAdapter with an injected mock client.""" + mock_client = MagicMock(spec=Minio) + return MinioAdapter(config=TEST_MINIO_CONFIG, client=mock_client) diff --git a/tests/unit/connection_health_test.py b/tests/unit/connection_aware_adapter_test.py similarity index 63% rename from tests/unit/connection_health_test.py rename to tests/unit/connection_aware_adapter_test.py index 8b09a06..1ea7ef2 100644 --- a/tests/unit/connection_health_test.py +++ b/tests/unit/connection_aware_adapter_test.py @@ -1,39 +1,25 @@ -"""Tests for TTL-cached connection health checks on adapters.""" +"""Unit tests for TTL-cached connection health checks on ConnectionAwareAdapter.""" from __future__ import annotations +from typing import cast from unittest.mock import MagicMock, patch -import pytest import redis from python_repositories.adapters.minio_adapter import MinioAdapter from python_repositories.adapters.redis_adapter import RedisAdapter - - -@pytest.fixture -def redis_adapter(monkeypatch: pytest.MonkeyPatch) -> RedisAdapter: - monkeypatch.setenv("REDIS_URI", "redis://localhost:6379") - return RedisAdapter() - - -@pytest.fixture -def minio_adapter(monkeypatch: pytest.MonkeyPatch) -> MinioAdapter: - monkeypatch.setenv("MINIO_ENDPOINT", "localhost:9000") - monkeypatch.setenv("MINIO_ACCESS_KEY", "minioadmin") - monkeypatch.setenv("MINIO_SECRET_KEY", "minioadmin") - monkeypatch.setenv("MINIO_BUCKET", "test-bucket") - return MinioAdapter() +from tests.conftest import TEST_MINIO_CONFIG, TEST_REDIS_CONFIG class TestRedisConnectionHealth: - def test_not_connected_when_no_client(self, redis_adapter: RedisAdapter) -> None: - assert not redis_adapter.is_connected() + def test_not_connected_when_no_client(self) -> None: + adapter = RedisAdapter(config=TEST_REDIS_CONFIG) + assert not adapter.is_connected() def test_connected_when_probe_succeeds(self, redis_adapter: RedisAdapter) -> None: - mock_client = MagicMock(spec=redis.Redis) + mock_client = cast(MagicMock, redis_adapter._client) mock_client.ping.return_value = True - redis_adapter._client = mock_client assert redis_adapter.is_connected() mock_client.ping.assert_called_once() @@ -41,16 +27,15 @@ class TestRedisConnectionHealth: def test_stale_connection_when_probe_fails( self, redis_adapter: RedisAdapter ) -> None: - mock_client = MagicMock(spec=redis.Redis) - mock_client.ping.side_effect = redis.ConnectionError("connection lost") - redis_adapter._client = mock_client + cast(MagicMock, redis_adapter._client).ping.side_effect = redis.ConnectionError( + "connection lost" + ) assert not redis_adapter.is_connected() def test_cache_hit_avoids_second_probe(self, redis_adapter: RedisAdapter) -> None: - mock_client = MagicMock(spec=redis.Redis) + mock_client = cast(MagicMock, redis_adapter._client) mock_client.ping.return_value = True - redis_adapter._client = mock_client with patch( "python_repositories.adapters.connection_aware_adapter.time.monotonic", @@ -62,9 +47,8 @@ class TestRedisConnectionHealth: mock_client.ping.assert_called_once() def test_cache_miss_runs_probe_again(self, redis_adapter: RedisAdapter) -> None: - mock_client = MagicMock(spec=redis.Redis) + mock_client = cast(MagicMock, redis_adapter._client) mock_client.ping.return_value = True - redis_adapter._client = mock_client with patch( "python_repositories.adapters.connection_aware_adapter.time.monotonic", @@ -76,9 +60,8 @@ class TestRedisConnectionHealth: assert mock_client.ping.call_count == 2 def test_disconnect_clears_cache(self, redis_adapter: RedisAdapter) -> None: - mock_client = MagicMock(spec=redis.Redis) + mock_client = cast(MagicMock, redis_adapter._client) mock_client.ping.return_value = True - redis_adapter._client = mock_client with patch( "python_repositories.adapters.connection_aware_adapter.time.monotonic", @@ -87,34 +70,35 @@ class TestRedisConnectionHealth: assert redis_adapter.is_connected() redis_adapter.disconnect() - redis_adapter._client = mock_client + reinjected = RedisAdapter( + config=redis_adapter._config, + client=mock_client, + ) with patch( "python_repositories.adapters.connection_aware_adapter.time.monotonic", return_value=100.0, ): - assert redis_adapter.is_connected() + assert reinjected.is_connected() assert mock_client.ping.call_count == 2 class TestMinioConnectionHealth: - def test_not_connected_when_no_client(self, minio_adapter: MinioAdapter) -> None: - assert not minio_adapter.is_connected() + def test_not_connected_when_no_client(self) -> None: + adapter = MinioAdapter(config=TEST_MINIO_CONFIG) + assert not adapter.is_connected() def test_not_connected_when_bucket_name_missing( self, minio_adapter: MinioAdapter ) -> None: - minio_adapter._client = MagicMock() minio_adapter._bucket_name = None assert not minio_adapter.is_connected() def test_connected_when_probe_succeeds(self, minio_adapter: MinioAdapter) -> None: - mock_client = MagicMock() + mock_client = cast(MagicMock, minio_adapter._client) mock_client.bucket_exists.return_value = True - minio_adapter._client = mock_client - minio_adapter._bucket_name = "test-bucket" assert minio_adapter.is_connected() mock_client.bucket_exists.assert_called_once_with("test-bucket") @@ -122,18 +106,15 @@ class TestMinioConnectionHealth: def test_stale_connection_when_probe_fails( self, minio_adapter: MinioAdapter ) -> None: - mock_client = MagicMock() - mock_client.bucket_exists.side_effect = Exception("connection lost") - minio_adapter._client = mock_client - minio_adapter._bucket_name = "test-bucket" + cast(MagicMock, minio_adapter._client).bucket_exists.side_effect = Exception( + "connection lost" + ) assert not minio_adapter.is_connected() def test_cache_hit_avoids_second_probe(self, minio_adapter: MinioAdapter) -> None: - mock_client = MagicMock() + mock_client = cast(MagicMock, minio_adapter._client) mock_client.bucket_exists.return_value = True - minio_adapter._client = mock_client - minio_adapter._bucket_name = "test-bucket" with patch( "python_repositories.adapters.connection_aware_adapter.time.monotonic", @@ -145,10 +126,8 @@ class TestMinioConnectionHealth: mock_client.bucket_exists.assert_called_once() def test_cache_miss_runs_probe_again(self, minio_adapter: MinioAdapter) -> None: - mock_client = MagicMock() + mock_client = cast(MagicMock, minio_adapter._client) mock_client.bucket_exists.return_value = True - minio_adapter._client = mock_client - minio_adapter._bucket_name = "test-bucket" with patch( "python_repositories.adapters.connection_aware_adapter.time.monotonic", @@ -160,10 +139,8 @@ class TestMinioConnectionHealth: assert mock_client.bucket_exists.call_count == 2 def test_disconnect_clears_cache(self, minio_adapter: MinioAdapter) -> None: - mock_client = MagicMock() + mock_client = cast(MagicMock, minio_adapter._client) mock_client.bucket_exists.return_value = True - minio_adapter._client = mock_client - minio_adapter._bucket_name = "test-bucket" with patch( "python_repositories.adapters.connection_aware_adapter.time.monotonic", @@ -172,13 +149,15 @@ class TestMinioConnectionHealth: assert minio_adapter.is_connected() minio_adapter.disconnect() - minio_adapter._client = mock_client - minio_adapter._bucket_name = "test-bucket" + reinjected = MinioAdapter( + config=minio_adapter._config, + client=mock_client, + ) with patch( "python_repositories.adapters.connection_aware_adapter.time.monotonic", return_value=100.0, ): - assert minio_adapter.is_connected() + assert reinjected.is_connected() assert mock_client.bucket_exists.call_count == 2 diff --git a/tests/integration/connection_aware_interface_test.py b/tests/unit/connection_aware_interface_test.py similarity index 96% rename from tests/integration/connection_aware_interface_test.py rename to tests/unit/connection_aware_interface_test.py index f4d3414..f805faf 100644 --- a/tests/integration/connection_aware_interface_test.py +++ b/tests/unit/connection_aware_interface_test.py @@ -1,4 +1,4 @@ -"""Integration tests for ConnectionAwareInterface.""" +"""Unit tests for ConnectionAwareInterface.""" import pytest from python_repositories.interfaces.connection_aware_interface import ( diff --git a/tests/integration/context_aware_interface_test.py b/tests/unit/context_aware_interface_test.py similarity index 95% rename from tests/integration/context_aware_interface_test.py rename to tests/unit/context_aware_interface_test.py index 63cf953..8bffcfd 100644 --- a/tests/integration/context_aware_interface_test.py +++ b/tests/unit/context_aware_interface_test.py @@ -1,4 +1,4 @@ -"""Integration tests for ContextAwareInterface.""" +"""Unit tests for ContextAwareInterface.""" from __future__ import annotations diff --git a/tests/unit/dotenv_loader_test.py b/tests/unit/dotenv_loader_test.py new file mode 100644 index 0000000..43522a2 --- /dev/null +++ b/tests/unit/dotenv_loader_test.py @@ -0,0 +1,26 @@ +"""Unit tests for dotenv_loader.""" + +from __future__ import annotations + +import os +from pathlib import Path + +import pytest + +from python_repositories.config.dotenv_loader import load_dotenv + + +def test_load_dotenv_loads_file( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + env_file = tmp_path / ".env" + env_file.write_text("DOTENV_TEST_VAR=loaded_value\n") + monkeypatch.delenv("DOTENV_TEST_VAR", raising=False) + loaded = load_dotenv(env_file) + assert loaded is True + assert os.getenv("DOTENV_TEST_VAR") == "loaded_value" + + +def test_load_dotenv_returns_false_for_missing_file(tmp_path: Path) -> None: + missing = tmp_path / "missing.env" + assert load_dotenv(missing) is False diff --git a/tests/integration/json_repository_interface_test.py b/tests/unit/json_repository_interface_test.py similarity index 97% rename from tests/integration/json_repository_interface_test.py rename to tests/unit/json_repository_interface_test.py index 0b8c458..88b98d8 100644 --- a/tests/integration/json_repository_interface_test.py +++ b/tests/unit/json_repository_interface_test.py @@ -1,4 +1,4 @@ -"""Integration tests for JsonRepositoryInterface.""" +"""Unit tests for JsonRepositoryInterface.""" import pytest from python_repositories.interfaces.json_repository_interface import ( diff --git a/tests/unit/minio_adapter_test.py b/tests/unit/minio_adapter_test.py new file mode 100644 index 0000000..05cdaf2 --- /dev/null +++ b/tests/unit/minio_adapter_test.py @@ -0,0 +1,55 @@ +"""Unit tests for MinioAdapter instantiation and injection.""" + +from __future__ import annotations + +from unittest.mock import MagicMock + +import pytest +from minio import Minio + +from python_repositories.adapters.minio_adapter import MinioAdapter +from python_repositories.interfaces import ObjectRepositoryInterface +from tests.conftest import TEST_MINIO_CONFIG + + +def test_should_adhere_to_interface() -> None: + assert issubclass(MinioAdapter, ObjectRepositoryInterface) + _ = MinioAdapter(config=TEST_MINIO_CONFIG) + + +def test_should_have_logger_when_instantiated() -> None: + adapter = MinioAdapter(config=TEST_MINIO_CONFIG) + assert hasattr(adapter, "logger") + assert adapter.logger is not None + + +def test_should_not_be_connected_when_instantiated() -> None: + adapter = MinioAdapter(config=TEST_MINIO_CONFIG) + assert not adapter.is_connected() + + +def test_constructs_with_injected_config_without_env( + monkeypatch: pytest.MonkeyPatch, +) -> None: + for var in ( + "MINIO_ENDPOINT", + "MINIO_ACCESS_KEY", + "MINIO_SECRET_KEY", + "MINIO_BUCKET", + ): + monkeypatch.delenv(var, raising=False) + adapter = MinioAdapter(config=TEST_MINIO_CONFIG) + assert adapter._config == TEST_MINIO_CONFIG + + +def test_injected_client_sets_bucket_name() -> None: + mock_client = MagicMock(spec=Minio) + adapter = MinioAdapter(config=TEST_MINIO_CONFIG, client=mock_client) + assert adapter._bucket_name == "test-bucket" + assert adapter._client is mock_client + + +def test_raises_when_client_provided_without_config() -> None: + mock_client = MagicMock(spec=Minio) + with pytest.raises(ValueError, match="config is required"): + MinioAdapter(client=mock_client) diff --git a/tests/unit/minio_config_test.py b/tests/unit/minio_config_test.py new file mode 100644 index 0000000..e530c6f --- /dev/null +++ b/tests/unit/minio_config_test.py @@ -0,0 +1,73 @@ +"""Unit tests for MinioConfig.""" + +from __future__ import annotations + +import pytest + +from python_repositories.config import MinioConfig + + +def _set_required_minio_env(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setenv("MINIO_ENDPOINT", "localhost:9000") + monkeypatch.setenv("MINIO_ACCESS_KEY", "access") + monkeypatch.setenv("MINIO_SECRET_KEY", "secret") + monkeypatch.setenv("MINIO_BUCKET", "my-bucket") + + +def test_from_env_loads_all_fields(monkeypatch: pytest.MonkeyPatch) -> None: + _set_required_minio_env(monkeypatch) + config = MinioConfig.from_env(use_dotenv=False) + assert config.endpoint == "localhost:9000" + assert config.access_key == "access" + assert config.secret_key == "secret" + assert config.bucket == "my-bucket" + assert config.secure is True + + +def test_from_env_defaults_secure_to_true_when_unset( + monkeypatch: pytest.MonkeyPatch, +) -> None: + _set_required_minio_env(monkeypatch) + monkeypatch.delenv("MINIO_SECURE", raising=False) + config = MinioConfig.from_env(use_dotenv=False) + assert config.secure is True + + +@pytest.mark.parametrize( + ("value", "expected"), + [ + ("true", True), + ("1", True), + ("yes", True), + ("false", False), + ("0", False), + ("no", False), + ], +) +def test_from_env_parses_secure( + monkeypatch: pytest.MonkeyPatch, + value: str, + expected: bool, +) -> None: + _set_required_minio_env(monkeypatch) + monkeypatch.setenv("MINIO_SECURE", value) + config = MinioConfig.from_env(use_dotenv=False) + assert config.secure is expected + + +def test_from_env_raises_for_invalid_secure(monkeypatch: pytest.MonkeyPatch) -> None: + _set_required_minio_env(monkeypatch) + monkeypatch.setenv("MINIO_SECURE", "not-a-bool") + with pytest.raises(ValueError, match="MINIO_SECURE"): + MinioConfig.from_env(use_dotenv=False) + + +def test_from_env_raises_when_endpoint_missing( + monkeypatch: pytest.MonkeyPatch, +) -> None: + monkeypatch.delenv("MINIO_ENDPOINT", raising=False) + monkeypatch.setenv("MINIO_ACCESS_KEY", "access") + monkeypatch.setenv("MINIO_SECRET_KEY", "secret") + monkeypatch.setenv("MINIO_BUCKET", "my-bucket") + with pytest.raises(Exception): + MinioConfig.from_env(use_dotenv=False) diff --git a/tests/integration/object_repository_interface_test.py b/tests/unit/object_repository_interface_test.py similarity index 98% rename from tests/integration/object_repository_interface_test.py rename to tests/unit/object_repository_interface_test.py index 012545c..4e567a4 100644 --- a/tests/integration/object_repository_interface_test.py +++ b/tests/unit/object_repository_interface_test.py @@ -1,4 +1,4 @@ -"""Integration tests for ObjectRepositoryInterface.""" +"""Unit tests for ObjectRepositoryInterface.""" from io import BytesIO diff --git a/tests/unit/redis_adapter_test.py b/tests/unit/redis_adapter_test.py new file mode 100644 index 0000000..9e036be --- /dev/null +++ b/tests/unit/redis_adapter_test.py @@ -0,0 +1,71 @@ +"""Unit tests for RedisAdapter instantiation and injection.""" + +from __future__ import annotations + +from unittest.mock import MagicMock + +import pytest +import redis + +from python_repositories.adapters.redis_adapter import RedisAdapter +from python_repositories.interfaces import JsonRepositoryInterface +from tests.conftest import TEST_REDIS_CONFIG + + +def test_should_adhere_to_interface() -> None: + assert issubclass(RedisAdapter, JsonRepositoryInterface) + _ = RedisAdapter(config=TEST_REDIS_CONFIG) + + +def test_should_have_logger_when_instantiated() -> None: + adapter = RedisAdapter(config=TEST_REDIS_CONFIG) + assert hasattr(adapter, "logger") + assert adapter.logger is not None + + +def test_should_not_be_connected_when_instantiated() -> None: + adapter = RedisAdapter(config=TEST_REDIS_CONFIG) + assert adapter._client is None + assert not adapter.is_connected() + + +def test_constructs_with_injected_config_without_env( + monkeypatch: pytest.MonkeyPatch, +) -> None: + monkeypatch.delenv("REDIS_URI", raising=False) + adapter = RedisAdapter(config=TEST_REDIS_CONFIG) + assert adapter._config == TEST_REDIS_CONFIG + + +def test_constructs_with_injected_client_without_env( + monkeypatch: pytest.MonkeyPatch, +) -> None: + monkeypatch.delenv("REDIS_URI", raising=False) + mock_client = MagicMock(spec=redis.Redis) + adapter = RedisAdapter(config=TEST_REDIS_CONFIG, client=mock_client) + assert adapter._client is mock_client + assert adapter._client_injected is True + + +def test_raises_when_client_provided_without_config() -> None: + mock_client = MagicMock(spec=redis.Redis) + with pytest.raises(ValueError, match="config is required"): + RedisAdapter(client=mock_client) + + +def test_disconnect_does_not_close_injected_client() -> None: + mock_client = MagicMock(spec=redis.Redis) + adapter = RedisAdapter(config=TEST_REDIS_CONFIG, client=mock_client) + adapter.disconnect() + mock_client.close.assert_not_called() + assert adapter._client is None + + +class CustomEnvRedisAdapter(RedisAdapter): + uri_env_var_name = "CUSTOM_REDIS_URI" + + +def test_subclass_custom_env_var_name(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setenv("CUSTOM_REDIS_URI", "redis://custom:6379") + adapter = CustomEnvRedisAdapter() + assert adapter._config.uri == "redis://custom:6379" diff --git a/tests/unit/redis_config_test.py b/tests/unit/redis_config_test.py new file mode 100644 index 0000000..d902ebe --- /dev/null +++ b/tests/unit/redis_config_test.py @@ -0,0 +1,27 @@ +"""Unit tests for RedisConfig.""" + +from __future__ import annotations + +import pytest + +from python_repositories.config import RedisConfig + + +def test_from_env_loads_uri(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setenv("REDIS_URI", "redis://example:6379") + config = RedisConfig.from_env(use_dotenv=False) + assert config.uri == "redis://example:6379" + + +def test_from_env_raises_when_uri_missing(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.delenv("REDIS_URI", raising=False) + with pytest.raises(Exception): + RedisConfig.from_env(use_dotenv=False) + + +def test_from_env_respects_custom_env_var_name( + monkeypatch: pytest.MonkeyPatch, +) -> None: + monkeypatch.setenv("CUSTOM_REDIS_URI", "redis://custom:6379") + config = RedisConfig.from_env("CUSTOM_REDIS_URI", use_dotenv=False) + assert config.uri == "redis://custom:6379" diff --git a/uv.lock b/uv.lock index 7a7a3b5..bce88e2 100644 --- a/uv.lock +++ b/uv.lock @@ -1059,6 +1059,7 @@ name = "python-repositories" version = "0.4.1" source = { editable = "." } dependencies = [ + { name = "python-dotenv" }, { name = "python-utils" }, { name = "structlog" }, ] @@ -1087,6 +1088,7 @@ dev = [ [package.metadata] requires-dist = [ { name = "minio", marker = "extra == 'minio'", specifier = ">=7.2.16" }, + { name = "python-dotenv", specifier = ">=1.0.0" }, { name = "python-utils", specifier = ">=0.1.0", index = "https://gitea.lille-vemmelund.dk/api/packages/brian/pypi/simple/" }, { name = "redis", marker = "extra == 'redis'", specifier = ">=6.4.0" }, { name = "structlog", specifier = ">=25.4.0" },