11import asyncio
22import hashlib
33import logging
4+ import mimetypes
5+ import sys
46import threading
57from collections .abc import (
68 AsyncIterable ,
1113)
1214from functools import cached_property
1315from http import HTTPStatus
14- from mimetypes import guess_type
1516from mmap import PAGESIZE
1617from pathlib import Path
1718from tempfile import TemporaryFile
5152EMPTY_STR_HASH = hashlib .sha256 (b"" ).hexdigest ()
5253PART_SIZE = 5 * 1024 * 1024 # 5MB
5354
55+ if sys .version_info >= (3 , 13 ):
56+
57+ def _guess_content_type (path : str ) -> str | None :
58+ return mimetypes .guess_file_type (path )[0 ]
59+ else :
60+
61+ def _guess_content_type (path : str ) -> str | None :
62+ return mimetypes .guess_type (path )[0 ]
63+
64+
5465HeadersType = dict | CIMultiDict | CIMultiDictProxy
5566
5667
@@ -208,12 +219,19 @@ def _make_headers(headers: HeadersType | None) -> CIMultiDict:
208219 def _prepare_headers (
209220 self ,
210221 headers : HeadersType | None ,
211- file_path : str = "" ,
222+ path_hint : str = "" ,
212223 ) -> CIMultiDict :
224+ """Prepare headers and infer Content-Type when not explicitly set.
225+
226+ If a ``Content-Type`` header is already present it is left unchanged.
227+ Otherwise the type is guessed from *path_hint* (typically the object
228+ key or a local file path) using :func:`mimetypes.guess_type`, falling
229+ back to ``application/octet-stream``.
230+ """
213231 headers = self ._make_headers (headers )
214232
215233 if hdrs .CONTENT_TYPE not in headers :
216- content_type = guess_type ( file_path )[ 0 ]
234+ content_type = _guess_content_type ( path_hint )
217235 if content_type is None :
218236 content_type = "application/octet-stream"
219237
@@ -227,15 +245,35 @@ def put(
227245 data : bytes | str | AsyncIterable [bytes ],
228246 ** kwargs ,
229247 ) -> RequestContextManager :
230- return self .request ("PUT" , object_name , data = data , ** kwargs )
248+ headers = self ._prepare_headers (
249+ kwargs .pop ("headers" , None ),
250+ object_name ,
251+ )
252+ return self .request (
253+ "PUT" ,
254+ object_name ,
255+ data = data ,
256+ headers = headers ,
257+ ** kwargs ,
258+ )
231259
232260 def post (
233261 self ,
234262 object_name : str ,
235263 data : bytes | str | AsyncIterable [bytes ] | None = None ,
236264 ** kwargs ,
237265 ) -> RequestContextManager :
238- return self .request ("POST" , object_name , data = data , ** kwargs )
266+ headers = self ._prepare_headers (
267+ kwargs .pop ("headers" , None ),
268+ object_name ,
269+ )
270+ return self .request (
271+ "POST" ,
272+ object_name ,
273+ data = data ,
274+ headers = headers ,
275+ ** kwargs ,
276+ )
239277
240278 async def put_file (
241279 self ,
@@ -324,6 +362,8 @@ async def put_file_multipart(
324362 part_size ,
325363 )
326364
365+ headers = self ._prepare_headers (headers , str (file_path ))
366+
327367 async with (
328368 Reader (
329369 file_path ,
@@ -399,6 +439,8 @@ async def put_multipart(
399439 for integrity purposes
400440 """
401441
442+ headers = self ._prepare_headers (headers , str (object_name ))
443+
402444 with TemporaryFile () as fp :
403445
404446 def place_temp_file ():
@@ -706,6 +748,86 @@ def presign_url(
706748 )
707749 )
708750
751+ def copy (
752+ self ,
753+ source : str ,
754+ destination : str ,
755+ * ,
756+ headers : HeadersType | None = None ,
757+ replace_metadata : bool = False ,
758+ content_type : str | None = None ,
759+ content_sha256 : str = EMPTY_STR_HASH ,
760+ ** kwargs ,
761+ ) -> RequestContextManager :
762+ """Server-side copy of an S3 object.
763+
764+ source: source object key (e.g. ``bucket/key``)
765+ destination: destination object key
766+ headers: additional headers
767+ replace_metadata: when *True* the destination receives the supplied
768+ metadata instead of inheriting the source metadata
769+ content_type: optional Content-Type override for the destination
770+ """
771+ headers = self ._prepare_headers (headers , destination )
772+ source_url = self ._url / source .lstrip ("/" )
773+ headers ["x-amz-copy-source" ] = quote (
774+ source_url .path ,
775+ safe = "/" ,
776+ )
777+ if replace_metadata :
778+ headers ["x-amz-metadata-directive" ] = "REPLACE"
779+ if content_type is not None :
780+ headers [hdrs .CONTENT_TYPE ] = content_type
781+ return self .request (
782+ "PUT" ,
783+ destination ,
784+ headers = headers ,
785+ content_sha256 = content_sha256 ,
786+ ** kwargs ,
787+ )
788+
789+ async def rename (
790+ self ,
791+ source : str ,
792+ destination : str ,
793+ * ,
794+ headers : HeadersType | None = None ,
795+ ** kwargs ,
796+ ) -> None :
797+ """Move an S3 object by copying then deleting the source.
798+
799+ This operation is **not** atomic — if the delete fails after a
800+ successful copy, the object will exist at both locations.
801+
802+ source: source object key
803+ destination: destination object key
804+ headers: additional headers forwarded to ``copy()``
805+ """
806+ async with self .copy (
807+ source ,
808+ destination ,
809+ headers = headers ,
810+ ** kwargs ,
811+ ) as resp :
812+ if resp .status != HTTPStatus .OK :
813+ payload = await resp .text ()
814+ raise AwsUploadError (
815+ resp ,
816+ f"Copy failed with status { resp .status } : { payload } " ,
817+ )
818+
819+ async with self .delete (source ) as resp :
820+ if resp .status not in (
821+ HTTPStatus .OK ,
822+ HTTPStatus .NO_CONTENT ,
823+ ):
824+ payload = await resp .text ()
825+ raise AwsError (
826+ resp ,
827+ f"Delete of source failed with status "
828+ f"{ resp .status } : { payload } " ,
829+ )
830+
709831 def multipart_upload (self , object_name : str ) -> "MultipartUploader" :
710832 """
711833 Get S3MultipartUploader for object_name
0 commit comments