22import hashlib
33import re
44import time
5+ import uuid
56from typing import Iterable , Optional
67import os
8+ from pathlib import Path
79
810import boto3
11+ import datetime as dt
912import pytest
1013from botocore .client import Config
1114from botocore .exceptions import ClientError
1215from mypy_boto3_s3 .client import S3Client
1316from mypy_boto3_s3 .type_defs import CreateBucketOutputTypeDef , PutObjectOutputTypeDef
17+ from s3transfer .manager import TransferManager
18+ from boto3 .s3 .transfer import TransferConfig
1419from testcontainers .core .container import DockerContainer # type: ignore[import-untyped]
1520from testcontainers .core .wait_strategies import LogMessageWaitStrategy
1621
1722UPLOAD_FILE_NAME = 'testfile.txt'
23+ UPLOAD_FILE_LENGTH = Path (UPLOAD_FILE_NAME ).stat ().st_size
24+ REGION = os .getenv ("AWS_REGION" , "us-east-1" )
25+ ONE_MB = 1024 * 1024
1826
1927container = (
2028 DockerContainer ("adobe/s3mock:4.9.0" )
@@ -94,6 +102,7 @@ def s3_client(endpoint_url) -> S3Client:
94102 retries = {'max_attempts' : _MAX_RETRIES },
95103 signature_version = 's3v4' ,
96104 s3 = {'addressing_style' : 'path' },
105+ max_pool_connections = 100 ,
97106 )
98107 return boto3 .client (
99108 's3' ,
@@ -105,6 +114,19 @@ def s3_client(endpoint_url) -> S3Client:
105114 verify = False , # Skip SSL certificate verification (use only in tests)
106115 )
107116
117+ @pytest .fixture (scope = "session" , autouse = True )
118+ def transfer_manager (s3_client : S3Client ) -> TransferManager :
119+ """
120+ Create a Transfer Manager equivalent with multipart enabled and high concurrency.
121+ """
122+ transfer_config = TransferConfig (
123+ multipart_threshold = 8 * 1024 * 1024 , # 8 MiB threshold for multipart
124+ multipart_chunksize = 8 * 1024 * 1024 , # 8 MiB parts
125+ max_concurrency = 100 , # similar to CRT maxConcurrency
126+ use_threads = True , # parallel uploads/downloads
127+ )
128+ return TransferManager (s3_client , config = transfer_config )
129+
108130@pytest .fixture (scope = "session" , autouse = True )
109131def s3_client_http (endpoint_url_http ) -> S3Client :
110132 config = Config (
@@ -113,6 +135,7 @@ def s3_client_http(endpoint_url_http) -> S3Client:
113135 retries = {'max_attempts' : _MAX_RETRIES },
114136 signature_version = 's3v4' ,
115137 s3 = {'addressing_style' : 'path' },
138+ max_pool_connections = 100 ,
116139 )
117140 return boto3 .client (
118141 's3' ,
@@ -123,6 +146,10 @@ def s3_client_http(endpoint_url_http) -> S3Client:
123146 endpoint_url = endpoint_url_http ,
124147 )
125148
149+ def upload_file_bytes () -> bytes :
150+ with open ('testfile.txt' , 'rb' ) as file :
151+ return file .read ()
152+
126153def delete_multipart_uploads (s3_client : S3Client , bucket_name : str ) -> None :
127154 """
128155 Abort all in-progress multipart uploads in the specified bucket.
@@ -218,9 +245,13 @@ def given_bucket(s3_client: S3Client, bucket_name: str) -> CreateBucketOutputTyp
218245 s3_client .get_waiter ("bucket_exists" ).wait (Bucket = bucket_name )
219246 return bucket
220247
221- def given_object (s3_client : S3Client , bucket_name : str , object_name : str = UPLOAD_FILE_NAME ) -> PutObjectOutputTypeDef :
222- with open ('testfile.txt' , 'rb' ) as file :
223- return s3_client .put_object (Bucket = bucket_name , Key = object_name , Body = file .read ())
248+ def given_object (s3_client : S3Client , bucket_name : str , object_name : str = UPLOAD_FILE_NAME , ** kwargs ) -> PutObjectOutputTypeDef :
249+ return s3_client .put_object (
250+ Bucket = bucket_name ,
251+ Key = object_name ,
252+ Body = upload_file_bytes (),
253+ ** kwargs
254+ )
224255
225256def compute_md5_etag (data : bytes ) -> str :
226257 # S3 single-part ETag is the hex MD5 in quotes
@@ -230,3 +261,14 @@ def compute_md5_etag(data: bytes) -> str:
230261def compute_sha256_checksum_b64 (data : bytes ) -> str :
231262 # AWS returns base64-encoded checksum for SHA256
232263 return base64 .b64encode (hashlib .sha256 (data ).digest ()).decode ("ascii" )
264+
265+ def random_name () -> str :
266+ return f"{ uuid .uuid4 ().hex } " [:63 ]
267+
268+ def special_key () -> str :
269+ # Includes spaces, unicode, URL-reserved chars that require escaping
270+ return 'spécial key/with spaces & symbols?#[]@!$&\' ()*+,;=.txt'
271+
272+ def now_utc () -> dt .datetime :
273+ # Use naive UTC timestamps as boto3 expects
274+ return dt .datetime .now (dt .UTC ).replace (tzinfo = None )
0 commit comments