Compare commits
13
Commits
6c66fe4e21
...
v2.1.0
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f28d3c4844 | ||
|
|
1cbcc1c19b | ||
|
|
a0e5c9dcca | ||
|
|
44b15cb21a | ||
|
|
f6ee9a3724 | ||
|
|
0829354cc4 | ||
|
|
a0d509a2a3 | ||
|
|
9b7b15a67e | ||
|
|
cd5b84b88e | ||
|
|
6d02f32ca5 | ||
|
|
57b396bcd3 | ||
|
|
093e538d5b | ||
|
|
b28ac6e803 |
@@ -0,0 +1,58 @@
|
|||||||
|
---
|
||||||
|
description: After first branch push, provide copy-ready PR title and body
|
||||||
|
alwaysApply: true
|
||||||
|
---
|
||||||
|
|
||||||
|
# PR Title and Comment After First Push
|
||||||
|
|
||||||
|
The user creates pull requests manually. After the **first push** of a feature branch to remote in a session, always end your response with a copy-ready PR title and PR comment.
|
||||||
|
|
||||||
|
## When to provide it
|
||||||
|
|
||||||
|
- After the first successful `git push` (or `git push -u origin <branch>`) for a branch in the current task
|
||||||
|
- Do **not** create the PR with `gh pr create` unless explicitly asked
|
||||||
|
- On later pushes to the same branch, only repeat if the user asks or the change set meaningfully updates what the PR should say
|
||||||
|
|
||||||
|
## PR title
|
||||||
|
|
||||||
|
- Use a fitting version-bump prefix when changes touch `python_repositories/`:
|
||||||
|
- `[patch]` or `[fix]` — bug fix
|
||||||
|
- `[minor]` or `[feat]` — new feature
|
||||||
|
- `[major]` or `[breaking]` — breaking change
|
||||||
|
- Docs-, CI-, or test-only changes: no prefix required
|
||||||
|
- Keep the title concise and descriptive
|
||||||
|
|
||||||
|
## PR comment format
|
||||||
|
|
||||||
|
Provide two separate fenced code blocks the user can copy directly:
|
||||||
|
|
||||||
|
1. **PR title** — single line in a code block
|
||||||
|
2. **PR comment** — body with `## Summary` and `## Test plan` sections, checklist items reflecting what was actually run
|
||||||
|
|
||||||
|
Example structure:
|
||||||
|
|
||||||
|
````
|
||||||
|
PR title:
|
||||||
|
|
||||||
|
```
|
||||||
|
[minor] Short description of the change
|
||||||
|
```
|
||||||
|
|
||||||
|
PR comment:
|
||||||
|
|
||||||
|
```
|
||||||
|
## Summary
|
||||||
|
|
||||||
|
- Bullet points of what changed and why
|
||||||
|
|
||||||
|
## Test plan
|
||||||
|
|
||||||
|
- [x] Commands or checks that were run
|
||||||
|
```
|
||||||
|
````
|
||||||
|
|
||||||
|
## Notes
|
||||||
|
|
||||||
|
- Match the repo PR template ([`.gitea/PULL_REQUEST_TEMPLATE.md`](.gitea/PULL_REQUEST_TEMPLATE.md))
|
||||||
|
- Be accurate: only mark test-plan items done if they were run
|
||||||
|
- Keep prose clear; the user copies this verbatim into Gitea
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
---
|
||||||
|
description: Unit test file naming and placement conventions
|
||||||
|
globs: tests/**/*.py
|
||||||
|
alwaysApply: false
|
||||||
|
---
|
||||||
|
|
||||||
|
# Test Organization
|
||||||
|
|
||||||
|
Unit tests use a one-to-one mapping between source modules and test files.
|
||||||
|
|
||||||
|
## Naming
|
||||||
|
|
||||||
|
- `python_repositories/<path>/<module>.py` → `tests/unit/<module>_test.py`
|
||||||
|
- Test files must end with `_test.py` (enforced by pre-commit `name-tests-test`)
|
||||||
|
|
||||||
|
## Placement
|
||||||
|
|
||||||
|
- Add tests to the existing `*_test.py` for the module under test
|
||||||
|
- Do not create cross-cutting test files (e.g. `structural_typing_test.py`); colocate with the relevant interface/adapter/config test file
|
||||||
|
- Shared fixtures → `tests/conftest.py`
|
||||||
|
- Module-specific helper classes → the matching test file
|
||||||
|
|
||||||
|
## Examples
|
||||||
|
|
||||||
|
```
|
||||||
|
json_repository_interface.py → tests/unit/json_repository_interface_test.py
|
||||||
|
redis_adapter.py → tests/unit/redis_adapter_test.py
|
||||||
|
redis_config.py → tests/unit/redis_config_test.py
|
||||||
|
```
|
||||||
|
|
||||||
|
Integration tests live under `tests/integration/<backend>/` with the same `_test.py` suffix.
|
||||||
@@ -7,6 +7,40 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|||||||
|
|
||||||
## [Unreleased]
|
## [Unreleased]
|
||||||
|
|
||||||
|
## [2.1.0] - 2026-07-11
|
||||||
|
|
||||||
|
### Summary
|
||||||
|
Add runtime-checkable Protocol typing to public interfaces
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
- 1cbcc1c Merge pull request '[minor] Add runtime-checkable Protocol typing to public interfaces' (#57) from cursor/protocol-abc-interfaces into main
|
||||||
|
- a0e5c9d Document test organization and add Cursor workflow rules.
|
||||||
|
- 44b15cb Move structural typing tests into interface unit test files.
|
||||||
|
- f6ee9a3 Add runtime-checkable Protocol typing to all public interfaces.
|
||||||
|
- 0829354 Merge pull request 'Add async future note and remove redundant pytest pythonpath' (#56) from cursor/readme-async-and-test-path-cleanup into main
|
||||||
|
- a0d509a Format CHANGELOG.md with Prettier.
|
||||||
|
- 9b7b15a Document possible async support and drop redundant pytest pythonpath.
|
||||||
|
|
||||||
|
## [2.0.5] - 2026-07-10
|
||||||
|
|
||||||
|
### Summary
|
||||||
|
|
||||||
|
Allow empty payloads in Redis and MinIO adapters
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- 6d02f32 Merge pull request '[patch] Allow empty payloads in Redis and MinIO adapters' (#55) from cursor/allow-empty-payloads into main
|
||||||
|
- 57b396b Allow empty payloads in Redis and MinIO adapters.
|
||||||
|
- 093e538 Merge pull request '[chore] Scope integration fixtures per backend subdirectory' (#54) from cursor/scoped-integration-fixtures into main
|
||||||
|
- b28ac6e Scope integration fixtures per backend subdirectory.
|
||||||
|
- 6c66fe4 Merge pull request 'Add consumer-facing CHANGELOG.md maintained by release CI' (#53) from cursor/consumer-changelog into main
|
||||||
|
- 089a128 Format CHANGELOG.md with Prettier
|
||||||
|
- 0a5bd39 Add consumer-facing CHANGELOG.md maintained by release CI.
|
||||||
|
- 683ee33 Merge pull request '[chore] add repository URL and Python 3.12 packaging metadata' (#52) from cursor/add-packaging-metadata into main
|
||||||
|
- 9df5032 Add repository URL and Python 3.12 packaging metadata.
|
||||||
|
- 4f6f33f Merge pull request 'Drop types-redis in favor of redis inline types' (#51) from cursor/drop-types-redis into main
|
||||||
|
- 10d43ce Drop types-redis in favor of redis inline types.
|
||||||
|
|
||||||
## [2.0.4] - 2026-07-10
|
## [2.0.4] - 2026-07-10
|
||||||
|
|
||||||
### Summary
|
### Summary
|
||||||
|
|||||||
@@ -12,8 +12,14 @@ Subclass an adapter in your own repository to add domain-specific methods while
|
|||||||
| **Adapters** | Technology-specific base classes (`RedisAdapter`, `MinioAdapter`) |
|
| **Adapters** | Technology-specific base classes (`RedisAdapter`, `MinioAdapter`) |
|
||||||
| **Your project** | Subclass an adapter and add domain methods |
|
| **Your project** | Subclass an adapter and add domain methods |
|
||||||
|
|
||||||
|
Each public interface is a `@runtime_checkable` `Protocol` with `@abstractmethod` members. **Subclass an adapter** when you need connection management and shared behavior — explicit subclasses get runtime instantiation guards and inherited default methods (e.g. `scan_keys`). **Type-annotate against an interface** when you want loose coupling — any object with the right methods satisfies the contract for mypy and `isinstance()` checks, without inheriting from this package.
|
||||||
|
|
||||||
Connection adapters expose `connect()`, `disconnect()`, and `is_connected()`. The latter verifies backend reachability with a cached health probe (default TTL: 1 second). Subclasses may override `health_check_ttl_seconds`. `connect()` is idempotent: calling it while already connected and healthy is a no-op.
|
Connection adapters expose `connect()`, `disconnect()`, and `is_connected()`. The latter verifies backend reachability with a cached health probe (default TTL: 1 second). Subclasses may override `health_check_ttl_seconds`. `connect()` is idempotent: calling it while already connected and healthy is a no-op.
|
||||||
|
|
||||||
|
## Future direction
|
||||||
|
|
||||||
|
The current API is synchronous. Async repository interfaces and adapters may be added in a future release; existing sync usage would remain supported.
|
||||||
|
|
||||||
## Optional dependencies
|
## Optional dependencies
|
||||||
|
|
||||||
Repository **interfaces** import with the base package. **Adapters** require the matching extra; importing an adapter without its extra raises `ImportError` with install instructions.
|
Repository **interfaces** import with the base package. **Adapters** require the matching extra; importing an adapter without its extra raises `ImportError` with install instructions.
|
||||||
@@ -161,10 +167,21 @@ uv sync --all-extras
|
|||||||
uv run pre-commit install # once per clone — runs hooks on git commit
|
uv run pre-commit install # once per clone — runs hooks on git commit
|
||||||
uv run pytest tests/unit/ -v # fast, no Docker
|
uv run pytest tests/unit/ -v # fast, no Docker
|
||||||
uv run pytest -m "not integration" -v # all non-Docker tests
|
uv run pytest -m "not integration" -v # all non-Docker tests
|
||||||
|
uv run pytest tests/integration/redis/ -v # Redis container only
|
||||||
|
uv run pytest tests/integration/minio/ -v # MinIO container only
|
||||||
uv run pytest -v # full suite (requires Docker)
|
uv run pytest -v # full suite (requires Docker)
|
||||||
```
|
```
|
||||||
|
|
||||||
Integration tests are marked with `@pytest.mark.integration` and require Docker (testcontainers). Run unit tests alone for quick local feedback.
|
Integration tests are marked with `@pytest.mark.integration` and require Docker (testcontainers). Backend-specific markers (`needs_redis`, `needs_minio`) let you run only the containers a test module needs. Run unit tests alone for quick local feedback.
|
||||||
|
|
||||||
|
### Test organization
|
||||||
|
|
||||||
|
Unit tests live in `tests/unit/` and follow a one-to-one naming convention: `<module>_test.py` tests `python_repositories/<module path>.py`. Examples:
|
||||||
|
|
||||||
|
- `json_repository_interface.py` → `tests/unit/json_repository_interface_test.py`
|
||||||
|
- `redis_adapter.py` → `tests/unit/redis_adapter_test.py`
|
||||||
|
|
||||||
|
Add new tests to the existing file for that module rather than creating cross-cutting test files. Shared fixtures belong in `tests/conftest.py`; module-specific helpers may live in the matching test file.
|
||||||
|
|
||||||
### CI base image
|
### CI base image
|
||||||
|
|
||||||
|
|||||||
+3
-2
@@ -1,6 +1,6 @@
|
|||||||
[project]
|
[project]
|
||||||
name = "python-repositories"
|
name = "python-repositories"
|
||||||
version = "2.0.4"
|
version = "2.1.0"
|
||||||
description = "Various python repository interfaces exposed as a python package."
|
description = "Various python repository interfaces exposed as a python package."
|
||||||
authors = [
|
authors = [
|
||||||
{ name = "Brian Bjarke Jensen", email = "schnitzelen@gmail.com" }
|
{ name = "Brian Bjarke Jensen", email = "schnitzelen@gmail.com" }
|
||||||
@@ -39,10 +39,11 @@ build-backend = "hatchling.build"
|
|||||||
|
|
||||||
[tool.pytest.ini_options]
|
[tool.pytest.ini_options]
|
||||||
testpaths = ["tests"]
|
testpaths = ["tests"]
|
||||||
pythonpath = ["."]
|
|
||||||
addopts = "--import-mode=importlib"
|
addopts = "--import-mode=importlib"
|
||||||
markers = [
|
markers = [
|
||||||
"integration: tests requiring Docker containers (deselect with '-m \"not integration\"')",
|
"integration: tests requiring Docker containers (deselect with '-m \"not integration\"')",
|
||||||
|
"needs_redis: integration test requiring a Redis container",
|
||||||
|
"needs_minio: integration test requiring a MinIO container",
|
||||||
]
|
]
|
||||||
|
|
||||||
[tool.coverage.run]
|
[tool.coverage.run]
|
||||||
|
|||||||
@@ -19,7 +19,7 @@ except ImportError as exc:
|
|||||||
) from exc
|
) from exc
|
||||||
|
|
||||||
|
|
||||||
class MinioAdapter(ObjectRepositoryInterface, ConnectionAwareAdapter):
|
class MinioAdapter(ConnectionAwareAdapter, ObjectRepositoryInterface):
|
||||||
"""Minio adapter exposing basic CRUD functionality."""
|
"""Minio adapter exposing basic CRUD functionality."""
|
||||||
|
|
||||||
endpoint_env_var_name: str = "MINIO_ENDPOINT"
|
endpoint_env_var_name: str = "MINIO_ENDPOINT"
|
||||||
@@ -114,12 +114,17 @@ class MinioAdapter(ObjectRepositoryInterface, ConnectionAwareAdapter):
|
|||||||
data: BytesIO,
|
data: BytesIO,
|
||||||
content_type: str = "application/octet-stream",
|
content_type: str = "application/octet-stream",
|
||||||
) -> None:
|
) -> None:
|
||||||
"""Put an object into the Minio bucket."""
|
"""Put an object into the Minio bucket.
|
||||||
|
|
||||||
|
Accepts zero-byte ``BytesIO``. A zero-byte object is returned by
|
||||||
|
``get()`` as an empty buffer, not ``None``. Use ``delete()`` to remove
|
||||||
|
an object entirely.
|
||||||
|
"""
|
||||||
# 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:
|
||||||
raise ValueError("object_name must be a non-empty string")
|
raise ValueError("object_name must be a non-empty string")
|
||||||
if not isinstance(data, BytesIO) or data.getbuffer().nbytes == 0:
|
if not isinstance(data, BytesIO):
|
||||||
raise ValueError("data must be a non-empty BytesIO object")
|
raise ValueError("data must be a BytesIO object")
|
||||||
if not isinstance(content_type, str) or len(content_type) == 0:
|
if not isinstance(content_type, str) or len(content_type) == 0:
|
||||||
raise ValueError("content_type must be a non-empty string")
|
raise ValueError("content_type must be a non-empty string")
|
||||||
# Check connection
|
# Check connection
|
||||||
@@ -143,7 +148,12 @@ class MinioAdapter(ObjectRepositoryInterface, ConnectionAwareAdapter):
|
|||||||
)
|
)
|
||||||
|
|
||||||
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.
|
||||||
|
|
||||||
|
Returns ``None`` when the object does not exist. Returns an empty
|
||||||
|
``BytesIO`` for a zero-byte object. Use ``value is not None`` to test
|
||||||
|
existence.
|
||||||
|
"""
|
||||||
# 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:
|
||||||
raise ValueError("object_name must be a non-empty string")
|
raise ValueError("object_name must be a non-empty string")
|
||||||
|
|||||||
@@ -21,7 +21,7 @@ except ImportError as exc:
|
|||||||
) from exc
|
) from exc
|
||||||
|
|
||||||
|
|
||||||
class RedisAdapter(JsonRepositoryInterface, ConnectionAwareAdapter):
|
class RedisAdapter(ConnectionAwareAdapter, JsonRepositoryInterface):
|
||||||
"""Redis adapter exposing basic CRUD functionality."""
|
"""Redis adapter exposing basic CRUD functionality."""
|
||||||
|
|
||||||
uri_env_var_name: str = "REDIS_URI"
|
uri_env_var_name: str = "REDIS_URI"
|
||||||
@@ -91,12 +91,17 @@ class RedisAdapter(JsonRepositoryInterface, ConnectionAwareAdapter):
|
|||||||
return False
|
return False
|
||||||
|
|
||||||
def set(self, key: str, data: dict[str, Any]) -> None:
|
def set(self, key: str, data: dict[str, Any]) -> None:
|
||||||
"""Set a JSON object in Redis."""
|
"""Set a JSON object in Redis.
|
||||||
|
|
||||||
|
Accepts any dict, including ``{}``. An empty dict creates a key that
|
||||||
|
``get()`` returns as ``{}``, not ``None``. Use ``delete()`` to remove a
|
||||||
|
key entirely.
|
||||||
|
"""
|
||||||
# Check input
|
# Check input
|
||||||
if not isinstance(key, str) or len(key) == 0:
|
if not isinstance(key, str) or len(key) == 0:
|
||||||
raise ValueError("Key must be a non-empty string")
|
raise ValueError("Key must be a non-empty string")
|
||||||
if not isinstance(data, dict) or len(data) == 0:
|
if not isinstance(data, dict):
|
||||||
raise ValueError("Data must be a non-empty dictionary")
|
raise ValueError("Data must be a dictionary")
|
||||||
# Check connection
|
# Check connection
|
||||||
self._require_connected()
|
self._require_connected()
|
||||||
assert self._client is not None
|
assert self._client is not None
|
||||||
@@ -105,7 +110,12 @@ class RedisAdapter(JsonRepositoryInterface, ConnectionAwareAdapter):
|
|||||||
self.logger.debug("Set key", key=key, data_keys=list(data.keys()))
|
self.logger.debug("Set key", key=key, data_keys=list(data.keys()))
|
||||||
|
|
||||||
def get(self, key: str) -> dict[str, Any] | None:
|
def get(self, key: str) -> dict[str, Any] | None:
|
||||||
"""Get a JSON object from Redis."""
|
"""Get a JSON object from Redis.
|
||||||
|
|
||||||
|
Returns ``None`` if the key is absent. Returns ``{}`` if the key exists
|
||||||
|
with an empty JSON object. Use ``value is not None`` to test existence;
|
||||||
|
avoid truthiness checks (``{}`` is falsy).
|
||||||
|
"""
|
||||||
# Check input
|
# Check input
|
||||||
if not isinstance(key, str) or len(key) == 0:
|
if not isinstance(key, str) or len(key) == 0:
|
||||||
raise ValueError("Key must be a non-empty string")
|
raise ValueError("Key must be a non-empty string")
|
||||||
|
|||||||
@@ -1,9 +1,11 @@
|
|||||||
"""Definition of ConnectionAwareInterface abstract base class."""
|
"""Definition of ConnectionAwareInterface protocol and abstract base class."""
|
||||||
|
|
||||||
from abc import ABC, abstractmethod
|
from abc import abstractmethod
|
||||||
|
from typing import Protocol, runtime_checkable
|
||||||
|
|
||||||
|
|
||||||
class ConnectionAwareInterface(ABC):
|
@runtime_checkable
|
||||||
|
class ConnectionAwareInterface(Protocol):
|
||||||
"""Interface that defines connection-related methods."""
|
"""Interface that defines connection-related methods."""
|
||||||
|
|
||||||
@abstractmethod
|
@abstractmethod
|
||||||
|
|||||||
@@ -1,12 +1,13 @@
|
|||||||
"""Definition of ContextAwareInterface abstract base class."""
|
"""Definition of ContextAwareInterface protocol and abstract base class."""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from abc import ABC, abstractmethod
|
from abc import abstractmethod
|
||||||
from typing import Self
|
from typing import Protocol, Self, runtime_checkable
|
||||||
|
|
||||||
|
|
||||||
class ContextAwareInterface(ABC):
|
@runtime_checkable
|
||||||
|
class ContextAwareInterface(Protocol):
|
||||||
"""Interface that defines context-related methods."""
|
"""Interface that defines context-related methods."""
|
||||||
|
|
||||||
@abstractmethod
|
@abstractmethod
|
||||||
|
|||||||
@@ -1,26 +1,37 @@
|
|||||||
"""Definition of JsonRepositoryInterface abstract base class."""
|
"""Definition of JsonRepositoryInterface protocol and abstract base class."""
|
||||||
|
|
||||||
from abc import ABC, abstractmethod
|
from abc import abstractmethod
|
||||||
from collections.abc import Iterator
|
from collections.abc import Iterator
|
||||||
from typing import Any
|
from typing import Any, Protocol, runtime_checkable
|
||||||
|
|
||||||
|
|
||||||
class JsonRepositoryInterface(ABC):
|
@runtime_checkable
|
||||||
|
class JsonRepositoryInterface(Protocol):
|
||||||
"""Interface that defines JSON document CRUD methods."""
|
"""Interface that defines JSON document CRUD methods."""
|
||||||
|
|
||||||
@abstractmethod
|
@abstractmethod
|
||||||
def get(self, key: str) -> dict[str, Any] | None:
|
def get(self, key: str) -> dict[str, Any] | None:
|
||||||
"""Get a JSON object by key."""
|
"""Get a JSON object by key.
|
||||||
|
|
||||||
|
Returns ``None`` when the key is absent. Returns ``{}`` when the key
|
||||||
|
exists with an empty JSON object. Use ``value is not None`` to test
|
||||||
|
existence; avoid truthiness checks (``{}`` is falsy).
|
||||||
|
"""
|
||||||
...
|
...
|
||||||
|
|
||||||
@abstractmethod
|
@abstractmethod
|
||||||
def set(self, key: str, data: dict[str, Any]) -> None:
|
def set(self, key: str, data: dict[str, Any]) -> None:
|
||||||
"""Set a JSON object by key."""
|
"""Set a JSON object by key.
|
||||||
|
|
||||||
|
Accepts any dict, including ``{}``. An empty dict creates a key that
|
||||||
|
``get()`` returns as ``{}``, not ``None``. Use ``delete()`` to remove a
|
||||||
|
key entirely.
|
||||||
|
"""
|
||||||
...
|
...
|
||||||
|
|
||||||
@abstractmethod
|
@abstractmethod
|
||||||
def delete(self, key: str) -> None:
|
def delete(self, key: str) -> None:
|
||||||
"""Delete a JSON object by key."""
|
"""Delete a JSON object by key, removing it entirely."""
|
||||||
...
|
...
|
||||||
|
|
||||||
@abstractmethod
|
@abstractmethod
|
||||||
|
|||||||
@@ -1,18 +1,22 @@
|
|||||||
"""Definition of ObjectRepositoryInterface abstract base class."""
|
"""Definition of ObjectRepositoryInterface protocol and abstract base class."""
|
||||||
|
|
||||||
from abc import ABC, abstractmethod
|
from abc import abstractmethod
|
||||||
from io import BytesIO
|
from io import BytesIO
|
||||||
|
from typing import Protocol, runtime_checkable
|
||||||
|
|
||||||
|
|
||||||
class ObjectRepositoryInterface(ABC):
|
@runtime_checkable
|
||||||
|
class ObjectRepositoryInterface(Protocol):
|
||||||
"""Interface that defines binary object CRUD methods."""
|
"""Interface that defines binary object CRUD methods."""
|
||||||
|
|
||||||
@abstractmethod
|
@abstractmethod
|
||||||
def get(self, object_name: str) -> BytesIO | None:
|
def get(self, object_name: str) -> BytesIO | None:
|
||||||
"""Get an object by name.
|
"""Get an object by name.
|
||||||
|
|
||||||
Returns None when the object does not exist. Raises ConnectionError when
|
Returns ``None`` when the object does not exist. Returns an empty
|
||||||
not connected. Other backend errors propagate to the caller.
|
``BytesIO`` for a zero-byte object. Use ``value is not None`` to test
|
||||||
|
existence. Raises ConnectionError when not connected. Other backend
|
||||||
|
errors propagate to the caller.
|
||||||
"""
|
"""
|
||||||
...
|
...
|
||||||
|
|
||||||
@@ -23,12 +27,17 @@ class ObjectRepositoryInterface(ABC):
|
|||||||
data: BytesIO,
|
data: BytesIO,
|
||||||
content_type: str = "application/octet-stream",
|
content_type: str = "application/octet-stream",
|
||||||
) -> None:
|
) -> None:
|
||||||
"""Put an object by name."""
|
"""Put an object by name.
|
||||||
|
|
||||||
|
Accepts zero-byte ``BytesIO``. A zero-byte object is returned by
|
||||||
|
``get()`` as an empty buffer, not ``None``. Use ``delete()`` to remove
|
||||||
|
an object entirely.
|
||||||
|
"""
|
||||||
...
|
...
|
||||||
|
|
||||||
@abstractmethod
|
@abstractmethod
|
||||||
def delete(self, object_name: str) -> None:
|
def delete(self, object_name: str) -> None:
|
||||||
"""Delete an object by name."""
|
"""Delete an object by name, removing it entirely."""
|
||||||
...
|
...
|
||||||
|
|
||||||
@abstractmethod
|
@abstractmethod
|
||||||
|
|||||||
@@ -1,51 +1,9 @@
|
|||||||
"""Integration tests configuration."""
|
"""Integration tests configuration."""
|
||||||
|
|
||||||
from collections.abc import Generator
|
|
||||||
import logging
|
import logging
|
||||||
from typing import Any, cast
|
|
||||||
|
|
||||||
from minio import Minio
|
|
||||||
import pytest
|
import pytest
|
||||||
import redis
|
|
||||||
import structlog
|
import structlog
|
||||||
from testcontainers.core.container import DockerContainer
|
|
||||||
from testcontainers.core.waiting_utils import WaitStrategy, WaitStrategyTarget
|
|
||||||
from testcontainers.minio import MinioContainer
|
|
||||||
|
|
||||||
from python_repositories.config import MinioConfig, RedisConfig
|
|
||||||
|
|
||||||
REDIS_PORT = 6379
|
|
||||||
|
|
||||||
MINIO_ACCESS_KEY = "minioadmin"
|
|
||||||
MINIO_SECRET_KEY = "minioadmin"
|
|
||||||
MINIO_BUCKET = "test-bucket"
|
|
||||||
|
|
||||||
|
|
||||||
class _RedisPingWaitStrategy(WaitStrategy):
|
|
||||||
def __init__(self) -> None:
|
|
||||||
super().__init__()
|
|
||||||
self.with_transient_exceptions(redis.exceptions.ConnectionError)
|
|
||||||
|
|
||||||
def wait_until_ready(self, container: WaitStrategyTarget) -> None:
|
|
||||||
redis_container = cast("RedisTestContainer", container)
|
|
||||||
if not self._poll(lambda: redis_container.get_client().ping()):
|
|
||||||
raise redis.exceptions.ConnectionError("Could not connect to Redis")
|
|
||||||
|
|
||||||
|
|
||||||
class RedisTestContainer(DockerContainer):
|
|
||||||
"""Redis container using wait strategies instead of the deprecated decorator."""
|
|
||||||
|
|
||||||
def __init__(self, image: str, port: int = REDIS_PORT) -> None:
|
|
||||||
super().__init__(image, _wait_strategy=_RedisPingWaitStrategy())
|
|
||||||
self.port = port
|
|
||||||
self.with_exposed_ports(self.port)
|
|
||||||
|
|
||||||
def get_client(self, **kwargs: Any) -> redis.Redis:
|
|
||||||
return redis.Redis(
|
|
||||||
host=self.get_container_host_ip(),
|
|
||||||
port=self.get_exposed_port(self.port),
|
|
||||||
**kwargs,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture(scope="session", autouse=True)
|
@pytest.fixture(scope="session", autouse=True)
|
||||||
@@ -64,96 +22,3 @@ def configure_logging() -> None:
|
|||||||
cache_logger_on_first_use=True,
|
cache_logger_on_first_use=True,
|
||||||
)
|
)
|
||||||
logging.basicConfig(level=logging.ERROR)
|
logging.basicConfig(level=logging.ERROR)
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture(scope="session")
|
|
||||||
def redis_container() -> Generator[str, None, None]:
|
|
||||||
"""Set up a Redis container for testing and yield the Redis URI."""
|
|
||||||
container = RedisTestContainer(
|
|
||||||
image="redis/redis-stack:7.2.0-v0",
|
|
||||||
)
|
|
||||||
container.start()
|
|
||||||
redis_host = container.get_container_host_ip()
|
|
||||||
redis_port = container.get_exposed_port(REDIS_PORT)
|
|
||||||
redis_uri = f"redis://{redis_host}:{redis_port}"
|
|
||||||
|
|
||||||
yield redis_uri
|
|
||||||
|
|
||||||
container.stop()
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture(scope="session")
|
|
||||||
def minio_container() -> Generator[dict[str, str], None, None]:
|
|
||||||
"""Set up a Minio container for testing and yield connection settings."""
|
|
||||||
container = MinioContainer(
|
|
||||||
image="minio/minio:latest",
|
|
||||||
access_key=MINIO_ACCESS_KEY,
|
|
||||||
secret_key=MINIO_SECRET_KEY,
|
|
||||||
)
|
|
||||||
container.start()
|
|
||||||
minio_host = container.get_container_host_ip()
|
|
||||||
minio_port = container.get_exposed_port(9000)
|
|
||||||
minio_endpoint = f"{minio_host}:{minio_port}"
|
|
||||||
env_vars = {
|
|
||||||
"MINIO_ENDPOINT": minio_endpoint,
|
|
||||||
"MINIO_ACCESS_KEY": MINIO_ACCESS_KEY,
|
|
||||||
"MINIO_SECRET_KEY": MINIO_SECRET_KEY,
|
|
||||||
"MINIO_BUCKET": MINIO_BUCKET,
|
|
||||||
"MINIO_SECURE": "false",
|
|
||||||
}
|
|
||||||
|
|
||||||
yield env_vars
|
|
||||||
|
|
||||||
container.stop()
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture(scope="session")
|
|
||||||
def redis_config(redis_container: str) -> RedisConfig:
|
|
||||||
"""Provide RedisConfig built from the test container."""
|
|
||||||
return RedisConfig(uri=redis_container)
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture(scope="session")
|
|
||||||
def minio_config(minio_container: dict[str, str]) -> MinioConfig:
|
|
||||||
"""Provide MinioConfig built from the test container."""
|
|
||||||
return MinioConfig(
|
|
||||||
endpoint=minio_container["MINIO_ENDPOINT"],
|
|
||||||
access_key=minio_container["MINIO_ACCESS_KEY"],
|
|
||||||
secret_key=minio_container["MINIO_SECRET_KEY"],
|
|
||||||
bucket=minio_container["MINIO_BUCKET"],
|
|
||||||
secure=False,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture(scope="session")
|
|
||||||
def raw_redis_client(redis_container: str) -> Generator[redis.Redis, None, None]:
|
|
||||||
"""Provide a raw Redis client connected to the test Redis container."""
|
|
||||||
client = redis.Redis.from_url(
|
|
||||||
url=redis_container,
|
|
||||||
socket_connect_timeout=10,
|
|
||||||
)
|
|
||||||
|
|
||||||
yield client
|
|
||||||
|
|
||||||
client.flushall()
|
|
||||||
client.close()
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture(scope="session")
|
|
||||||
def raw_minio_client(minio_container: dict[str, str]) -> Generator[Minio, None, None]:
|
|
||||||
"""Provide a raw Minio client connected to the test Minio container."""
|
|
||||||
client = Minio(
|
|
||||||
endpoint=minio_container["MINIO_ENDPOINT"],
|
|
||||||
access_key=minio_container["MINIO_ACCESS_KEY"],
|
|
||||||
secret_key=minio_container["MINIO_SECRET_KEY"],
|
|
||||||
secure=False,
|
|
||||||
)
|
|
||||||
bucket_name = minio_container["MINIO_BUCKET"]
|
|
||||||
if not client.bucket_exists(bucket_name):
|
|
||||||
client.make_bucket(bucket_name)
|
|
||||||
|
|
||||||
yield client
|
|
||||||
|
|
||||||
objects = client.list_objects(bucket_name, recursive=True)
|
|
||||||
for obj in objects:
|
|
||||||
client.remove_object(bucket_name, obj.object_name)
|
|
||||||
|
|||||||
@@ -0,0 +1,55 @@
|
|||||||
|
"""Examples integration test fixtures."""
|
||||||
|
|
||||||
|
from collections.abc import Generator
|
||||||
|
|
||||||
|
from minio import Minio
|
||||||
|
import pytest
|
||||||
|
import redis
|
||||||
|
|
||||||
|
from python_repositories.config import MinioConfig, RedisConfig
|
||||||
|
from tests.integration.minio._containers import (
|
||||||
|
minio_config_from_env,
|
||||||
|
minio_env,
|
||||||
|
raw_minio_client_from_env,
|
||||||
|
)
|
||||||
|
from tests.integration.redis._containers import (
|
||||||
|
raw_redis_client_from_container,
|
||||||
|
redis_config_from_container,
|
||||||
|
redis_uri,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(scope="session")
|
||||||
|
def redis_container() -> Generator[str, None, None]:
|
||||||
|
"""Set up a Redis container for testing and yield the Redis URI."""
|
||||||
|
yield from redis_uri()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(scope="session")
|
||||||
|
def redis_config(redis_container: str) -> RedisConfig:
|
||||||
|
"""Provide RedisConfig built from the test container."""
|
||||||
|
return redis_config_from_container(redis_container)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(scope="session")
|
||||||
|
def raw_redis_client(redis_container: str) -> Generator[redis.Redis, None, None]:
|
||||||
|
"""Provide a raw Redis client connected to the test Redis container."""
|
||||||
|
yield from raw_redis_client_from_container(redis_container)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(scope="session")
|
||||||
|
def minio_container() -> Generator[dict[str, str], None, None]:
|
||||||
|
"""Set up a Minio container for testing and yield connection settings."""
|
||||||
|
yield from minio_env()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(scope="session")
|
||||||
|
def minio_config(minio_container: dict[str, str]) -> MinioConfig:
|
||||||
|
"""Provide MinioConfig built from the test container."""
|
||||||
|
return minio_config_from_env(minio_container)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(scope="session")
|
||||||
|
def raw_minio_client(minio_container: dict[str, str]) -> Generator[Minio, None, None]:
|
||||||
|
"""Provide a raw Minio client connected to the test Minio container."""
|
||||||
|
yield from raw_minio_client_from_env(minio_container)
|
||||||
@@ -13,7 +13,11 @@ from python_repositories.examples.artifact_object_repository import (
|
|||||||
)
|
)
|
||||||
from python_repositories.examples.user_json_repository import UserJsonRepository
|
from python_repositories.examples.user_json_repository import UserJsonRepository
|
||||||
|
|
||||||
pytestmark = pytest.mark.integration
|
pytestmark = [
|
||||||
|
pytest.mark.integration,
|
||||||
|
pytest.mark.needs_redis,
|
||||||
|
pytest.mark.needs_minio,
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture(scope="module", autouse=True)
|
@pytest.fixture(scope="module", autouse=True)
|
||||||
@@ -0,0 +1,86 @@
|
|||||||
|
"""MinIO container session helpers for integration tests."""
|
||||||
|
|
||||||
|
from collections.abc import Generator
|
||||||
|
|
||||||
|
from minio import Minio
|
||||||
|
from testcontainers.minio import MinioContainer
|
||||||
|
|
||||||
|
from python_repositories.config import MinioConfig
|
||||||
|
|
||||||
|
MINIO_ACCESS_KEY = "minioadmin"
|
||||||
|
MINIO_SECRET_KEY = "minioadmin"
|
||||||
|
MINIO_BUCKET = "test-bucket"
|
||||||
|
|
||||||
|
_minio_env: dict[str, str] | None = None
|
||||||
|
_minio_container: MinioContainer | None = None
|
||||||
|
_raw_minio_client: Minio | None = None
|
||||||
|
_minio_env_refs = 0
|
||||||
|
_raw_minio_client_refs = 0
|
||||||
|
|
||||||
|
|
||||||
|
def minio_env() -> Generator[dict[str, str], None, None]:
|
||||||
|
"""Yield session-scoped MinIO connection settings, starting the container once."""
|
||||||
|
global _minio_env, _minio_container, _minio_env_refs
|
||||||
|
if _minio_env is None:
|
||||||
|
_minio_container = MinioContainer(
|
||||||
|
image="minio/minio:latest",
|
||||||
|
access_key=MINIO_ACCESS_KEY,
|
||||||
|
secret_key=MINIO_SECRET_KEY,
|
||||||
|
)
|
||||||
|
_minio_container.start()
|
||||||
|
minio_host = _minio_container.get_container_host_ip()
|
||||||
|
minio_port = _minio_container.get_exposed_port(9000)
|
||||||
|
minio_endpoint = f"{minio_host}:{minio_port}"
|
||||||
|
_minio_env = {
|
||||||
|
"MINIO_ENDPOINT": minio_endpoint,
|
||||||
|
"MINIO_ACCESS_KEY": MINIO_ACCESS_KEY,
|
||||||
|
"MINIO_SECRET_KEY": MINIO_SECRET_KEY,
|
||||||
|
"MINIO_BUCKET": MINIO_BUCKET,
|
||||||
|
"MINIO_SECURE": "false",
|
||||||
|
}
|
||||||
|
|
||||||
|
_minio_env_refs += 1
|
||||||
|
yield _minio_env
|
||||||
|
_minio_env_refs -= 1
|
||||||
|
|
||||||
|
if _minio_env_refs == 0 and _minio_container is not None:
|
||||||
|
_minio_container.stop()
|
||||||
|
_minio_container = None
|
||||||
|
_minio_env = None
|
||||||
|
|
||||||
|
|
||||||
|
def minio_config_from_env(env: dict[str, str]) -> MinioConfig:
|
||||||
|
"""Build MinioConfig from container environment settings."""
|
||||||
|
return MinioConfig(
|
||||||
|
endpoint=env["MINIO_ENDPOINT"],
|
||||||
|
access_key=env["MINIO_ACCESS_KEY"],
|
||||||
|
secret_key=env["MINIO_SECRET_KEY"],
|
||||||
|
bucket=env["MINIO_BUCKET"],
|
||||||
|
secure=False,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def raw_minio_client_from_env(env: dict[str, str]) -> Generator[Minio, None, None]:
|
||||||
|
"""Yield a session-scoped raw MinIO client, reusing one client per session."""
|
||||||
|
global _raw_minio_client, _raw_minio_client_refs
|
||||||
|
if _raw_minio_client is None:
|
||||||
|
_raw_minio_client = Minio(
|
||||||
|
endpoint=env["MINIO_ENDPOINT"],
|
||||||
|
access_key=env["MINIO_ACCESS_KEY"],
|
||||||
|
secret_key=env["MINIO_SECRET_KEY"],
|
||||||
|
secure=False,
|
||||||
|
)
|
||||||
|
bucket_name = env["MINIO_BUCKET"]
|
||||||
|
if not _raw_minio_client.bucket_exists(bucket_name):
|
||||||
|
_raw_minio_client.make_bucket(bucket_name)
|
||||||
|
|
||||||
|
_raw_minio_client_refs += 1
|
||||||
|
yield _raw_minio_client
|
||||||
|
_raw_minio_client_refs -= 1
|
||||||
|
|
||||||
|
if _raw_minio_client_refs == 0 and _raw_minio_client is not None:
|
||||||
|
bucket_name = env["MINIO_BUCKET"]
|
||||||
|
objects = _raw_minio_client.list_objects(bucket_name, recursive=True)
|
||||||
|
for obj in objects:
|
||||||
|
_raw_minio_client.remove_object(bucket_name, obj.object_name)
|
||||||
|
_raw_minio_client = None
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
"""MinIO integration test fixtures."""
|
||||||
|
|
||||||
|
from collections.abc import Generator
|
||||||
|
|
||||||
|
from minio import Minio
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from python_repositories.config import MinioConfig
|
||||||
|
from tests.integration.minio._containers import (
|
||||||
|
minio_config_from_env,
|
||||||
|
minio_env,
|
||||||
|
raw_minio_client_from_env,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(scope="session")
|
||||||
|
def minio_container() -> Generator[dict[str, str], None, None]:
|
||||||
|
"""Set up a Minio container for testing and yield connection settings."""
|
||||||
|
yield from minio_env()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(scope="session")
|
||||||
|
def minio_config(minio_container: dict[str, str]) -> MinioConfig:
|
||||||
|
"""Provide MinioConfig built from the test container."""
|
||||||
|
return minio_config_from_env(minio_container)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(scope="session")
|
||||||
|
def raw_minio_client(minio_container: dict[str, str]) -> Generator[Minio, None, None]:
|
||||||
|
"""Provide a raw Minio client connected to the test Minio container."""
|
||||||
|
yield from raw_minio_client_from_env(minio_container)
|
||||||
+39
-1
@@ -15,7 +15,7 @@ from python_repositories.adapters.minio_adapter import MinioAdapter
|
|||||||
from python_repositories.config import MinioConfig
|
from python_repositories.config import MinioConfig
|
||||||
from tests.conftest import TEST_MINIO_CONFIG
|
from tests.conftest import TEST_MINIO_CONFIG
|
||||||
|
|
||||||
pytestmark = pytest.mark.integration
|
pytestmark = [pytest.mark.integration, pytest.mark.needs_minio]
|
||||||
|
|
||||||
|
|
||||||
def same_data(
|
def same_data(
|
||||||
@@ -328,6 +328,44 @@ def test_should_raise_value_error_on_invalid_put_data(
|
|||||||
minio_adapter.put(object_name, invalid) # type: ignore
|
minio_adapter.put(object_name, invalid) # type: ignore
|
||||||
|
|
||||||
|
|
||||||
|
def test_should_put_and_get_empty_bytesio(
|
||||||
|
minio_adapter: MinioAdapter,
|
||||||
|
minio_config: MinioConfig,
|
||||||
|
) -> None:
|
||||||
|
"""Test that the MinioAdapter can put and get a zero-byte object."""
|
||||||
|
object_name = "empty_object"
|
||||||
|
empty_data = BytesIO()
|
||||||
|
assert minio_adapter.get(object_name) is None
|
||||||
|
minio_adapter.put(object_name, empty_data)
|
||||||
|
received_data = minio_adapter.get(object_name)
|
||||||
|
assert received_data is not None
|
||||||
|
assert same_data(empty_data, received_data)
|
||||||
|
minio_adapter._client.remove_object(minio_config.bucket, object_name) # type: ignore[union-attr]
|
||||||
|
|
||||||
|
|
||||||
|
def test_should_distinguish_missing_object_from_empty_object(
|
||||||
|
minio_adapter: MinioAdapter,
|
||||||
|
minio_config: MinioConfig,
|
||||||
|
) -> None:
|
||||||
|
"""Test that missing objects and zero-byte objects are distinguishable."""
|
||||||
|
object_name = "empty_object"
|
||||||
|
minio_adapter.put(object_name, BytesIO())
|
||||||
|
assert minio_adapter.get("other_object") is None
|
||||||
|
minio_adapter.delete(object_name)
|
||||||
|
assert minio_adapter.get(object_name) is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_should_list_zero_byte_object(
|
||||||
|
minio_adapter: MinioAdapter,
|
||||||
|
minio_config: MinioConfig,
|
||||||
|
) -> None:
|
||||||
|
"""Test that a zero-byte object appears in object listings."""
|
||||||
|
object_name = "empty_object"
|
||||||
|
minio_adapter.put(object_name, BytesIO())
|
||||||
|
assert object_name in minio_adapter.list_objects()
|
||||||
|
minio_adapter._client.remove_object(minio_config.bucket, object_name) # type: ignore[union-attr]
|
||||||
|
|
||||||
|
|
||||||
def test_should_raise_value_error_on_invalid_put_content_type(
|
def test_should_raise_value_error_on_invalid_put_content_type(
|
||||||
data: BytesIO,
|
data: BytesIO,
|
||||||
minio_adapter: MinioAdapter,
|
minio_adapter: MinioAdapter,
|
||||||
@@ -0,0 +1,90 @@
|
|||||||
|
"""Redis container session helpers for integration tests."""
|
||||||
|
|
||||||
|
from collections.abc import Generator
|
||||||
|
from typing import Any, cast
|
||||||
|
|
||||||
|
import redis
|
||||||
|
from testcontainers.core.container import DockerContainer
|
||||||
|
from testcontainers.core.waiting_utils import WaitStrategy, WaitStrategyTarget
|
||||||
|
|
||||||
|
from python_repositories.config import RedisConfig
|
||||||
|
|
||||||
|
REDIS_PORT = 6379
|
||||||
|
|
||||||
|
|
||||||
|
class _RedisPingWaitStrategy(WaitStrategy):
|
||||||
|
def __init__(self) -> None:
|
||||||
|
super().__init__()
|
||||||
|
self.with_transient_exceptions(redis.exceptions.ConnectionError)
|
||||||
|
|
||||||
|
def wait_until_ready(self, container: WaitStrategyTarget) -> None:
|
||||||
|
redis_container = cast("RedisTestContainer", container)
|
||||||
|
if not self._poll(lambda: redis_container.get_client().ping()):
|
||||||
|
raise redis.exceptions.ConnectionError("Could not connect to Redis")
|
||||||
|
|
||||||
|
|
||||||
|
class RedisTestContainer(DockerContainer):
|
||||||
|
"""Redis container using wait strategies instead of the deprecated decorator."""
|
||||||
|
|
||||||
|
def __init__(self, image: str, port: int = REDIS_PORT) -> None:
|
||||||
|
super().__init__(image, _wait_strategy=_RedisPingWaitStrategy())
|
||||||
|
self.port = port
|
||||||
|
self.with_exposed_ports(self.port)
|
||||||
|
|
||||||
|
def get_client(self, **kwargs: Any) -> redis.Redis:
|
||||||
|
return redis.Redis(
|
||||||
|
host=self.get_container_host_ip(),
|
||||||
|
port=self.get_exposed_port(self.port),
|
||||||
|
**kwargs,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
_redis_uri: str | None = None
|
||||||
|
_redis_container: RedisTestContainer | None = None
|
||||||
|
_raw_redis_client: redis.Redis | None = None
|
||||||
|
_redis_uri_refs = 0
|
||||||
|
_raw_redis_client_refs = 0
|
||||||
|
|
||||||
|
|
||||||
|
def redis_uri() -> Generator[str, None, None]:
|
||||||
|
"""Yield a session-scoped Redis URI, starting the container once."""
|
||||||
|
global _redis_uri, _redis_container, _redis_uri_refs
|
||||||
|
if _redis_uri is None:
|
||||||
|
_redis_container = RedisTestContainer(image="redis/redis-stack:7.2.0-v0")
|
||||||
|
_redis_container.start()
|
||||||
|
redis_host = _redis_container.get_container_host_ip()
|
||||||
|
redis_port = _redis_container.get_exposed_port(REDIS_PORT)
|
||||||
|
_redis_uri = f"redis://{redis_host}:{redis_port}"
|
||||||
|
|
||||||
|
_redis_uri_refs += 1
|
||||||
|
yield _redis_uri
|
||||||
|
_redis_uri_refs -= 1
|
||||||
|
|
||||||
|
if _redis_uri_refs == 0 and _redis_container is not None:
|
||||||
|
_redis_container.stop()
|
||||||
|
_redis_container = None
|
||||||
|
_redis_uri = None
|
||||||
|
|
||||||
|
|
||||||
|
def redis_config_from_container(uri: str) -> RedisConfig:
|
||||||
|
"""Build RedisConfig from a container URI."""
|
||||||
|
return RedisConfig(uri=uri)
|
||||||
|
|
||||||
|
|
||||||
|
def raw_redis_client_from_container(uri: str) -> Generator[redis.Redis, None, None]:
|
||||||
|
"""Yield a session-scoped raw Redis client, reusing one client per session."""
|
||||||
|
global _raw_redis_client, _raw_redis_client_refs
|
||||||
|
if _raw_redis_client is None:
|
||||||
|
_raw_redis_client = redis.Redis.from_url(
|
||||||
|
url=uri,
|
||||||
|
socket_connect_timeout=10,
|
||||||
|
)
|
||||||
|
|
||||||
|
_raw_redis_client_refs += 1
|
||||||
|
yield _raw_redis_client
|
||||||
|
_raw_redis_client_refs -= 1
|
||||||
|
|
||||||
|
if _raw_redis_client_refs == 0 and _raw_redis_client is not None:
|
||||||
|
_raw_redis_client.flushall()
|
||||||
|
_raw_redis_client.close()
|
||||||
|
_raw_redis_client = None
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
"""Redis integration test fixtures."""
|
||||||
|
|
||||||
|
from collections.abc import Generator
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
import redis
|
||||||
|
|
||||||
|
from python_repositories.config import RedisConfig
|
||||||
|
from tests.integration.redis._containers import (
|
||||||
|
raw_redis_client_from_container,
|
||||||
|
redis_config_from_container,
|
||||||
|
redis_uri,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(scope="session")
|
||||||
|
def redis_container() -> Generator[str, None, None]:
|
||||||
|
"""Set up a Redis container for testing and yield the Redis URI."""
|
||||||
|
yield from redis_uri()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(scope="session")
|
||||||
|
def redis_config(redis_container: str) -> RedisConfig:
|
||||||
|
"""Provide RedisConfig built from the test container."""
|
||||||
|
return redis_config_from_container(redis_container)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(scope="session")
|
||||||
|
def raw_redis_client(redis_container: str) -> Generator[redis.Redis, None, None]:
|
||||||
|
"""Provide a raw Redis client connected to the test Redis container."""
|
||||||
|
yield from raw_redis_client_from_container(redis_container)
|
||||||
+35
-2
@@ -10,7 +10,7 @@ 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.config import RedisConfig
|
from python_repositories.config import RedisConfig
|
||||||
|
|
||||||
pytestmark = pytest.mark.integration
|
pytestmark = [pytest.mark.integration, pytest.mark.needs_redis]
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture(scope="module")
|
@pytest.fixture(scope="module")
|
||||||
@@ -190,12 +190,45 @@ def test_should_raise_value_error_on_invalid_set_data(
|
|||||||
) -> None:
|
) -> None:
|
||||||
"""Test that the RedisAdapter raises ValueError when setting with invalid data."""
|
"""Test that the RedisAdapter raises ValueError when setting with invalid data."""
|
||||||
key = "test_key"
|
key = "test_key"
|
||||||
invalid_data = ["", 123, None, [], {}]
|
invalid_data = ["", 123, None, []]
|
||||||
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_set_and_get_empty_dict(
|
||||||
|
redis_adapter: RedisAdapter,
|
||||||
|
) -> None:
|
||||||
|
"""Test that the RedisAdapter can set and get an empty dict."""
|
||||||
|
key = "empty_key"
|
||||||
|
assert redis_adapter.get(key) is None
|
||||||
|
redis_adapter.set(key, {})
|
||||||
|
value = redis_adapter.get(key)
|
||||||
|
assert value is not None
|
||||||
|
assert value == {}
|
||||||
|
|
||||||
|
|
||||||
|
def test_should_distinguish_missing_key_from_empty_dict(
|
||||||
|
redis_adapter: RedisAdapter,
|
||||||
|
) -> None:
|
||||||
|
"""Test that missing keys and empty dicts are distinguishable."""
|
||||||
|
key = "empty_key"
|
||||||
|
redis_adapter.set(key, {})
|
||||||
|
assert redis_adapter.get("other_key") is None
|
||||||
|
redis_adapter.delete(key)
|
||||||
|
assert redis_adapter.get(key) is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_should_list_empty_dict_key(
|
||||||
|
redis_adapter: RedisAdapter,
|
||||||
|
) -> None:
|
||||||
|
"""Test that a key with an empty dict appears in key listings."""
|
||||||
|
key = "empty_key"
|
||||||
|
redis_adapter.set(key, {})
|
||||||
|
assert key in redis_adapter.list_keys(key)
|
||||||
|
assert key in list(redis_adapter.scan_keys(key))
|
||||||
|
|
||||||
|
|
||||||
def test_should_raise_connection_error_on_set_when_not_connected(
|
def test_should_raise_connection_error_on_set_when_not_connected(
|
||||||
redis_config: RedisConfig,
|
redis_config: RedisConfig,
|
||||||
data: dict[str, str],
|
data: dict[str, str],
|
||||||
@@ -7,6 +7,24 @@ from python_repositories.interfaces.connection_aware_interface import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class FakeConnection:
|
||||||
|
"""Plain class that satisfies ConnectionAwareInterface without inheritance."""
|
||||||
|
|
||||||
|
def connect(self) -> None:
|
||||||
|
pass
|
||||||
|
|
||||||
|
def disconnect(self) -> None:
|
||||||
|
pass
|
||||||
|
|
||||||
|
def is_connected(self) -> bool:
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
def accepts_connection_aware(connection: ConnectionAwareInterface) -> None:
|
||||||
|
"""Type-checking hook for ConnectionAwareInterface structural subtyping."""
|
||||||
|
connection.is_connected()
|
||||||
|
|
||||||
|
|
||||||
def test_instantiation_fails_when_connect_not_implemented() -> None:
|
def test_instantiation_fails_when_connect_not_implemented() -> None:
|
||||||
"""Test that instantiation fails if connect is not implemented."""
|
"""Test that instantiation fails if connect is not implemented."""
|
||||||
|
|
||||||
@@ -53,3 +71,10 @@ 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_structural_subtyping() -> None:
|
||||||
|
"""Test that a plain class satisfies ConnectionAwareInterface structurally."""
|
||||||
|
connection: ConnectionAwareInterface = FakeConnection()
|
||||||
|
accepts_connection_aware(connection)
|
||||||
|
assert isinstance(connection, ConnectionAwareInterface)
|
||||||
|
|||||||
@@ -7,6 +7,24 @@ import pytest
|
|||||||
from python_repositories.interfaces.context_aware_interface import ContextAwareInterface
|
from python_repositories.interfaces.context_aware_interface import ContextAwareInterface
|
||||||
|
|
||||||
|
|
||||||
|
class FakeContextManager:
|
||||||
|
"""Plain class that satisfies ContextAwareInterface without inheritance."""
|
||||||
|
|
||||||
|
def __enter__(self) -> FakeContextManager:
|
||||||
|
return self
|
||||||
|
|
||||||
|
def __exit__(
|
||||||
|
self, exc_type: type | None, exc_val: object | None, exc_tb: object | None
|
||||||
|
) -> None:
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
def accepts_context_aware(context: ContextAwareInterface) -> None:
|
||||||
|
"""Type-checking hook for ContextAwareInterface structural subtyping."""
|
||||||
|
with context:
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
def test_instantiation_fails_when_enter_not_implemented() -> None:
|
def test_instantiation_fails_when_enter_not_implemented() -> None:
|
||||||
"""Test that instantiation fails if __enter__ is not implemented."""
|
"""Test that instantiation fails if __enter__ is not implemented."""
|
||||||
|
|
||||||
@@ -33,3 +51,10 @@ 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_structural_subtyping() -> None:
|
||||||
|
"""Test that a plain class satisfies ContextAwareInterface structurally."""
|
||||||
|
context: ContextAwareInterface = FakeContextManager()
|
||||||
|
accepts_context_aware(context)
|
||||||
|
assert isinstance(context, ContextAwareInterface)
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
"""Unit tests for JsonRepositoryInterface."""
|
"""Unit tests for JsonRepositoryInterface."""
|
||||||
|
|
||||||
|
from collections.abc import Iterator
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
@@ -9,6 +10,36 @@ from python_repositories.interfaces.json_repository_interface import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class InMemoryJsonRepo:
|
||||||
|
"""Plain class that satisfies JsonRepositoryInterface without inheritance."""
|
||||||
|
|
||||||
|
def get(self, key: str) -> dict[str, Any] | None:
|
||||||
|
return None
|
||||||
|
|
||||||
|
def set(self, key: str, data: dict[str, Any]) -> None:
|
||||||
|
pass
|
||||||
|
|
||||||
|
def delete(self, key: str) -> None:
|
||||||
|
pass
|
||||||
|
|
||||||
|
def list_keys(self, pattern: str) -> list[str]:
|
||||||
|
return []
|
||||||
|
|
||||||
|
def scan_keys(
|
||||||
|
self,
|
||||||
|
pattern: str,
|
||||||
|
*,
|
||||||
|
count: int | None = None,
|
||||||
|
) -> Iterator[str]:
|
||||||
|
del count
|
||||||
|
yield from self.list_keys(pattern)
|
||||||
|
|
||||||
|
|
||||||
|
def accepts_json_repo(repo: JsonRepositoryInterface) -> None:
|
||||||
|
"""Type-checking hook for JsonRepositoryInterface structural subtyping."""
|
||||||
|
repo.get("key")
|
||||||
|
|
||||||
|
|
||||||
def test_instantiation_fails_when_get_not_implemented() -> None:
|
def test_instantiation_fails_when_get_not_implemented() -> None:
|
||||||
"""Test that instantiation fails if get is not implemented."""
|
"""Test that instantiation fails if get is not implemented."""
|
||||||
|
|
||||||
@@ -104,3 +135,10 @@ def test_scan_keys_defaults_to_list_keys() -> None:
|
|||||||
repository = Complete()
|
repository = Complete()
|
||||||
|
|
||||||
assert list(repository.scan_keys("user")) == ["user-1", "user-2"]
|
assert list(repository.scan_keys("user")) == ["user-1", "user-2"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_structural_subtyping() -> None:
|
||||||
|
"""Test that a plain class satisfies JsonRepositoryInterface structurally."""
|
||||||
|
repo: JsonRepositoryInterface = InMemoryJsonRepo()
|
||||||
|
accepts_json_repo(repo)
|
||||||
|
assert isinstance(repo, JsonRepositoryInterface)
|
||||||
|
|||||||
@@ -9,6 +9,32 @@ from python_repositories.interfaces.object_repository_interface import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class InMemoryObjectRepo:
|
||||||
|
"""Plain class that satisfies ObjectRepositoryInterface without inheritance."""
|
||||||
|
|
||||||
|
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
|
||||||
|
|
||||||
|
def list_objects(self, prefix: str = "") -> list[str]:
|
||||||
|
return []
|
||||||
|
|
||||||
|
|
||||||
|
def accepts_object_repo(repo: ObjectRepositoryInterface) -> None:
|
||||||
|
"""Type-checking hook for ObjectRepositoryInterface structural subtyping."""
|
||||||
|
repo.get("object")
|
||||||
|
|
||||||
|
|
||||||
def test_instantiation_fails_when_get_not_implemented() -> None:
|
def test_instantiation_fails_when_get_not_implemented() -> None:
|
||||||
"""Test that instantiation fails if get is not implemented."""
|
"""Test that instantiation fails if get is not implemented."""
|
||||||
|
|
||||||
@@ -98,3 +124,10 @@ def test_instantiation_fails_when_list_objects_not_implemented() -> None:
|
|||||||
|
|
||||||
with pytest.raises(TypeError):
|
with pytest.raises(TypeError):
|
||||||
_ = Incomplete() # type: ignore
|
_ = Incomplete() # type: ignore
|
||||||
|
|
||||||
|
|
||||||
|
def test_structural_subtyping() -> None:
|
||||||
|
"""Test that a plain class satisfies ObjectRepositoryInterface structurally."""
|
||||||
|
repo: ObjectRepositoryInterface = InMemoryObjectRepo()
|
||||||
|
accepts_object_repo(repo)
|
||||||
|
assert isinstance(repo, ObjectRepositoryInterface)
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
version = 1
|
version = 1
|
||||||
revision = 3
|
revision = 2
|
||||||
requires-python = ">=3.12"
|
requires-python = ">=3.12"
|
||||||
resolution-markers = [
|
resolution-markers = [
|
||||||
"python_full_version >= '3.15'",
|
"python_full_version >= '3.15'",
|
||||||
@@ -1056,7 +1056,7 @@ wheels = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "python-repositories"
|
name = "python-repositories"
|
||||||
version = "2.0.4"
|
version = "2.1.0"
|
||||||
source = { editable = "." }
|
source = { editable = "." }
|
||||||
dependencies = [
|
dependencies = [
|
||||||
{ name = "python-dotenv" },
|
{ name = "python-dotenv" },
|
||||||
|
|||||||
Reference in New Issue
Block a user