boto3-assist 0.2.0__tar.gz → 0.2.1__tar.gz

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.
Files changed (99) hide show
  1. {boto3_assist-0.2.0 → boto3_assist-0.2.1}/PKG-INFO +1 -1
  2. {boto3_assist-0.2.0 → boto3_assist-0.2.1}/pyproject.toml +1 -1
  3. {boto3_assist-0.2.0 → boto3_assist-0.2.1}/requirements-dev.txt +1 -0
  4. {boto3_assist-0.2.0 → boto3_assist-0.2.1}/src/boto3_assist/cloudwatch/cloudwatch_connection.py +2 -4
  5. {boto3_assist-0.2.0 → boto3_assist-0.2.1}/src/boto3_assist/dynamodb/dynamodb.py +2 -2
  6. {boto3_assist-0.2.0 → boto3_assist-0.2.1}/src/boto3_assist/dynamodb/dynamodb_connection.py +28 -2
  7. {boto3_assist-0.2.0 → boto3_assist-0.2.1}/src/boto3_assist/dynamodb/dynamodb_index.py +6 -2
  8. {boto3_assist-0.2.0 → boto3_assist-0.2.1}/src/boto3_assist/dynamodb/dynamodb_model_base.py +2 -3
  9. boto3_assist-0.2.1/src/boto3_assist/errors/custom_exceptions.py +34 -0
  10. boto3_assist-0.2.1/src/boto3_assist/s3/s3.py +476 -0
  11. boto3_assist-0.2.1/src/boto3_assist/s3/s3_connection.py +120 -0
  12. boto3_assist-0.2.1/src/boto3_assist/utilities/file_operations.py +105 -0
  13. boto3_assist-0.2.1/src/boto3_assist/utilities/http_utility.py +42 -0
  14. boto3_assist-0.2.1/src/boto3_assist/version.py +1 -0
  15. boto3_assist-0.2.1/tests/dynamodb/models/user_model.py +64 -0
  16. boto3_assist-0.2.0/src/boto3_assist/version.py +0 -1
  17. boto3_assist-0.2.0/tests/dynamodb/models/user_model.py +0 -79
  18. {boto3_assist-0.2.0 → boto3_assist-0.2.1}/.env.docker +0 -0
  19. {boto3_assist-0.2.0 → boto3_assist-0.2.1}/.env.docker.001 +0 -0
  20. {boto3_assist-0.2.0 → boto3_assist-0.2.1}/.env.docker.nosql.workbench +0 -0
  21. {boto3_assist-0.2.0 → boto3_assist-0.2.1}/.env.unittest +0 -0
  22. {boto3_assist-0.2.0 → boto3_assist-0.2.1}/.gitignore +0 -0
  23. {boto3_assist-0.2.0 → boto3_assist-0.2.1}/.vscode/launch.json +0 -0
  24. {boto3_assist-0.2.0 → boto3_assist-0.2.1}/.vscode/settings.json +0 -0
  25. {boto3_assist-0.2.0 → boto3_assist-0.2.1}/.vscode/tasks.json +0 -0
  26. {boto3_assist-0.2.0 → boto3_assist-0.2.1}/LICENSE-EXPLAINED.txt +0 -0
  27. {boto3_assist-0.2.0 → boto3_assist-0.2.1}/LICENSE.txt +0 -0
  28. {boto3_assist-0.2.0 → boto3_assist-0.2.1}/README.md +0 -0
  29. {boto3_assist-0.2.0 → boto3_assist-0.2.1}/aws_regions_with_status.csv +0 -0
  30. {boto3_assist-0.2.0 → boto3_assist-0.2.1}/aws_regions_with_status.json +0 -0
  31. {boto3_assist-0.2.0 → boto3_assist-0.2.1}/devops/build.py +0 -0
  32. {boto3_assist-0.2.0 → boto3_assist-0.2.1}/devops/readme.md +0 -0
  33. {boto3_assist-0.2.0 → boto3_assist-0.2.1}/examples/__init__.py +0 -0
  34. {boto3_assist-0.2.0 → boto3_assist-0.2.1}/examples/cloudwatch/log_report.py +0 -0
  35. {boto3_assist-0.2.0 → boto3_assist-0.2.1}/examples/dynamodb/models/order_item_model.py +0 -0
  36. {boto3_assist-0.2.0 → boto3_assist-0.2.1}/examples/dynamodb/models/order_model.py +0 -0
  37. {boto3_assist-0.2.0 → boto3_assist-0.2.1}/examples/dynamodb/models/product_model.py +0 -0
  38. {boto3_assist-0.2.0 → boto3_assist-0.2.1}/examples/dynamodb/models/user_model.py +0 -0
  39. {boto3_assist-0.2.0 → boto3_assist-0.2.1}/examples/dynamodb/models/user_post_model.py +0 -0
  40. {boto3_assist-0.2.0 → boto3_assist-0.2.1}/examples/dynamodb/order_example/main.py +0 -0
  41. {boto3_assist-0.2.0 → boto3_assist-0.2.1}/examples/dynamodb/order_example/products.json +0 -0
  42. {boto3_assist-0.2.0 → boto3_assist-0.2.1}/examples/dynamodb/services/order_item_service.py +0 -0
  43. {boto3_assist-0.2.0 → boto3_assist-0.2.1}/examples/dynamodb/services/order_service.py +0 -0
  44. {boto3_assist-0.2.0 → boto3_assist-0.2.1}/examples/dynamodb/services/product_service.py +0 -0
  45. {boto3_assist-0.2.0 → boto3_assist-0.2.1}/examples/dynamodb/services/table_service.py +0 -0
  46. {boto3_assist-0.2.0 → boto3_assist-0.2.1}/examples/dynamodb/services/user_post_service.py +0 -0
  47. {boto3_assist-0.2.0 → boto3_assist-0.2.1}/examples/dynamodb/services/user_service.py +0 -0
  48. {boto3_assist-0.2.0 → boto3_assist-0.2.1}/examples/dynamodb/services/user_service_client_example.py +0 -0
  49. {boto3_assist-0.2.0 → boto3_assist-0.2.1}/examples/dynamodb/services/user_service_resource_example.py +0 -0
  50. {boto3_assist-0.2.0 → boto3_assist-0.2.1}/examples/dynamodb/user_post_example/main.py +0 -0
  51. {boto3_assist-0.2.0 → boto3_assist-0.2.1}/examples/ec2/regions_report.py +0 -0
  52. {boto3_assist-0.2.0 → boto3_assist-0.2.1}/module-headers.txt +0 -0
  53. {boto3_assist-0.2.0 → boto3_assist-0.2.1}/mypy.ini +0 -0
  54. {boto3_assist-0.2.0 → boto3_assist-0.2.1}/requirements.txt +0 -0
  55. {boto3_assist-0.2.0 → boto3_assist-0.2.1}/run-checks.sh +0 -0
  56. {boto3_assist-0.2.0 → boto3_assist-0.2.1}/src/boto3_assist/__init__.py +0 -0
  57. {boto3_assist-0.2.0 → boto3_assist-0.2.1}/src/boto3_assist/boto3session.py +0 -0
  58. {boto3_assist-0.2.0 → boto3_assist-0.2.1}/src/boto3_assist/cloudwatch/cloudwatch_connection_tracker.py +0 -0
  59. {boto3_assist-0.2.0 → boto3_assist-0.2.1}/src/boto3_assist/cloudwatch/cloudwatch_log_connection.py +0 -0
  60. {boto3_assist-0.2.0 → boto3_assist-0.2.1}/src/boto3_assist/cloudwatch/cloudwatch_logs.py +0 -0
  61. {boto3_assist-0.2.0 → boto3_assist-0.2.1}/src/boto3_assist/cloudwatch/cloudwatch_query.py +0 -0
  62. {boto3_assist-0.2.0 → boto3_assist-0.2.1}/src/boto3_assist/connection.py +0 -0
  63. {boto3_assist-0.2.0 → boto3_assist-0.2.1}/src/boto3_assist/connection_tracker.py +0 -0
  64. {boto3_assist-0.2.0 → boto3_assist-0.2.1}/src/boto3_assist/dynamodb/dynamodb_connection_tracker.py +0 -0
  65. {boto3_assist-0.2.0 → boto3_assist-0.2.1}/src/boto3_assist/dynamodb/dynamodb_helpers.py +0 -0
  66. {boto3_assist-0.2.0 → boto3_assist-0.2.1}/src/boto3_assist/dynamodb/dynamodb_importer.py +0 -0
  67. {boto3_assist-0.2.0 → boto3_assist-0.2.1}/src/boto3_assist/dynamodb/dynamodb_iservice.py +0 -0
  68. {boto3_assist-0.2.0 → boto3_assist-0.2.1}/src/boto3_assist/dynamodb/dynamodb_key.py +0 -0
  69. {boto3_assist-0.2.0 → boto3_assist-0.2.1}/src/boto3_assist/dynamodb/dynamodb_model_base_interfaces.py +0 -0
  70. {boto3_assist-0.2.0 → boto3_assist-0.2.1}/src/boto3_assist/dynamodb/dynamodb_reindexer.py +0 -0
  71. {boto3_assist-0.2.0 → boto3_assist-0.2.1}/src/boto3_assist/dynamodb/dynamodb_reserved_words.py +0 -0
  72. {boto3_assist-0.2.0 → boto3_assist-0.2.1}/src/boto3_assist/dynamodb/dynamodb_reserved_words.txt +0 -0
  73. {boto3_assist-0.2.0 → boto3_assist-0.2.1}/src/boto3_assist/dynamodb/readme.md +0 -0
  74. {boto3_assist-0.2.0 → boto3_assist-0.2.1}/src/boto3_assist/dynamodb/troubleshooting.md +0 -0
  75. {boto3_assist-0.2.0 → boto3_assist-0.2.1}/src/boto3_assist/ec2/ec2_connection.py +0 -0
  76. {boto3_assist-0.2.0 → boto3_assist-0.2.1}/src/boto3_assist/environment_services/__init__.py +0 -0
  77. {boto3_assist-0.2.0 → boto3_assist-0.2.1}/src/boto3_assist/environment_services/environment_loader.py +0 -0
  78. {boto3_assist-0.2.0 → boto3_assist-0.2.1}/src/boto3_assist/environment_services/environment_variables.py +0 -0
  79. {boto3_assist-0.2.0 → boto3_assist-0.2.1}/src/boto3_assist/utilities/datetime_utility.py +0 -0
  80. {boto3_assist-0.2.0 → boto3_assist-0.2.1}/src/boto3_assist/utilities/logging_utility.py +0 -0
  81. {boto3_assist-0.2.0 → boto3_assist-0.2.1}/src/boto3_assist/utilities/serialization_utility.py +0 -0
  82. {boto3_assist-0.2.0 → boto3_assist-0.2.1}/src/boto3_assist/utilities/string_utility.py +0 -0
  83. {boto3_assist-0.2.0 → boto3_assist-0.2.1}/tests/__init__.py +0 -0
  84. {boto3_assist-0.2.0 → boto3_assist-0.2.1}/tests/__top/__init__.py +0 -0
  85. {boto3_assist-0.2.0 → boto3_assist-0.2.1}/tests/dynamodb/__init__.py +0 -0
  86. {boto3_assist-0.2.0 → boto3_assist-0.2.1}/tests/dynamodb/dynamodb_model_base_test.py +0 -0
  87. {boto3_assist-0.2.0 → boto3_assist-0.2.1}/tests/dynamodb/dynamodb_model_projections_test.py +0 -0
  88. {boto3_assist-0.2.0 → boto3_assist-0.2.1}/tests/dynamodb/dynamodb_model_serializtion_test.py +0 -0
  89. {boto3_assist-0.2.0 → boto3_assist-0.2.1}/tests/dynamodb/dynamodb_moto_sorting_test.py +0 -0
  90. {boto3_assist-0.2.0 → boto3_assist-0.2.1}/tests/dynamodb/dynamodb_reindex_test.py +0 -0
  91. {boto3_assist-0.2.0 → boto3_assist-0.2.1}/tests/dynamodb/models/cms/base.py +0 -0
  92. {boto3_assist-0.2.0 → boto3_assist-0.2.1}/tests/dynamodb/models/cms/content_block.py +0 -0
  93. {boto3_assist-0.2.0 → boto3_assist-0.2.1}/tests/dynamodb/models/cms/page.py +0 -0
  94. {boto3_assist-0.2.0 → boto3_assist-0.2.1}/tests/dynamodb/models/cms/template.py +0 -0
  95. {boto3_assist-0.2.0 → boto3_assist-0.2.1}/tests/dynamodb/models/simple_model.py +0 -0
  96. {boto3_assist-0.2.0 → boto3_assist-0.2.1}/tests/examples_test/__init__.py +0 -0
  97. {boto3_assist-0.2.0 → boto3_assist-0.2.1}/tests/examples_test/user_service_test.py +0 -0
  98. {boto3_assist-0.2.0 → boto3_assist-0.2.1}/tests/utilities/__init__.py +0 -0
  99. {boto3_assist-0.2.0 → boto3_assist-0.2.1}/tests/utilities/serialization_utility_test.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.3
