Compare commits

..
5 Commits
Author SHA1 Message Date
CI Bot cd5b84b88e chore: release v2.0.5 [skip ci] 2026-07-10 19:44:15 +00:00
brian 6d02f32ca5 Merge pull request '[patch] Allow empty payloads in Redis and MinIO adapters' (#55) from cursor/allow-empty-payloads into main
Release on merge to main / release (push) Successful in 15s
Test Python Package / integration-tests (push) Successful in 23s
Test Python Package / coverage-report (push) Successful in 10s
Test Python Package / unit-tests (push) Successful in 13s
Code Quality Pipeline / code-quality (push) Successful in 19s
Reviewed-on: LilleVemmelund/python-repositories#55
2026-07-10 21:43:59 +02:00
Brian Bjarke JensenandCursor 57b396bcd3 Allow empty payloads in Redis and MinIO adapters.
PR Title Check / check-title (pull_request) Successful in 8s
Test Python Package / unit-tests (pull_request) Successful in 12s
Code Quality Pipeline / code-quality (pull_request) Successful in 20s
Test Python Package / integration-tests (pull_request) Successful in 24s
Test Python Package / coverage-report (pull_request) Successful in 11s
Relax write validation so {} and zero-byte BytesIO round-trip correctly,
document None-vs-empty semantics on interfaces and adapters, and add
integration tests for placeholders and existence distinction.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-10 21:39:51 +02:00
brian 093e538d5b Merge pull request '[chore] Scope integration fixtures per backend subdirectory' (#54) from cursor/scoped-integration-fixtures into main
Release on merge to main / release (push) Successful in 7s
Code Quality Pipeline / code-quality (push) Successful in 19s
Test Python Package / unit-tests (push) Successful in 14s
Build CI Image / build-and-push (push) Successful in 38s
Test Python Package / integration-tests (push) Successful in 50s
Test Python Package / coverage-report (push) Successful in 12s
Reviewed-on: LilleVemmelund/python-repositories#54
2026-07-10 21:16:27 +02:00
Brian Bjarke JensenandCursor b28ac6e803 Scope integration fixtures per backend subdirectory.
PR Title Check / check-title (pull_request) Successful in 7s
Test Python Package / unit-tests (pull_request) Successful in 13s
Test Python Package / integration-tests (pull_request) Successful in 23s
Test Python Package / coverage-report (pull_request) Successful in 10s
Code Quality Pipeline / code-quality (pull_request) Successful in 49s
Split Redis and MinIO container setup into backend-specific conftest modules so only the containers needed for collected tests are imported and started.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-10 21:11:53 +02:00
17 changed files with 450 additions and 158 deletions
+18
View File
@@ -7,6 +7,24 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## [Unreleased]
## [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
### Summary
+3 -1
View File
@@ -161,10 +161,12 @@ uv sync --all-extras
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 -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)
```
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.
### CI base image
+3 -1
View File
@@ -1,6 +1,6 @@
[project]
name = "python-repositories"
version = "2.0.4"
version = "2.0.5"
description = "Various python repository interfaces exposed as a python package."
authors = [
{ name = "Brian Bjarke Jensen", email = "schnitzelen@gmail.com" }
@@ -43,6 +43,8 @@ pythonpath = ["."]
addopts = "--import-mode=importlib"
markers = [
"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]
+14 -4
View File
@@ -114,12 +114,17 @@ class MinioAdapter(ObjectRepositoryInterface, ConnectionAwareAdapter):
data: BytesIO,
content_type: str = "application/octet-stream",
) -> 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
if not isinstance(object_name, str) or len(object_name) == 0:
raise ValueError("object_name must be a non-empty string")
if not isinstance(data, BytesIO) or data.getbuffer().nbytes == 0:
raise ValueError("data must be a non-empty BytesIO object")
if not isinstance(data, BytesIO):
raise ValueError("data must be a BytesIO object")
if not isinstance(content_type, str) or len(content_type) == 0:
raise ValueError("content_type must be a non-empty string")
# Check connection
@@ -143,7 +148,12 @@ class MinioAdapter(ObjectRepositoryInterface, ConnectionAwareAdapter):
)
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
if not isinstance(object_name, str) or len(object_name) == 0:
raise ValueError("object_name must be a non-empty string")
+14 -4
View File
@@ -91,12 +91,17 @@ class RedisAdapter(JsonRepositoryInterface, ConnectionAwareAdapter):
return False
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
if not isinstance(key, str) or len(key) == 0:
raise ValueError("Key must be a non-empty string")
if not isinstance(data, dict) or len(data) == 0:
raise ValueError("Data must be a non-empty dictionary")
if not isinstance(data, dict):
raise ValueError("Data must be a dictionary")
# Check connection
self._require_connected()
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()))
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
if not isinstance(key, str) or len(key) == 0:
raise ValueError("Key must be a non-empty string")
@@ -10,17 +10,27 @@ class JsonRepositoryInterface(ABC):
@abstractmethod
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
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
def delete(self, key: str) -> None:
"""Delete a JSON object by key."""
"""Delete a JSON object by key, removing it entirely."""
...
@abstractmethod
@@ -11,8 +11,10 @@ class ObjectRepositoryInterface(ABC):
def get(self, object_name: str) -> BytesIO | None:
"""Get an object by name.
Returns None when the object does not exist. Raises ConnectionError when
not connected. Other backend errors propagate to the caller.
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. Raises ConnectionError when not connected. Other backend
errors propagate to the caller.
"""
...
@@ -23,12 +25,17 @@ class ObjectRepositoryInterface(ABC):
data: BytesIO,
content_type: str = "application/octet-stream",
) -> 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
def delete(self, object_name: str) -> None:
"""Delete an object by name."""
"""Delete an object by name, removing it entirely."""
...
@abstractmethod
-135
View File
@@ -1,51 +1,9 @@
"""Integration tests configuration."""
from collections.abc import Generator
import logging
from typing import Any, cast
from minio import Minio
import pytest
import redis
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)
@@ -64,96 +22,3 @@ def configure_logging() -> None:
cache_logger_on_first_use=True,
)
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)
+55
View File
@@ -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
pytestmark = pytest.mark.integration
pytestmark = [
pytest.mark.integration,
pytest.mark.needs_redis,
pytest.mark.needs_minio,
]
@pytest.fixture(scope="module", autouse=True)
+86
View File
@@ -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
+31
View File
@@ -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)
@@ -15,7 +15,7 @@ from python_repositories.adapters.minio_adapter import MinioAdapter
from python_repositories.config import MinioConfig
from tests.conftest import TEST_MINIO_CONFIG
pytestmark = pytest.mark.integration
pytestmark = [pytest.mark.integration, pytest.mark.needs_minio]
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
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(
data: BytesIO,
minio_adapter: MinioAdapter,
+90
View File
@@ -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
+31
View File
@@ -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)
@@ -10,7 +10,7 @@ from redis.commands.json.path import Path as RedisPath
from python_repositories.adapters.redis_adapter import RedisAdapter
from python_repositories.config import RedisConfig
pytestmark = pytest.mark.integration
pytestmark = [pytest.mark.integration, pytest.mark.needs_redis]
@pytest.fixture(scope="module")
@@ -190,12 +190,45 @@ def test_should_raise_value_error_on_invalid_set_data(
) -> None:
"""Test that the RedisAdapter raises ValueError when setting with invalid data."""
key = "test_key"
invalid_data = ["", 123, None, [], {}]
invalid_data = ["", 123, None, []]
for data in invalid_data:
with pytest.raises(ValueError):
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(
redis_config: RedisConfig,
data: dict[str, str],
Generated
+2 -2
View File
@@ -1,5 +1,5 @@
version = 1
revision = 3
revision = 2
requires-python = ">=3.12"
resolution-markers = [
"python_full_version >= '3.15'",
@@ -1056,7 +1056,7 @@ wheels = [
[[package]]
name = "python-repositories"
version = "2.0.4"
version = "2.0.5"
source = { editable = "." }
dependencies = [
{ name = "python-dotenv" },