diff --git a/python_repositories/adapters/minio_adapter.py b/python_repositories/adapters/minio_adapter.py index 561299c..3e71d9f 100644 --- a/python_repositories/adapters/minio_adapter.py +++ b/python_repositories/adapters/minio_adapter.py @@ -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") diff --git a/python_repositories/adapters/redis_adapter.py b/python_repositories/adapters/redis_adapter.py index 70d279e..88bdbfa 100644 --- a/python_repositories/adapters/redis_adapter.py +++ b/python_repositories/adapters/redis_adapter.py @@ -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") diff --git a/python_repositories/interfaces/json_repository_interface.py b/python_repositories/interfaces/json_repository_interface.py index 45c70e7..66ae079 100644 --- a/python_repositories/interfaces/json_repository_interface.py +++ b/python_repositories/interfaces/json_repository_interface.py @@ -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 diff --git a/python_repositories/interfaces/object_repository_interface.py b/python_repositories/interfaces/object_repository_interface.py index 54cb9b4..85482f9 100644 --- a/python_repositories/interfaces/object_repository_interface.py +++ b/python_repositories/interfaces/object_repository_interface.py @@ -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 diff --git a/tests/integration/minio/minio_adapter_test.py b/tests/integration/minio/minio_adapter_test.py index 21d12c7..1c1584d 100644 --- a/tests/integration/minio/minio_adapter_test.py +++ b/tests/integration/minio/minio_adapter_test.py @@ -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, diff --git a/tests/integration/redis/redis_adapter_test.py b/tests/integration/redis/redis_adapter_test.py index 4369d22..e85e705 100644 --- a/tests/integration/redis/redis_adapter_test.py +++ b/tests/integration/redis/redis_adapter_test.py @@ -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],