Compare commits
3
Commits
093e538d5b
...
v2.0.5
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
cd5b84b88e | ||
|
|
6d02f32ca5 | ||
|
|
57b396bcd3 |
@@ -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
|
||||
|
||||
+1
-1
@@ -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 = "[email protected]" }
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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],
|
||||
|
||||
@@ -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" },
|
||||
|
||||
Reference in New Issue
Block a user