Skip to content

Commit c3afbaa

Browse files
Merge pull request #119 from negatic/add-minio-streaming
add minio object streaming
2 parents a80fc40 + a847f71 commit c3afbaa

4 files changed

Lines changed: 197 additions & 2 deletions

File tree

archipy/adapters/minio/adapters.py

Lines changed: 115 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import logging
22
from collections.abc import Callable
3-
from typing import Any, NoReturn, TypeVar, override
3+
from typing import Any, BinaryIO, NoReturn, TypeVar, override
44

55
import boto3
66
from botocore.client import Config
@@ -811,3 +811,117 @@ def copy_object(
811811
self._handle_connection_exception(e, "copy_object")
812812
except Exception as e:
813813
self._handle_general_exception(e, "copy_object")
814+
815+
@override
816+
def put_object_stream(
817+
self,
818+
bucket_name: str,
819+
object_name: str,
820+
data: bytes | BinaryIO,
821+
length: int = -1,
822+
content_type: str = "application/octet-stream",
823+
) -> None:
824+
"""Upload data from a bytes buffer or binary stream to a bucket.
825+
826+
Unlike put_object which requires a local file path, this method accepts
827+
in-memory bytes or any binary stream, avoiding the need for a temporary file.
828+
829+
Args:
830+
bucket_name: Destination bucket name.
831+
object_name: Object name in the bucket.
832+
data: Content to upload as raw bytes or a binary stream (BinaryIO).
833+
length: Content length in bytes. If -1, computed automatically for bytes;
834+
for streams, providing the exact length avoids buffering overhead.
835+
content_type: MIME type of the content. Defaults to "application/octet-stream".
836+
837+
Raises:
838+
InvalidArgumentError: If bucket_name, object_name, or data is invalid.
839+
NotFoundError: If the bucket does not exist.
840+
PermissionDeniedError: If permission to upload is denied.
841+
ResourceExhaustedError: If storage limits are exceeded.
842+
ServiceUnavailableError: If the S3 service is unavailable.
843+
StorageError: If there's a storage-related error.
844+
"""
845+
try:
846+
if not bucket_name or not object_name:
847+
raise InvalidArgumentError(
848+
argument_name=(
849+
"bucket_name or object_name"
850+
if not all([bucket_name, object_name])
851+
else "bucket_name"
852+
if not bucket_name
853+
else "object_name"
854+
),
855+
)
856+
857+
kwargs: dict[str, Any] = {
858+
"Bucket": bucket_name,
859+
"Key": object_name,
860+
"Body": data,
861+
"ContentType": content_type,
862+
}
863+
864+
if isinstance(data, bytes):
865+
kwargs["ContentLength"] = len(data) if length < 0 else length
866+
elif length >= 0:
867+
kwargs["ContentLength"] = length
868+
869+
self._client.put_object(**kwargs)
870+
if hasattr(self.list_objects, "clear_cache"):
871+
self.list_objects.clear_cache()
872+
except InvalidArgumentError:
873+
raise
874+
except ClientError as e:
875+
self._handle_client_exception(e, "put_object_stream")
876+
except (ConnectionError, EndpointConnectionError) as e:
877+
self._handle_connection_exception(e, "put_object_stream")
878+
except Exception as e:
879+
self._handle_general_exception(e, "put_object_stream")
880+
881+
@override
882+
def get_object_stream(self, bucket_name: str, object_name: str) -> bytes:
883+
"""Download an object and return its content as bytes.
884+
885+
Unlike get_object which requires a local file path, this method returns
886+
the object content directly in memory, avoiding a temporary file.
887+
888+
Args:
889+
bucket_name: Source bucket name.
890+
object_name: Object name in the bucket.
891+
892+
Returns:
893+
bytes: The full content of the object.
894+
895+
Raises:
896+
InvalidArgumentError: If any required parameter is empty.
897+
NotFoundError: If the bucket or object does not exist.
898+
PermissionDeniedError: If permission to download is denied.
899+
ServiceUnavailableError: If the S3 service is unavailable.
900+
StorageError: If there's a storage-related error.
901+
"""
902+
try:
903+
if not bucket_name or not object_name:
904+
raise InvalidArgumentError(
905+
argument_name=(
906+
"bucket_name or object_name"
907+
if not all([bucket_name, object_name])
908+
else "bucket_name"
909+
if not bucket_name
910+
else "object_name"
911+
),
912+
)
913+
response = self._client.get_object(Bucket=bucket_name, Key=object_name)
914+
except InvalidArgumentError:
915+
raise
916+
except ClientError as e:
917+
self._handle_client_exception(e, "get_object_stream")
918+
raise
919+
except (ConnectionError, EndpointConnectionError) as e:
920+
self._handle_connection_exception(e, "get_object_stream")
921+
raise
922+
except Exception as e:
923+
self._handle_general_exception(e, "get_object_stream")
924+
raise
925+
else:
926+
content: bytes = response["Body"].read()
927+
return content

archipy/adapters/minio/ports.py

Lines changed: 41 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
"""MinIO port definitions for ArchiPy."""
22

33
from abc import abstractmethod
4-
from typing import Any
4+
from typing import Any, BinaryIO
55

66
# Define type aliases for better type hinting
77
MinioObjectType = dict[str, Any]
@@ -110,3 +110,43 @@ def copy_object(
110110
) -> None:
111111
"""Copy an object within or between buckets."""
112112
raise NotImplementedError
113+
114+
@abstractmethod
115+
def put_object_stream(
116+
self,
117+
bucket_name: str,
118+
object_name: str,
119+
data: bytes | BinaryIO,
120+
length: int = -1,
121+
content_type: str = "application/octet-stream",
122+
) -> None:
123+
"""Upload data from a bytes buffer or binary stream to a bucket.
124+
125+
Unlike put_object which requires a local file path, this method accepts
126+
in-memory bytes or any binary stream, avoiding the need for a temporary file.
127+
128+
Args:
129+
bucket_name: Destination bucket name.
130+
object_name: Object name in the bucket.
131+
data: Content to upload as raw bytes or a binary stream (BinaryIO).
132+
length: Content length in bytes. If -1, computed automatically for bytes;
133+
for streams, providing the exact length avoids buffering overhead.
134+
content_type: MIME type of the content. Defaults to "application/octet-stream".
135+
"""
136+
raise NotImplementedError
137+
138+
@abstractmethod
139+
def get_object_stream(self, bucket_name: str, object_name: str) -> bytes:
140+
"""Download an object and return its content as bytes.
141+
142+
Unlike get_object which requires a local file path, this method returns
143+
the object content directly in memory, avoiding a temporary file.
144+
145+
Args:
146+
bucket_name: Source bucket name.
147+
object_name: Object name in the bucket.
148+
149+
Returns:
150+
bytes: The full content of the object.
151+
"""
152+
raise NotImplementedError

features/minio_adapter.feature

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -45,3 +45,15 @@ Feature: MinIO Operations Testing
4545
When I copy object "test.txt" from bucket "test-bucket" to "test-copy.txt" in the same bucket
4646
Then the object "test-copy.txt" should exist in bucket "test-bucket"
4747
And downloading "test-copy.txt" from "test-bucket" should return content "Hello World"
48+
49+
Scenario: Upload bytes buffer and retrieve via stream
50+
Given a bucket named "test-bucket" exists
51+
When I upload the bytes "Hello Streaming World" as "stream-bytes.txt" to bucket "test-bucket"
52+
Then the object "stream-bytes.txt" should exist in bucket "test-bucket"
53+
And the streaming download of "stream-bytes.txt" from "test-bucket" should return "Hello Streaming World"
54+
55+
Scenario: Upload binary stream and retrieve via stream
56+
Given a bucket named "test-bucket" exists
57+
When I upload a binary stream with content "Binary Stream Data" as "stream-bio.txt" to bucket "test-bucket"
58+
Then the object "stream-bio.txt" should exist in bucket "test-bucket"
59+
And the streaming download of "stream-bio.txt" from "test-bucket" should return "Binary Stream Data"

features/steps/minio_adapter_steps.py

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
# features/steps/minio_steps.py
2+
import io
23
import json
34
import os
45
import tempfile
@@ -218,3 +219,31 @@ def step_verify_policy(context, bucket_name):
218219
assert "s3:GetObject" in policy, "Policy doesn't contain read permission"
219220
assert "s3:PutObject" not in policy, "Policy contains write permission"
220221
context.logger.info(f"Verified read-only policy for '{bucket_name}'")
222+
223+
224+
@when('I upload the bytes "{content}" as "{object_name}" to bucket "{bucket_name}"')
225+
def step_upload_bytes_stream(context, content, object_name, bucket_name):
226+
adapter = get_minio_adapter(context)
227+
data = content.encode("utf-8")
228+
adapter.put_object_stream(bucket_name, object_name, data, content_type="text/plain; charset=utf-8")
229+
context.logger.info(f"Uploaded bytes stream '{object_name}' to '{bucket_name}'")
230+
231+
232+
@when('I upload a binary stream with content "{content}" as "{object_name}" to bucket "{bucket_name}"')
233+
def step_upload_binary_stream(context, content, object_name, bucket_name):
234+
adapter = get_minio_adapter(context)
235+
raw = content.encode("utf-8")
236+
stream = io.BytesIO(raw)
237+
adapter.put_object_stream(
238+
bucket_name, object_name, stream, length=len(raw), content_type="text/plain; charset=utf-8"
239+
)
240+
context.logger.info(f"Uploaded binary stream '{object_name}' to '{bucket_name}'")
241+
242+
243+
@then('the streaming download of "{object_name}" from "{bucket_name}" should return "{expected_content}"')
244+
def step_stream_download_verify(context, object_name, bucket_name, expected_content):
245+
adapter = get_minio_adapter(context)
246+
content_bytes = adapter.get_object_stream(bucket_name, object_name)
247+
content = content_bytes.decode("utf-8")
248+
assert content == expected_content, f"Content mismatch: expected '{expected_content}', got '{content}'"
249+
context.logger.info(f"Verified streaming download content of '{object_name}'")

0 commit comments

Comments
 (0)