diff --git a/README.md b/README.md index 10a8f3c..7440821 100644 --- a/README.md +++ b/README.md @@ -1,16 +1,105 @@ # python-repositories -Various python repository interfaces exposed as a python package. +Unified repository interfaces and technology-specific adapters for Python projects. + +Subclass an adapter in your own repository to add domain-specific methods while reusing connection management and CRUD operations. + +## Architecture + +| Layer | Responsibility | +|-------|----------------| +| **Interfaces** | Abstract contracts for connection, context, and CRUD | +| **Adapters** | Technology-specific base classes (`RedisAdapter`, `MinioAdapter`) | +| **Your project** | Subclass an adapter and add domain methods | ## Optional dependencies -This package supports interacting with multiple different backends: - -- Redis -- MinIO - -To add support for a specific backend install this package with one or more of these optional packages: +Install with the extras you need: ```bash -uv add python-repositories[redis, minio] +uv add python-repositories[redis] +uv add python-repositories[minio] +uv add python-repositories[redis,minio] ``` + +### Redis (`JsonRepositoryInterface`) + +Requires Redis with the RedisJSON module (e.g. redis-stack). + +| Environment variable | Description | +|---------------------|-------------| +| `REDIS_URI` | Redis connection URL (e.g. `redis://localhost:6379`) | + +### MinIO (`ObjectRepositoryInterface`) + +| Environment variable | Description | +|---------------------|-------------| +| `MINIO_ENDPOINT` | MinIO server endpoint | +| `MINIO_ACCESS_KEY` | Access key | +| `MINIO_SECRET_KEY` | Secret key | +| `MINIO_BUCKET` | Bucket name (created on connect if missing) | + +## Quick start + +### JSON documents with Redis + +```python +from python_repositories.examples.user_json_repository import UserJsonRepository + +with UserJsonRepository() as repo: + repo.save_user("alice", {"name": "Alice", "email": "alice@example.com"}) + user = repo.get_user("alice") + repo.delete_user("alice") +``` + +### Binary objects with MinIO + +```python +from io import BytesIO + +from python_repositories.examples.artifact_object_repository import ( + ArtifactObjectRepository, +) + +with ArtifactObjectRepository() as repo: + repo.store_artifact("report-1", BytesIO(b"pdf bytes here")) + data = repo.get_artifact("report-1") +``` + +### Subclassing in your own project + +```python +from python_repositories import RedisAdapter + +class UserRepository(RedisAdapter): + def _key(self, user_id: str) -> str: + return f"user:{user_id}" + + def get_user(self, user_id: str) -> dict | None: + return self.get(self._key(user_id)) + + def save_user(self, user_id: str, user: dict) -> None: + self.set(self._key(user_id), user) +``` + +## Public API + +```python +from python_repositories import ( + ConnectionAwareInterface, + ContextAwareInterface, + JsonRepositoryInterface, + ObjectRepositoryInterface, + RedisAdapter, + MinioAdapter, +) +``` + +## Development + +```bash +uv sync --all-extras +uv run pytest tests/integration/ -v +``` + +Integration tests require Docker (testcontainers). diff --git a/pyproject.toml b/pyproject.toml index 6f87f89..6f1a724 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -26,6 +26,14 @@ minio = [ "minio>=7.2.16", ] +[build-system] +requires = ["hatchling"] +build-backend = "hatchling.build" + +[tool.pytest.ini_options] +testpaths = ["tests"] +pythonpath = ["."] + [tool.uv.sources] python-utils = { index = "gitea" } diff --git a/python_repositories/__init__.py b/python_repositories/__init__.py index 670152d..bb5154d 100644 --- a/python_repositories/__init__.py +++ b/python_repositories/__init__.py @@ -1,6 +1,18 @@ """python_repositories: Unified repository interfaces and adapters.""" -from . import adapters -from . import interfaces +from .adapters import MinioAdapter, RedisAdapter +from .interfaces import ( + ConnectionAwareInterface, + ContextAwareInterface, + JsonRepositoryInterface, + ObjectRepositoryInterface, +) -__all__ = ["adapters", "interfaces"] +__all__ = [ + "ConnectionAwareInterface", + "ContextAwareInterface", + "JsonRepositoryInterface", + "ObjectRepositoryInterface", + "RedisAdapter", + "MinioAdapter", +] diff --git a/python_repositories/adapters/minio_adapter.py b/python_repositories/adapters/minio_adapter.py index d391a95..e0053e1 100644 --- a/python_repositories/adapters/minio_adapter.py +++ b/python_repositories/adapters/minio_adapter.py @@ -8,8 +8,9 @@ import structlog from python_utils import check_env from python_repositories.interfaces import ( - ContextAwareInterface, ConnectionAwareInterface, + ContextAwareInterface, + ObjectRepositoryInterface, ) # Handle optional dependencies @@ -18,6 +19,7 @@ if find_spec("minio") is not None: class MinioAdapter( + ObjectRepositoryInterface, ContextAwareInterface, ConnectionAwareInterface, ): @@ -113,7 +115,7 @@ class MinioAdapter( self.logger.debug(res) return res - def _put( + def put( self, object_name: str, data: BytesIO, @@ -147,7 +149,7 @@ class MinioAdapter( f"Put object '{object_name}' into bucket '{self._bucket_name}'" ) - def _get(self, object_name: str) -> BytesIO | None: + def get(self, object_name: str) -> BytesIO | None: """Get an object from the Minio bucket.""" # Check input if not isinstance(object_name, str) or len(object_name) == 0: @@ -182,7 +184,7 @@ class MinioAdapter( self.logger.error(repr(exc)) return None - def _delete(self, object_name: str) -> None: + def delete(self, object_name: str) -> None: """Delete an object from the Minio bucket.""" # Check input if not isinstance(object_name, str) or len(object_name) == 0: @@ -200,7 +202,7 @@ class MinioAdapter( f"Deleted object '{object_name}' from bucket '{self._bucket_name}'" ) - def _list_objects(self, prefix: str = "") -> list[str]: + def list_objects(self, prefix: str = "") -> list[str]: """List objects in the Minio bucket with an optional prefix.""" # Check input if not isinstance(prefix, str): diff --git a/python_repositories/adapters/redis_adapter.py b/python_repositories/adapters/redis_adapter.py index 8b3ed8b..5e0c455 100644 --- a/python_repositories/adapters/redis_adapter.py +++ b/python_repositories/adapters/redis_adapter.py @@ -9,8 +9,9 @@ import structlog from python_utils import check_env from python_repositories.interfaces import ( - ContextAwareInterface, ConnectionAwareInterface, + ContextAwareInterface, + JsonRepositoryInterface, ) # Handle optional dependencies @@ -20,6 +21,7 @@ if find_spec("redis") is not None: class RedisAdapter( + JsonRepositoryInterface, ContextAwareInterface, ConnectionAwareInterface, ): @@ -92,7 +94,7 @@ class RedisAdapter( self.logger.debug(res) return res - def _set(self, key: str, data: dict) -> None: + def set(self, key: str, data: dict) -> None: """Set a JSON object in Redis.""" # Check input if not isinstance(key, str) or len(key) == 0: @@ -106,7 +108,7 @@ class RedisAdapter( self._client.json().set(key, self.path, data) self.logger.debug(f"Set {key} to {data}") - def _get(self, key: str) -> dict | None: + def get(self, key: str) -> dict | None: """Get a JSON object from Redis.""" # Check input if not isinstance(key, str) or len(key) == 0: @@ -122,7 +124,7 @@ class RedisAdapter( self.logger.debug(f"Got {data} from {key}") return data - def _delete(self, key: str) -> None: + def delete(self, key: str) -> None: """Delete data from Redis.""" # Check input if not isinstance(key, str) or len(key) == 0: @@ -134,7 +136,7 @@ class RedisAdapter( self._client.json().delete(key) self.logger.debug(f"Deleted {key}") - def _list_keys(self, pattern: str) -> list[str]: + def list_keys(self, pattern: str) -> list[str]: """List keys in Redis matching a pattern.""" # Check input if not isinstance(pattern, str) or len(pattern) == 0: diff --git a/python_repositories/examples/__init__.py b/python_repositories/examples/__init__.py new file mode 100644 index 0000000..a219e6f --- /dev/null +++ b/python_repositories/examples/__init__.py @@ -0,0 +1 @@ +"""Example domain repositories built on technology adapters.""" diff --git a/python_repositories/examples/artifact_object_repository.py b/python_repositories/examples/artifact_object_repository.py new file mode 100644 index 0000000..420f5bb --- /dev/null +++ b/python_repositories/examples/artifact_object_repository.py @@ -0,0 +1,23 @@ +"""Example domain repository backed by MinIO objects.""" + +from io import BytesIO + +from python_repositories.adapters.minio_adapter import MinioAdapter + + +class ArtifactObjectRepository(MinioAdapter): + """Example: domain repository backed by MinIO objects.""" + + def _object_name(self, artifact_id: str) -> str: + return f"artifacts/{artifact_id}" + + def get_artifact(self, artifact_id: str) -> BytesIO | None: + return self.get(self._object_name(artifact_id)) + + def store_artifact( + self, + artifact_id: str, + data: BytesIO, + content_type: str = "application/octet-stream", + ) -> None: + self.put(self._object_name(artifact_id), data, content_type) diff --git a/python_repositories/examples/user_json_repository.py b/python_repositories/examples/user_json_repository.py new file mode 100644 index 0000000..9f9962f --- /dev/null +++ b/python_repositories/examples/user_json_repository.py @@ -0,0 +1,19 @@ +"""Example domain repository backed by Redis JSON.""" + +from python_repositories.adapters.redis_adapter import RedisAdapter + + +class UserJsonRepository(RedisAdapter): + """Example: domain repository backed by Redis JSON.""" + + def _key(self, user_id: str) -> str: + return f"user:{user_id}" + + def get_user(self, user_id: str) -> dict | None: + return self.get(self._key(user_id)) + + def save_user(self, user_id: str, user: dict) -> None: + self.set(self._key(user_id), user) + + def delete_user(self, user_id: str) -> None: + self.delete(self._key(user_id)) diff --git a/python_repositories/interfaces/__init__.py b/python_repositories/interfaces/__init__.py index 9d47a8d..c643fa6 100644 --- a/python_repositories/interfaces/__init__.py +++ b/python_repositories/interfaces/__init__.py @@ -2,8 +2,16 @@ from .connection_aware_interface import ( ConnectionAwareInterface as ConnectionAwareInterface, ) from .context_aware_interface import ContextAwareInterface as ContextAwareInterface +from .json_repository_interface import ( + JsonRepositoryInterface as JsonRepositoryInterface, +) +from .object_repository_interface import ( + ObjectRepositoryInterface as ObjectRepositoryInterface, +) __all__ = [ "ConnectionAwareInterface", "ContextAwareInterface", + "JsonRepositoryInterface", + "ObjectRepositoryInterface", ] diff --git a/python_repositories/interfaces/connection_aware_interface.py b/python_repositories/interfaces/connection_aware_interface.py index 58ea48c..a1d1cbb 100644 --- a/python_repositories/interfaces/connection_aware_interface.py +++ b/python_repositories/interfaces/connection_aware_interface.py @@ -9,15 +9,15 @@ class ConnectionAwareInterface(ABC): @abstractmethod def connect(self) -> None: """Connect to resource.""" - raise NotImplementedError() + ... @abstractmethod def disconnect(self) -> None: """Disconnect from resource.""" - raise NotImplementedError() + ... @property @abstractmethod def is_connected(self) -> bool: """Check if connected to resource.""" - raise NotImplementedError() + ... diff --git a/python_repositories/interfaces/context_aware_interface.py b/python_repositories/interfaces/context_aware_interface.py index 63829be..eaf5f71 100644 --- a/python_repositories/interfaces/context_aware_interface.py +++ b/python_repositories/interfaces/context_aware_interface.py @@ -1,4 +1,4 @@ -"""Definition of ConnectionAwareInterface abstract base class.""" +"""Definition of ContextAwareInterface abstract base class.""" from __future__ import annotations @@ -6,16 +6,16 @@ from abc import ABC, abstractmethod class ContextAwareInterface(ABC): - """Interface that defined context-related methods.""" + """Interface that defines context-related methods.""" @abstractmethod def __enter__(self) -> ContextAwareInterface: """Enter the context.""" - raise NotImplementedError() + ... @abstractmethod def __exit__( self, exc_type: type | None, exc_val: object | None, exc_tb: object | None ) -> None: """Exit the context.""" - raise NotImplementedError() + ... diff --git a/python_repositories/interfaces/json_repository_interface.py b/python_repositories/interfaces/json_repository_interface.py new file mode 100644 index 0000000..1e271e4 --- /dev/null +++ b/python_repositories/interfaces/json_repository_interface.py @@ -0,0 +1,27 @@ +"""Definition of JsonRepositoryInterface abstract base class.""" + +from abc import ABC, abstractmethod + + +class JsonRepositoryInterface(ABC): + """Interface that defines JSON document CRUD methods.""" + + @abstractmethod + def get(self, key: str) -> dict | None: + """Get a JSON object by key.""" + ... + + @abstractmethod + def set(self, key: str, data: dict) -> None: + """Set a JSON object by key.""" + ... + + @abstractmethod + def delete(self, key: str) -> None: + """Delete a JSON object by key.""" + ... + + @abstractmethod + def list_keys(self, pattern: str) -> list[str]: + """List keys matching a glob pattern.""" + ... diff --git a/python_repositories/interfaces/object_repository_interface.py b/python_repositories/interfaces/object_repository_interface.py new file mode 100644 index 0000000..3fd58c0 --- /dev/null +++ b/python_repositories/interfaces/object_repository_interface.py @@ -0,0 +1,33 @@ +"""Definition of ObjectRepositoryInterface abstract base class.""" + +from abc import ABC, abstractmethod +from io import BytesIO + + +class ObjectRepositoryInterface(ABC): + """Interface that defines binary object CRUD methods.""" + + @abstractmethod + def get(self, object_name: str) -> BytesIO | None: + """Get an object by name.""" + ... + + @abstractmethod + def put( + self, + object_name: str, + data: BytesIO, + content_type: str = "application/octet-stream", + ) -> None: + """Put an object by name.""" + ... + + @abstractmethod + def delete(self, object_name: str) -> None: + """Delete an object by name.""" + ... + + @abstractmethod + def list_objects(self, prefix: str = "") -> list[str]: + """List object names with an optional prefix.""" + ... diff --git a/tests/integration/connection_aware_interface_test.py b/tests/integration/connection_aware_interface_test.py index 23e9d62..d7d2e72 100644 --- a/tests/integration/connection_aware_interface_test.py +++ b/tests/integration/connection_aware_interface_test.py @@ -54,66 +54,3 @@ def test_instantiation_fails_when_is_connected_not_implemented() -> None: with pytest.raises(TypeError): _ = Incomplete() # type: ignore - - -def test_connect_raises_not_implemented_if_not_overwritten() -> None: - """Test that connect raises NotImplementedError if not implemented.""" - - class Incomplete(ConnectionAwareInterface): - """A class that does not implement connect.""" - - def connect(self) -> None: - super().connect() # type: ignore - - def disconnect(self) -> None: - pass - - @property - def is_connected(self) -> bool: - return False - - instance = Incomplete() - with pytest.raises(NotImplementedError): - instance.connect() - - -def test_disconnect_raises_not_implemented_if_not_overwritten() -> None: - """Test that disconnect raises NotImplementedError if not implemented.""" - - class Incomplete(ConnectionAwareInterface): - """A class that does not implement disconnect.""" - - def connect(self) -> None: - pass - - def disconnect(self) -> None: - super().disconnect() # type: ignore - - @property - def is_connected(self) -> bool: - return False - - instance = Incomplete() - with pytest.raises(NotImplementedError): - instance.disconnect() - - -def test_is_connected_raises_not_implemented_if_not_overwritten() -> None: - """Test that is_connected raises NotImplementedError if not implemented.""" - - class Incomplete(ConnectionAwareInterface): - """A class that does not implement is_connected.""" - - def connect(self) -> None: - pass - - def disconnect(self) -> None: - pass - - @property - def is_connected(self) -> bool: - return super().is_connected # type: ignore - - instance = Incomplete() - with pytest.raises(NotImplementedError): - _ = instance.is_connected diff --git a/tests/integration/context_aware_interface_test.py b/tests/integration/context_aware_interface_test.py index ca6aa48..63cf953 100644 --- a/tests/integration/context_aware_interface_test.py +++ b/tests/integration/context_aware_interface_test.py @@ -1,6 +1,7 @@ """Integration tests for ContextAwareInterface.""" from __future__ import annotations + import pytest from python_repositories.interfaces.context_aware_interface import ContextAwareInterface @@ -31,42 +32,3 @@ def test_instantiation_fails_when_exit_not_implemented() -> None: with pytest.raises(TypeError): _ = Incomplete() # type: ignore - - -def test_enter_raises_not_implemented_if_not_overwritten() -> None: - """Test that __enter__ raises NotImplementedError if not implemented.""" - - class Incomplete(ContextAwareInterface): - """A class that does not implement __enter__.""" - - def __enter__(self) -> Incomplete: - super().__enter__() # type: ignore - return self - - def __exit__( - self, exc_type: type | None, exc_val: object | None, exc_tb: object | None - ) -> None: - pass - - instance = Incomplete() - with pytest.raises(NotImplementedError): - instance.__enter__() - - -def test_exit_raises_not_implemented_if_not_overwritten() -> None: - """Test that __exit__ raises NotImplementedError if not implemented.""" - - class Incomplete(ContextAwareInterface): - """A class that does not implement __exit__.""" - - def __enter__(self) -> ContextAwareInterface: - return self - - def __exit__( - self, exc_type: type | None, exc_val: object | None, exc_tb: object | None - ) -> None: - super().__exit__(exc_type, exc_val, exc_tb) # type: ignore - - instance = Incomplete() - with pytest.raises(NotImplementedError): - instance.__exit__(None, None, None) diff --git a/tests/integration/examples_test.py b/tests/integration/examples_test.py new file mode 100644 index 0000000..4586bcd --- /dev/null +++ b/tests/integration/examples_test.py @@ -0,0 +1,57 @@ +"""Integration tests for example domain repositories.""" + +from collections.abc import Generator +from io import BytesIO +import random + +import pytest +from python_repositories.examples.artifact_object_repository import ( + ArtifactObjectRepository, +) +from python_repositories.examples.user_json_repository import UserJsonRepository + + +@pytest.fixture(scope="module") +def user_data() -> Generator[dict[str, str]]: + """Provide sample user data for tests.""" + yield {"name": "Alice", "email": "alice@example.com"} + + +@pytest.fixture(scope="module") +def artifact_data() -> Generator[BytesIO]: + """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.""" + with UserJsonRepository() as repo: + repo.save_user("alice", user_data) + assert repo.get_user("alice") == user_data + + +def test_user_json_repository_delete( + redis_container: str, + user_data: dict[str, str], +) -> None: + """Test that UserJsonRepository can delete a user.""" + with UserJsonRepository() as repo: + repo.save_user("alice", user_data) + repo.delete_user("alice") + assert repo.get_user("alice") is None + + +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.""" + with ArtifactObjectRepository() as repo: + repo.store_artifact("report-1", artifact_data) + received = repo.get_artifact("report-1") + assert received is not None + artifact_data.seek(0) + assert received.read() == artifact_data.read() diff --git a/tests/integration/json_repository_interface_test.py b/tests/integration/json_repository_interface_test.py new file mode 100644 index 0000000..0b8c458 --- /dev/null +++ b/tests/integration/json_repository_interface_test.py @@ -0,0 +1,82 @@ +"""Integration tests for JsonRepositoryInterface.""" + +import pytest +from python_repositories.interfaces.json_repository_interface import ( + JsonRepositoryInterface, +) + + +def test_instantiation_fails_when_get_not_implemented() -> None: + """Test that instantiation fails if get is not implemented.""" + + class Incomplete(JsonRepositoryInterface): + """A class that does not implement get.""" + + def set(self, key: str, data: dict) -> None: + pass + + def delete(self, key: str) -> None: + pass + + def list_keys(self, pattern: str) -> list[str]: + return [] + + with pytest.raises(TypeError): + _ = Incomplete() # type: ignore + + +def test_instantiation_fails_when_set_not_implemented() -> None: + """Test that instantiation fails if set is not implemented.""" + + class Incomplete(JsonRepositoryInterface): + """A class that does not implement set.""" + + def get(self, key: str) -> dict | None: + return None + + def delete(self, key: str) -> None: + pass + + def list_keys(self, pattern: str) -> list[str]: + return [] + + with pytest.raises(TypeError): + _ = Incomplete() # type: ignore + + +def test_instantiation_fails_when_delete_not_implemented() -> None: + """Test that instantiation fails if delete is not implemented.""" + + class Incomplete(JsonRepositoryInterface): + """A class that does not implement delete.""" + + def get(self, key: str) -> dict | None: + return None + + def set(self, key: str, data: dict) -> None: + pass + + def list_keys(self, pattern: str) -> list[str]: + return [] + + with pytest.raises(TypeError): + _ = Incomplete() # type: ignore + + +def test_instantiation_fails_when_list_keys_not_implemented() -> None: + """Test that instantiation fails if list_keys is not implemented.""" + + class Incomplete(JsonRepositoryInterface): + """A class that does not implement list_keys.""" + + def get(self, key: str) -> dict | None: + return None + + def set(self, key: str, data: dict) -> None: + pass + + def delete(self, key: str) -> None: + pass + + with pytest.raises(TypeError): + _ = Incomplete() # type: ignore diff --git a/tests/integration/minio_adapter_test.py b/tests/integration/minio_adapter_test.py index 95ad882..fa66a47 100644 --- a/tests/integration/minio_adapter_test.py +++ b/tests/integration/minio_adapter_test.py @@ -1,6 +1,3 @@ -# pylint: disable=protected-access -# The above line disables pylint's protected member access warnings for this file, -# allowing tests to access MinioAdapter's internal methods as needed for integration testing. """Integration tests for the MinioAdapter.""" from collections.abc import Generator @@ -13,6 +10,7 @@ import os import logging from minio import S3Error from python_repositories.adapters.minio_adapter import MinioAdapter +from python_repositories.interfaces import ObjectRepositoryInterface def same_data( @@ -103,7 +101,7 @@ def clear_minio( def test_should_adhere_to_interface() -> None: """Test that the MinioAdapter adheres to the expected interface.""" - # Instantiation fails if interface not adhered to + assert issubclass(MinioAdapter, ObjectRepositoryInterface) _ = MinioAdapter() @@ -187,7 +185,7 @@ def test_should_get_data( # Arrange object_name, expected_data = data_in_minio # Act - received_data = minio_adapter._get(object_name) + received_data = minio_adapter.get(object_name) # Assert assert received_data is not None assert same_data(expected_data, received_data) @@ -200,7 +198,7 @@ def test_should_get_none_for_nonexistent_object( # Arrange object_name = "nonexistent_object" # Act - received_data = minio_adapter._get(object_name) + received_data = minio_adapter.get(object_name) # Assert assert received_data is None @@ -214,7 +212,7 @@ def test_should_raise_value_error_on_invalid_get_object_name( # Act & Assert for object_name in invalid_object_names: with pytest.raises(ValueError): - minio_adapter._get(object_name) # type: ignore + minio_adapter.get(object_name) # type: ignore def test_should_raise_connection_error_on_get_when_not_connected( @@ -225,7 +223,7 @@ def test_should_raise_connection_error_on_get_when_not_connected( adapter = MinioAdapter() # not connected # Act & Assert with pytest.raises(ConnectionError): - adapter._get("some_object") + adapter.get("some_object") def test_should_log_warning_when_getting_nonexistent_object( @@ -249,7 +247,7 @@ def test_should_log_warning_when_getting_nonexistent_object( object_name = "missing-object" # Act with caplog.at_level("WARNING"): - result = adapter._get(object_name) + result = adapter.get(object_name) # Assert assert result is None assert ( @@ -280,7 +278,7 @@ def test_should_log_error_when_getting_with_s3error_other_than_no_such_key( object_name = "missing-object" # Act with caplog.at_level("ERROR"): - result = adapter._get(object_name) + result = adapter.get(object_name) # Assert assert result is None assert repr(other_s3error) in caplog.text @@ -299,7 +297,7 @@ def test_should_log_error_when_getting_with_general_exception( object_name = "missing-object" # Act with caplog.at_level("ERROR"): - result = adapter._get(object_name) + result = adapter.get(object_name) # Assert assert result is None assert repr(general_exception) in caplog.text @@ -312,12 +310,12 @@ def test_should_put_data( """Test that the MinioAdapter can put data into a bucket.""" # Arrange object_name = "new_test_object" - received_data = minio_adapter._get(object_name) + received_data = minio_adapter.get(object_name) assert received_data is None # ensure object does not exist yet # Act - minio_adapter._put(object_name, data) + minio_adapter.put(object_name, data) # Assert - received_data = minio_adapter._get(object_name) + received_data = minio_adapter.get(object_name) assert received_data is not None assert same_data(data, received_data) # Cleanup @@ -333,13 +331,13 @@ def test_should_update_data( # Arrange object_name, _ = data_in_minio new_data = BytesIO(random.randbytes(2**21)) # 2 MiB - received_data = minio_adapter._get(object_name) + 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) + minio_adapter.put(object_name, new_data) # Assert - received_data = minio_adapter._get(object_name) + received_data = minio_adapter.get(object_name) assert received_data is not None assert same_data(new_data, received_data) @@ -354,7 +352,7 @@ def test_should_raise_value_error_on_invalid_put_object_name( # Act & Assert for object_name in invalid_object_names: with pytest.raises(ValueError): - minio_adapter._put(object_name, data) # type: ignore + minio_adapter.put(object_name, data) # type: ignore def test_should_raise_value_error_on_invalid_put_data( @@ -367,7 +365,7 @@ def test_should_raise_value_error_on_invalid_put_data( # Act & Assert for data in invalid_data: with pytest.raises(ValueError): - minio_adapter._put(object_name, data) # type: ignore + minio_adapter.put(object_name, data) # type: ignore def test_should_raise_value_error_on_invalid_put_content_type( @@ -381,7 +379,7 @@ def test_should_raise_value_error_on_invalid_put_content_type( # Act & Assert for content_type in invalid_content_types: with pytest.raises(ValueError): - minio_adapter._put(object_name, data, content_type) # type: ignore + minio_adapter.put(object_name, data, content_type) # type: ignore def test_should_raise_connection_error_on_put_when_not_connected( @@ -393,7 +391,7 @@ def test_should_raise_connection_error_on_put_when_not_connected( adapter = MinioAdapter() # not connected # Act & Assert with pytest.raises(ConnectionError): - adapter._put("some_object", data) + adapter.put("some_object", data) def test_should_delete_object( @@ -403,12 +401,12 @@ def test_should_delete_object( """Test that the MinioAdapter can delete an object from a bucket.""" # Arrange object_name, _ = data_in_minio - received_data = minio_adapter._get(object_name) + received_data = minio_adapter.get(object_name) assert received_data is not None # ensure object exists # Act - minio_adapter._delete(object_name) + minio_adapter.delete(object_name) # Assert - received_data = minio_adapter._get(object_name) + received_data = minio_adapter.get(object_name) assert received_data is None @@ -421,7 +419,7 @@ def test_should_raise_value_error_on_invalid_delete_object_name( # Act & Assert for object_name in invalid_object_names: with pytest.raises(ValueError): - minio_adapter._delete(object_name) # type: ignore + minio_adapter.delete(object_name) # type: ignore def test_should_raise_connection_error_on_delete_when_not_connected( @@ -432,7 +430,7 @@ def test_should_raise_connection_error_on_delete_when_not_connected( adapter = MinioAdapter() # not connected # Act & Assert with pytest.raises(ConnectionError): - adapter._delete("some_object") + adapter.delete("some_object") def test_should_list_objects( @@ -444,9 +442,9 @@ def test_should_list_objects( object_name, _ = data_in_minio new_data = BytesIO(random.randbytes(2**21)) # 2 MiB new_data_name = "another_test_object" - minio_adapter._put(new_data_name, new_data) + minio_adapter.put(new_data_name, new_data) # Act - objects = minio_adapter._list_objects() + objects = minio_adapter.list_objects() # Assert assert isinstance(objects, list) assert len(objects) == 2 @@ -463,10 +461,10 @@ def test_should_list_objects_with_prefix( object_name, _ = data_in_minio new_data = BytesIO(random.randbytes(2**21)) # 2 MiB new_data_name = "prefix_test_object" - minio_adapter._put(new_data_name, new_data) + minio_adapter.put(new_data_name, new_data) prefix = "prefix_" # Act - objects = minio_adapter._list_objects(prefix) + objects = minio_adapter.list_objects(prefix) # Assert assert isinstance(objects, list) assert len(objects) == 1 @@ -483,7 +481,7 @@ def test_should_raise_value_error_on_invalid_list_objects_prefix( # Act & Assert for prefix in invalid_prefixes: with pytest.raises(ValueError): - minio_adapter._list_objects(prefix) # type: ignore + minio_adapter.list_objects(prefix) # type: ignore def test_should_raise_connection_error_on_list_objects_when_not_connected( @@ -494,7 +492,7 @@ def test_should_raise_connection_error_on_list_objects_when_not_connected( adapter = MinioAdapter() # not connected # Act & Assert with pytest.raises(ConnectionError): - adapter._list_objects() + adapter.list_objects() # allows local debugging by running file as script diff --git a/tests/integration/object_repository_interface_test.py b/tests/integration/object_repository_interface_test.py new file mode 100644 index 0000000..012545c --- /dev/null +++ b/tests/integration/object_repository_interface_test.py @@ -0,0 +1,99 @@ +"""Integration tests for ObjectRepositoryInterface.""" + +from io import BytesIO + +import pytest +from python_repositories.interfaces.object_repository_interface import ( + ObjectRepositoryInterface, +) + + +def test_instantiation_fails_when_get_not_implemented() -> None: + """Test that instantiation fails if get is not implemented.""" + + class Incomplete(ObjectRepositoryInterface): + """A class that does not implement get.""" + + def put( + self, + object_name: str, + data: BytesIO, + content_type: str = "application/octet-stream", + ) -> None: + pass + + def delete(self, object_name: str) -> None: + pass + + def list_objects(self, prefix: str = "") -> list[str]: + return [] + + with pytest.raises(TypeError): + _ = Incomplete() # type: ignore + + +def test_instantiation_fails_when_put_not_implemented() -> None: + """Test that instantiation fails if put is not implemented.""" + + class Incomplete(ObjectRepositoryInterface): + """A class that does not implement put.""" + + def get(self, object_name: str) -> BytesIO | None: + return None + + def delete(self, object_name: str) -> None: + pass + + def list_objects(self, prefix: str = "") -> list[str]: + return [] + + with pytest.raises(TypeError): + _ = Incomplete() # type: ignore + + +def test_instantiation_fails_when_delete_not_implemented() -> None: + """Test that instantiation fails if delete is not implemented.""" + + class Incomplete(ObjectRepositoryInterface): + """A class that does not implement delete.""" + + def get(self, object_name: str) -> BytesIO | None: + return None + + def put( + self, + object_name: str, + data: BytesIO, + content_type: str = "application/octet-stream", + ) -> None: + pass + + def list_objects(self, prefix: str = "") -> list[str]: + return [] + + with pytest.raises(TypeError): + _ = Incomplete() # type: ignore + + +def test_instantiation_fails_when_list_objects_not_implemented() -> None: + """Test that instantiation fails if list_objects is not implemented.""" + + class Incomplete(ObjectRepositoryInterface): + """A class that does not implement list_objects.""" + + def get(self, object_name: str) -> BytesIO | None: + return None + + def put( + self, + object_name: str, + data: BytesIO, + content_type: str = "application/octet-stream", + ) -> None: + pass + + def delete(self, object_name: str) -> None: + pass + + with pytest.raises(TypeError): + _ = Incomplete() # type: ignore diff --git a/tests/integration/redis_adapter_test.py b/tests/integration/redis_adapter_test.py index 293ae9a..95ab9bb 100644 --- a/tests/integration/redis_adapter_test.py +++ b/tests/integration/redis_adapter_test.py @@ -1,6 +1,3 @@ -# pylint: disable=protected-access -# The above line disables pylint's protected member access warnings for this file, -# allowing tests to access RedisAdapter's internal methods as needed for integration testing. """Integration tests for the RedisAdapter.""" from collections.abc import Generator @@ -8,6 +5,7 @@ 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 @pytest.fixture(scope="module") @@ -50,7 +48,7 @@ def clear_redis(raw_redis_client: redis.Redis) -> None: def test_should_adhere_to_interface(redis_container: str) -> None: """Test that the RedisAdapter adheres to the expected interface.""" - # Instantiation fails if interface not adhered to + assert issubclass(RedisAdapter, JsonRepositoryInterface) _ = RedisAdapter() @@ -134,7 +132,7 @@ def test_should_get_value( # Arrange key, data = data_in_redis # Act - value = redis_adapter._get(key) + value = redis_adapter.get(key) # Assert assert value is not None assert value == data @@ -145,7 +143,7 @@ def test_should_get_none_for_missing_key( ) -> None: """Test that getting a non-existent key returns None.""" # Act - value = redis_adapter._get("nonexistent_key") + value = redis_adapter.get("nonexistent_key") # Assert assert value is None @@ -159,7 +157,7 @@ def test_should_raise_value_error_on_invalid_get_key( # Act & Assert for key in invalid_keys: with pytest.raises(ValueError): - redis_adapter._get(key) # type: ignore + redis_adapter.get(key) # type: ignore def test_should_raise_connection_error_on_get_when_not_connected( @@ -170,7 +168,7 @@ def test_should_raise_connection_error_on_get_when_not_connected( adapter = RedisAdapter() # not connected # Act & Assert with pytest.raises(ConnectionError): - adapter._get("some_key") + adapter.get("some_key") def test_should_set_value( @@ -180,12 +178,12 @@ def test_should_set_value( """Test that the RedisAdapter can set a value.""" # Arrange key = "test_key" - received_data = redis_adapter._get(key) + received_data = redis_adapter.get(key) assert received_data is None # Ensure key does not exist # Act - redis_adapter._set(key, data) + redis_adapter.set(key, data) # Assert - received_data = redis_adapter._get(key) + received_data = redis_adapter.get(key) assert received_data is not None assert received_data == data @@ -198,13 +196,13 @@ def test_should_update_value( # Arrange key, _ = data_in_redis new_data = {"new_key": "new_value"} - received_data = redis_adapter._get(key) + received_data = redis_adapter.get(key) assert received_data is not None assert received_data != new_data # Act - redis_adapter._set(key, new_data) + redis_adapter.set(key, new_data) # Assert - assert redis_adapter._get(key) == new_data + assert redis_adapter.get(key) == new_data def test_should_raise_value_error_on_invalid_set_key( @@ -217,7 +215,7 @@ def test_should_raise_value_error_on_invalid_set_key( # Act & Assert for key in invalid_keys: with pytest.raises(ValueError): - redis_adapter._set(key, data) # type: ignore + redis_adapter.set(key, data) # type: ignore def test_should_raise_value_error_on_invalid_set_data( @@ -230,7 +228,7 @@ def test_should_raise_value_error_on_invalid_set_data( # Act & Assert for data in invalid_data: with pytest.raises(ValueError): - redis_adapter._set(key, data) # type: ignore + redis_adapter.set(key, data) # type: ignore def test_should_raise_connection_error_on_set_when_not_connected( @@ -243,7 +241,7 @@ def test_should_raise_connection_error_on_set_when_not_connected( key = "test_key" # Act & Assert with pytest.raises(ConnectionError): - adapter._set(key, data) + adapter.set(key, data) def test_should_delete_key( @@ -253,12 +251,12 @@ def test_should_delete_key( """Test that deleting a key removes it from Redis.""" # Arrange key, _ = data_in_redis - received_data = redis_adapter._get(key) + received_data = redis_adapter.get(key) assert received_data is not None # Ensure key exists # Act - redis_adapter._delete(key) + redis_adapter.delete(key) # Assert - assert redis_adapter._get(key) is None + assert redis_adapter.get(key) is None def test_should_raise_value_error_on_invalid_delete_key( @@ -270,7 +268,7 @@ def test_should_raise_value_error_on_invalid_delete_key( # Act & Assert for key in invalid_keys: with pytest.raises(ValueError): - redis_adapter._delete(key) # type: ignore + redis_adapter.delete(key) # type: ignore def test_should_raise_connection_error_on_delete_when_not_connected( @@ -281,7 +279,7 @@ def test_should_raise_connection_error_on_delete_when_not_connected( adapter = RedisAdapter() # Act & Assert with pytest.raises(ConnectionError): - adapter._delete("some_key") + adapter.delete("some_key") def test_should_list_keys( @@ -289,10 +287,10 @@ def test_should_list_keys( ) -> None: """Test listing keys matching a pattern returns correct keys.""" # Arrange - redis_adapter._set("key1", {"a": 1}) - redis_adapter._set("key2", {"b": 2}) + redis_adapter.set("key1", {"a": 1}) + redis_adapter.set("key2", {"b": 2}) # Act - keys = redis_adapter._list_keys("key*") + keys = redis_adapter.list_keys("key*") # Assert assert set(keys) == {"key1", "key2"} @@ -306,7 +304,7 @@ def test_should_raise_value_error_on_invalid_list_keys_pattern( # Act & Assert for pattern in invalid_patterns: with pytest.raises(ValueError): - redis_adapter._list_keys(pattern) # type: ignore + redis_adapter.list_keys(pattern) # type: ignore def test_should_raise_connection_error_on_list_keys_when_not_connected( @@ -317,7 +315,7 @@ def test_should_raise_connection_error_on_list_keys_when_not_connected( adapter = RedisAdapter() # Act & Assert with pytest.raises(ConnectionError): - adapter._list_keys("some_pattern") + adapter.list_keys("some_pattern") # allows local debugging by running file as script diff --git a/uv.lock b/uv.lock index ba00dc3..ce86470 100644 --- a/uv.lock +++ b/uv.lock @@ -806,7 +806,7 @@ wheels = [ [[package]] name = "python-repositories" version = "0.1.0" -source = { virtual = "." } +source = { editable = "." } dependencies = [ { name = "python-utils" }, { name = "structlog" },