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.
@@ -67,8 +67,8 @@ class ConnectionTracker:
67
67
 
68
68
  if not self.issue_stack_trace:
69
69
  stack_trace_message = (
70
- f"\nTo 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"
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\nMOCK Testing may show this message as well, in which case you can dismiss this warning.\n\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 os
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.utilities.datetime_utility import DatetimeUtility
19
- from boto3_assist.utilities.file_operations import FileOperations
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
- def create_bucket(self, *, bucket_name: str) -> None:
55
- """
56
- Create an S3 bucket
57
- :param bucket_name: Bucket to create
58
- :return: True if bucket is created, else False
59
- """
60
- try:
61
- self.client.create_bucket(Bucket=bucket_name)
62
- logger.info(f"Bucket {bucket_name} created")
63
- except ClientError as e:
64
- logger.exception(e)
65
- raise e
66
-
67
- def generate_presigned_url(
68
- self,
69
- *,
70
- bucket_name: str,
71
- key_path: str,
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
@@ -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
- from boto3_assist.boto3session import Boto3SessionManager
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.6.1'
1
+ __version__ = '0.7.0'
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: boto3_assist
3
- Version: 0.6.1
3
+ Version: 0.7.0
4
4
  Summary: Additional boto3 wrappers to make your life a little easier
5
5
  Author-email: Eric Wilson <boto3-assist@geekcafe.com>
6
6
  License-File: LICENSE-EXPLAINED.txt
@@ -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=bfImvNVX-0Lhb-ombOurWUpNLdI0qVDil-kokBdIFkY,4345
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=gd3s3RotD0_KL90Tua-YkOr60Jm2C2_wvlEhAT08068,22
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=DFCJs5z1mMIT8nZfnqPyr_cvhi9-FePuYH--tzD7b5E,17104
39
- boto3_assist/s3/s3_connection.py,sha256=FI1AhZV4UbTXQRTb4TqL9mv88Gt018rPZVFBvLetVAw,2163
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=sBY80aQO-fTRanlHryZFMQBxdo6OvLRvnZjZrQepHlI,9283
48
- boto3_assist-0.6.1.dist-info/METADATA,sha256=MVB3qF040hI48bRH8xhXXkyX9aewgYUUiGtUubi2l-0,1728
49
- boto3_assist-0.6.1.dist-info/WHEEL,sha256=qtCwoSJWgHk21S1Kb4ihdzI2rlJ1ZKaIurTj_ngOhyQ,87
50
- boto3_assist-0.6.1.dist-info/licenses/LICENSE-EXPLAINED.txt,sha256=WFREvTpfTjPjDHpOLADxJpCKpIla3Ht87RUUGii4ODU,606
51
- boto3_assist-0.6.1.dist-info/licenses/LICENSE.txt,sha256=PXDhFWS5L5aOTkVhNvoitHKbAkgxqMI2uUPQyrnXGiI,1105
52
- boto3_assist-0.6.1.dist-info/RECORD,,
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,,