2
2
  Name: boto3_assist
3
- Version: 0.2.0
3
+ Version: 0.2.1
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
  Classifier: License :: Other/Proprietary License
@@ -7,7 +7,7 @@ packages = ["src/boto3_assist"]
7
7
 
8
8
  [project]
9
9
  name = "boto3_assist"
10
- version = "0.2.0"
10
+ version = "0.2.1"
11
11
  authors = [
12
12
  { name="Eric Wilson", email="boto3-assist@geekcafe.com" }
13
13
  ]
@@ -4,6 +4,7 @@ mypy
4
4
  mypy_boto3_dynamodb
5
5
  mypy_boto3_ec2
6
6
  mypy_boto3_cloudwatch
7
+ mypy_boto3_s3
7
8
  moto [dynamodb2] # mocks for unit tests
8
9
  # setuptools
9
10
  types-python-dateutil
@@ -8,10 +8,8 @@ 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
+
12
+
15
13
  from boto3_assist.cloudwatch.cloudwatch_connection_tracker import (
16
14
  CloudWatchConnectionTracker,
17
15
  )
@@ -144,7 +144,7 @@ class DynamoDB(DynamoDBConnection):
144
144
  expression_attribute_names: Optional[dict] = None,
145
145
  source: Optional[str] = None,
