boto3-assist 0.6.1__py3-none-any.whl → 0.7.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- boto3_assist/connection_tracker.py +4 -4
- boto3_assist/s3/s3.py +21 -501
- boto3_assist/s3/s3_bucket.py +67 -0
- boto3_assist/s3/s3_connection.py +1 -4
- boto3_assist/s3/s3_object.py +606 -0
- boto3_assist/utilities/string_utility.py +30 -0
- boto3_assist/version.py +1 -1
- {boto3_assist-0.6.1.dist-info → boto3_assist-0.7.0.dist-info}/METADATA +1 -1
- {boto3_assist-0.6.1.dist-info → boto3_assist-0.7.0.dist-info}/RECORD +12 -10
- {boto3_assist-0.6.1.dist-info → boto3_assist-0.7.0.dist-info}/WHEEL +0 -0
- {boto3_assist-0.6.1.dist-info → boto3_assist-0.7.0.dist-info}/licenses/LICENSE-EXPLAINED.txt +0 -0
- {boto3_assist-0.6.1.dist-info → boto3_assist-0.7.0.dist-info}/licenses/LICENSE.txt +0 -0
|
@@ -67,8 +67,8 @@ class ConnectionTracker:
|
|
|
67
67
|
|
|
68
68
|
if not self.issue_stack_trace:
|
|
69
69
|
stack_trace_message = (
|
|
70
|
-
f"
|
|
71
|
-
f"set the environment variable {self.__stack_trace_env_var} to true
|
|
70
|
+
f"📄 NOTE: To add additional information 👀 to the log and determine where additional connections are being created: "
|
|
71
|
+
f"set the environment variable 👉{self.__stack_trace_env_var}👈 to true ✅. \n"
|
|
72
72
|
)
|
|
73
73
|
else:
|
|
74
74
|
stack = "\n".join(traceback.format_stack())
|
|
@@ -83,8 +83,8 @@ class ConnectionTracker:
|
|
|
83
83
|
"instead of creating a new one. Connections are expensive in terms of time and latency. "
|
|
84
84
|
"If you are seeing performance issues, check how and where you are creating your "
|
|
85
85
|
"connections. You should be able to pass the connection to your other objects "
|
|
86
|
-
"and reuse your boto3 connections."
|
|
87
|
-
"\n
|
|
86
|
+
"and reuse your boto3 connections. "
|
|
87
|
+
"\n🧪 MOCK Testing may show this message as well, in which case you can dismiss this warning.🧪\n"
|
|
88
88
|
f"{stack_trace_message}"
|
|
89
89
|
)
|
|
90
90
|
|
boto3_assist/s3/s3.py
CHANGED
|
@@ -4,20 +4,13 @@ Maintainers: Eric Wilson
|
|
|
4
4
|
MIT License. See Project Root for the license information.
|
|
5
5
|
"""
|
|
6
6
|
|
|
7
|
-
import
|
|
8
|
-
import tempfile
|
|
9
|
-
import time
|
|
10
|
-
import io
|
|
11
|
-
from typing import Any, Dict, List, Optional
|
|
7
|
+
from typing import Optional, cast
|
|
12
8
|
|
|
13
9
|
from aws_lambda_powertools import Logger
|
|
14
|
-
from botocore.exceptions import ClientError
|
|
15
10
|
|
|
16
|
-
from boto3_assist.errors.custom_exceptions import InvalidHttpMethod
|
|
17
11
|
from boto3_assist.s3.s3_connection import S3Connection
|
|
18
|
-
from boto3_assist.
|
|
19
|
-
from boto3_assist.
|
|
20
|
-
from boto3_assist.utilities.http_utility import HttpUtility
|
|
12
|
+
from boto3_assist.s3.s3_object import S3Object
|
|
13
|
+
from boto3_assist.s3.s3_bucket import S3Bucket
|
|
21
14
|
|
|
22
15
|
logger = Logger(child=True)
|
|
23
16
|
|
|
@@ -51,494 +44,21 @@ class S3(S3Connection):
|
|
|
51
44
|
aws_secret_access_key=aws_secret_access_key,
|
|
52
45
|
)
|
|
53
46
|
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
"""
|
|
60
|
-
|
|
61
|
-
self
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
self
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
user_id: str,
|
|
73
|
-
file_name: str,
|
|
74
|
-
meta_data: dict | None = None,
|
|
75
|
-
expiration=3600,
|
|
76
|
-
method_type="POST",
|
|
77
|
-
) -> Dict[str, Any]:
|
|
78
|
-
"""
|
|
79
|
-
Create a signed URL for uploading a file to S3.
|
|
80
|
-
:param bucket_name: The name of the S3 bucket.
|
|
81
|
-
:param user_id: The user ID of the user uploading the file.
|
|
82
|
-
:param file_name: The file name of the file being uploaded.
|
|
83
|
-
:param aws_profile: The name of the AWS profile to use.
|
|
84
|
-
:param aws_region: The name of the AWS region to use.
|
|
85
|
-
:param expiration: The number of seconds the URL is valid for.
|
|
86
|
-
:return: The signed URL.
|
|
87
|
-
"""
|
|
88
|
-
start = DatetimeUtility.get_utc_now()
|
|
89
|
-
logger.debug(
|
|
90
|
-
f"Creating signed URL for bucket {bucket_name} for user {user_id} and file {file_name} at {start} UTC"
|
|
91
|
-
)
|
|
92
|
-
|
|
93
|
-
file_extension = FileOperations.get_file_extension(file_name)
|
|
94
|
-
|
|
95
|
-
local_meta = {
|
|
96
|
-
"user_id": f"{user_id}",
|
|
97
|
-
"file_name": f"{file_name}",
|
|
98
|
-
"extension": f"{file_extension}",
|
|
99
|
-
"method": "pre-signed-upload",
|
|
100
|
-
}
|
|
101
|
-
|
|
102
|
-
if not meta_data:
|
|
103
|
-
meta_data = local_meta
|
|
104
|
-
else:
|
|
105
|
-
meta_data.update(local_meta)
|
|
106
|
-
|
|
107
|
-
key = key_path
|
|
108
|
-
method_type = method_type.upper()
|
|
109
|
-
|
|
110
|
-
signed_url: str | Dict[str, Any]
|
|
111
|
-
if method_type == "PUT":
|
|
112
|
-
signed_url = self.client.generate_presigned_url(
|
|
113
|
-
"put_object",
|
|
114
|
-
Params={
|
|
115
|
-
"Bucket": f"{bucket_name}",
|
|
116
|
-
"Key": f"{key}",
|
|
117
|
-
# NOTE: if you include the ContentType or Metadata then its required in the when they upload the file
|
|
118
|
-
# Otherwise you will get a `SignatureDoesNotMatch` error
|
|
119
|
-
# for now I'm commenting it out.
|
|
120
|
-
#'ContentType': 'application/octet-stream',
|
|
121
|
-
#'ACL': 'private',
|
|
122
|
-
# "Metadata": meta_data,
|
|
123
|
-
},
|
|
124
|
-
ExpiresIn=expiration, # URL is valid for x seconds
|
|
125
|
-
)
|
|
126
|
-
elif method_type == "POST":
|
|
127
|
-
signed_url = self.client.generate_presigned_post(
|
|
128
|
-
bucket_name,
|
|
129
|
-
key,
|
|
130
|
-
ExpiresIn=expiration, # URL is valid for x seconds
|
|
131
|
-
)
|
|
132
|
-
elif method_type == "GET":
|
|
133
|
-
signed_url = self.client.generate_presigned_url(
|
|
134
|
-
"get_object",
|
|
135
|
-
Params={
|
|
136
|
-
"Bucket": f"{bucket_name}",
|
|
137
|
-
"Key": f"{key}",
|
|
138
|
-
},
|
|
139
|
-
ExpiresIn=expiration, # URL is valid for x seconds
|
|
140
|
-
)
|
|
141
|
-
else:
|
|
142
|
-
raise InvalidHttpMethod(
|
|
143
|
-
f'Unknown method type was referenced. valid types are "PUT", "POST", "GET" , "{method_type}" as used '
|
|
144
|
-
)
|
|
145
|
-
|
|
146
|
-
end = DatetimeUtility.get_utc_now()
|
|
147
|
-
logger.debug(f"Signed URL created in {end-start}")
|
|
148
|
-
|
|
149
|
-
response = {
|
|
150
|
-
"signed_url": signed_url,
|
|
151
|
-
"key": key,
|
|
152
|
-
"meta_data": meta_data,
|
|
153
|
-
}
|
|
154
|
-
|
|
155
|
-
return response
|
|
156
|
-
|
|
157
|
-
def upload_file_obj(self, *, bucket: str, key: str, file_obj: bytes | str) -> str:
|
|
158
|
-
"""
|
|
159
|
-
Uploads a file object to s3. Returns the full s3 path s3://<bucket>/<key>
|
|
160
|
-
"""
|
|
161
|
-
|
|
162
|
-
if key.startswith("/"):
|
|
163
|
-
# remove the first slash
|
|
164
|
-
key = key[1:]
|
|
165
|
-
|
|
166
|
-
logger.debug(
|
|
167
|
-
{
|
|
168
|
-
"metric_filter": "upload_file_to_s3",
|
|
169
|
-
"bucket": bucket,
|
|
170
|
-
"key": key,
|
|
171
|
-
}
|
|
172
|
-
)
|
|
173
|
-
try:
|
|
174
|
-
# convert if necessary
|
|
175
|
-
file_obj: bytes = (
|
|
176
|
-
file_obj.encode("utf-8") if isinstance(file_obj, str) else file_obj
|
|
177
|
-
)
|
|
178
|
-
self.client.upload_fileobj(
|
|
179
|
-
Fileobj=io.BytesIO(file_obj), Bucket=bucket, Key=key
|
|
180
|
-
)
|
|
181
|
-
|
|
182
|
-
except ClientError as ce:
|
|
183
|
-
error = {
|
|
184
|
-
"metric_filter": "upload_file_to_s3_failure",
|
|
185
|
-
"s3 upload": "failure",
|
|
186
|
-
"bucket": bucket,
|
|
187
|
-
"key": key,
|
|
188
|
-
}
|
|
189
|
-
logger.error(error)
|
|
190
|
-
raise RuntimeError(error) from ce
|
|
191
|
-
|
|
192
|
-
return f"s3://{bucket}/{key}"
|
|
193
|
-
|
|
194
|
-
def upload_file(
|
|
195
|
-
self,
|
|
196
|
-
*,
|
|
197
|
-
bucket: str,
|
|
198
|
-
key: str,
|
|
199
|
-
local_file_path: str,
|
|
200
|
-
throw_error_on_failure: bool = False,
|
|
201
|
-
) -> str | None:
|
|
202
|
-
"""
|
|
203
|
-
Uploads a file to s3. Returns the full s3 path s3://<bucket>/<key>
|
|
204
|
-
"""
|
|
205
|
-
|
|
206
|
-
if key.startswith("/"):
|
|
207
|
-
# remove the first slash
|
|
208
|
-
key = key[1:]
|
|
209
|
-
|
|
210
|
-
# build the path
|
|
211
|
-
s3_path = f"s3://{bucket}/{key}"
|
|
212
|
-
|
|
213
|
-
logger.debug(
|
|
214
|
-
{
|
|
215
|
-
"metric_filter": "upload_file_to_s3",
|
|
216
|
-
"bucket": bucket,
|
|
217
|
-
"key": key,
|
|
218
|
-
"local_file_path": local_file_path,
|
|
219
|
-
}
|
|
220
|
-
)
|
|
221
|
-
try:
|
|
222
|
-
self.client.upload_file(local_file_path, bucket, key)
|
|
223
|
-
|
|
224
|
-
except ClientError as ce:
|
|
225
|
-
error = {
|
|
226
|
-
"metric_filter": "upload_file_to_s3_failure",
|
|
227
|
-
"s3 upload": "failure",
|
|
228
|
-
"bucket": bucket,
|
|
229
|
-
"key": key,
|
|
230
|
-
"local_file_path": local_file_path,
|
|
231
|
-
}
|
|
232
|
-
logger.error(error)
|
|
233
|
-
|
|
234
|
-
if throw_error_on_failure:
|
|
235
|
-
raise RuntimeError(error) from ce
|
|
236
|
-
|
|
237
|
-
return None
|
|
238
|
-
|
|
239
|
-
return s3_path
|
|
240
|
-
|
|
241
|
-
def download_file(
|
|
242
|
-
self,
|
|
243
|
-
*,
|
|
244
|
-
bucket: str,
|
|
245
|
-
key: str,
|
|
246
|
-
local_directory: str | None = None,
|
|
247
|
-
local_file_path: str | None = None,
|
|
248
|
-
retry_attempts: int = 3,
|
|
249
|
-
retry_sleep: int = 5,
|
|
250
|
-
) -> str:
|
|
251
|
-
"""Download a file from s3"""
|
|
252
|
-
exception: Exception | None = None
|
|
253
|
-
|
|
254
|
-
if retry_attempts == 0:
|
|
255
|
-
retry_attempts = 1
|
|
256
|
-
|
|
257
|
-
for i in range(retry_attempts):
|
|
258
|
-
exception = None
|
|
259
|
-
try:
|
|
260
|
-
path = self.download_file_no_retries(
|
|
261
|
-
bucket=bucket,
|
|
262
|
-
key=key,
|
|
263
|
-
local_directory=local_directory,
|
|
264
|
-
local_file_path=local_file_path,
|
|
265
|
-
)
|
|
266
|
-
if path and os.path.exists(path):
|
|
267
|
-
return path
|
|
268
|
-
|
|
269
|
-
except Exception as e: # pylint: disable=w0718
|
|
270
|
-
logger.warning(
|
|
271
|
-
{
|
|
272
|
-
"action": "download_file",
|
|
273
|
-
"result": "failure",
|
|
274
|
-
"exception": str(e),
|
|
275
|
-
"attempt": i + 1,
|
|
276
|
-
"retry_attempts": retry_attempts,
|
|
277
|
-
}
|
|
278
|
-
)
|
|
279
|
-
|
|
280
|
-
exception = e
|
|
281
|
-
|
|
282
|
-
# sleep for a bit
|
|
283
|
-
attempt = i + 1
|
|
284
|
-
time.sleep(attempt * retry_sleep)
|
|
285
|
-
|
|
286
|
-
if exception:
|
|
287
|
-
logger.exception(
|
|
288
|
-
{
|
|
289
|
-
"action": "download_file",
|
|
290
|
-
"result": "failure",
|
|
291
|
-
"exception": str(exception),
|
|
292
|
-
"retry_attempts": retry_attempts,
|
|
293
|
-
}
|
|
294
|
-
)
|
|
295
|
-
|
|
296
|
-
raise exception from exception
|
|
297
|
-
|
|
298
|
-
raise RuntimeError("Unable to download file")
|
|
299
|
-
|
|
300
|
-
def download_file_no_retries(
|
|
301
|
-
self,
|
|
302
|
-
bucket: str,
|
|
303
|
-
key: str,
|
|
304
|
-
local_directory: str | None = None,
|
|
305
|
-
local_file_path: str | None = None,
|
|
306
|
-
) -> str:
|
|
307
|
-
"""
|
|
308
|
-
Downloads a file from s3
|
|
309
|
-
|
|
310
|
-
Args:
|
|
311
|
-
bucket (str): s3 bucket
|
|
312
|
-
key (str): the s3 object key
|
|
313
|
-
local_directory (str, optional): Local directory to download to. Defaults to None.
|
|
314
|
-
If None, we'll use a local tmp directory.
|
|
315
|
-
|
|
316
|
-
Raises:
|
|
317
|
-
e:
|
|
318
|
-
|
|
319
|
-
Returns:
|
|
320
|
-
str: Path to the downloaded file.
|
|
321
|
-
"""
|
|
322
|
-
|
|
323
|
-
decoded_object_key: str
|
|
324
|
-
try:
|
|
325
|
-
logger.debug(
|
|
326
|
-
{
|
|
327
|
-
"action": "downloading file",
|
|
328
|
-
"bucket": bucket,
|
|
329
|
-
"key": key,
|
|
330
|
-
"local_directory": local_directory,
|
|
331
|
-
}
|
|
332
|
-
)
|
|
333
|
-
return self.__download_file(bucket, key, local_directory, local_file_path)
|
|
334
|
-
except FileNotFoundError:
|
|
335
|
-
logger.warning(
|
|
336
|
-
{
|
|
337
|
-
"metric_filter": "download_file_error",
|
|
338
|
-
"error": "FileNotFoundError",
|
|
339
|
-
"message": "attempting to find it decoded",
|
|
340
|
-
"bucket": bucket,
|
|
341
|
-
"key": key,
|
|
342
|
-
}
|
|
343
|
-
)
|
|
344
|
-
|
|
345
|
-
# attempt to decode the key
|
|
346
|
-
decoded_object_key = HttpUtility.decode_url(key)
|
|
347
|
-
|
|
348
|
-
logger.error(
|
|
349
|
-
{
|
|
350
|
-
"metric_filter": "download_file_error",
|
|
351
|
-
"error": "FileNotFoundError",
|
|
352
|
-
"message": "attempting to find it decoded",
|
|
353
|
-
"bucket": bucket,
|
|
354
|
-
"key": key,
|
|
355
|
-
"decoded_object_key": decoded_object_key,
|
|
356
|
-
}
|
|
357
|
-
)
|
|
358
|
-
|
|
359
|
-
return self.__download_file(bucket, decoded_object_key, local_directory)
|
|
360
|
-
|
|
361
|
-
except Exception as e:
|
|
362
|
-
logger.error(
|
|
363
|
-
{
|
|
364
|
-
"metric_filter": "download_file_error",
|
|
365
|
-
"error": str(e),
|
|
366
|
-
"bucket": bucket,
|
|
367
|
-
"decoded_object_key": decoded_object_key,
|
|
368
|
-
}
|
|
369
|
-
)
|
|
370
|
-
raise e
|
|
371
|
-
|
|
372
|
-
def stream_file(self, bucket_name: str, key: str) -> Dict[str, Any]:
|
|
373
|
-
"""
|
|
374
|
-
Gets a file from s3 and returns the response.
|
|
375
|
-
The "Body" is a streaming body object. You can read it like a file.
|
|
376
|
-
For example:
|
|
377
|
-
|
|
378
|
-
with response["Body"] as f:
|
|
379
|
-
data = f.read()
|
|
380
|
-
print(data)
|
|
381
|
-
|
|
382
|
-
"""
|
|
383
|
-
|
|
384
|
-
logger.debug(
|
|
385
|
-
{
|
|
386
|
-
"source": "download_file",
|
|
387
|
-
"action": "downloading a file from s3",
|
|
388
|
-
"bucket": bucket_name,
|
|
389
|
-
"key": key,
|
|
390
|
-
}
|
|
391
|
-
)
|
|
392
|
-
|
|
393
|
-
response: Dict[str, Any] = {}
|
|
394
|
-
error = None
|
|
395
|
-
|
|
396
|
-
try:
|
|
397
|
-
response = dict(self.client.get_object(Bucket=bucket_name, Key=key))
|
|
398
|
-
|
|
399
|
-
logger.debug(
|
|
400
|
-
{"metric_filter": "s3_download_response", "response": str(response)}
|
|
401
|
-
)
|
|
402
|
-
|
|
403
|
-
except Exception as e: # pylint: disable=W0718
|
|
404
|
-
error = str(e)
|
|
405
|
-
logger.error({"metric_filter": "s3_download_error", "error": str(e)})
|
|
406
|
-
raise RuntimeError(
|
|
407
|
-
{
|
|
408
|
-
"metric_filter": "s3_download_error",
|
|
409
|
-
"error": str(e),
|
|
410
|
-
"bucket": bucket_name,
|
|
411
|
-
"key": key,
|
|
412
|
-
}
|
|
413
|
-
) from e
|
|
414
|
-
|
|
415
|
-
finally:
|
|
416
|
-
logger.debug(
|
|
417
|
-
{
|
|
418
|
-
"source": "download_file",
|
|
419
|
-
"action": "downloading a file from s3",
|
|
420
|
-
"bucket": bucket_name,
|
|
421
|
-
"key": key,
|
|
422
|
-
"response": response,
|
|
423
|
-
"errors": error,
|
|
424
|
-
}
|
|
425
|
-
)
|
|
426
|
-
|
|
427
|
-
return response
|
|
428
|
-
|
|
429
|
-
def __download_file(
|
|
430
|
-
self,
|
|
431
|
-
bucket: str,
|
|
432
|
-
key: str,
|
|
433
|
-
local_directory: str | None = None,
|
|
434
|
-
local_file_path: str | None = None,
|
|
435
|
-
):
|
|
436
|
-
if local_directory and local_file_path:
|
|
437
|
-
raise ValueError(
|
|
438
|
-
"Only one of local_directory or local_file_path can be provided"
|
|
439
|
-
)
|
|
440
|
-
|
|
441
|
-
if local_directory and not os.path.exists(local_directory):
|
|
442
|
-
FileOperations.makedirs(local_directory)
|
|
443
|
-
|
|
444
|
-
if local_file_path and not os.path.exists(os.path.dirname(local_file_path)):
|
|
445
|
-
FileOperations.makedirs(os.path.dirname(local_file_path))
|
|
446
|
-
|
|
447
|
-
file_name = self.__get_file_name_from_path(key)
|
|
448
|
-
if local_directory is None and local_file_path is None:
|
|
449
|
-
local_path = self.get_local_path_for_file(file_name)
|
|
450
|
-
elif local_directory:
|
|
451
|
-
local_path = os.path.join(local_directory, file_name)
|
|
452
|
-
else:
|
|
453
|
-
local_path = local_file_path
|
|
454
|
-
|
|
455
|
-
logger.debug(
|
|
456
|
-
{
|
|
457
|
-
"source": "download_file",
|
|
458
|
-
"action": "downloading a file from s3",
|
|
459
|
-
"bucket": bucket,
|
|
460
|
-
"key": key,
|
|
461
|
-
"file_name": file_name,
|
|
462
|
-
"local_path": local_path,
|
|
463
|
-
}
|
|
464
|
-
)
|
|
465
|
-
|
|
466
|
-
error: str | None = None
|
|
467
|
-
try:
|
|
468
|
-
self.client.download_file(bucket, key, local_path)
|
|
469
|
-
|
|
470
|
-
except Exception as e: # pylint: disable=W0718
|
|
471
|
-
error = str(e)
|
|
472
|
-
logger.error({"metric_filter": "s3_download_error", "error": str(e)})
|
|
473
|
-
|
|
474
|
-
file_exist = os.path.exists(local_path)
|
|
475
|
-
|
|
476
|
-
logger.debug(
|
|
477
|
-
{
|
|
478
|
-
"source": "download_file",
|
|
479
|
-
"action": "downloading a file from s3",
|
|
480
|
-
"bucket": bucket,
|
|
481
|
-
"key": key,
|
|
482
|
-
"file_name": file_name,
|
|
483
|
-
"local_path": local_path,
|
|
484
|
-
"file_downloaded": file_exist,
|
|
485
|
-
"errors": error,
|
|
486
|
-
}
|
|
487
|
-
)
|
|
488
|
-
|
|
489
|
-
if not file_exist:
|
|
490
|
-
raise FileNotFoundError("File Failed to download (does not exist) from S3.")
|
|
491
|
-
|
|
492
|
-
return local_path
|
|
493
|
-
|
|
494
|
-
def __get_file_name_from_path(self, path: str) -> str:
|
|
495
|
-
"""
|
|
496
|
-
Get a file name from the path
|
|
497
|
-
|
|
498
|
-
Args:
|
|
499
|
-
path (str): a file path
|
|
500
|
-
|
|
501
|
-
Returns:
|
|
502
|
-
str: the file name
|
|
503
|
-
"""
|
|
504
|
-
return path.rsplit("/")[-1]
|
|
505
|
-
|
|
506
|
-
def get_local_path_for_file(self, file_name: str):
|
|
507
|
-
"""
|
|
508
|
-
Get a local temp location for a file.
|
|
509
|
-
This is designed to work with lambda functions.
|
|
510
|
-
The /tmp directory is the only writeable location for lambda functions.
|
|
511
|
-
"""
|
|
512
|
-
temp_dir = self.get_temp_directory()
|
|
513
|
-
# use /tmp it's the only writeable location for lambda
|
|
514
|
-
local_path = os.path.join(temp_dir, file_name)
|
|
515
|
-
return local_path
|
|
516
|
-
|
|
517
|
-
def get_temp_directory(self):
|
|
518
|
-
"""
|
|
519
|
-
Determines the appropriate temporary directory based on the environment.
|
|
520
|
-
If running in AWS Lambda, returns '/tmp'.
|
|
521
|
-
Otherwise, returns the system's standard temp directory.
|
|
522
|
-
"""
|
|
523
|
-
if "AWS_LAMBDA_FUNCTION_NAME" in os.environ:
|
|
524
|
-
# In AWS Lambda environment
|
|
525
|
-
return "/tmp"
|
|
526
|
-
else:
|
|
527
|
-
# Not in AWS Lambda, use the system's default temp directory
|
|
528
|
-
return tempfile.gettempdir()
|
|
529
|
-
|
|
530
|
-
def encode(
|
|
531
|
-
self, text: str, encoding: str = "utf-8", errors: str = "strict"
|
|
532
|
-
) -> bytes:
|
|
533
|
-
"""
|
|
534
|
-
Encodes a string for s3
|
|
535
|
-
"""
|
|
536
|
-
return text.encode(encoding=encoding, errors=errors)
|
|
537
|
-
|
|
538
|
-
def decode(
|
|
539
|
-
self, file_obj: bytes, encoding: str = "utf-8", errors: str = "strict"
|
|
540
|
-
) -> str:
|
|
541
|
-
"""
|
|
542
|
-
Decodes bytes to a string
|
|
543
|
-
"""
|
|
544
|
-
return file_obj.decode(encoding=encoding, errors=errors)
|
|
47
|
+
self.__s3_object: S3Object | None = None
|
|
48
|
+
self.__s3_bucket: S3Bucket | None = None
|
|
49
|
+
|
|
50
|
+
@property
|
|
51
|
+
def object(self) -> S3Object:
|
|
52
|
+
"""s3 object"""
|
|
53
|
+
if self.__s3_object is None:
|
|
54
|
+
connection = cast(S3Connection, self)
|
|
55
|
+
self.__s3_object = S3Object(connection)
|
|
56
|
+
return self.__s3_object
|
|
57
|
+
|
|
58
|
+
@property
|
|
59
|
+
def bucket(self) -> S3Bucket:
|
|
60
|
+
"""s3 bucket"""
|
|
61
|
+
if self.__s3_bucket is None:
|
|
62
|
+
connection = cast(S3Connection, self)
|
|
63
|
+
self.__s3_bucket = S3Bucket(connection)
|
|
64
|
+
return self.__s3_bucket
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Geek Cafe, LLC
|
|
3
|
+
Maintainers: Eric Wilson
|
|
4
|
+
MIT License. See Project Root for the license information.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from typing import Any, Dict
|
|
8
|
+
|
|
9
|
+
from aws_lambda_powertools import Logger
|
|
10
|
+
from botocore.exceptions import ClientError
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
from boto3_assist.s3.s3_connection import S3Connection
|
|
14
|
+
|
|
15
|
+
logger = Logger(child=True)
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class S3Bucket:
|
|
19
|
+
"""Common S3 Actions"""
|
|
20
|
+
|
|
21
|
+
def __init__(self, connection: S3Connection):
|
|
22
|
+
self.connection = connection or S3Connection()
|
|
23
|
+
|
|
24
|
+
def create(self, *, bucket_name: str) -> Dict[str, Any]:
|
|
25
|
+
"""
|
|
26
|
+
Create an S3 bucket
|
|
27
|
+
:param bucket_name: Bucket to create
|
|
28
|
+
:return: True if bucket is created, else False
|
|
29
|
+
"""
|
|
30
|
+
try:
|
|
31
|
+
response = self.connection.client.create_bucket(Bucket=bucket_name)
|
|
32
|
+
logger.info(f"Bucket {bucket_name} created")
|
|
33
|
+
|
|
34
|
+
return dict(response)
|
|
35
|
+
except ClientError as e:
|
|
36
|
+
logger.exception(e)
|
|
37
|
+
raise e
|
|
38
|
+
|
|
39
|
+
def enable_versioning(self, *, bucket_name: str) -> None:
|
|
40
|
+
"""
|
|
41
|
+
Enable versioning on an S3 bucket
|
|
42
|
+
:param bucket_name: Bucket to enable versioning on
|
|
43
|
+
:return: None
|
|
44
|
+
"""
|
|
45
|
+
try:
|
|
46
|
+
self.connection.client.put_bucket_versioning(
|
|
47
|
+
Bucket=bucket_name, VersioningConfiguration={"Status": "Enabled"}
|
|
48
|
+
)
|
|
49
|
+
logger.info(f"Versioning enabled on bucket {bucket_name}")
|
|
50
|
+
except ClientError as e:
|
|
51
|
+
logger.exception(e)
|
|
52
|
+
raise e
|
|
53
|
+
|
|
54
|
+
def disable_versioning(self, *, bucket_name: str) -> None:
|
|
55
|
+
"""
|
|
56
|
+
Disable versioning on an S3 bucket
|
|
57
|
+
:param bucket_name: Bucket to disable versioning on
|
|
58
|
+
:return: None
|
|
59
|
+
"""
|
|
60
|
+
try:
|
|
61
|
+
self.connection.client.put_bucket_versioning(
|
|
62
|
+
Bucket=bucket_name, VersioningConfiguration={"Status": "Suspended"}
|
|
63
|
+
)
|
|
64
|
+
logger.info(f"Versioning disabled on bucket {bucket_name}")
|
|
65
|
+
except ClientError as e:
|
|
66
|
+
logger.exception(e)
|
|
67
|
+
raise e
|
boto3_assist/s3/s3_connection.py
CHANGED
|
@@ -8,10 +8,7 @@ from typing import Optional
|
|
|
8
8
|
from typing import TYPE_CHECKING
|
|
9
9
|
|
|
10
10
|
from aws_lambda_powertools import Logger
|
|
11
|
-
|
|
12
|
-
from boto3_assist.environment_services.environment_variables import (
|
|
13
|
-
EnvironmentVariables,
|
|
14
|
-
)
|
|
11
|
+
|
|
15
12
|
from boto3_assist.connection import Connection
|
|
16
13
|
|
|
17
14
|
if TYPE_CHECKING:
|
|
@@ -0,0 +1,606 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Geek Cafe, LLC
|
|
3
|
+
Maintainers: Eric Wilson
|
|
4
|
+
MIT License. See Project Root for the license information.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
import os
|
|
8
|
+
import tempfile
|
|
9
|
+
import time
|
|
10
|
+
import io
|
|
11
|
+
from typing import Any, Dict, Optional, List
|
|
12
|
+
|
|
13
|
+
from aws_lambda_powertools import Logger
|
|
14
|
+
from botocore.exceptions import ClientError
|
|
15
|
+
|
|
16
|
+
from boto3_assist.errors.custom_exceptions import InvalidHttpMethod
|
|
17
|
+
from boto3_assist.s3.s3_connection import S3Connection
|
|
18
|
+
from boto3_assist.utilities.datetime_utility import DatetimeUtility
|
|
19
|
+
from boto3_assist.utilities.file_operations import FileOperations
|
|
20
|
+
from boto3_assist.utilities.http_utility import HttpUtility
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
logger = Logger(child=True)
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class S3Object:
|
|
27
|
+
"""S3 Object Actions"""
|
|
28
|
+
|
|
29
|
+
def __init__(self, connection: S3Connection):
|
|
30
|
+
self.connection = connection or S3Connection()
|
|
31
|
+
|
|
32
|
+
def delete(self, *, bucket_name: str, key: str) -> Dict[str, Any]:
|
|
33
|
+
"""
|
|
34
|
+
Deletes an object key
|
|
35
|
+
|
|
36
|
+
Args:
|
|
37
|
+
bucket_name (str): The AWS Bucket Name
|
|
38
|
+
key (str): The Object Key
|
|
39
|
+
"""
|
|
40
|
+
s3 = self.connection.client
|
|
41
|
+
# see if the object exists
|
|
42
|
+
try:
|
|
43
|
+
response = s3.head_object(Bucket=bucket_name, Key=key)
|
|
44
|
+
response = s3.delete_object(Bucket=bucket_name, Key=key)
|
|
45
|
+
except s3.exceptions.NoSuchKey:
|
|
46
|
+
response = {"ResponseMetadata": {"HTTPStatusCode": 404}}
|
|
47
|
+
except s3.exceptions.ClientError as e:
|
|
48
|
+
if e.response.get("Error", {}).get("Code") == "404":
|
|
49
|
+
response = {"ResponseMetadata": {"HTTPStatusCode": 404}}
|
|
50
|
+
else:
|
|
51
|
+
raise e
|
|
52
|
+
|
|
53
|
+
return dict(response)
|
|
54
|
+
|
|
55
|
+
def delete_all_versions(
|
|
56
|
+
self, *, bucket_name: str, key: str, include_deleted: bool = False
|
|
57
|
+
) -> List[str]:
|
|
58
|
+
"""
|
|
59
|
+
Deletes an object key and all the versions for that object key
|
|
60
|
+
|
|
61
|
+
Args:
|
|
62
|
+
bucket_name (str): The AWS Bucket Name
|
|
63
|
+
key (str): The Object Kuye
|
|
64
|
+
include_deleted (bool, optional): Should deleted files be removed as well.
|
|
65
|
+
If True it will look for the object keys with the deleted marker and remove it.
|
|
66
|
+
Defaults to False.
|
|
67
|
+
"""
|
|
68
|
+
s3 = self.connection.client
|
|
69
|
+
paginator = s3.get_paginator("list_object_versions")
|
|
70
|
+
files: List[str] = []
|
|
71
|
+
|
|
72
|
+
for page in paginator.paginate(Bucket=bucket_name, Prefix=key):
|
|
73
|
+
# Delete object versions
|
|
74
|
+
if "Versions" in page:
|
|
75
|
+
for version in page["Versions"]:
|
|
76
|
+
s3.delete_object(
|
|
77
|
+
Bucket=bucket_name,
|
|
78
|
+
Key=version["Key"],
|
|
79
|
+
VersionId=version["VersionId"],
|
|
80
|
+
)
|
|
81
|
+
|
|
82
|
+
files.append(f"{version['Key']} - {version['VersionId']}")
|
|
83
|
+
|
|
84
|
+
if include_deleted:
|
|
85
|
+
# delete a previous files that may have just been a soft delete.
|
|
86
|
+
if "DeleteMarkers" in page:
|
|
87
|
+
for marker in page["DeleteMarkers"]:
|
|
88
|
+
s3.delete_object(
|
|
89
|
+
Bucket=bucket_name,
|
|
90
|
+
Key=marker["Key"],
|
|
91
|
+
VersionId=marker["VersionId"],
|
|
92
|
+
)
|
|
93
|
+
|
|
94
|
+
files.append(
|
|
95
|
+
f"{marker['Key']}:{marker['VersionId']}:delete-marker"
|
|
96
|
+
)
|
|
97
|
+
else:
|
|
98
|
+
response = self.delete(bucket_name=bucket_name, key=key)
|
|
99
|
+
if response["ResponseMetadata"]["HTTPStatusCode"] == 404:
|
|
100
|
+
return files
|
|
101
|
+
|
|
102
|
+
files.append(key)
|
|
103
|
+
|
|
104
|
+
return files
|
|
105
|
+
|
|
106
|
+
def generate_presigned_url(
|
|
107
|
+
self,
|
|
108
|
+
*,
|
|
109
|
+
bucket_name: str,
|
|
110
|
+
key_path: str,
|
|
111
|
+
file_name: str,
|
|
112
|
+
meta_data: Optional[dict] = None,
|
|
113
|
+
expiration: int = 3600,
|
|
114
|
+
method_type: str = "POST",
|
|
115
|
+
user_id: Optional[str] = None,
|
|
116
|
+
) -> Dict[str, Any]:
|
|
117
|
+
"""
|
|
118
|
+
Create a signed URL for uploading a file to S3.
|
|
119
|
+
:param bucket_name: The name of the S3 bucket.
|
|
120
|
+
:param user_id: The user ID of the user uploading the file.
|
|
121
|
+
:param file_name: The file name of the file being uploaded.
|
|
122
|
+
:param aws_profile: The name of the AWS profile to use.
|
|
123
|
+
:param aws_region: The name of the AWS region to use.
|
|
124
|
+
:param expiration: The number of seconds the URL is valid for.
|
|
125
|
+
:return: The signed URL.
|
|
126
|
+
"""
|
|
127
|
+
start = DatetimeUtility.get_utc_now()
|
|
128
|
+
logger.debug(
|
|
129
|
+
f"Creating signed URL for bucket {bucket_name} for user {user_id} and file {file_name} at {start} UTC"
|
|
130
|
+
)
|
|
131
|
+
|
|
132
|
+
file_extension = FileOperations.get_file_extension(file_name)
|
|
133
|
+
|
|
134
|
+
local_meta = {
|
|
135
|
+
"user_id": f"{user_id}",
|
|
136
|
+
"file_name": f"{file_name}",
|
|
137
|
+
"extension": f"{file_extension}",
|
|
138
|
+
"method": "pre-signed-upload",
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
if not meta_data:
|
|
142
|
+
meta_data = local_meta
|
|
143
|
+
else:
|
|
144
|
+
meta_data.update(local_meta)
|
|
145
|
+
|
|
146
|
+
key = key_path
|
|
147
|
+
method_type = method_type.upper()
|
|
148
|
+
|
|
149
|
+
signed_url: str | Dict[str, Any]
|
|
150
|
+
if method_type == "PUT":
|
|
151
|
+
signed_url = self.connection.client.generate_presigned_url(
|
|
152
|
+
"put_object",
|
|
153
|
+
Params={
|
|
154
|
+
"Bucket": f"{bucket_name}",
|
|
155
|
+
"Key": f"{key}",
|
|
156
|
+
# NOTE: if you include the ContentType or Metadata then its required in the when they upload the file
|
|
157
|
+
# Otherwise you will get a `SignatureDoesNotMatch` error
|
|
158
|
+
# for now I'm commenting it out.
|
|
159
|
+
#'ContentType': 'application/octet-stream',
|
|
160
|
+
#'ACL': 'private',
|
|
161
|
+
# "Metadata": meta_data,
|
|
162
|
+
},
|
|
163
|
+
ExpiresIn=expiration, # URL is valid for x seconds
|
|
164
|
+
)
|
|
165
|
+
elif method_type == "POST":
|
|
166
|
+
signed_url = self.connection.client.generate_presigned_post(
|
|
167
|
+
bucket_name,
|
|
168
|
+
key,
|
|
169
|
+
ExpiresIn=expiration, # URL is valid for x seconds
|
|
170
|
+
)
|
|
171
|
+
elif method_type == "GET":
|
|
172
|
+
signed_url = self.connection.client.generate_presigned_url(
|
|
173
|
+
"get_object",
|
|
174
|
+
Params={
|
|
175
|
+
"Bucket": f"{bucket_name}",
|
|
176
|
+
"Key": f"{key}",
|
|
177
|
+
},
|
|
178
|
+
ExpiresIn=expiration, # URL is valid for x seconds
|
|
179
|
+
)
|
|
180
|
+
else:
|
|
181
|
+
raise InvalidHttpMethod(
|
|
182
|
+
f'Unknown method type was referenced. valid types are "PUT", "POST", "GET" , "{method_type}" as used '
|
|
183
|
+
)
|
|
184
|
+
|
|
185
|
+
end = DatetimeUtility.get_utc_now()
|
|
186
|
+
logger.debug(f"Signed URL created in {end-start}")
|
|
187
|
+
|
|
188
|
+
response = {
|
|
189
|
+
"signed_url": signed_url,
|
|
190
|
+
"key": key,
|
|
191
|
+
"meta_data": meta_data,
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
return response
|
|
195
|
+
|
|
196
|
+
def upload_file_obj(self, *, bucket: str, key: str, file_obj: bytes | str) -> str:
|
|
197
|
+
"""
|
|
198
|
+
Uploads a file object to s3. Returns the full s3 path s3://<bucket>/<key>
|
|
199
|
+
"""
|
|
200
|
+
|
|
201
|
+
if key.startswith("/"):
|
|
202
|
+
# remove the first slash
|
|
203
|
+
key = key[1:]
|
|
204
|
+
|
|
205
|
+
logger.debug(
|
|
206
|
+
{
|
|
207
|
+
"metric_filter": "upload_file_to_s3",
|
|
208
|
+
"bucket": bucket,
|
|
209
|
+
"key": key,
|
|
210
|
+
}
|
|
211
|
+
)
|
|
212
|
+
try:
|
|
213
|
+
# convert if necessary
|
|
214
|
+
file_obj: bytes = (
|
|
215
|
+
file_obj.encode("utf-8") if isinstance(file_obj, str) else file_obj
|
|
216
|
+
)
|
|
217
|
+
self.connection.client.upload_fileobj(
|
|
218
|
+
Fileobj=io.BytesIO(file_obj), Bucket=bucket, Key=key
|
|
219
|
+
)
|
|
220
|
+
|
|
221
|
+
except ClientError as ce:
|
|
222
|
+
error = {
|
|
223
|
+
"metric_filter": "upload_file_to_s3_failure",
|
|
224
|
+
"s3 upload": "failure",
|
|
225
|
+
"bucket": bucket,
|
|
226
|
+
"key": key,
|
|
227
|
+
}
|
|
228
|
+
logger.error(error)
|
|
229
|
+
raise RuntimeError(error) from ce
|
|
230
|
+
|
|
231
|
+
return f"s3://{bucket}/{key}"
|
|
232
|
+
|
|
233
|
+
def upload_file(
|
|
234
|
+
self,
|
|
235
|
+
*,
|
|
236
|
+
bucket: str,
|
|
237
|
+
key: str,
|
|
238
|
+
local_file_path: str,
|
|
239
|
+
throw_error_on_failure: bool = False,
|
|
240
|
+
) -> str | None:
|
|
241
|
+
"""
|
|
242
|
+
Uploads a file to s3. Returns the full s3 path s3://<bucket>/<key>
|
|
243
|
+
"""
|
|
244
|
+
|
|
245
|
+
if key.startswith("/"):
|
|
246
|
+
# remove the first slash
|
|
247
|
+
key = key[1:]
|
|
248
|
+
|
|
249
|
+
# build the path
|
|
250
|
+
s3_path = f"s3://{bucket}/{key}"
|
|
251
|
+
|
|
252
|
+
logger.debug(
|
|
253
|
+
{
|
|
254
|
+
"metric_filter": "upload_file_to_s3",
|
|
255
|
+
"bucket": bucket,
|
|
256
|
+
"key": key,
|
|
257
|
+
"local_file_path": local_file_path,
|
|
258
|
+
}
|
|
259
|
+
)
|
|
260
|
+
try:
|
|
261
|
+
self.connection.client.upload_file(local_file_path, bucket, key)
|
|
262
|
+
|
|
263
|
+
except ClientError as ce:
|
|
264
|
+
error = {
|
|
265
|
+
"metric_filter": "upload_file_to_s3_failure",
|
|
266
|
+
"s3 upload": "failure",
|
|
267
|
+
"bucket": bucket,
|
|
268
|
+
"key": key,
|
|
269
|
+
"local_file_path": local_file_path,
|
|
270
|
+
}
|
|
271
|
+
logger.error(error)
|
|
272
|
+
|
|
273
|
+
if throw_error_on_failure:
|
|
274
|
+
raise RuntimeError(error) from ce
|
|
275
|
+
|
|
276
|
+
return None
|
|
277
|
+
|
|
278
|
+
return s3_path
|
|
279
|
+
|
|
280
|
+
def download_file(
|
|
281
|
+
self,
|
|
282
|
+
*,
|
|
283
|
+
bucket: str,
|
|
284
|
+
key: str,
|
|
285
|
+
local_directory: str | None = None,
|
|
286
|
+
local_file_path: str | None = None,
|
|
287
|
+
retry_attempts: int = 3,
|
|
288
|
+
retry_sleep: int = 5,
|
|
289
|
+
) -> str:
|
|
290
|
+
"""Download a file from s3"""
|
|
291
|
+
exception: Exception | None = None
|
|
292
|
+
|
|
293
|
+
if retry_attempts == 0:
|
|
294
|
+
retry_attempts = 1
|
|
295
|
+
|
|
296
|
+
for i in range(retry_attempts):
|
|
297
|
+
exception = None
|
|
298
|
+
try:
|
|
299
|
+
path = self.download_file_no_retries(
|
|
300
|
+
bucket=bucket,
|
|
301
|
+
key=key,
|
|
302
|
+
local_directory=local_directory,
|
|
303
|
+
local_file_path=local_file_path,
|
|
304
|
+
)
|
|
305
|
+
if path and os.path.exists(path):
|
|
306
|
+
return path
|
|
307
|
+
|
|
308
|
+
except Exception as e: # pylint: disable=w0718
|
|
309
|
+
logger.warning(
|
|
310
|
+
{
|
|
311
|
+
"action": "download_file",
|
|
312
|
+
"result": "failure",
|
|
313
|
+
"exception": str(e),
|
|
314
|
+
"attempt": i + 1,
|
|
315
|
+
"retry_attempts": retry_attempts,
|
|
316
|
+
}
|
|
317
|
+
)
|
|
318
|
+
|
|
319
|
+
exception = e
|
|
320
|
+
|
|
321
|
+
# sleep for a bit
|
|
322
|
+
attempt = i + 1
|
|
323
|
+
time.sleep(attempt * retry_sleep)
|
|
324
|
+
|
|
325
|
+
if exception:
|
|
326
|
+
logger.exception(
|
|
327
|
+
{
|
|
328
|
+
"action": "download_file",
|
|
329
|
+
"result": "failure",
|
|
330
|
+
"exception": str(exception),
|
|
331
|
+
"retry_attempts": retry_attempts,
|
|
332
|
+
}
|
|
333
|
+
)
|
|
334
|
+
|
|
335
|
+
raise exception from exception
|
|
336
|
+
|
|
337
|
+
raise RuntimeError("Unable to download file")
|
|
338
|
+
|
|
339
|
+
def download_file_no_retries(
|
|
340
|
+
self,
|
|
341
|
+
bucket: str,
|
|
342
|
+
key: str,
|
|
343
|
+
local_directory: str | None = None,
|
|
344
|
+
local_file_path: str | None = None,
|
|
345
|
+
) -> str:
|
|
346
|
+
"""
|
|
347
|
+
Downloads a file from s3
|
|
348
|
+
|
|
349
|
+
Args:
|
|
350
|
+
bucket (str): s3 bucket
|
|
351
|
+
key (str): the s3 object key
|
|
352
|
+
local_directory (str, optional): Local directory to download to. Defaults to None.
|
|
353
|
+
If None, we'll use a local tmp directory.
|
|
354
|
+
|
|
355
|
+
Raises:
|
|
356
|
+
e:
|
|
357
|
+
|
|
358
|
+
Returns:
|
|
359
|
+
str: Path to the downloaded file.
|
|
360
|
+
"""
|
|
361
|
+
|
|
362
|
+
decoded_object_key: str
|
|
363
|
+
try:
|
|
364
|
+
logger.debug(
|
|
365
|
+
{
|
|
366
|
+
"action": "downloading file",
|
|
367
|
+
"bucket": bucket,
|
|
368
|
+
"key": key,
|
|
369
|
+
"local_directory": local_directory,
|
|
370
|
+
}
|
|
371
|
+
)
|
|
372
|
+
return self.__download_file(bucket, key, local_directory, local_file_path)
|
|
373
|
+
except FileNotFoundError:
|
|
374
|
+
logger.warning(
|
|
375
|
+
{
|
|
376
|
+
"metric_filter": "download_file_error",
|
|
377
|
+
"error": "FileNotFoundError",
|
|
378
|
+
"message": "attempting to find it decoded",
|
|
379
|
+
"bucket": bucket,
|
|
380
|
+
"key": key,
|
|
381
|
+
}
|
|
382
|
+
)
|
|
383
|
+
|
|
384
|
+
# attempt to decode the key
|
|
385
|
+
decoded_object_key = HttpUtility.decode_url(key)
|
|
386
|
+
|
|
387
|
+
logger.error(
|
|
388
|
+
{
|
|
389
|
+
"metric_filter": "download_file_error",
|
|
390
|
+
"error": "FileNotFoundError",
|
|
391
|
+
"message": "attempting to find it decoded",
|
|
392
|
+
"bucket": bucket,
|
|
393
|
+
"key": key,
|
|
394
|
+
"decoded_object_key": decoded_object_key,
|
|
395
|
+
}
|
|
396
|
+
)
|
|
397
|
+
|
|
398
|
+
return self.__download_file(bucket, decoded_object_key, local_directory)
|
|
399
|
+
|
|
400
|
+
except Exception as e:
|
|
401
|
+
logger.error(
|
|
402
|
+
{
|
|
403
|
+
"metric_filter": "download_file_error",
|
|
404
|
+
"error": str(e),
|
|
405
|
+
"bucket": bucket,
|
|
406
|
+
"decoded_object_key": decoded_object_key,
|
|
407
|
+
}
|
|
408
|
+
)
|
|
409
|
+
raise e
|
|
410
|
+
|
|
411
|
+
def stream_file(self, bucket_name: str, key: str) -> Dict[str, Any]:
|
|
412
|
+
"""
|
|
413
|
+
Gets a file from s3 and returns the response.
|
|
414
|
+
The "Body" is a streaming body object. You can read it like a file.
|
|
415
|
+
For example:
|
|
416
|
+
|
|
417
|
+
with response["Body"] as f:
|
|
418
|
+
data = f.read()
|
|
419
|
+
print(data)
|
|
420
|
+
|
|
421
|
+
"""
|
|
422
|
+
|
|
423
|
+
logger.debug(
|
|
424
|
+
{
|
|
425
|
+
"source": "download_file",
|
|
426
|
+
"action": "downloading a file from s3",
|
|
427
|
+
"bucket": bucket_name,
|
|
428
|
+
"key": key,
|
|
429
|
+
}
|
|
430
|
+
)
|
|
431
|
+
|
|
432
|
+
response: Dict[str, Any] = {}
|
|
433
|
+
error = None
|
|
434
|
+
|
|
435
|
+
try:
|
|
436
|
+
response = dict(
|
|
437
|
+
self.connection.client.get_object(Bucket=bucket_name, Key=key)
|
|
438
|
+
)
|
|
439
|
+
|
|
440
|
+
logger.debug(
|
|
441
|
+
{"metric_filter": "s3_download_response", "response": str(response)}
|
|
442
|
+
)
|
|
443
|
+
|
|
444
|
+
except Exception as e: # pylint: disable=W0718
|
|
445
|
+
error = str(e)
|
|
446
|
+
logger.error({"metric_filter": "s3_download_error", "error": str(e)})
|
|
447
|
+
raise RuntimeError(
|
|
448
|
+
{
|
|
449
|
+
"metric_filter": "s3_download_error",
|
|
450
|
+
"error": str(e),
|
|
451
|
+
"bucket": bucket_name,
|
|
452
|
+
"key": key,
|
|
453
|
+
}
|
|
454
|
+
) from e
|
|
455
|
+
|
|
456
|
+
finally:
|
|
457
|
+
logger.debug(
|
|
458
|
+
{
|
|
459
|
+
"source": "download_file",
|
|
460
|
+
"action": "downloading a file from s3",
|
|
461
|
+
"bucket": bucket_name,
|
|
462
|
+
"key": key,
|
|
463
|
+
"response": response,
|
|
464
|
+
"errors": error,
|
|
465
|
+
}
|
|
466
|
+
)
|
|
467
|
+
|
|
468
|
+
return response
|
|
469
|
+
|
|
470
|
+
def __download_file(
|
|
471
|
+
self,
|
|
472
|
+
bucket: str,
|
|
473
|
+
key: str,
|
|
474
|
+
local_directory: str | None = None,
|
|
475
|
+
local_file_path: str | None = None,
|
|
476
|
+
):
|
|
477
|
+
if local_directory and local_file_path:
|
|
478
|
+
raise ValueError(
|
|
479
|
+
"Only one of local_directory or local_file_path can be provided"
|
|
480
|
+
)
|
|
481
|
+
|
|
482
|
+
if local_directory and not os.path.exists(local_directory):
|
|
483
|
+
FileOperations.makedirs(local_directory)
|
|
484
|
+
|
|
485
|
+
if local_file_path and not os.path.exists(os.path.dirname(local_file_path)):
|
|
486
|
+
FileOperations.makedirs(os.path.dirname(local_file_path))
|
|
487
|
+
|
|
488
|
+
file_name = self.__get_file_name_from_path(key)
|
|
489
|
+
if local_directory is None and local_file_path is None:
|
|
490
|
+
local_path = self.get_local_path_for_file(file_name)
|
|
491
|
+
elif local_directory:
|
|
492
|
+
local_path = os.path.join(local_directory, file_name)
|
|
493
|
+
else:
|
|
494
|
+
local_path = local_file_path
|
|
495
|
+
|
|
496
|
+
logger.debug(
|
|
497
|
+
{
|
|
498
|
+
"source": "download_file",
|
|
499
|
+
"action": "downloading a file from s3",
|
|
500
|
+
"bucket": bucket,
|
|
501
|
+
"key": key,
|
|
502
|
+
"file_name": file_name,
|
|
503
|
+
"local_path": local_path,
|
|
504
|
+
}
|
|
505
|
+
)
|
|
506
|
+
|
|
507
|
+
error: str | None = None
|
|
508
|
+
try:
|
|
509
|
+
self.connection.client.download_file(bucket, key, local_path)
|
|
510
|
+
|
|
511
|
+
except Exception as e: # pylint: disable=W0718
|
|
512
|
+
error = str(e)
|
|
513
|
+
logger.error({"metric_filter": "s3_download_error", "error": str(e)})
|
|
514
|
+
|
|
515
|
+
file_exist = os.path.exists(local_path)
|
|
516
|
+
|
|
517
|
+
logger.debug(
|
|
518
|
+
{
|
|
519
|
+
"source": "download_file",
|
|
520
|
+
"action": "downloading a file from s3",
|
|
521
|
+
"bucket": bucket,
|
|
522
|
+
"key": key,
|
|
523
|
+
"file_name": file_name,
|
|
524
|
+
"local_path": local_path,
|
|
525
|
+
"file_downloaded": file_exist,
|
|
526
|
+
"errors": error,
|
|
527
|
+
}
|
|
528
|
+
)
|
|
529
|
+
|
|
530
|
+
if not file_exist:
|
|
531
|
+
raise FileNotFoundError("File Failed to download (does not exist) from S3.")
|
|
532
|
+
|
|
533
|
+
return local_path
|
|
534
|
+
|
|
535
|
+
def __get_file_name_from_path(self, path: str) -> str:
|
|
536
|
+
"""
|
|
537
|
+
Get a file name from the path
|
|
538
|
+
|
|
539
|
+
Args:
|
|
540
|
+
path (str): a file path
|
|
541
|
+
|
|
542
|
+
Returns:
|
|
543
|
+
str: the file name
|
|
544
|
+
"""
|
|
545
|
+
return path.rsplit("/")[-1]
|
|
546
|
+
|
|
547
|
+
def get_local_path_for_file(self, file_name: str):
|
|
548
|
+
"""
|
|
549
|
+
Get a local temp location for a file.
|
|
550
|
+
This is designed to work with lambda functions.
|
|
551
|
+
The /tmp directory is the only writeable location for lambda functions.
|
|
552
|
+
"""
|
|
553
|
+
temp_dir = self.get_temp_directory()
|
|
554
|
+
# use /tmp it's the only writeable location for lambda
|
|
555
|
+
local_path = os.path.join(temp_dir, file_name)
|
|
556
|
+
return local_path
|
|
557
|
+
|
|
558
|
+
def get_temp_directory(self):
|
|
559
|
+
"""
|
|
560
|
+
Determines the appropriate temporary directory based on the environment.
|
|
561
|
+
If running in AWS Lambda, returns '/tmp'.
|
|
562
|
+
Otherwise, returns the system's standard temp directory.
|
|
563
|
+
"""
|
|
564
|
+
if "AWS_LAMBDA_FUNCTION_NAME" in os.environ:
|
|
565
|
+
# In AWS Lambda environment
|
|
566
|
+
return "/tmp"
|
|
567
|
+
else:
|
|
568
|
+
# Not in AWS Lambda, use the system's default temp directory
|
|
569
|
+
return tempfile.gettempdir()
|
|
570
|
+
|
|
571
|
+
def encode(
|
|
572
|
+
self, text: str, encoding: str = "utf-8", errors: str = "strict"
|
|
573
|
+
) -> bytes:
|
|
574
|
+
"""
|
|
575
|
+
Encodes a string for s3
|
|
576
|
+
"""
|
|
577
|
+
return text.encode(encoding=encoding, errors=errors)
|
|
578
|
+
|
|
579
|
+
def decode(
|
|
580
|
+
self, file_obj: bytes, encoding: str = "utf-8", errors: str = "strict"
|
|
581
|
+
) -> str:
|
|
582
|
+
"""
|
|
583
|
+
Decodes bytes to a string
|
|
584
|
+
"""
|
|
585
|
+
return file_obj.decode(encoding=encoding, errors=errors)
|
|
586
|
+
|
|
587
|
+
def list_versions(self, bucket: str, prefix: str = "") -> List[str]:
|
|
588
|
+
"""
|
|
589
|
+
List all versions of objects in an S3 bucket with a given prefix.
|
|
590
|
+
|
|
591
|
+
Args:
|
|
592
|
+
bucket (str): The name of the S3 bucket.
|
|
593
|
+
prefix (str, optional): The prefix to filter objects by. Defaults to "".
|
|
594
|
+
|
|
595
|
+
Returns:
|
|
596
|
+
list: A list of dictionaries containing information about each object version.
|
|
597
|
+
"""
|
|
598
|
+
versions = []
|
|
599
|
+
paginator = self.connection.client.get_paginator("list_object_versions")
|
|
600
|
+
page_iterator = paginator.paginate(Bucket=bucket, Prefix=prefix)
|
|
601
|
+
|
|
602
|
+
for page in page_iterator:
|
|
603
|
+
if "Versions" in page:
|
|
604
|
+
versions.extend(page["Versions"])
|
|
605
|
+
|
|
606
|
+
return versions
|
|
@@ -195,6 +195,36 @@ class StringUtility:
|
|
|
195
195
|
hash_object.update(encoded_string)
|
|
196
196
|
return hash_object.hexdigest()
|
|
197
197
|
|
|
198
|
+
@staticmethod
|
|
199
|
+
def generate_idempotent_uuid(
|
|
200
|
+
namespace: uuid.UUID | str, unique_string: str, case_sensitive: bool = False
|
|
201
|
+
) -> str:
|
|
202
|
+
"""
|
|
203
|
+
Generates an idempotnent UUID, which is useful for creates
|
|
204
|
+
|
|
205
|
+
Args:
|
|
206
|
+
namespace (GUID | str): A namespace for your id, it must be a UUID or a string in a UUID format
|
|
207
|
+
unique_string (str): A unique string like an email address, a tenant name.
|
|
208
|
+
Use a combination for more granularity:
|
|
209
|
+
tenant-name:email
|
|
210
|
+
vendor:product-name
|
|
211
|
+
vendor:product-id
|
|
212
|
+
etc
|
|
213
|
+
|
|
214
|
+
Returns:
|
|
215
|
+
str: a string representation of a UUID
|
|
216
|
+
"""
|
|
217
|
+
if isinstance(namespace, str):
|
|
218
|
+
namespace = uuid.UUID(namespace)
|
|
219
|
+
|
|
220
|
+
if not unique_string:
|
|
221
|
+
raise ValueError("unique_string cannot be empty")
|
|
222
|
+
|
|
223
|
+
if not case_sensitive:
|
|
224
|
+
unique_string = unique_string.lower()
|
|
225
|
+
|
|
226
|
+
return str(uuid.uuid5(namespace, unique_string))
|
|
227
|
+
|
|
198
228
|
@staticmethod
|
|
199
229
|
def get_size_in_kb(input_string: str | dict) -> float:
|
|
200
230
|
"""
|
boto3_assist/version.py
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
__version__ = '0.
|
|
1
|
+
__version__ = '0.7.0'
|
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
boto3_assist/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
2
2
|
boto3_assist/boto3session.py,sha256=Q9sByNC0r_aMQfHnIEnxtTaiCMUqikm8UeSTxV7-Np0,6632
|
|
3
3
|
boto3_assist/connection.py,sha256=CNGkAIRyfrELoWrV0ziQBA3oHacNFuLL3i8faUPRiO0,3486
|
|
4
|
-
boto3_assist/connection_tracker.py,sha256=
|
|
4
|
+
boto3_assist/connection_tracker.py,sha256=UgfR9RlvXf3A4ssMr3gDMpw89ka8mSRvJn4M34SzhbU,4378
|
|
5
5
|
boto3_assist/http_status_codes.py,sha256=G0zRSWenwavYKETvDF9tNVUXQz3Ae2gXdBETYbjvJe8,3284
|
|
6
|
-
boto3_assist/version.py,sha256=
|
|
6
|
+
boto3_assist/version.py,sha256=09hol5ovL6ArILJIJZ-MiscnOjKa6RjaETmtdgJpW2c,22
|
|
7
7
|
boto3_assist/aws_lambda/event_info.py,sha256=OkZ4WzuGaHEu_T8sB188KBgShAJhZpWASALKRGBOhMg,14648
|
|
8
8
|
boto3_assist/cloudwatch/cloudwatch_connection.py,sha256=mnGWaLSQpHh5EeY7Ek_2o9JKHJxOELIYtQVMX1IaHn4,2480
|
|
9
9
|
boto3_assist/cloudwatch/cloudwatch_connection_tracker.py,sha256=mzRtO1uHrcfJNh1XrGEiXdTqxwEP8d1RqJkraMNkgK0,410
|
|
@@ -35,8 +35,10 @@ boto3_assist/environment_services/environment_loader.py,sha256=zCA4mRdVWMLKzjDRv
|
|
|
35
35
|
boto3_assist/environment_services/environment_variables.py,sha256=4ccBKdPt6O7hcRT3zBHd8vqu8yQU8udmoD5RLAT3iMs,6801
|
|
36
36
|
boto3_assist/errors/custom_exceptions.py,sha256=zC2V2Y4PUtKj3uLPn8mB-JessksKWJWvKM9kp1dmvt8,760
|
|
37
37
|
boto3_assist/models/serializable_model.py,sha256=ZMrRJRvJWLY8PBSKK_nPCgYKv1qUxDPEVdcADKbIHsI,266
|
|
38
|
-
boto3_assist/s3/s3.py,sha256=
|
|
39
|
-
boto3_assist/s3/
|
|
38
|
+
boto3_assist/s3/s3.py,sha256=ESTPXtyDi8mrwHaYNWjQLNGTuTUV4CxKDqw-O_KGzKs,2052
|
|
39
|
+
boto3_assist/s3/s3_bucket.py,sha256=GfyBbuI5BWz_ybwU_nDqUZiC0wt24PNt49GKZmb05OY,2018
|
|
40
|
+
boto3_assist/s3/s3_connection.py,sha256=0JgEDNoDFPQTo5hQe-lS8mWnFBJ2S8MDSl0LPG__lZo,2008
|
|
41
|
+
boto3_assist/s3/s3_object.py,sha256=kH9apiVZ-lWGtcSqcLvaAI-2g011ecrvg8wP7y9u140,19482
|
|
40
42
|
boto3_assist/utilities/datetime_utility.py,sha256=dgAMB9VqakrYIPXlSoVQiLSsc_yhrJK4gMfJO9mX90w,11112
|
|
41
43
|
boto3_assist/utilities/dictionaroy_utility.py,sha256=PjUrerEd6uhmw37A-k7xe_DWHvXZGGoMqT6xjUqmWBI,893
|
|
42
44
|
boto3_assist/utilities/file_operations.py,sha256=Zy8fu8fpuVNf7U9NimrLdy5FRF71XSI159cnRdzmzGY,3411
|
|
@@ -44,9 +46,9 @@ boto3_assist/utilities/http_utility.py,sha256=koFv7Va-8ng-47Nt1K2Sh7Ti95e62IYs9V
|
|
|
44
46
|
boto3_assist/utilities/logging_utility.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
45
47
|
boto3_assist/utilities/numbers_utility.py,sha256=KIiNkBSRbfNWvtXG5SdHp625LTiW12VtADUa4ZlWMFo,8709
|
|
46
48
|
boto3_assist/utilities/serialization_utility.py,sha256=KJhit0lI1lr8hhndW6UhKQSZD3c2RH555Ni7eP-e5Ms,16557
|
|
47
|
-
boto3_assist/utilities/string_utility.py,sha256=
|
|
48
|
-
boto3_assist-0.
|
|
49
|
-
boto3_assist-0.
|
|
50
|
-
boto3_assist-0.
|
|
51
|
-
boto3_assist-0.
|
|
52
|
-
boto3_assist-0.
|
|
49
|
+
boto3_assist/utilities/string_utility.py,sha256=5BpDaqGZI8cSM-3YFQLU1fKcWcqG9r1_GPgDstCWFIs,10318
|
|
50
|
+
boto3_assist-0.7.0.dist-info/METADATA,sha256=0cBmMT5N4of5YzMQhUlK3S_ovOEaxRItYa_JQnjH16I,1728
|
|
51
|
+
boto3_assist-0.7.0.dist-info/WHEEL,sha256=qtCwoSJWgHk21S1Kb4ihdzI2rlJ1ZKaIurTj_ngOhyQ,87
|
|
52
|
+
boto3_assist-0.7.0.dist-info/licenses/LICENSE-EXPLAINED.txt,sha256=WFREvTpfTjPjDHpOLADxJpCKpIla3Ht87RUUGii4ODU,606
|
|
53
|
+
boto3_assist-0.7.0.dist-info/licenses/LICENSE.txt,sha256=PXDhFWS5L5aOTkVhNvoitHKbAkgxqMI2uUPQyrnXGiI,1105
|
|
54
|
+
boto3_assist-0.7.0.dist-info/RECORD,,
|
|
File without changes
|
{boto3_assist-0.6.1.dist-info → boto3_assist-0.7.0.dist-info}/licenses/LICENSE-EXPLAINED.txt
RENAMED
|
File without changes
|
|
File without changes
|