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/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
+