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 <[email protected]>
This commit is contained in:
Brian Bjarke Jensen
2026-07-10 21:39:51 +02:00
co-authored by Cursor
parent 093e538d5b
commit 57b396bcd3
6 changed files with 124 additions and 16 deletions
+14 -4
View File
@@ -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")
+14 -4
View File
@@ -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")
@@ -10,17 +10,27 @@ class JsonRepositoryInterface(ABC):
@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
@@ -11,8 +11,10 @@ class ObjectRepositoryInterface(ABC):
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 +25,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
@@ -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,
+34 -1
View File
@@ -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],