|
1 | 1 | import logging |
2 | 2 | from collections.abc import Callable |
3 | | -from typing import Any, NoReturn, TypeVar, override |
| 3 | +from typing import Any, BinaryIO, NoReturn, TypeVar, override |
4 | 4 |
|
5 | 5 | import boto3 |
6 | 6 | from botocore.client import Config |
@@ -811,3 +811,117 @@ def copy_object( |
811 | 811 | self._handle_connection_exception(e, "copy_object") |
812 | 812 | except Exception as e: |
813 | 813 | 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 |
0 commit comments