Add public repository interfaces and subclassable adapter CRUD API.
Define split JSON and object repository ABCs, promote adapter methods to public CRUD, add example domain repositories, modernize interface stubs, and make the package installable for tests. Co-authored-by: Cursor <[email protected]>
This commit is contained in:
co-authored by
Cursor
parent
f4280f15c1
commit
e98fd3b90d
@@ -1,16 +1,105 @@
|
|||||||
# python-repositories
|
# 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
|
## Optional dependencies
|
||||||
|
|
||||||
This package supports interacting with multiple different backends:
|
Install with the extras you need:
|
||||||
|
|
||||||
- Redis
|
|
||||||
- MinIO
|
|
||||||
|
|
||||||
To add support for a specific backend install this package with one or more of these optional packages:
|
|
||||||
|
|
||||||
```bash
|
```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": "[email protected]"})
|
||||||
|
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).
|
||||||
|
|||||||
@@ -26,6 +26,14 @@ minio = [
|
|||||||
"minio>=7.2.16",
|
"minio>=7.2.16",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[build-system]
|
||||||
|
requires = ["hatchling"]
|
||||||
|
build-backend = "hatchling.build"
|
||||||
|
|
||||||
|
[tool.pytest.ini_options]
|
||||||
|
testpaths = ["tests"]
|
||||||
|
pythonpath = ["."]
|
||||||
|
|
||||||
[tool.uv.sources]
|
[tool.uv.sources]
|
||||||
python-utils = { index = "gitea" }
|
python-utils = { index = "gitea" }
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,18 @@
|
|||||||
"""python_repositories: Unified repository interfaces and adapters."""
|
"""python_repositories: Unified repository interfaces and adapters."""
|
||||||
|
|
||||||
from . import adapters
|
from .adapters import MinioAdapter, RedisAdapter
|
||||||
from . import interfaces
|
from .interfaces import (
|
||||||
|
ConnectionAwareInterface,
|
||||||
|
ContextAwareInterface,
|
||||||
|
JsonRepositoryInterface,
|
||||||
|
ObjectRepositoryInterface,
|
||||||
|
)
|
||||||
|
|
||||||
__all__ = ["adapters", "interfaces"]
|
__all__ = [
|
||||||
|
"ConnectionAwareInterface",
|
||||||
|
"ContextAwareInterface",
|
||||||
|
"JsonRepositoryInterface",
|
||||||
|
"ObjectRepositoryInterface",
|
||||||
|
"RedisAdapter",
|
||||||
|
"MinioAdapter",
|
||||||
|
]
|
||||||
|
|||||||
@@ -8,8 +8,9 @@ import structlog
|
|||||||
from python_utils import check_env
|
from python_utils import check_env
|
||||||
|
|
||||||
from python_repositories.interfaces import (
|
from python_repositories.interfaces import (
|
||||||
ContextAwareInterface,
|
|
||||||
ConnectionAwareInterface,
|
ConnectionAwareInterface,
|
||||||
|
ContextAwareInterface,
|
||||||
|
ObjectRepositoryInterface,
|
||||||
)
|
)
|
||||||
|
|
||||||
# Handle optional dependencies
|
# Handle optional dependencies
|
||||||
@@ -18,6 +19,7 @@ if find_spec("minio") is not None:
|
|||||||
|
|
||||||
|
|
||||||
class MinioAdapter(
|
class MinioAdapter(
|
||||||
|
ObjectRepositoryInterface,
|
||||||
ContextAwareInterface,
|
ContextAwareInterface,
|
||||||
ConnectionAwareInterface,
|
ConnectionAwareInterface,
|
||||||
):
|
):
|
||||||
@@ -113,7 +115,7 @@ class MinioAdapter(
|
|||||||
self.logger.debug(res)
|
self.logger.debug(res)
|
||||||
return res
|
return res
|
||||||
|
|
||||||
def _put(
|
def put(
|
||||||
self,
|
self,
|
||||||
object_name: str,
|
object_name: str,
|
||||||
data: BytesIO,
|
data: BytesIO,
|
||||||
@@ -147,7 +149,7 @@ class MinioAdapter(
|
|||||||
f"Put object '{object_name}' into bucket '{self._bucket_name}'"
|
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."""
|
"""Get an object from the Minio bucket."""
|
||||||
# Check input
|
# Check input
|
||||||
if not isinstance(object_name, str) or len(object_name) == 0:
|
if not isinstance(object_name, str) or len(object_name) == 0:
|
||||||
@@ -182,7 +184,7 @@ class MinioAdapter(
|
|||||||
self.logger.error(repr(exc))
|
self.logger.error(repr(exc))
|
||||||
return None
|
return None
|
||||||
|
|
||||||
def _delete(self, object_name: str) -> None:
|
def delete(self, object_name: str) -> None:
|
||||||
"""Delete an object from the Minio bucket."""
|
"""Delete an object from the Minio bucket."""
|
||||||
# Check input
|
# Check input
|
||||||
if not isinstance(object_name, str) or len(object_name) == 0:
|
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}'"
|
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."""
|
"""List objects in the Minio bucket with an optional prefix."""
|
||||||
# Check input
|
# Check input
|
||||||
if not isinstance(prefix, str):
|
if not isinstance(prefix, str):
|
||||||
|
|||||||
@@ -9,8 +9,9 @@ import structlog
|
|||||||
from python_utils import check_env
|
from python_utils import check_env
|
||||||
|
|
||||||
from python_repositories.interfaces import (
|
from python_repositories.interfaces import (
|
||||||
ContextAwareInterface,
|
|
||||||
ConnectionAwareInterface,
|
ConnectionAwareInterface,
|
||||||
|
ContextAwareInterface,
|
||||||
|
JsonRepositoryInterface,
|
||||||
)
|
)
|
||||||
|
|
||||||
# Handle optional dependencies
|
# Handle optional dependencies
|
||||||
@@ -20,6 +21,7 @@ if find_spec("redis") is not None:
|
|||||||
|
|
||||||
|
|
||||||
class RedisAdapter(
|
class RedisAdapter(
|
||||||
|
JsonRepositoryInterface,
|
||||||
ContextAwareInterface,
|
ContextAwareInterface,
|
||||||
ConnectionAwareInterface,
|
ConnectionAwareInterface,
|
||||||
):
|
):
|
||||||
@@ -92,7 +94,7 @@ class RedisAdapter(
|
|||||||
self.logger.debug(res)
|
self.logger.debug(res)
|
||||||
return 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."""
|
"""Set a JSON object in Redis."""
|
||||||
# Check input
|
# Check input
|
||||||
if not isinstance(key, str) or len(key) == 0:
|
if not isinstance(key, str) or len(key) == 0:
|
||||||
@@ -106,7 +108,7 @@ class RedisAdapter(
|
|||||||
self._client.json().set(key, self.path, data)
|
self._client.json().set(key, self.path, data)
|
||||||
self.logger.debug(f"Set {key} to {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."""
|
"""Get a JSON object from Redis."""
|
||||||
# Check input
|
# Check input
|
||||||
if not isinstance(key, str) or len(key) == 0:
|
if not isinstance(key, str) or len(key) == 0:
|
||||||
@@ -122,7 +124,7 @@ class RedisAdapter(
|
|||||||
self.logger.debug(f"Got {data} from {key}")
|
self.logger.debug(f"Got {data} from {key}")
|
||||||
return data
|
return data
|
||||||
|
|
||||||
def _delete(self, key: str) -> None:
|
def delete(self, key: str) -> None:
|
||||||
"""Delete data from Redis."""
|
"""Delete data from Redis."""
|
||||||
# Check input
|
# Check input
|
||||||
if not isinstance(key, str) or len(key) == 0:
|
if not isinstance(key, str) or len(key) == 0:
|
||||||
@@ -134,7 +136,7 @@ class RedisAdapter(
|
|||||||
self._client.json().delete(key)
|
self._client.json().delete(key)
|
||||||
self.logger.debug(f"Deleted {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."""
|
"""List keys in Redis matching a pattern."""
|
||||||
# Check input
|
# Check input
|
||||||
if not isinstance(pattern, str) or len(pattern) == 0:
|
if not isinstance(pattern, str) or len(pattern) == 0:
|
||||||
|
|||||||
@@ -0,0 +1 @@
|
|||||||
|
"""Example domain repositories built on technology adapters."""
|
||||||
@@ -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)
|
||||||
@@ -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))
|
||||||
@@ -2,8 +2,16 @@ from .connection_aware_interface import (
|
|||||||
ConnectionAwareInterface as ConnectionAwareInterface,
|
ConnectionAwareInterface as ConnectionAwareInterface,
|
||||||
)
|
)
|
||||||
from .context_aware_interface import ContextAwareInterface as ContextAwareInterface
|
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__ = [
|
__all__ = [
|
||||||
"ConnectionAwareInterface",
|
"ConnectionAwareInterface",
|
||||||
"ContextAwareInterface",
|
"ContextAwareInterface",
|
||||||
|
"JsonRepositoryInterface",
|
||||||
|
"ObjectRepositoryInterface",
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -9,15 +9,15 @@ class ConnectionAwareInterface(ABC):
|
|||||||
@abstractmethod
|
@abstractmethod
|
||||||
def connect(self) -> None:
|
def connect(self) -> None:
|
||||||
"""Connect to resource."""
|
"""Connect to resource."""
|
||||||
raise NotImplementedError()
|
...
|
||||||
|
|
||||||
@abstractmethod
|
@abstractmethod
|
||||||
def disconnect(self) -> None:
|
def disconnect(self) -> None:
|
||||||
"""Disconnect from resource."""
|
"""Disconnect from resource."""
|
||||||
raise NotImplementedError()
|
...
|
||||||
|
|
||||||
@property
|
@property
|
||||||
@abstractmethod
|
@abstractmethod
|
||||||
def is_connected(self) -> bool:
|
def is_connected(self) -> bool:
|
||||||
"""Check if connected to resource."""
|
"""Check if connected to resource."""
|
||||||
raise NotImplementedError()
|
...
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
"""Definition of ConnectionAwareInterface abstract base class."""
|
"""Definition of ContextAwareInterface abstract base class."""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
@@ -6,16 +6,16 @@ from abc import ABC, abstractmethod
|
|||||||
|
|
||||||
|
|
||||||
class ContextAwareInterface(ABC):
|
class ContextAwareInterface(ABC):
|
||||||
"""Interface that defined context-related methods."""
|
"""Interface that defines context-related methods."""
|
||||||
|
|
||||||
@abstractmethod
|
@abstractmethod
|
||||||
def __enter__(self) -> ContextAwareInterface:
|
def __enter__(self) -> ContextAwareInterface:
|
||||||
"""Enter the context."""
|
"""Enter the context."""
|
||||||
raise NotImplementedError()
|
...
|
||||||
|
|
||||||
@abstractmethod
|
@abstractmethod
|
||||||
def __exit__(
|
def __exit__(
|
||||||
self, exc_type: type | None, exc_val: object | None, exc_tb: object | None
|
self, exc_type: type | None, exc_val: object | None, exc_tb: object | None
|
||||||
) -> None:
|
) -> None:
|
||||||
"""Exit the context."""
|
"""Exit the context."""
|
||||||
raise NotImplementedError()
|
...
|
||||||
|
|||||||
@@ -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."""
|
||||||
|
...
|
||||||
@@ -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."""
|
||||||
|
...
|
||||||
@@ -54,66 +54,3 @@ def test_instantiation_fails_when_is_connected_not_implemented() -> None:
|
|||||||
|
|
||||||
with pytest.raises(TypeError):
|
with pytest.raises(TypeError):
|
||||||
_ = Incomplete() # type: ignore
|
_ = 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
|
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
"""Integration tests for ContextAwareInterface."""
|
"""Integration tests for ContextAwareInterface."""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
from python_repositories.interfaces.context_aware_interface import ContextAwareInterface
|
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):
|
with pytest.raises(TypeError):
|
||||||
_ = Incomplete() # type: ignore
|
_ = 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)
|
|
||||||
|
|||||||
@@ -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": "[email protected]"}
|
||||||
|
|
||||||
|
|
||||||
|
@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()
|
||||||
@@ -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
|
||||||
@@ -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."""
|
"""Integration tests for the MinioAdapter."""
|
||||||
|
|
||||||
from collections.abc import Generator
|
from collections.abc import Generator
|
||||||
@@ -13,6 +10,7 @@ import os
|
|||||||
import logging
|
import logging
|
||||||
from minio import S3Error
|
from minio import S3Error
|
||||||
from python_repositories.adapters.minio_adapter import MinioAdapter
|
from python_repositories.adapters.minio_adapter import MinioAdapter
|
||||||
|
from python_repositories.interfaces import ObjectRepositoryInterface
|
||||||
|
|
||||||
|
|
||||||
def same_data(
|
def same_data(
|
||||||
@@ -103,7 +101,7 @@ def clear_minio(
|
|||||||
|
|
||||||
def test_should_adhere_to_interface() -> None:
|
def test_should_adhere_to_interface() -> None:
|
||||||
"""Test that the MinioAdapter adheres to the expected interface."""
|
"""Test that the MinioAdapter adheres to the expected interface."""
|
||||||
# Instantiation fails if interface not adhered to
|
assert issubclass(MinioAdapter, ObjectRepositoryInterface)
|
||||||
_ = MinioAdapter()
|
_ = MinioAdapter()
|
||||||
|
|
||||||
|
|
||||||
@@ -187,7 +185,7 @@ def test_should_get_data(
|
|||||||
# Arrange
|
# Arrange
|
||||||
object_name, expected_data = data_in_minio
|
object_name, expected_data = data_in_minio
|
||||||
# Act
|
# Act
|
||||||
received_data = minio_adapter._get(object_name)
|
received_data = minio_adapter.get(object_name)
|
||||||
# Assert
|
# Assert
|
||||||
assert received_data is not None
|
assert received_data is not None
|
||||||
assert same_data(expected_data, received_data)
|
assert same_data(expected_data, received_data)
|
||||||
@@ -200,7 +198,7 @@ def test_should_get_none_for_nonexistent_object(
|
|||||||
# Arrange
|
# Arrange
|
||||||
object_name = "nonexistent_object"
|
object_name = "nonexistent_object"
|
||||||
# Act
|
# Act
|
||||||
received_data = minio_adapter._get(object_name)
|
received_data = minio_adapter.get(object_name)
|
||||||
# Assert
|
# Assert
|
||||||
assert received_data is None
|
assert received_data is None
|
||||||
|
|
||||||
@@ -214,7 +212,7 @@ def test_should_raise_value_error_on_invalid_get_object_name(
|
|||||||
# Act & Assert
|
# Act & Assert
|
||||||
for object_name in invalid_object_names:
|
for object_name in invalid_object_names:
|
||||||
with pytest.raises(ValueError):
|
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(
|
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
|
adapter = MinioAdapter() # not connected
|
||||||
# Act & Assert
|
# Act & Assert
|
||||||
with pytest.raises(ConnectionError):
|
with pytest.raises(ConnectionError):
|
||||||
adapter._get("some_object")
|
adapter.get("some_object")
|
||||||
|
|
||||||
|
|
||||||
def test_should_log_warning_when_getting_nonexistent_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"
|
object_name = "missing-object"
|
||||||
# Act
|
# Act
|
||||||
with caplog.at_level("WARNING"):
|
with caplog.at_level("WARNING"):
|
||||||
result = adapter._get(object_name)
|
result = adapter.get(object_name)
|
||||||
# Assert
|
# Assert
|
||||||
assert result is None
|
assert result is None
|
||||||
assert (
|
assert (
|
||||||
@@ -280,7 +278,7 @@ def test_should_log_error_when_getting_with_s3error_other_than_no_such_key(
|
|||||||
object_name = "missing-object"
|
object_name = "missing-object"
|
||||||
# Act
|
# Act
|
||||||
with caplog.at_level("ERROR"):
|
with caplog.at_level("ERROR"):
|
||||||
result = adapter._get(object_name)
|
result = adapter.get(object_name)
|
||||||
# Assert
|
# Assert
|
||||||
assert result is None
|
assert result is None
|
||||||
assert repr(other_s3error) in caplog.text
|
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"
|
object_name = "missing-object"
|
||||||
# Act
|
# Act
|
||||||
with caplog.at_level("ERROR"):
|
with caplog.at_level("ERROR"):
|
||||||
result = adapter._get(object_name)
|
result = adapter.get(object_name)
|
||||||
# Assert
|
# Assert
|
||||||
assert result is None
|
assert result is None
|
||||||
assert repr(general_exception) in caplog.text
|
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."""
|
"""Test that the MinioAdapter can put data into a bucket."""
|
||||||
# Arrange
|
# Arrange
|
||||||
object_name = "new_test_object"
|
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
|
assert received_data is None # ensure object does not exist yet
|
||||||
# Act
|
# Act
|
||||||
minio_adapter._put(object_name, data)
|
minio_adapter.put(object_name, data)
|
||||||
# Assert
|
# Assert
|
||||||
received_data = minio_adapter._get(object_name)
|
received_data = minio_adapter.get(object_name)
|
||||||
assert received_data is not None
|
assert received_data is not None
|
||||||
assert same_data(data, received_data)
|
assert same_data(data, received_data)
|
||||||
# Cleanup
|
# Cleanup
|
||||||
@@ -333,13 +331,13 @@ def test_should_update_data(
|
|||||||
# Arrange
|
# Arrange
|
||||||
object_name, _ = data_in_minio
|
object_name, _ = data_in_minio
|
||||||
new_data = BytesIO(random.randbytes(2**21)) # 2 MiB
|
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 received_data is not None
|
||||||
assert not same_data(received_data, new_data)
|
assert not same_data(received_data, new_data)
|
||||||
# Act
|
# Act
|
||||||
minio_adapter._put(object_name, new_data)
|
minio_adapter.put(object_name, new_data)
|
||||||
# Assert
|
# Assert
|
||||||
received_data = minio_adapter._get(object_name)
|
received_data = minio_adapter.get(object_name)
|
||||||
assert received_data is not None
|
assert received_data is not None
|
||||||
assert same_data(new_data, received_data)
|
assert same_data(new_data, received_data)
|
||||||
|
|
||||||
@@ -354,7 +352,7 @@ def test_should_raise_value_error_on_invalid_put_object_name(
|
|||||||
# Act & Assert
|
# Act & Assert
|
||||||
for object_name in invalid_object_names:
|
for object_name in invalid_object_names:
|
||||||
with pytest.raises(ValueError):
|
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(
|
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
|
# Act & Assert
|
||||||
for data in invalid_data:
|
for data in invalid_data:
|
||||||
with pytest.raises(ValueError):
|
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(
|
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
|
# Act & Assert
|
||||||
for content_type in invalid_content_types:
|
for content_type in invalid_content_types:
|
||||||
with pytest.raises(ValueError):
|
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(
|
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
|
adapter = MinioAdapter() # not connected
|
||||||
# Act & Assert
|
# Act & Assert
|
||||||
with pytest.raises(ConnectionError):
|
with pytest.raises(ConnectionError):
|
||||||
adapter._put("some_object", data)
|
adapter.put("some_object", data)
|
||||||
|
|
||||||
|
|
||||||
def test_should_delete_object(
|
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."""
|
"""Test that the MinioAdapter can delete an object from a bucket."""
|
||||||
# Arrange
|
# Arrange
|
||||||
object_name, _ = data_in_minio
|
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
|
assert received_data is not None # ensure object exists
|
||||||
# Act
|
# Act
|
||||||
minio_adapter._delete(object_name)
|
minio_adapter.delete(object_name)
|
||||||
# Assert
|
# Assert
|
||||||
received_data = minio_adapter._get(object_name)
|
received_data = minio_adapter.get(object_name)
|
||||||
assert received_data is None
|
assert received_data is None
|
||||||
|
|
||||||
|
|
||||||
@@ -421,7 +419,7 @@ def test_should_raise_value_error_on_invalid_delete_object_name(
|
|||||||
# Act & Assert
|
# Act & Assert
|
||||||
for object_name in invalid_object_names:
|
for object_name in invalid_object_names:
|
||||||
with pytest.raises(ValueError):
|
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(
|
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
|
adapter = MinioAdapter() # not connected
|
||||||
# Act & Assert
|
# Act & Assert
|
||||||
with pytest.raises(ConnectionError):
|
with pytest.raises(ConnectionError):
|
||||||
adapter._delete("some_object")
|
adapter.delete("some_object")
|
||||||
|
|
||||||
|
|
||||||
def test_should_list_objects(
|
def test_should_list_objects(
|
||||||
@@ -444,9 +442,9 @@ def test_should_list_objects(
|
|||||||
object_name, _ = data_in_minio
|
object_name, _ = data_in_minio
|
||||||
new_data = BytesIO(random.randbytes(2**21)) # 2 MiB
|
new_data = BytesIO(random.randbytes(2**21)) # 2 MiB
|
||||||
new_data_name = "another_test_object"
|
new_data_name = "another_test_object"
|
||||||
minio_adapter._put(new_data_name, new_data)
|
minio_adapter.put(new_data_name, new_data)
|
||||||
# Act
|
# Act
|
||||||
objects = minio_adapter._list_objects()
|
objects = minio_adapter.list_objects()
|
||||||
# Assert
|
# Assert
|
||||||
assert isinstance(objects, list)
|
assert isinstance(objects, list)
|
||||||
assert len(objects) == 2
|
assert len(objects) == 2
|
||||||
@@ -463,10 +461,10 @@ def test_should_list_objects_with_prefix(
|
|||||||
object_name, _ = data_in_minio
|
object_name, _ = data_in_minio
|
||||||
new_data = BytesIO(random.randbytes(2**21)) # 2 MiB
|
new_data = BytesIO(random.randbytes(2**21)) # 2 MiB
|
||||||
new_data_name = "prefix_test_object"
|
new_data_name = "prefix_test_object"
|
||||||
minio_adapter._put(new_data_name, new_data)
|
minio_adapter.put(new_data_name, new_data)
|
||||||
prefix = "prefix_"
|
prefix = "prefix_"
|
||||||
# Act
|
# Act
|
||||||
objects = minio_adapter._list_objects(prefix)
|
objects = minio_adapter.list_objects(prefix)
|
||||||
# Assert
|
# Assert
|
||||||
assert isinstance(objects, list)
|
assert isinstance(objects, list)
|
||||||
assert len(objects) == 1
|
assert len(objects) == 1
|
||||||
@@ -483,7 +481,7 @@ def test_should_raise_value_error_on_invalid_list_objects_prefix(
|
|||||||
# Act & Assert
|
# Act & Assert
|
||||||
for prefix in invalid_prefixes:
|
for prefix in invalid_prefixes:
|
||||||
with pytest.raises(ValueError):
|
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(
|
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
|
adapter = MinioAdapter() # not connected
|
||||||
# Act & Assert
|
# Act & Assert
|
||||||
with pytest.raises(ConnectionError):
|
with pytest.raises(ConnectionError):
|
||||||
adapter._list_objects()
|
adapter.list_objects()
|
||||||
|
|
||||||
|
|
||||||
# allows local debugging by running file as script
|
# allows local debugging by running file as script
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -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."""
|
"""Integration tests for the RedisAdapter."""
|
||||||
|
|
||||||
from collections.abc import Generator
|
from collections.abc import Generator
|
||||||
@@ -8,6 +5,7 @@ import pytest
|
|||||||
import redis
|
import redis
|
||||||
from redis.commands.json.path import Path as RedisPath
|
from redis.commands.json.path import Path as RedisPath
|
||||||
from python_repositories.adapters.redis_adapter import RedisAdapter
|
from python_repositories.adapters.redis_adapter import RedisAdapter
|
||||||
|
from python_repositories.interfaces import JsonRepositoryInterface
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture(scope="module")
|
@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:
|
def test_should_adhere_to_interface(redis_container: str) -> None:
|
||||||
"""Test that the RedisAdapter adheres to the expected interface."""
|
"""Test that the RedisAdapter adheres to the expected interface."""
|
||||||
# Instantiation fails if interface not adhered to
|
assert issubclass(RedisAdapter, JsonRepositoryInterface)
|
||||||
_ = RedisAdapter()
|
_ = RedisAdapter()
|
||||||
|
|
||||||
|
|
||||||
@@ -134,7 +132,7 @@ def test_should_get_value(
|
|||||||
# Arrange
|
# Arrange
|
||||||
key, data = data_in_redis
|
key, data = data_in_redis
|
||||||
# Act
|
# Act
|
||||||
value = redis_adapter._get(key)
|
value = redis_adapter.get(key)
|
||||||
# Assert
|
# Assert
|
||||||
assert value is not None
|
assert value is not None
|
||||||
assert value == data
|
assert value == data
|
||||||
@@ -145,7 +143,7 @@ def test_should_get_none_for_missing_key(
|
|||||||
) -> None:
|
) -> None:
|
||||||
"""Test that getting a non-existent key returns None."""
|
"""Test that getting a non-existent key returns None."""
|
||||||
# Act
|
# Act
|
||||||
value = redis_adapter._get("nonexistent_key")
|
value = redis_adapter.get("nonexistent_key")
|
||||||
# Assert
|
# Assert
|
||||||
assert value is None
|
assert value is None
|
||||||
|
|
||||||
@@ -159,7 +157,7 @@ def test_should_raise_value_error_on_invalid_get_key(
|
|||||||
# Act & Assert
|
# Act & Assert
|
||||||
for key in invalid_keys:
|
for key in invalid_keys:
|
||||||
with pytest.raises(ValueError):
|
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(
|
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
|
adapter = RedisAdapter() # not connected
|
||||||
# Act & Assert
|
# Act & Assert
|
||||||
with pytest.raises(ConnectionError):
|
with pytest.raises(ConnectionError):
|
||||||
adapter._get("some_key")
|
adapter.get("some_key")
|
||||||
|
|
||||||
|
|
||||||
def test_should_set_value(
|
def test_should_set_value(
|
||||||
@@ -180,12 +178,12 @@ def test_should_set_value(
|
|||||||
"""Test that the RedisAdapter can set a value."""
|
"""Test that the RedisAdapter can set a value."""
|
||||||
# Arrange
|
# Arrange
|
||||||
key = "test_key"
|
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
|
assert received_data is None # Ensure key does not exist
|
||||||
# Act
|
# Act
|
||||||
redis_adapter._set(key, data)
|
redis_adapter.set(key, data)
|
||||||
# Assert
|
# Assert
|
||||||
received_data = redis_adapter._get(key)
|
received_data = redis_adapter.get(key)
|
||||||
assert received_data is not None
|
assert received_data is not None
|
||||||
assert received_data == data
|
assert received_data == data
|
||||||
|
|
||||||
@@ -198,13 +196,13 @@ def test_should_update_value(
|
|||||||
# Arrange
|
# Arrange
|
||||||
key, _ = data_in_redis
|
key, _ = data_in_redis
|
||||||
new_data = {"new_key": "new_value"}
|
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 is not None
|
||||||
assert received_data != new_data
|
assert received_data != new_data
|
||||||
# Act
|
# Act
|
||||||
redis_adapter._set(key, new_data)
|
redis_adapter.set(key, new_data)
|
||||||
# Assert
|
# 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(
|
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
|
# Act & Assert
|
||||||
for key in invalid_keys:
|
for key in invalid_keys:
|
||||||
with pytest.raises(ValueError):
|
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(
|
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
|
# Act & Assert
|
||||||
for data in invalid_data:
|
for data in invalid_data:
|
||||||
with pytest.raises(ValueError):
|
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(
|
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"
|
key = "test_key"
|
||||||
# Act & Assert
|
# Act & Assert
|
||||||
with pytest.raises(ConnectionError):
|
with pytest.raises(ConnectionError):
|
||||||
adapter._set(key, data)
|
adapter.set(key, data)
|
||||||
|
|
||||||
|
|
||||||
def test_should_delete_key(
|
def test_should_delete_key(
|
||||||
@@ -253,12 +251,12 @@ def test_should_delete_key(
|
|||||||
"""Test that deleting a key removes it from Redis."""
|
"""Test that deleting a key removes it from Redis."""
|
||||||
# Arrange
|
# Arrange
|
||||||
key, _ = data_in_redis
|
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
|
assert received_data is not None # Ensure key exists
|
||||||
# Act
|
# Act
|
||||||
redis_adapter._delete(key)
|
redis_adapter.delete(key)
|
||||||
# Assert
|
# 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(
|
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
|
# Act & Assert
|
||||||
for key in invalid_keys:
|
for key in invalid_keys:
|
||||||
with pytest.raises(ValueError):
|
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(
|
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()
|
adapter = RedisAdapter()
|
||||||
# Act & Assert
|
# Act & Assert
|
||||||
with pytest.raises(ConnectionError):
|
with pytest.raises(ConnectionError):
|
||||||
adapter._delete("some_key")
|
adapter.delete("some_key")
|
||||||
|
|
||||||
|
|
||||||
def test_should_list_keys(
|
def test_should_list_keys(
|
||||||
@@ -289,10 +287,10 @@ def test_should_list_keys(
|
|||||||
) -> None:
|
) -> None:
|
||||||
"""Test listing keys matching a pattern returns correct keys."""
|
"""Test listing keys matching a pattern returns correct keys."""
|
||||||
# Arrange
|
# Arrange
|
||||||
redis_adapter._set("key1", {"a": 1})
|
redis_adapter.set("key1", {"a": 1})
|
||||||
redis_adapter._set("key2", {"b": 2})
|
redis_adapter.set("key2", {"b": 2})
|
||||||
# Act
|
# Act
|
||||||
keys = redis_adapter._list_keys("key*")
|
keys = redis_adapter.list_keys("key*")
|
||||||
# Assert
|
# Assert
|
||||||
assert set(keys) == {"key1", "key2"}
|
assert set(keys) == {"key1", "key2"}
|
||||||
|
|
||||||
@@ -306,7 +304,7 @@ def test_should_raise_value_error_on_invalid_list_keys_pattern(
|
|||||||
# Act & Assert
|
# Act & Assert
|
||||||
for pattern in invalid_patterns:
|
for pattern in invalid_patterns:
|
||||||
with pytest.raises(ValueError):
|
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(
|
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()
|
adapter = RedisAdapter()
|
||||||
# Act & Assert
|
# Act & Assert
|
||||||
with pytest.raises(ConnectionError):
|
with pytest.raises(ConnectionError):
|
||||||
adapter._list_keys("some_pattern")
|
adapter.list_keys("some_pattern")
|
||||||
|
|
||||||
|
|
||||||
# allows local debugging by running file as script
|
# allows local debugging by running file as script
|
||||||
|
|||||||
@@ -806,7 +806,7 @@ wheels = [
|
|||||||
[[package]]
|
[[package]]
|
||||||
name = "python-repositories"
|
name = "python-repositories"
|
||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
source = { virtual = "." }
|
source = { editable = "." }
|
||||||
dependencies = [
|
dependencies = [
|
||||||
{ name = "python-utils" },
|
{ name = "python-utils" },
|
||||||
{ name = "structlog" },
|
{ name = "structlog" },
|
||||||
|
|||||||
Reference in New Issue
Block a user