146
146
  call_type: str = "resource",
147
- ) -> dict: ...
147
+ ) -> Dict[str, Any]: ...
148
148
 
149
149
  @overload
150
150
  def get(
@@ -158,7 +158,7 @@ class DynamoDB(DynamoDBConnection):
158
158
  expression_attribute_names: Optional[dict] = None,
159
159
  source: Optional[str] = None,
160
160
  call_type: str = "resource",
161
- ) -> dict: ...
161
+ ) -> Dict[str, Any]: ...
162
162
 
163
163
  @tracer.capture_method
164
164
  def get(
@@ -98,7 +98,7 @@ class DynamoDBConnection:
98
98
  return self.__session
99
99
 
100
100
  @property
101
- def dynamodb_client(self) -> DynamoDBClient:
101
+ def client(self) -> DynamoDBClient:
102
102
  """DynamoDB Client Connection"""
103
103
  if self.__dynamodb_client is None:
104
104
  logger.info("Creating DynamoDB Client")
@@ -108,13 +108,26 @@ class DynamoDBConnection:
108
108
  raise RuntimeError("DynamoDB Client is not available")
109
109
  return self.__dynamodb_client
110
110
 
111
+ @client.setter
112
+ def client(self, value: DynamoDBClient):
113
+ logger.info("Setting DynamoDB Client")
114
+ self.__dynamodb_client = value
115
+
116
+ @property
117
+ def dynamodb_client(self) -> DynamoDBClient:
118
+ """
119
+ DynamoDB Client Connection
120
+ - Backward Compatible. You should use client instead
121
+ """
122
+ return self.client
123
+
111
124
  @dynamodb_client.setter
112
125
  def dynamodb_client(self, value: DynamoDBClient):
113
126
  logger.info("Setting DynamoDB Client")
114
127
  self.__dynamodb_client = value
115
128
 
116
129
  @property
117
- def dynamodb_resource(self) -> DynamoDBServiceResource:
130
+ def resource(self) -> DynamoDBServiceResource:
118
131
  """DynamoDB Resource Connection"""
119
132
  if self.__dynamodb_resource is None:
120
133
  logger.info("Creating DynamoDB Resource")
@@ -125,6 +138,19 @@ class DynamoDBConnection:
125
138
 
126
139
  return self.__dynamodb_resource
127
140
 
141
+ @resource.setter
142
+ def resource(self, value: DynamoDBServiceResource):
143
+ logger.info("Setting DynamoDB Resource")
144
+ self.__dynamodb_resource = value
145
+
146
+ @property
147
+ def dynamodb_resource(self) -> DynamoDBServiceResource:
148
+ """
149
+ DynamoDB Resource Connection
150
+ - Backward Compatible. You should use resource instead
151
+ """
152
+ return self.resource
153
+
128
154
  @dynamodb_resource.setter
129
155
  def dynamodb_resource(self, value: DynamoDBServiceResource):
130
156
  logger.info("Setting DynamoDB Resource")
@@ -47,14 +47,18 @@ class DynamoDBIndexes:
47
47
  for _, v in self.__indexes.items():
48
48
  if v.partition_key.attribute_name == index.partition_key.attribute_name:
49
49
  raise ValueError(
50
- f"Index {index.name} already exists with partition key {index.partition_key.attribute_name}"
50
+ f"The attrubute {index.partition_key.attribute_name} is already being used by index "
51
+ f"{v.name}. "
52
+ f"Reusing this attribute would over write the value on index {v.name}"
51
53
  )
52
54
  # check if the gsi1.sort_key.attribute_name exists
53
55
  if index.sort_key is not None:
54
56
  for _, v in self.__indexes.items():
55
57
  if v.sort_key.attribute_name == index.sort_key.attribute_name:
56
58
  raise ValueError(
57
- f"Index {index.name} already exists with sort key {index.sort_key.attribute_name}"
59
+ f"The attrubute {index.sort_key.attribute_name} is already being used by index "
60
+ f"{v.name}. "
61
+ f"Reusing this attribute would over write the value on index {v.name}"
58
62
  )
59
63
 
60
64
  self.__indexes[index.name] = index
@@ -9,8 +9,7 @@ import datetime as dt
9
9
  import decimal
10
10
  import inspect
11
11
  import uuid
12
- import base64
13
- from typing import TypeVar, List
12
+ from typing import TypeVar, List, Dict, Any
14
13
  from boto3.dynamodb.types import TypeSerializer
15
14
  from boto3_assist.utilities.serialization_utility import Serialization
16
15
  from boto3_assist.dynamodb.dynamodb_helpers import DynamoDBHelpers
@@ -126,7 +125,7 @@ class DynamoDBModelBase:
126
125
  def projection_expression_attribute_names(self, value: dict | None):
127
126
  self.__projection_expression_attribute_names = value
128
127
 
129
- def map(self: T, item: dict | DynamoDBModelBase | None) -> T | None:
128
+ def map(self: T, item: Dict[str, Any] | DynamoDBModelBase | None) -> T | None:
130
129
  """
131
130
  Map the item to the instance. If the item is a DynamoDBModelBase,
132
131
  it will be converted to a dictionary first and then mapped.
@@ -0,0 +1,34 @@
1
+ class Error(Exception):
2
+ """Base class for exceptions in this module."""
3
+
4
+
5
+ class DbFailures(Error):
6
+ """DB Failure Error"""
7
+
8
+
9
+ class InvalidHttpMethod(Exception):
10
+ """Invalid Http Method"""
11
+
12
+ def __init__(
13
+ self,
14
+ code=422,
15
+ message="Invalid Http Method",
16
+ ):
17
+ """The user account is not valid"""
18
+ self.message = {
19
+ "status_code": code,
20
+ "message": message,
21
+ }
22
+ super().__init__(self.message)
23
+
24
+
25
+ class InvalidRoutePath(Exception):
26
+ """Invalid Http Route"""
27
+
28
+ def __init__(self, message="Invalid Route"):
29
+ """Invalid Route"""
30
+ self.message = {
31
+ "status_code": 404,
32
+ "message": message,
33
+ }
34
+ super().__init__(self.message)
@@ -0,0 +1,476 @@
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
+ from typing import Any, Dict, List, Optional
11
+
12
+ from aws_lambda_powertools import Logger
13
+ from botocore.exceptions import ClientError
14
+
15
+ from boto3_assist.errors.custom_exceptions import InvalidHttpMethod
16
+ from boto3_assist.s3.s3_connection import S3Connection
17
+ from boto3_assist.utilities.datetime_utility import DatetimeUtility
18
+ from boto3_assist.utilities.file_operations import FileOperations
19
+ from boto3_assist.utilities.http_utility import HttpUtility
20
+
21
+ logger = Logger(child=True)
22
+
23
+
24
+ class S3(S3Connection):
25
+ """Common S3 Actions"""
26
+
27
+ def __init__(
28
+ self,
29
+ *,
30
+ aws_profile: Optional[str] = None,
31
+ aws_region: Optional[str] = None,
32
+ aws_end_point_url: Optional[str] = None,
33
+ aws_access_key_id: Optional[str] = None,
34
+ aws_secret_access_key: Optional[str] = None,
35
+ ) -> None:
36
+ """_summary_
37
+
38
+ Args:
39
+ aws_profile (Optional[str], optional): _description_. Defaults to None.
40
+ aws_region (Optional[str], optional): _description_. Defaults to None.
41
+ aws_end_point_url (Optional[str], optional): _description_. Defaults to None.
42
+ aws_access_key_id (Optional[str], optional): _description_. Defaults to None.
43
+ aws_secret_access_key (Optional[str], optional): _description_. Defaults to None.
44
+ """
45
+ super().__init__(
46
+ aws_profile=aws_profile,
47
+ aws_region=aws_region,
48
+ aws_end_point_url=aws_end_point_url,
49
+ aws_access_key_id=aws_access_key_id,
50
+ aws_secret_access_key=aws_secret_access_key,
51
+ )
52
+
53
+ def generate_presigned_url(
54
+ self,
55
+ bucket_name: str,
56
+ key_path: str,
57
+ user_id: str,
58
+ file_name: str,
59
+ meta_data: dict | None = None,
60
+ expiration=3600,
61
+ method_type="POST",
62
+ ) -> Dict[str, Any]:
63
+ """
64
+ Create a signed URL for uploading a file to S3.
65
+ :param bucket_name: The name of the S3 bucket.
66
+ :param user_id: The user ID of the user uploading the file.
67
+ :param file_name: The file name of the file being uploaded.
68
+ :param aws_profile: The name of the AWS profile to use.
69
+ :param aws_region: The name of the AWS region to use.
70
+ :param expiration: The number of seconds the URL is valid for.
71
+ :return: The signed URL.
72
+ """
73
+ start = DatetimeUtility.get_utc_now()
74
+ logger.debug(
75
+ f"Creating signed URL for bucket {bucket_name} for user {user_id} and file {file_name} at {start} UTC"
76
+ )
77
+
78
+ file_extension = FileOperations.get_file_extension(file_name)
79
+
80
+ local_meta = {
81
+ "user_id": f"{user_id}",
82
+ "file_name": f"{file_name}",
83
+ "extension": f"{file_extension}",
84
+ "method": "pre-signed-upload",
85
+ }
86
+
87
+ if not meta_data:
88
+ meta_data = local_meta
89
+ else:
90
+ meta_data.update(local_meta)
91
+
92
+ object_key = key_path
93
+ method_type = method_type.upper()
94
+
95
+ signed_url: str | Dict[str, Any]
96
+ if method_type == "PUT":
97
+ signed_url = self.client.generate_presigned_url(
98
+ "put_object",
99
+ Params={
100
+ "Bucket": f"{bucket_name}",
101
+ "Key": f"{object_key}",
102
+ # NOTE: if you include the ContentType or Metadata then its required in the when they upload the file
103
+ # Otherwise you will get a `SignatureDoesNotMatch` error
104
+ # for now I'm commenting it out.
105
+ #'ContentType': 'application/octet-stream',
106
+ #'ACL': 'private',
107
+ # "Metadata": meta_data,
108
+ },
109
+ ExpiresIn=expiration, # URL is valid for x seconds
110
+ )
111
+ elif method_type == "POST":
112
+ signed_url = self.client.generate_presigned_post(
113
+ bucket_name,
114
+ object_key,
115
+ ExpiresIn=expiration, # URL is valid for x seconds
116
+ )
117
+ elif method_type == "GET":
118
+ signed_url = self.client.generate_presigned_url(
119
+ "get_object",
120
+ Params={
121
+ "Bucket": f"{bucket_name}",
122
+ "Key": f"{object_key}",
123
+ },
124
+ ExpiresIn=expiration, # URL is valid for x seconds
125
+ )
126
+ else:
127
+ raise InvalidHttpMethod(
128
+ f'Unknown method type was referenced. valid types are "PUT", "POST", "GET" , "{method_type}" as used '
129
+ )
130
+
131
+ end = DatetimeUtility.get_utc_now()
132
+ logger.debug(f"Signed URL created in {end-start}")
133
+
134
+ response = {
135
+ "signed_url": signed_url,
136
+ "object_key": object_key,
137
+ "meta_data": meta_data,
138
+ }
139
+
140
+ return response
141
+
142
+ def upload_file(
143
+ self,
144
+ bucket: str,
145
+ key: str,
146
+ local_file_path: str,
147
+ throw_error_on_failure: bool = False,
148
+ ) -> str | None:
149
+ """
150
+ Uploads a file to s3. Returns the full s3 path s3://<bucket>/<key>
151
+ """
152
+
153
+ if key.startswith("/"):
154
+ # remove the first slash
155
+ key = key[1:]
156
+
157
+ # build the path
158
+ s3_path = f"s3://{bucket}/{key}"
159
+
160
+ logger.debug(
161
+ {
162
+ "metric_filter": "upload_file_to_s3",
163
+ "bucket": bucket,
164
+ "key": key,
165
+ "local_file_path": local_file_path,
166
+ }
167
+ )
168
+ try:
169
+ self.client.upload_file(local_file_path, bucket, key)
170
+
171
+ except ClientError as ce:
172
+ error = {
173
+ "metric_filter": "upload_file_to_s3_failure",
174
+ "s3 upload": "failure",
175
+ "bucket": bucket,
176
+ "key": key,
177
+ "local_file_path": local_file_path,
178
+ }
179
+ logger.error(error)
180
+
181
+ if throw_error_on_failure:
182
+ raise RuntimeError(error) from ce
183
+
184
+ return None
185
+
186
+ return s3_path
187
+
188
+ def download_file(
189
+ self,
190
+ bucket: str,
191
+ object_key: str,
192
+ local_directory: str | None = None,
193
+ local_file_path: str | None = None,
194
+ retry_attempts: int = 3,
195
+ retry_sleep: int = 5,
196
+ ) -> str:
197
+ """Download a file from s3"""
198
+ exception: Exception | None = None
199
+
200
+ if retry_attempts == 0:
201
+ retry_attempts = 1
202
+
203
+ for i in range(retry_attempts):
204
+ exception = None
205
+ try:
206
+ path = self.download_file_no_retries(
207
+ bucket=bucket,
208
+ object_key=object_key,
209
+ local_directory=local_directory,
210
+ local_file_path=local_file_path,
211
+ )
212
+ if path and os.path.exists(path):
213
+ return path
214
+
215
+ except Exception as e: # pylint: disable=w0718
216
+ logger.warning(
217
+ {
218
+ "action": "download_file",
219
+ "result": "failure",
220
+ "exception": str(e),
221
+ "attempt": i + 1,
222
+ "retry_attempts": retry_attempts,
223
+ }
224
+ )
225
+
226
+ exception = e
227
+
228
+ # sleep for a bit
229
+ attempt = i + 1
230
+ time.sleep(attempt * retry_sleep)
231
+
232
+ if exception:
233
+ logger.exception(
234
+ {
235
+ "action": "download_file",
236
+ "result": "failure",
237
+ "exception": str(exception),
238
+ "retry_attempts": retry_attempts,
239
+ }
240
+ )
241
+
242
+ raise exception from exception
243
+
244
+ raise RuntimeError("Unable to download file")
245
+
246
+ def download_file_no_retries(
247
+ self,
248
+ bucket: str,
249
+ object_key: str,
250
+ local_directory: str | None = None,
251
+ local_file_path: str | None = None,
252
+ ) -> str:
253
+ """
254
+ Downloads a file from s3
255
+
256
+ Args:
257
+ bucket (str): s3 bucket
258
+ object_key (str): the s3 object key
259
+ local_directory (str, optional): Local directory to download to. Defaults to None.
260
+ If None, we'll use a local tmp directory.
261
+
262
+ Raises:
263
+ e:
264
+
265
+ Returns:
266
+ str: Path to the downloaded file.
267
+ """
268
+
269
+ decoded_object_key: str
270
+ try:
271
+ logger.debug(
272
+ {
273
+ "action": "downloading file",
274
+ "bucket": bucket,
275
+ "object_key": object_key,
276
+ "local_directory": local_directory,
277
+ }
278
+ )
279
+ return self.__download_file(
280
+ bucket, object_key, local_directory, local_file_path
281
+ )
282
+ except FileNotFoundError:
283
+ logger.warning(
284
+ {
285
+ "metric_filter": "download_file_error",
286
+ "error": "FileNotFoundError",
287
+ "message": "attempting to find it decoded",
288
+ "bucket": bucket,
289
+ "object_key": object_key,
290
+ }
291
+ )
292
+
293
+ # attempt to decode the object_key
294
+ decoded_object_key = HttpUtility.decode_url(object_key)
295
+
296
+ logger.error(
297
+ {
298
+ "metric_filter": "download_file_error",
299
+ "error": "FileNotFoundError",
300
+ "message": "attempting to find it decoded",
301
+ "bucket": bucket,
302
+ "object_key": object_key,
303
+ "decoded_object_key": decoded_object_key,
304
+ }
305
+ )
306
+
307
+ return self.__download_file(bucket, decoded_object_key, local_directory)
308
+
309
+ except Exception as e:
310
+ logger.error(
311
+ {
312
+ "metric_filter": "download_file_error",
313
+ "error": str(e),
314
+ "bucket": bucket,
315
+ "decoded_object_key": decoded_object_key,
316
+ }
317
+ )
318
+ raise e
319
+
320
+ def stream_file(self, bucket_name: str, object_key: str) -> Dict[str, Any]:
321
+ """
322
+ Gets a file from s3 and returns the response.
323
+ The "Body" is a streaming body object. You can read it like a file.
324
+ For example:
325
+
326
+ with response["Body"] as f:
327
+ data = f.read()
328
+ print(data)
329
+
330
+ """
331
+
332
+ logger.debug(
333
+ {
334
+ "source": "download_file",
335
+ "action": "downloading a file from s3",
336
+ "bucket": bucket_name,
337
+ "key": object_key,
338
+ }
339
+ )
340
+
341
+ response: Dict[str, Any] = {}
342
+ error = None
343
+
344
+ try:
345
+ response = dict(self.client.get_object(Bucket=bucket_name, Key=object_key))
346
+
347
+ logger.debug(
348
+ {"metric_filter": "s3_download_response", "response": str(response)}
349
+ )
350
+
351
+ except Exception as e: # pylint: disable=W0718
352
+ error = str(e)
353
+ logger.error({"metric_filter": "s3_download_error", "error": str(e)})
354
+ raise RuntimeError(
355
+ {
356
+ "metric_filter": "s3_download_error",
357
+ "error": str(e),
358
+ "bucket": bucket_name,
359
+ "key": object_key,
360
+ }
361
+ ) from e
362
+
363
+ finally:
364
+ logger.debug(
365
+ {
366
+ "source": "download_file",
367
+ "action": "downloading a file from s3",
368
+ "bucket": bucket_name,
369
+ "key": object_key,
370
+ "response": response,
371
+ "errors": error,
372
+ }
373
+ )
374
+
375
+ return response
376
+
377
+ def __download_file(
378
+ self,
379
+ bucket: str,
380
+ key: str,
381
+ local_directory: str | None = None,
382
+ local_file_path: str | None = None,
383
+ ):
384
+ if local_directory and local_file_path:
385
+ raise ValueError(
386
+ "Only one of local_directory or local_file_path can be provided"
387
+ )
388
+
389
+ if local_directory and not os.path.exists(local_directory):
390
+ FileOperations.makedirs(local_directory)
391
+
392
+ if local_file_path and not os.path.exists(os.path.dirname(local_file_path)):
393
+ FileOperations.makedirs(os.path.dirname(local_file_path))
394
+
395
+ file_name = self.__get_file_name_from_path(key)
396
+ if local_directory is None and local_file_path is None:
397
+ local_path = self.get_local_path_for_file(file_name)
398
+ elif local_directory:
399
+ local_path = os.path.join(local_directory, file_name)
400
+ else:
401
+ local_path = local_file_path
402
+
403
+ logger.debug(
404
+ {
405
+ "source": "download_file",
406
+ "action": "downloading a file from s3",
407
+ "bucket": bucket,
408
+ "key": key,
409
+ "file_name": file_name,
410
+ "local_path": local_path,
411
+ }
412
+ )
413
+
414
+ error: str | None = None
415
+ try:
416
+ self.client.download_file(bucket, key, local_path)
417
+
418
+ except Exception as e: # pylint: disable=W0718
419
+ error = str(e)
420
+ logger.error({"metric_filter": "s3_download_error", "error": str(e)})
421
+
422
+ file_exist = os.path.exists(local_path)
423
+
424
+ logger.debug(
425
+ {
426
+ "source": "download_file",
427
+ "action": "downloading a file from s3",
428
+ "bucket": bucket,
429
+ "key": key,
430
+ "file_name": file_name,
431
+ "local_path": local_path,
432
+ "file_downloaded": file_exist,
433
+ "errors": error,
434
+ }
435
+ )
436
+
437
+ if not file_exist:
438
+ raise FileNotFoundError("File Failed to download (does not exist) from S3.")
439
+
440
+ return local_path
441
+
442
+ def __get_file_name_from_path(self, path: str) -> str:
443
+ """
444
+ Get a file name from the path
445
+
446
+ Args:
447
+ path (str): a file path
448
+
449
+ Returns:
450
+ str: the file name
451
+ """
452
+ return path.rsplit("/")[-1]
453
+
454
+ def get_local_path_for_file(self, file_name: str):
455
+ """
456
+ Get a local temp location for a file.
457
+ This is designed to work with lambda functions.
458
+ The /tmp directory is the only writeable location for lambda functions.
459
+ """
460
+ temp_dir = self.get_temp_directory()
461
+ # use /tmp it's the only writeable location for lambda
462
+ local_path = os.path.join(temp_dir, file_name)
463
+ return local_path
464
+
465
+ def get_temp_directory(self):
466
+ """
467
+ Determines the appropriate temporary directory based on the environment.
468
+ If running in AWS Lambda, returns '/tmp'.
469
+ Otherwise, returns the system's standard temp directory.
470
+ """
471
+ if "AWS_LAMBDA_FUNCTION_NAME" in os.environ:
472
+ # In AWS Lambda environment
473
+ return "/tmp"
474
+ else:
475
+ # Not in AWS Lambda, use the system's default temp directory
476
+ return tempfile.gettempdir()