Skip to content

Commit b239095

Browse files
authored
Merge pull request #161 from etianen/text-mode
Adding support for opening files in text mode
2 parents 62c41bb + 9e57768 commit b239095

File tree

5 files changed

+227
-90
lines changed

5 files changed

+227
-90
lines changed

CHANGELOG.rst

+5
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,11 @@
11
django-s3-storage changelog
22
===========================
33

4+
0.15.0
5+
------
6+
7+
- Added support for opening files in text mode on Python 3.11+ (@etianen).
8+
49
0.14.0
510
------
611

django_s3_storage/__init__.py

+1-1
Original file line numberDiff line numberDiff line change
@@ -3,4 +3,4 @@
33
"""
44

55

6-
__version__ = (0, 14, 0)
6+
__version__ = (0, 15, 0)

django_s3_storage/storage.py

+106-47
Original file line numberDiff line numberDiff line change
@@ -6,15 +6,15 @@
66
import shutil
77
from contextlib import closing
88
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
1111
from tempfile import SpooledTemporaryFile
1212
from threading import local
1313
from urllib.parse import urljoin, urlsplit, urlunsplit
1414

1515
import boto3
16-
from botocore.client import Config
1716
from boto3.s3.transfer import TransferConfig
17+
from botocore.client import Config
1818
from botocore.exceptions import ClientError
1919
from django.conf import settings
2020
from django.contrib.staticfiles.storage import ManifestFilesMixin
@@ -41,6 +41,7 @@ def _do_wrap_errors(self, name, *args, **kwargs):
4141
if code == "NoSuchKey":
4242
err_cls = FileNotFoundError
4343
raise err_cls(f"S3Storage error at {name!r}: {force_str(ex)}")
44+
4445
return _do_wrap_errors
4546

4647

@@ -63,6 +64,7 @@ def do_wrap_path_impl(self, name, *args, **kwargs):
6364
# posix paths. We fix this by converting paths to system form, passing them to the default implementation, then
6465
# converting them back to posix paths.
6566
return _to_posix_path(func(self, _to_sys_path(name), *args, **kwargs))
67+
6668
return do_wrap_path_impl
6769

6870

@@ -108,18 +110,24 @@ def __init__(self, storage):
108110
if storage.settings.AWS_ACCESS_KEY_ID:
109111
connection_kwargs["aws_access_key_id"] = storage.settings.AWS_ACCESS_KEY_ID
110112
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
112116
if storage.settings.AWS_SESSION_TOKEN:
113117
connection_kwargs["aws_session_token"] = storage.settings.AWS_SESSION_TOKEN
114118
if storage.settings.AWS_S3_ENDPOINT_URL:
115119
connection_kwargs["endpoint_url"] = storage.settings.AWS_S3_ENDPOINT_URL
116120
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+
)
123131

124132

125133
@deconstructible
@@ -155,7 +163,7 @@ class S3Storage(Storage):
155163
"AWS_S3_FILE_OVERWRITE": False,
156164
"AWS_S3_USE_THREADS": True,
157165
"AWS_S3_MAX_POOL_CONNECTIONS": 10,
158-
"AWS_S3_CONNECT_TIMEOUT": 60 # 60 seconds
166+
"AWS_S3_CONNECT_TIMEOUT": 60, # 60 seconds
159167
}
160168

161169
s3_settings_suffix = ""
@@ -178,16 +186,24 @@ def _setup(self):
178186
setting_key,
179187
self._kwargs.get(
180188
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+
),
182194
),
183195
)
184196
# Validate settings.
185197
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+
)
187201
# Create a thread-local connection manager.
188202
self._connections = _Local(self)
189203
# 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+
)
191207

192208
@property
193209
def s3_connection(self):
@@ -201,8 +217,8 @@ def __init__(self, **kwargs):
201217
# Check for unknown kwargs.
202218
for kwarg_key in kwargs.keys():
203219
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
206222
):
207223
raise ImproperlyConfigured(f"Unknown S3Storage parameter: {kwarg_key}")
208224
# Set up the storage.
@@ -211,7 +227,9 @@ def __init__(self, **kwargs):
211227
# Re-initialize the storage if an AWS setting changes.
212228
setting_changed.connect(self._setting_changed_received)
213229
# 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+
)
215233
# All done!
216234
super().__init__()
217235

@@ -237,7 +255,9 @@ def __reduce__(self):
237255
def _get_key_name(self, name):
238256
if name.startswith("/"):
239257
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+
)
241261

242262
def _object_params(self, name):
243263
params = {
@@ -256,22 +276,29 @@ def _object_put_params(self, name):
256276
),
257277
"Metadata": {
258278
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()
261280
},
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",
263284
}
264285
params.update(self._object_params(name))
265286
# 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+
)
267290
if content_disposition:
268291
params["ContentDisposition"] = content_disposition
269292
# 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+
)
271296
if content_langauge:
272297
params["ContentLanguage"] = content_langauge
273298
# 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
275302
if isinstance(self.settings.AWS_S3_ENCRYPT_KEY, str):
276303
params["ServerSideEncryption"] = self.settings.AWS_S3_ENCRYPT_KEY
277304
if self.settings.AWS_S3_KMS_ENCRYPTION_KEY_ID:
@@ -283,11 +310,11 @@ def _object_put_params(self, name):
283310

284311
def new_temporary_file(self):
285312
"""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.
287314

