morp 7.0.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.
- morp/__init__.py +23 -0
- morp/compat.py +4 -0
- morp/config.py +173 -0
- morp/exception.py +35 -0
- morp/interface/__init__.py +81 -0
- morp/interface/base.py +441 -0
- morp/interface/dropfile.py +160 -0
- morp/interface/postgres.py +368 -0
- morp/interface/sqs.py +396 -0
- morp/message.py +354 -0
- morp-7.0.0.dist-info/METADATA +212 -0
- morp-7.0.0.dist-info/RECORD +15 -0
- morp-7.0.0.dist-info/WHEEL +5 -0
- morp-7.0.0.dist-info/licenses/LICENSE.txt +21 -0
- morp-7.0.0.dist-info/top_level.txt +2 -0
morp/interface/sqs.py
ADDED
|
@@ -0,0 +1,396 @@
|
|
|
1
|
+
from contextlib import contextmanager
|
|
2
|
+
import re
|
|
3
|
+
import itertools
|
|
4
|
+
import base64
|
|
5
|
+
|
|
6
|
+
import boto3
|
|
7
|
+
from botocore.exceptions import ClientError
|
|
8
|
+
from botocore.credentials import RefreshableCredentials
|
|
9
|
+
from botocore.session import get_session
|
|
10
|
+
from datatypes import Datetime, logging
|
|
11
|
+
|
|
12
|
+
from ..compat import *
|
|
13
|
+
from .base import Interface
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
logger = logging.getLogger(__name__)
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class Region(String):
|
|
20
|
+
"""Small wrapper that just makes sure the AWS region is valid"""
|
|
21
|
+
def __new__(cls, region_name):
|
|
22
|
+
if not region_name:
|
|
23
|
+
session = boto3.Session()
|
|
24
|
+
region_name = session.region_name
|
|
25
|
+
if not region_name:
|
|
26
|
+
raise ValueError("No region name found")
|
|
27
|
+
|
|
28
|
+
return super().__new__(cls, region_name)
|
|
29
|
+
|
|
30
|
+
@classmethod
|
|
31
|
+
def names(cls):
|
|
32
|
+
"""Return all available regions for SQS"""
|
|
33
|
+
session = boto3.Session()
|
|
34
|
+
return session.get_available_regions("ec2")
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
class RefreshableSession(boto3.Session):
|
|
38
|
+
"""Boto Session wrapper which can use a refreshable session, this allows
|
|
39
|
+
role assumption to automatically refresh without the interface having to do
|
|
40
|
+
anything
|
|
41
|
+
|
|
42
|
+
:Example:
|
|
43
|
+
session = RefreshableSession(connection_config)
|
|
44
|
+
|
|
45
|
+
# we now can cache this client object without worrying about expiring
|
|
46
|
+
# credentials
|
|
47
|
+
client = session.client("s3")
|
|
48
|
+
|
|
49
|
+
this is based off of this: https://stackoverflow.com/q/63724485
|
|
50
|
+
"""
|
|
51
|
+
def __init__(self, connection_config):
|
|
52
|
+
"""
|
|
53
|
+
:param connection_config: dict, this is the connection dict passed to
|
|
54
|
+
the interface, it should be just passed to this
|
|
55
|
+
"""
|
|
56
|
+
self.connection_config = connection_config
|
|
57
|
+
|
|
58
|
+
# get refreshable credentials
|
|
59
|
+
refreshable_credentials = RefreshableCredentials.create_from_metadata(
|
|
60
|
+
metadata=self._get_credentials(),
|
|
61
|
+
refresh_using=self._get_credentials,
|
|
62
|
+
method="sts-assume-role",
|
|
63
|
+
)
|
|
64
|
+
|
|
65
|
+
# attach refreshable credentials current session
|
|
66
|
+
session = get_session()
|
|
67
|
+
session._credentials = refreshable_credentials
|
|
68
|
+
session.set_config_variable(
|
|
69
|
+
"region",
|
|
70
|
+
self.connection_config.options["region"]
|
|
71
|
+
)
|
|
72
|
+
super().__init__(botocore_session=session)
|
|
73
|
+
|
|
74
|
+
def _get_credentials(self):
|
|
75
|
+
"""Get session credentials
|
|
76
|
+
|
|
77
|
+
This is a separate method because it will be used as a callback in the
|
|
78
|
+
refreshable credentials object that's created in __init__
|
|
79
|
+
|
|
80
|
+
:returns: dict
|
|
81
|
+
"""
|
|
82
|
+
region = self.connection_config.options["region"]
|
|
83
|
+
|
|
84
|
+
session = boto3.Session(
|
|
85
|
+
region_name=region,
|
|
86
|
+
profile_name=self.connection_config.options.get(
|
|
87
|
+
"profile_name",
|
|
88
|
+
None
|
|
89
|
+
),
|
|
90
|
+
)
|
|
91
|
+
|
|
92
|
+
session_ttl = self.connection_config.options.get("session_ttl", 3600)
|
|
93
|
+
|
|
94
|
+
# if an sts arn is given, get credential by assuming given role
|
|
95
|
+
if arn := self.connection_config.options.get("arn", ""):
|
|
96
|
+
sts_client = session.client(
|
|
97
|
+
service_name="sts",
|
|
98
|
+
region_name=region,
|
|
99
|
+
)
|
|
100
|
+
|
|
101
|
+
response = sts_client.assume_role(
|
|
102
|
+
RoleArn=arn,
|
|
103
|
+
RoleSessionName=self.connection_config.options.get(
|
|
104
|
+
"session_name",
|
|
105
|
+
"morp"
|
|
106
|
+
),
|
|
107
|
+
DurationSeconds=session_ttl,
|
|
108
|
+
).get("Credentials")
|
|
109
|
+
|
|
110
|
+
credentials = {
|
|
111
|
+
"access_key": response.get("AccessKeyId"),
|
|
112
|
+
"secret_key": response.get("SecretAccessKey"),
|
|
113
|
+
"token": response.get("SessionToken"),
|
|
114
|
+
"expiry_time": response.get("Expiration").isoformat(),
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
else:
|
|
118
|
+
session_credentials = session.get_credentials().__dict__
|
|
119
|
+
credentials = {
|
|
120
|
+
"access_key": session_credentials.get("access_key"),
|
|
121
|
+
"secret_key": session_credentials.get("secret_key"),
|
|
122
|
+
"token": session_credentials.get("token"),
|
|
123
|
+
"expiry_time": Datetime(seconds=session_ttl).isoformat(),
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
return credentials
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
class SQS(Interface):
|
|
130
|
+
"""wraps amazon's SQS to make it work with our generic interface
|
|
131
|
+
|
|
132
|
+
https://boto3.amazonaws.com/v1/documentation/api/latest/reference/services/sqs.html
|
|
133
|
+
https://boto.readthedocs.org/en/latest/ref/sqs.html
|
|
134
|
+
http://boto3.readthedocs.io/en/latest/guide/sqs.html
|
|
135
|
+
http://michaelhallsmoore.com/blog/Python-Message-Queues-with-Amazon-Simple-Queue-Service
|
|
136
|
+
http://aws.amazon.com/sqs/
|
|
137
|
+
"""
|
|
138
|
+
_connection = None
|
|
139
|
+
|
|
140
|
+
async def _connect(self, connection_config):
|
|
141
|
+
# 12 hours max (from Amazon)
|
|
142
|
+
self.connection_config.options['vtimeout_max'] = 43200
|
|
143
|
+
|
|
144
|
+
region = Region(self.connection_config.options.get('region', ''))
|
|
145
|
+
self.connection_config.options['region'] = region
|
|
146
|
+
|
|
147
|
+
session = RefreshableSession(self.connection_config)
|
|
148
|
+
|
|
149
|
+
boto_kwargs = {}
|
|
150
|
+
for opt in self.connection_config.options:
|
|
151
|
+
if opt.startswith("boto_"):
|
|
152
|
+
v = self.connection_config.options[opt]
|
|
153
|
+
boto_kwargs[opt.replace("boto_", "")] = v
|
|
154
|
+
if boto_kwargs:
|
|
155
|
+
logger.debug("SQS using boto kwargs: %s", boto_kwargs)
|
|
156
|
+
|
|
157
|
+
self._connection = session.resource("sqs", **boto_kwargs)
|
|
158
|
+
|
|
159
|
+
logger.debug("SQS connected to region %s", region)
|
|
160
|
+
|
|
161
|
+
async def get_connection(self):
|
|
162
|
+
return self._connection
|
|
163
|
+
|
|
164
|
+
async def _close(self):
|
|
165
|
+
"""closes out the client and gets rid of connection"""
|
|
166
|
+
if self._connection:
|
|
167
|
+
client = self._connection.meta.client
|
|
168
|
+
self._close_client(client)
|
|
169
|
+
|
|
170
|
+
self._connection = None
|
|
171
|
+
|
|
172
|
+
async def _close_client(self, client):
|
|
173
|
+
"""closes open sessions on client
|
|
174
|
+
|
|
175
|
+
this code comes from:
|
|
176
|
+
https://github.com/boto/botocore/pull/1810
|
|
177
|
+
|
|
178
|
+
it closes the connections to fix unclosed connection warnings in py3
|
|
179
|
+
|
|
180
|
+
Specifically, this warning:
|
|
181
|
+
|
|
182
|
+
ResourceWarning: unclosed <ssl.SSLSocket ...
|
|
183
|
+
ResourceWarning: Enable tracemalloc to get the object allocation
|
|
184
|
+
traceback
|
|
185
|
+
|
|
186
|
+
see also:
|
|
187
|
+
https://github.com/boto/boto3/issues/454
|
|
188
|
+
https://github.com/boto/botocore/pull/1231
|
|
189
|
+
|
|
190
|
+
this might also be a good/alternate solution for this problem:
|
|
191
|
+
https://github.com/boto/boto3/issues/454#issuecomment-335614919
|
|
192
|
+
|
|
193
|
+
:param client: an amazon services client whose sessions will be closed
|
|
194
|
+
"""
|
|
195
|
+
client._endpoint.http_session._manager.clear()
|
|
196
|
+
for manager in client._endpoint.http_session._proxy_managers.values():
|
|
197
|
+
manager.close()
|
|
198
|
+
|
|
199
|
+
def get_attrs(self, **kwargs):
|
|
200
|
+
attrs = {}
|
|
201
|
+
options = self.connection_config.options
|
|
202
|
+
|
|
203
|
+
# we use max_timeout here because we will release the message
|
|
204
|
+
# sooner according to our release algo but on exceptional error
|
|
205
|
+
# let's use our global max setting
|
|
206
|
+
vtimeout = options.get('max_timeout')
|
|
207
|
+
if vtimeout:
|
|
208
|
+
# if not string fails with:
|
|
209
|
+
# Invalid type for parameter Attributes.VisibilityTimeout, value:
|
|
210
|
+
# 3600,
|
|
211
|
+
# type: <type 'int'>, valid types: <type 'basestring'>
|
|
212
|
+
attrs["VisibilityTimeout"] = String(
|
|
213
|
+
min(vtimeout, options["vtimeout_max"])
|
|
214
|
+
)
|
|
215
|
+
|
|
216
|
+
for k, v in itertools.chain(options.items(), kwargs.items()):
|
|
217
|
+
if re.match("^[A-Z][a-zA-Z]+$", k):
|
|
218
|
+
attrs[k] = v
|
|
219
|
+
|
|
220
|
+
return attrs
|
|
221
|
+
|
|
222
|
+
@contextmanager
|
|
223
|
+
def queue(self, name, connection, **kwargs):
|
|
224
|
+
# http://boto3.readthedocs.io/en/latest/reference/services/sqs.html#SQS.Queue
|
|
225
|
+
try:
|
|
226
|
+
q = None
|
|
227
|
+
|
|
228
|
+
try:
|
|
229
|
+
q = connection.get_queue_by_name(QueueName=name)
|
|
230
|
+
yield q
|
|
231
|
+
|
|
232
|
+
except ClientError as e:
|
|
233
|
+
if (
|
|
234
|
+
self._is_client_error_match(
|
|
235
|
+
e,
|
|
236
|
+
["AWS.SimpleQueueService.NonExistentQueue"]
|
|
237
|
+
)
|
|
238
|
+
):
|
|
239
|
+
if kwargs.get("create_queue", True):
|
|
240
|
+
attrs = self.get_attrs(**kwargs)
|
|
241
|
+
q = connection.create_queue(
|
|
242
|
+
QueueName=name,
|
|
243
|
+
Attributes=attrs
|
|
244
|
+
)
|
|
245
|
+
|
|
246
|
+
yield q
|
|
247
|
+
|
|
248
|
+
else:
|
|
249
|
+
raise
|
|
250
|
+
|
|
251
|
+
except Exception as e:
|
|
252
|
+
self.raise_error(e)
|
|
253
|
+
|
|
254
|
+
finally:
|
|
255
|
+
if q:
|
|
256
|
+
self._close_client(q.meta.client)
|
|
257
|
+
|
|
258
|
+
def _fields_to_body(self, fields):
|
|
259
|
+
"""This base64 encodes the fields because SQS expects a string, not
|
|
260
|
+
bytes
|
|
261
|
+
|
|
262
|
+
https://boto3.amazonaws.com/v1/documentation/api/latest/reference/services/sqs/queue/send_message.html
|
|
263
|
+
|
|
264
|
+
:param: dict, the fields to send to the backend
|
|
265
|
+
:returns: str, the body, base64 encoded
|
|
266
|
+
"""
|
|
267
|
+
body = super()._fields_to_body(fields)
|
|
268
|
+
return String(base64.b64encode(body))
|
|
269
|
+
|
|
270
|
+
async def _send(self, name, connection, body, **kwargs):
|
|
271
|
+
with self.queue(name, connection) as q:
|
|
272
|
+
delay_seconds = kwargs.get('delay_seconds', 0)
|
|
273
|
+
if delay_seconds > 900:
|
|
274
|
+
# https://docs.aws.amazon.com/AWSSimpleQueueService/latest/APIReference/API_SendMessage.html
|
|
275
|
+
self.warning(
|
|
276
|
+
"delay_seconds({}) cannot be greater than 900",
|
|
277
|
+
delay_seconds,
|
|
278
|
+
)
|
|
279
|
+
delay_seconds = 900
|
|
280
|
+
|
|
281
|
+
# http://boto3.readthedocs.io/en/latest/reference/services/sqs.html#SQS.Queue.send_message
|
|
282
|
+
receipt = q.send_message(
|
|
283
|
+
MessageBody=body,
|
|
284
|
+
DelaySeconds=delay_seconds
|
|
285
|
+
)
|
|
286
|
+
return receipt["MessageId"], receipt
|
|
287
|
+
|
|
288
|
+
async def _count(self, name, connection, **kwargs):
|
|
289
|
+
ret = 0
|
|
290
|
+
with self.queue(name, connection) as q:
|
|
291
|
+
ret = int(q.attributes.get('ApproximateNumberOfMessages', 0))
|
|
292
|
+
return ret
|
|
293
|
+
|
|
294
|
+
async def _clear(self, name, connection, **kwargs):
|
|
295
|
+
with self.queue(name, connection) as q:
|
|
296
|
+
try:
|
|
297
|
+
q.purge()
|
|
298
|
+
|
|
299
|
+
except ClientError as e:
|
|
300
|
+
if (
|
|
301
|
+
not self._is_client_error_match(
|
|
302
|
+
e,
|
|
303
|
+
["AWS.SimpleQueueService.PurgeQueueInProgress"]
|
|
304
|
+
)
|
|
305
|
+
):
|
|
306
|
+
raise
|
|
307
|
+
|
|
308
|
+
async def _delete(self, name, connection, **kwargs):
|
|
309
|
+
with self.queue(name, connection, create_queue=False) as q:
|
|
310
|
+
if q:
|
|
311
|
+
q.delete()
|
|
312
|
+
|
|
313
|
+
def _body_to_fields(self, body):
|
|
314
|
+
"""Before sending body to parent's body_to_fields() it will base64
|
|
315
|
+
decode it
|
|
316
|
+
|
|
317
|
+
:param body: str, the body returned from the backend
|
|
318
|
+
"""
|
|
319
|
+
return super()._body_to_fields(base64.b64decode(body))
|
|
320
|
+
|
|
321
|
+
def _recv_to_fields(self, _id, body, raw):
|
|
322
|
+
fields = super()._recv_to_fields(_id, body, raw)
|
|
323
|
+
|
|
324
|
+
# http://boto3.readthedocs.io/en/latest/reference/services/sqs.html#SQS.Queue.receive_messages
|
|
325
|
+
fields["_count"] = int(raw.attributes.get('ApproximateReceiveCount', 1))
|
|
326
|
+
# created_stamp = int(raw.attributes.get('SentTimestamp', 0.0)) / 1000.0
|
|
327
|
+
# if created_stamp:
|
|
328
|
+
# fields["_created"] = Datetime(created_stamp)
|
|
329
|
+
|
|
330
|
+
return fields
|
|
331
|
+
|
|
332
|
+
async def _recv(self, name, connection, **kwargs):
|
|
333
|
+
timeout = kwargs.get('timeout', None)
|
|
334
|
+
if timeout is not None:
|
|
335
|
+
if timeout == 0:
|
|
336
|
+
kwargs["timeout"] = 20
|
|
337
|
+
|
|
338
|
+
# http://docs.aws.amazon.com/AWSSimpleQueueService/latest/SQSDeveloperGuide/sqs-long-polling.html
|
|
339
|
+
if timeout < 1 or timeout > 20:
|
|
340
|
+
raise ValueError('timeout must be between 1 and 20')
|
|
341
|
+
|
|
342
|
+
vtimeout = kwargs.get('vtimeout', None) # !!! I'm not sure this works
|
|
343
|
+
with self.queue(name, connection) as q:
|
|
344
|
+
_id = body = raw = None
|
|
345
|
+
kwargs = {
|
|
346
|
+
"MaxNumberOfMessages": 1,
|
|
347
|
+
"AttributeNames": ["ApproximateReceiveCount", "SentTimestamp"],
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
if timeout:
|
|
351
|
+
kwargs["WaitTimeSeconds"] = timeout
|
|
352
|
+
|
|
353
|
+
if vtimeout:
|
|
354
|
+
kwargs["VisibilityTimeout"] = min(
|
|
355
|
+
vtimeout,
|
|
356
|
+
self.connection_config.options['vtimeout_max']
|
|
357
|
+
)
|
|
358
|
+
|
|
359
|
+
msgs = q.receive_messages(**kwargs)
|
|
360
|
+
if msgs:
|
|
361
|
+
raw = msgs[0]
|
|
362
|
+
body = raw.body
|
|
363
|
+
_id = raw.message_id
|
|
364
|
+
|
|
365
|
+
return _id, body, raw
|
|
366
|
+
|
|
367
|
+
async def _release(self, name, fields, connection, **kwargs):
|
|
368
|
+
with self.queue(name, connection) as q:
|
|
369
|
+
# http://stackoverflow.com/questions/14404007/release-a-message-back-to-sqs
|
|
370
|
+
# http://docs.aws.amazon.com/AWSSimpleQueueService/latest/SQSDeveloperGuide/AboutVT.html
|
|
371
|
+
# When you [change] a message's visibility timeout, the new timeout
|
|
372
|
+
# applies only to that particular receipt of the message.
|
|
373
|
+
# ChangeMessageVisibility does not affect the timeout for the queue
|
|
374
|
+
# or later receipts of the message. If for some reason you don't
|
|
375
|
+
# delete the message and receive it again, its visibility timeout is
|
|
376
|
+
# the original value set for the queue.
|
|
377
|
+
delay_seconds = kwargs.get('delay_seconds', 0)
|
|
378
|
+
q.change_message_visibility_batch(Entries=[{
|
|
379
|
+
"Id": fields["_id"],
|
|
380
|
+
"ReceiptHandle": fields["_raw"].receipt_handle,
|
|
381
|
+
"VisibilityTimeout": delay_seconds
|
|
382
|
+
}])
|
|
383
|
+
|
|
384
|
+
async def _ack(self, name, fields, connection, **kwargs):
|
|
385
|
+
with self.queue(name, connection) as q:
|
|
386
|
+
q.delete_messages(Entries=[
|
|
387
|
+
{
|
|
388
|
+
'Id': fields["_id"],
|
|
389
|
+
'ReceiptHandle': fields["_raw"].receipt_handle,
|
|
390
|
+
}
|
|
391
|
+
])
|
|
392
|
+
# http://boto3.readthedocs.io/en/latest/reference/services/sqs.html#SQS.Message.delete
|
|
393
|
+
|
|
394
|
+
def _is_client_error_match(self, e, codes):
|
|
395
|
+
return e.response["Error"]["Code"] in codes
|
|
396
|
+
|