6
6
import shutil
7
7
from contextlib import closing
8
8
from datetime import timezone
9
- from functools import wraps , partial
10
- from io import TextIOBase
9
+ from functools import partial , wraps
10
+ from io import TextIOBase , TextIOWrapper
11
11
from tempfile import SpooledTemporaryFile
12
12
from threading import local
13
13
from urllib .parse import urljoin , urlsplit , urlunsplit
14
14
15
15
import boto3
16
- from botocore .client import Config
17
16
from boto3 .s3 .transfer import TransferConfig
17
+ from botocore .client import Config
18
18
from botocore .exceptions import ClientError
19
19
from django .conf import settings
20
20
from django .contrib .staticfiles .storage import ManifestFilesMixin
@@ -41,6 +41,7 @@ def _do_wrap_errors(self, name, *args, **kwargs):
41
41
if code == "NoSuchKey" :
42
42
err_cls = FileNotFoundError
43
43
raise err_cls (f"S3Storage error at { name !r} : { force_str (ex )} " )
44
+
44
45
return _do_wrap_errors
45
46
46
47
@@ -63,6 +64,7 @@ def do_wrap_path_impl(self, name, *args, **kwargs):
63
64
# posix paths. We fix this by converting paths to system form, passing them to the default implementation, then
64
65
# converting them back to posix paths.
65
66
return _to_posix_path (func (self , _to_sys_path (name ), * args , ** kwargs ))
67
+
66
68
return do_wrap_path_impl
67
69
68
70
@@ -108,18 +110,24 @@ def __init__(self, storage):
108
110
if storage .settings .AWS_ACCESS_KEY_ID :
109
111
connection_kwargs ["aws_access_key_id" ] = storage .settings .AWS_ACCESS_KEY_ID
110
112
if storage .settings .AWS_SECRET_ACCESS_KEY :
111
- connection_kwargs ["aws_secret_access_key" ] = storage .settings .AWS_SECRET_ACCESS_KEY
113
+ connection_kwargs [
114
+ "aws_secret_access_key"
115
+ ] = storage .settings .AWS_SECRET_ACCESS_KEY
112
116
if storage .settings .AWS_SESSION_TOKEN :
113
117
connection_kwargs ["aws_session_token" ] = storage .settings .AWS_SESSION_TOKEN
114
118
if storage .settings .AWS_S3_ENDPOINT_URL :
115
119
connection_kwargs ["endpoint_url" ] = storage .settings .AWS_S3_ENDPOINT_URL
116
120
self .session = boto3 .session .Session ()
117
- self .s3_connection = self .session .client ("s3" , config = Config (
118
- s3 = {"addressing_style" : storage .settings .AWS_S3_ADDRESSING_STYLE },
119
- signature_version = storage .settings .AWS_S3_SIGNATURE_VERSION ,
120
- max_pool_connections = storage .settings .AWS_S3_MAX_POOL_CONNECTIONS ,
121
- connect_timeout = storage .settings .AWS_S3_CONNECT_TIMEOUT
122
- ), ** connection_kwargs )
121
+ self .s3_connection = self .session .client (
122
+ "s3" ,
123
+ config = Config (
124
+ s3 = {"addressing_style" : storage .settings .AWS_S3_ADDRESSING_STYLE },
125
+ signature_version = storage .settings .AWS_S3_SIGNATURE_VERSION ,
126
+ max_pool_connections = storage .settings .AWS_S3_MAX_POOL_CONNECTIONS ,
127
+ connect_timeout = storage .settings .AWS_S3_CONNECT_TIMEOUT ,
128
+ ),
129
+ ** connection_kwargs ,
130
+ )
123
131
124
132
125
133
@deconstructible
@@ -155,7 +163,7 @@ class S3Storage(Storage):
155
163
"AWS_S3_FILE_OVERWRITE" : False ,
156
164
"AWS_S3_USE_THREADS" : True ,
157
165
"AWS_S3_MAX_POOL_CONNECTIONS" : 10 ,
158
- "AWS_S3_CONNECT_TIMEOUT" : 60 # 60 seconds
166
+ "AWS_S3_CONNECT_TIMEOUT" : 60 , # 60 seconds
159
167
}
160
168
161
169
s3_settings_suffix = ""
@@ -178,16 +186,24 @@ def _setup(self):
178
186
setting_key ,
179
187
self ._kwargs .get (
180
188
setting_key .lower (),
181
- getattr (settings , setting_key + self .s3_settings_suffix , setting_default_value ),
189
+ getattr (
190
+ settings ,
191
+ setting_key + self .s3_settings_suffix ,
192
+ setting_default_value ,
193
+ ),
182
194
),
183
195
)
184
196
# Validate settings.
185
197
if not self .settings .AWS_S3_BUCKET_NAME :
186
- raise ImproperlyConfigured (f"Setting AWS_S3_BUCKET_NAME{ self .s3_settings_suffix } is required." )
198
+ raise ImproperlyConfigured (
199
+ f"Setting AWS_S3_BUCKET_NAME{ self .s3_settings_suffix } is required."
200
+ )
187
201
# Create a thread-local connection manager.
188
202
self ._connections = _Local (self )
189
203
# Set transfer config for S3 operations
190
- self ._transfer_config = TransferConfig (use_threads = self .settings .AWS_S3_USE_THREADS )
204
+ self ._transfer_config = TransferConfig (
205
+ use_threads = self .settings .AWS_S3_USE_THREADS
206
+ )
191
207
192
208
@property
193
209
def s3_connection (self ):
@@ -201,8 +217,8 @@ def __init__(self, **kwargs):
201
217
# Check for unknown kwargs.
202
218
for kwarg_key in kwargs .keys ():
203
219
if (
204
- kwarg_key .upper () not in self .default_auth_settings and
205
- kwarg_key .upper () not in self .default_s3_settings
220
+ kwarg_key .upper () not in self .default_auth_settings
221
+ and kwarg_key .upper () not in self .default_s3_settings
206
222
):
207
223
raise ImproperlyConfigured (f"Unknown S3Storage parameter: { kwarg_key } " )
208
224
# Set up the storage.
@@ -211,7 +227,9 @@ def __init__(self, **kwargs):
211
227
# Re-initialize the storage if an AWS setting changes.
212
228
setting_changed .connect (self ._setting_changed_received )
213
229
# Register system checks.
214
- checks .register (partial (self .__class__ ._system_checks , self ), checks .Tags .security )
230
+ checks .register (
231
+ partial (self .__class__ ._system_checks , self ), checks .Tags .security
232
+ )
215
233
# All done!
216
234
super ().__init__ ()
217
235
@@ -237,7 +255,9 @@ def __reduce__(self):
237
255
def _get_key_name (self , name ):
238
256
if name .startswith ("/" ):
239
257
name = name [1 :]
240
- return posixpath .normpath (posixpath .join (self .settings .AWS_S3_KEY_PREFIX , _to_posix_path (name )))
258
+ return posixpath .normpath (
259
+ posixpath .join (self .settings .AWS_S3_KEY_PREFIX , _to_posix_path (name ))
260
+ )
241
261
242
262
def _object_params (self , name ):
243
263
params = {
@@ -256,22 +276,29 @@ def _object_put_params(self, name):
256
276
),
257
277
"Metadata" : {
258
278
key : _callable_setting (value , name )
259
- for key , value
260
- in self .settings .AWS_S3_METADATA .items ()
279
+ for key , value in self .settings .AWS_S3_METADATA .items ()
261
280
},
262
- "StorageClass" : "REDUCED_REDUNDANCY" if self .settings .AWS_S3_REDUCED_REDUNDANCY else "STANDARD" ,
281
+ "StorageClass" : "REDUCED_REDUNDANCY"
282
+ if self .settings .AWS_S3_REDUCED_REDUNDANCY
283
+ else "STANDARD" ,
263
284
}
264
285
params .update (self ._object_params (name ))
265
286
# Set content disposition.
266
- content_disposition = _callable_setting (self .settings .AWS_S3_CONTENT_DISPOSITION , name )
287
+ content_disposition = _callable_setting (
288
+ self .settings .AWS_S3_CONTENT_DISPOSITION , name
289
+ )
267
290
if content_disposition :
268
291
params ["ContentDisposition" ] = content_disposition
269
292
# Set content langauge.
270
- content_langauge = _callable_setting (self .settings .AWS_S3_CONTENT_LANGUAGE , name )
293
+ content_langauge = _callable_setting (
294
+ self .settings .AWS_S3_CONTENT_LANGUAGE , name
295
+ )
271
296
if content_langauge :
272
297
params ["ContentLanguage" ] = content_langauge
273
298
# Set server-side encryption.
274
- if self .settings .AWS_S3_ENCRYPT_KEY : # If this if False / None / empty then no encryption
299
+ if (
300
+ self .settings .AWS_S3_ENCRYPT_KEY
301
+ ): # If this if False / None / empty then no encryption
275
302
if isinstance (self .settings .AWS_S3_ENCRYPT_KEY , str ):
276
303
params ["ServerSideEncryption" ] = self .settings .AWS_S3_ENCRYPT_KEY
277
304
if self .settings .AWS_S3_KMS_ENCRYPTION_KEY_ID :
@@ -283,11 +310,11 @@ def _object_put_params(self, name):
283
310
284
311
def new_temporary_file (self ):
285
312
"""Returns a new file to use when opening from or saving to S3"""
286
- return SpooledTemporaryFile (max_size = 1024 * 1024 * 10 ) # 10 MB.
313
+ return SpooledTemporaryFile (max_size = 1024 * 1024 * 10 ) # 10 MB.
287
314
288
315
@_wrap_errors
289
316
def _open (self , name , mode = "rb" ):
290
- if mode != "rb" :
317
+ if mode not in ( "rb" , "rt" , "r" ) :
291
318
raise ValueError ("S3 files can only be opened in read-only mode" )
292
319
# Load the key into a temporary file. It would be nice to stream the
293
320
# content, but S3 doesn't support seeking, which is sometimes needed.
@@ -298,6 +325,9 @@ def _open(self, name, mode="rb"):
298
325
# Un-gzip if required.
299
326
if obj .get ("ContentEncoding" ) == "gzip" :
300
327
content = gzip .GzipFile (name , "rb" , fileobj = content )
328
+ # Decode text if required.
329
+ if "b" not in mode :
330
+ content = TextIOWrapper (content )
301
331
# All done!
302
332
return S3File (content , name , self )
303
333
@@ -325,7 +355,12 @@ def _save(self, name, content):
325
355
# Check if the content type is compressible.
326
356
content_type_family , content_type_subtype = content_type .lower ().split ("/" )
327
357
content_type_subtype = content_type_subtype .split ("+" )[- 1 ]
328
- if content_type_family == "text" or content_type_subtype in ("xml" , "json" , "html" , "javascript" ):
358
+ if content_type_family == "text" or content_type_subtype in (
359
+ "xml" ,
360
+ "json" ,
361
+ "html" ,
362
+ "javascript" ,
363
+ ):
329
364
# Compress the content.
330
365
temp_file = self .new_temporary_file ()
331
366
temp_files .append (temp_file )
@@ -337,7 +372,9 @@ def _save(self, name, content):
337
372
temp_file .seek (0 )
338
373
content = temp_file
339
374
put_params ["ContentEncoding" ] = "gzip"
340
- put_params ["Metadata" ][_UNCOMPRESSED_SIZE_META_KEY ] = f"{ orig_size :d} "
375
+ put_params ["Metadata" ][
376
+ _UNCOMPRESSED_SIZE_META_KEY
377
+ ] = f"{ orig_size :d} "
341
378
else :
342
379
content .seek (0 )
343
380
# Save the file.
@@ -346,8 +383,13 @@ def _save(self, name, content):
346
383
original_close = content .close
347
384
content .close = lambda : None
348
385
try :
349
- self .s3_connection .upload_fileobj (content , put_params .pop ('Bucket' ), put_params .pop ('Key' ),
350
- ExtraArgs = put_params , Config = self ._transfer_config )
386
+ self .s3_connection .upload_fileobj (
387
+ content ,
388
+ put_params .pop ("Bucket" ),
389
+ put_params .pop ("Key" ),
390
+ ExtraArgs = put_params ,
391
+ Config = self ._transfer_config ,
392
+ )
351
393
finally :
352
394
# Restore the original close method.
353
395
content .close = original_close
@@ -385,8 +427,8 @@ def delete(self, name):
385
427
@_wrap_errors
386
428
def copy (self , src_name , dst_name ):
387
429
self .s3_connection .copy_object (
388
- CopySource = self ._object_params (src_name ),
389
- ** self . _object_params ( dst_name ) )
430
+ CopySource = self ._object_params (src_name ), ** self . _object_params ( dst_name )
431
+ )
390
432
391
433
@_wrap_errors
392
434
def rename (self , src_name , dst_name ):
@@ -402,7 +444,8 @@ def exists(self, name):
402
444
results = self .s3_connection .list_objects_v2 (
403
445
Bucket = self .settings .AWS_S3_BUCKET_NAME ,
404
446
MaxKeys = 1 ,
405
- Prefix = self ._get_key_name (name ) + "/" , # Add the slash again, since _get_key_name removes it.
447
+ Prefix = self ._get_key_name (name )
448
+ + "/" , # Add the slash again, since _get_key_name removes it.
406
449
)
407
450
except ClientError :
408
451
return False
@@ -449,7 +492,9 @@ def url(self, name, extra_params=None, client_method="get_object"):
449
492
# Use a public URL, if specified.
450
493
if self .settings .AWS_S3_PUBLIC_URL :
451
494
if extra_params or client_method != "get_object" :
452
- raise ValueError ("Use of extra_params or client_method is not allowed with AWS_S3_PUBLIC_URL" )
495
+ raise ValueError (
496
+ "Use of extra_params or client_method is not allowed with AWS_S3_PUBLIC_URL"
497
+ )
453
498
return urljoin (self .settings .AWS_S3_PUBLIC_URL , filepath_to_uri (name ))
454
499
# Otherwise, generate the URL.
455
500
params = extra_params .copy () if extra_params else {}
@@ -462,8 +507,16 @@ def url(self, name, extra_params=None, client_method="get_object"):
462
507
# Strip off the query params if we're not interested in bucket auth.
463
508
if not self .settings .AWS_S3_BUCKET_AUTH :
464
509
if extra_params or client_method != "get_object" :
465
- raise ValueError ("Use of extra_params or client_method is not allowed with AWS_S3_BUCKET_AUTH" )
466
- url = urlunsplit (urlsplit (url )[:3 ] + ("" , "" ,))
510
+ raise ValueError (
511
+ "Use of extra_params or client_method is not allowed with AWS_S3_BUCKET_AUTH"
512
+ )
513
+ url = urlunsplit (
514
+ urlsplit (url )[:3 ]
515
+ + (
516
+ "" ,
517
+ "" ,
518
+ )
519
+ )
467
520
# All done!
468
521
return url
469
522
@@ -501,8 +554,9 @@ def sync_meta_iter(self):
501
554
put_params ["ContentEncoding" ] = content_encoding
502
555
if content_encoding == "gzip" :
503
556
try :
504
- put_params ["Metadata" ][_UNCOMPRESSED_SIZE_META_KEY ] = \
505
- obj ["Metadata" ][_UNCOMPRESSED_SIZE_META_KEY ]
557
+ put_params ["Metadata" ][_UNCOMPRESSED_SIZE_META_KEY ] = obj [
558
+ "Metadata"
559
+ ][_UNCOMPRESSED_SIZE_META_KEY ]
506
560
except KeyError :
507
561
pass
508
562
# Update the metadata.
@@ -513,7 +567,7 @@ def sync_meta_iter(self):
513
567
"Key" : self ._get_key_name (name ),
514
568
},
515
569
MetadataDirective = "REPLACE" ,
516
- ** put_params
570
+ ** put_params ,
517
571
)
518
572
yield name
519
573
@@ -529,23 +583,28 @@ class StaticS3Storage(S3Storage):
529
583
"""
530
584
531
585
default_s3_settings = S3Storage .default_s3_settings .copy ()
532
- default_s3_settings .update ({
533
- "AWS_S3_BUCKET_AUTH" : False ,
534
- })
586
+ default_s3_settings .update (
587
+ {
588
+ "AWS_S3_BUCKET_AUTH" : False ,
589
+ }
590
+ )
535
591
536
592
s3_settings_suffix = "_STATIC"
537
593
538
594
539
595
class ManifestStaticS3Storage (ManifestFilesMixin , StaticS3Storage ):
540
-
541
596
default_s3_settings = StaticS3Storage .default_s3_settings .copy ()
542
- default_s3_settings .update ({
543
- "AWS_S3_MAX_AGE_SECONDS_CACHED" : 60 * 60 * 24 * 365 , # 1 year.
544
- })
597
+ default_s3_settings .update (
598
+ {
599
+ "AWS_S3_MAX_AGE_SECONDS_CACHED" : 60 * 60 * 24 * 365 , # 1 year.
600
+ }
601
+ )
545
602
546
603
def post_process (self , * args , ** kwargs ):
547
604
initial_aws_s3_max_age_seconds = self .settings .AWS_S3_MAX_AGE_SECONDS
548
- self .settings .AWS_S3_MAX_AGE_SECONDS = self .settings .AWS_S3_MAX_AGE_SECONDS_CACHED
605
+ self .settings .AWS_S3_MAX_AGE_SECONDS = (
606
+ self .settings .AWS_S3_MAX_AGE_SECONDS_CACHED
607
+ )
549
608
try :
550
609
yield from super ().post_process (* args , ** kwargs )
551
610
finally :
0 commit comments