3131from ..utils import get_stream_chunk
3232
3333__all__ = (
34+ "OnUpload" ,
3435 "S3MultipartUpload" ,
3536 "S3Object" ,
3637 "S3ObjectParams" ,
@@ -200,10 +201,10 @@ class S3Session:
200201 ...
201202 """
202203
203- endpoint_url : str
204- access_key : str
205- secret_key : str
206- region : str
204+ endpoint_url : str | None = None
205+ access_key : str | None = None
206+ secret_key : str | None = None
207+ region : str | None = None
207208 s3_config : Config | None = None
208209 s3_client : botocore .client .BaseClient | None = None
209210 http_client : niquests .AsyncSession = field (default_factory = niquests .AsyncSession )
@@ -212,7 +213,8 @@ class S3Session:
212213 def __post_init__ (self ):
213214 if self .s3_client is None :
214215 self ._botocore_session = botocore .session .Session ()
215- self ._botocore_session .set_credentials (self .access_key , self .secret_key )
216+ if self .access_key is not None and self .secret_key is not None :
217+ self ._botocore_session .set_credentials (self .access_key , self .secret_key )
216218 self .s3_client = self ._botocore_session .create_client (
217219 "s3" ,
218220 endpoint_url = self .endpoint_url ,
@@ -307,7 +309,7 @@ async def file_upload(
307309 data : AsyncIterator [bytes ],
308310 * ,
309311 min_part_size : int = 5 * 1024 * 1024 ,
310- on_chunk_received : Callable [[ bytes ], None ] | None = None ,
312+ on_upload : OnUpload | None = None ,
311313 content_length : int | None = None ,
312314 ** kwargs : Unpack [S3ObjectParams ],
313315 ) -> None :
@@ -319,7 +321,7 @@ async def file_upload(
319321 key ,
320322 data ,
321323 min_part_size = min_part_size ,
322- on_chunk_received = on_chunk_received ,
324+ on_upload = on_upload ,
323325 content_length = content_length ,
324326 ** kwargs ,
325327 )
@@ -392,7 +394,7 @@ class UploadPart(TypedDict):
392394class S3MultipartUpload (NamedTuple ):
393395 fetch_create : Callable [[], Awaitable [str ]]
394396 fetch_complete : Callable [[], Awaitable [niquests .Response ]]
395- upload_part : Callable [[bytes ], Awaitable [UploadPart ]]
397+ upload_part : Callable [[bytes | bytearray ], Awaitable [UploadPart ]]
396398 generate_presigned_url : Callable [..., str ]
397399 fetch_abort : Callable [[], Awaitable [niquests .Response ]]
398400
@@ -508,12 +510,21 @@ async def s3_list_files(
508510 break
509511
510512
513+ type OnUpload = Callable [[niquests .PreparedRequest ], None ]
514+
515+
516+ def _upload_hooks (on_upload : OnUpload | None ) -> dict | None :
517+ return {"on_upload" : [on_upload ]} if on_upload else None
518+
519+
511520async def s3_put_object (
512521 s3 : botocore .client .BaseClient ,
513522 client : niquests .AsyncSession ,
514523 bucket : str ,
515524 key : str ,
516- data : bytes ,
525+ data : bytes | bytearray ,
526+ * ,
527+ on_upload : OnUpload | None = None ,
517528 ** kwargs : Unpack [S3ObjectParams ],
518529) -> niquests .Response :
519530 """
@@ -529,7 +540,9 @@ async def s3_put_object(
529540 ClientMethod = "put_object" ,
530541 Params = presigned_params ,
531542 )
532- resp = (await client .put (url , data = data , headers = headers if headers else None )).raise_for_status ()
543+ resp = (
544+ await client .put (url , data = data , headers = headers if headers else None , hooks = _upload_hooks (on_upload ))
545+ ).raise_for_status ()
533546 return resp
534547
535548
@@ -638,6 +651,7 @@ async def s3_multipart_upload(
638651 key : str ,
639652 * ,
640653 expires_in : int = 3600 ,
654+ on_upload : OnUpload | None = None ,
641655 ** kwargs : Unpack [S3ObjectParams ],
642656) -> AsyncIterator [S3MultipartUpload ]:
643657 """Async context manager for S3 multipart upload with automatic cleanup."""
@@ -670,12 +684,12 @@ async def fetch_abort():
670684 _has_been_aborted = True
671685 return abort_resp
672686
673- async def upload_part (data : bytes ) -> UploadPart :
687+ async def upload_part (data : bytes | bytearray ) -> UploadPart :
674688 nonlocal _part_number , _parts
675689 if upload_id is None :
676690 raise ValueError ("Upload ID is not set" )
677691 presigned_url = _generate_presigned_url ("upload_part" , UploadId = upload_id , PartNumber = _part_number )
678- upload_resp = (await client .put (presigned_url , data = data )).raise_for_status ()
692+ upload_resp = (await client .put (presigned_url , data = data , hooks = _upload_hooks ( on_upload ) )).raise_for_status ()
679693 _etag = upload_resp .headers .get ("ETag" )
680694 etag : str | None = _etag .decode () if isinstance (_etag , bytes ) else _etag
681695 _part : UploadPart = {"PartNumber" : _part_number , "ETag" : etag }
@@ -723,38 +737,35 @@ async def s3_file_upload(
723737 * ,
724738 # 5MB minimum for S3 parts
725739 min_part_size : int = 5 * 1024 * 1024 ,
726- on_chunk_received : Callable [[ bytes ], None ] | None = None ,
740+ on_upload : OnUpload | None = None ,
727741 content_length : int | None = None ,
728742 ** kwargs : Unpack [S3ObjectParams ],
729743) -> None :
730744 """
731745 Upload a file to S3 from an async byte stream.
732746
733747 Uses multipart upload for large files. If `content_length` is provided and smaller
734- than `min_part_size`, uses a single PUT instead. Use `on_chunk_received` callback
735- to track upload progress.
748+ than `min_part_size`, uses a single PUT instead. The optional `on_upload` callback
749+ receives a `niquests.PreparedRequest` with an `upload_progress` attribute for
750+ fine-grained byte-level progress tracking.
736751 """
737752 if content_length is not None and content_length < min_part_size :
738753 # Small file - use single PUT operation
739- _data = b""
754+ _data = bytearray ()
740755 async for chunk in data :
741- _data += chunk
742- if on_chunk_received :
743- on_chunk_received (chunk )
744- await s3_put_object (s3 , client , bucket = bucket , key = key , data = _data , ** kwargs )
756+ _data .extend (chunk )
757+ await s3_put_object (s3 , client , bucket = bucket , key = key , data = bytes (_data ), on_upload = on_upload , ** kwargs )
745758 return
746759
747- async with s3_multipart_upload (s3 , client , bucket = bucket , key = key , ** kwargs ) as mpart :
760+ async with s3_multipart_upload (s3 , client , bucket = bucket , key = key , on_upload = on_upload , ** kwargs ) as mpart :
748761 await mpart .fetch_create ()
749762 has_uploaded_parts = False
750763 async for chunk in get_stream_chunk (data , min_size = min_part_size ):
751- if on_chunk_received :
752- on_chunk_received (chunk )
753764 if len (chunk ) < min_part_size :
754765 if not has_uploaded_parts :
755766 # No parts uploaded yet, abort multipart and use single PUT
756767 await mpart .fetch_abort ()
757- await s3_put_object (s3 , client , bucket = bucket , key = key , data = chunk , ** kwargs )
768+ await s3_put_object (s3 , client , bucket = bucket , key = key , data = chunk , on_upload = on_upload , ** kwargs )
758769 else :
759770 # Parts already uploaded, upload final chunk as last part (S3 allows last part to be smaller)
760771 await mpart .upload_part (chunk )
0 commit comments