288315
@_wrap_errors
289316
def _open(self, name, mode="rb"):
290-
if mode != "rb":
317+
if mode not in ("rb", "rt", "r"):
291318
raise ValueError("S3 files can only be opened in read-only mode")
292319
# Load the key into a temporary file. It would be nice to stream the
293320
# content, but S3 doesn't support seeking, which is sometimes needed.
@@ -298,6 +325,9 @@ def _open(self, name, mode="rb"):
298325
# Un-gzip if required.
299326
if obj.get("ContentEncoding") == "gzip":
300327
content = gzip.GzipFile(name, "rb", fileobj=content)
328+
# Decode text if required.
329+
if "b" not in mode:
330+
content = TextIOWrapper(content)
301331
# All done!
302332
return S3File(content, name, self)
303333

@@ -325,7 +355,12 @@ def _save(self, name, content):
325355
# Check if the content type is compressible.
326356
content_type_family, content_type_subtype = content_type.lower().split("/")
327357
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+
):
329364
# Compress the content.
330365
temp_file = self.new_temporary_file()
331366
temp_files.append(temp_file)
@@ -337,7 +372,9 @@ def _save(self, name, content):
337372
temp_file.seek(0)
338373
content = temp_file
339374
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}"
341378
else:
342379
content.seek(0)
343380
# Save the file.
@@ -346,8 +383,13 @@ def _save(self, name, content):
346383
original_close = content.close
347384
content.close = lambda: None
348385
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+
)
351393
finally:
352394
# Restore the original close method.
353395
content.close = original_close
@@ -385,8 +427,8 @@ def delete(self, name):
385427
@_wrap_errors
386428
def copy(self, src_name, dst_name):
387429
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+
)
390432

391433
@_wrap_errors
392434
def rename(self, src_name, dst_name):
@@ -402,7 +444,8 @@ def exists(self, name):
402444
results = self.s3_connection.list_objects_v2(
403445
Bucket=self.settings.AWS_S3_BUCKET_NAME,
404446
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.
406449
)
407450
except ClientError:
408451
return False
@@ -449,7 +492,9 @@ def url(self, name, extra_params=None, client_method="get_object"):
449492
# Use a public URL, if specified.
450493
if self.settings.AWS_S3_PUBLIC_URL:
451494
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+
)
453498
return urljoin(self.settings.AWS_S3_PUBLIC_URL, filepath_to_uri(name))
454499
# Otherwise, generate the URL.
455500
params = extra_params.copy() if extra_params else {}
@@ -462,8 +507,16 @@ def url(self, name, extra_params=None, client_method="get_object"):
462507
# Strip off the query params if we're not interested in bucket auth.
463508
if not self.settings.AWS_S3_BUCKET_AUTH:
464509
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+
)
467520
# All done!
468521
return url
469522

@@ -501,8 +554,9 @@ def sync_meta_iter(self):
501554
put_params["ContentEncoding"] = content_encoding
502555
if content_encoding == "gzip":
503556
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]
506560
except KeyError:
507561
pass
508562
# Update the metadata.
@@ -513,7 +567,7 @@ def sync_meta_iter(self):
513567
"Key": self._get_key_name(name),
514568
},
515569
MetadataDirective="REPLACE",
516-
**put_params
570+
**put_params,
517571
)
518572
yield name
519573

@@ -529,23 +583,28 @@ class StaticS3Storage(S3Storage):
529583
"""
530584

531585
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+
)
535591

536592
s3_settings_suffix = "_STATIC"
537593

538594

539595
class ManifestStaticS3Storage(ManifestFilesMixin, StaticS3Storage):
540-
541596
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+
)
545602

546603
def post_process(self, *args, **kwargs):
547604
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+
)
549608
try:
550609
yield from super().post_process(*args, **kwargs)
551610
finally:

pyproject.toml

Whitespace-only changes.

0 commit comments

Comments
 (0)