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/message.py ADDED
@@ -0,0 +1,354 @@
1
+ # -*- coding: utf-8 -*-
2
+ from contextlib import asynccontextmanager, AbstractAsyncContextManager
3
+ import logging
4
+ import inspect
5
+ import typing
6
+
7
+ from datatypes import (
8
+ ReflectClass,
9
+ ReflectName,
10
+ ReflectType,
11
+ make_dict,
12
+ classproperty,
13
+ )
14
+
15
+ from .compat import *
16
+ from .interface import get_interface
17
+ from .exception import ReleaseMessage, AckMessage
18
+ from .config import environ
19
+
20
+
21
+ logger = logging.getLogger(__name__)
22
+
23
+
24
+ class Message(object):
25
+ """
26
+ this is the base class for sending and handling a message
27
+
28
+ to add a new message to your application, just subclass this class
29
+
30
+ :example:
31
+ import morp
32
+
33
+ class CustomMessage(morp.Message):
34
+ # message fields
35
+ foo: int
36
+ bar: str
37
+
38
+ # class fields start with an underscore
39
+ _ignored: bool = False
40
+
41
+ def handle(self):
42
+ # target will be called when the message is consumed using
43
+ # the CustomMessage.handle method
44
+ pass
45
+
46
+ m1 = CustomMessage(foo=1, bar="one")
47
+ await m1.send() # m1 is sent with foo and bar fields
48
+
49
+ m2 = await CustomMessage.create(foo=2, bar="two")
50
+ # m2 was created and sent with foo and bar fields
51
+
52
+ await CustomMessage.process(2)
53
+ # both m1 and m2 were consumed and their .target methods called
54
+
55
+ By default, all subclasses will go to the same queue and then when the
56
+ queue is consumed the correct child class will be created and consume the
57
+ message with its `.handle` method.
58
+
59
+ If you would like your subclass to use a different queue then just set the
60
+ `._name` property on the class and it will use a different queue
61
+ """
62
+ _connection_name: str = ""
63
+ """the name of the connection to use to retrieve the interface"""
64
+
65
+ _name: str = "morp-messages"
66
+ """The queue name, see .get_name()"""
67
+
68
+ _classpath_key: str = "_classpath"
69
+ """The key that will be used to hold the Message's child class's full
70
+ classpath, see `._to_interface` and `.from_interface`"""
71
+
72
+ _message_classes: dict = {}
73
+ """Holds all the children message classes. See `__init_subclass__`"""
74
+
75
+ @classproperty
76
+ def interface(cls):
77
+ return get_interface(cls._connection_name)
78
+
79
+ @classproperty
80
+ def schema(cls):
81
+ schema = {}
82
+
83
+ for field_name, field_type in typing.get_type_hints(cls).items():
84
+ if field_name.startswith("_"):
85
+ continue
86
+
87
+ schema[field_name] = ReflectType(field_type)
88
+
89
+ # cache the value so we don't need to generate it again
90
+ cls.schema = schema
91
+ return cls.schema
92
+
93
+ @property
94
+ def fields(self):
95
+ fields = {}
96
+
97
+ for field_name in self.schema.keys():
98
+ v = getattr(self, field_name, typing.NoReturn)
99
+ if v is not typing.NoReturn:
100
+ fields[field_name] = v
101
+
102
+ return fields
103
+
104
+ def __init__(self, fields=None, **fields_kwargs):
105
+ fields = make_dict(fields, fields_kwargs)
106
+ self._from_interface(fields)
107
+
108
+ def __init_subclass__(cls):
109
+ """When a child class is loaded into memory it will be saved into
110
+ .orm_classes, this way every orm class knows about all the other orm
111
+ classes, this is the method that makes that possible magically
112
+
113
+ https://peps.python.org/pep-0487/
114
+ """
115
+ cls._message_classes[f"{cls.__module__}:{cls.__qualname__}"] = cls
116
+
117
+ def __contains__(self, field_name):
118
+ v = getattr(self, field_name, typing.NoReturn)
119
+ return v is not typing.NoReturn
120
+ #return hasattr(self, key)
121
+ #return key in self.fields
122
+
123
+ async def send(self, **kwargs):
124
+ """send the message using the configured interface for this class
125
+
126
+ :param **kwargs:
127
+ :keyword delay_seconds: int, how many seconds before the message can
128
+ be processed. The max value is interface specific, (eg, it can
129
+ only be 900s max (15 minutes) per SQS docs)
130
+ """
131
+ queue_off = environ.DISABLED
132
+ name = self.get_name()
133
+ fields = self._to_interface()
134
+ if queue_off:
135
+ logger.warning("DISABLED - Would have sent {} to {}".format(
136
+ fields,
137
+ name,
138
+ ))
139
+
140
+ else:
141
+ logger.info("Sending message with '{}' keys to '{}'".format(
142
+ "', '".join(fields.keys()),
143
+ name
144
+ ))
145
+
146
+ hydrate_fields = await self.interface.send(
147
+ name=name,
148
+ fields=fields,
149
+ **kwargs,
150
+ )
151
+ # we mimic hydration
152
+ self._from_interface(hydrate_fields)
153
+ self._hydrate_fields = hydrate_fields
154
+
155
+ @classmethod
156
+ def get_name(cls):
157
+ """This is what's used as the official queue name, it takes `.name`
158
+ and combines it with the `MORP_PREFIX` environment variable
159
+
160
+ :returns: str, the queue name
161
+ """
162
+ name = cls._name
163
+ if env_name := environ.PREFIX:
164
+ name = "{}-{}".format(env_name, name)
165
+ return name
166
+
167
+ @classmethod
168
+ async def process(cls, count=0, **kwargs):
169
+ """wait for messages to come in and handle them by calling the incoming
170
+ message's `handle` method
171
+
172
+ :example:
173
+ # handle 10 messages by receiving them and calling `handle` on the
174
+ # message instance
175
+ Message.process(10)
176
+
177
+ :param count: int, if you only want to handle N messages, pass in count
178
+ :param **kwargs: any other params will get passed to underlying
179
+ `._recv` methods
180
+ """
181
+ max_count = count
182
+ count = 0
183
+ while not max_count or count < max_count:
184
+ count += 1
185
+ logger.debug("Receiving {}/{}".format(
186
+ count,
187
+ max_count if max_count else "Infinity"
188
+ ))
189
+
190
+ async with cls._recv(**kwargs) as m:
191
+ r = m.handle()
192
+ while inspect.iscoroutine(r):
193
+ r = await r
194
+
195
+ if r is False:
196
+ raise ReleaseMessage()
197
+
198
+ @classmethod
199
+ @asynccontextmanager
200
+ async def _recv(cls, **kwargs) -> AbstractAsyncContextManager:
201
+ """Internal context manager that receives a message to be processed
202
+
203
+ Called from `.process` to receive messages and repeatedly calls
204
+ `._recv_for` to actually receive a message
205
+
206
+ :keyword timeout: int, how long to wait before yielding None
207
+ """
208
+ m = None
209
+ # 20 is the max long polling timeout per Amazon
210
+ kwargs.setdefault('timeout', 20)
211
+ while not m:
212
+ async with cls._recv_for(**kwargs) as m:
213
+ if m:
214
+ yield m
215
+
216
+ @classmethod
217
+ @asynccontextmanager
218
+ async def _recv_for(
219
+ cls,
220
+ timeout: int|float,
221
+ **kwargs,
222
+ ) -> AbstractAsyncContextManager:
223
+ """Internal context manager. Try and receive a message, return None
224
+ if a message is not received within timeout
225
+
226
+ :param timeout: float|int, how many seconds before yielding None
227
+ :returns: generator[Message]
228
+ """
229
+ i = cls.interface
230
+ name = cls.get_name()
231
+ ack_on_recv = kwargs.pop('ack_on_recv', False)
232
+ logger.debug(
233
+ "Waiting to receive on {} for {} seconds".format(name, timeout)
234
+ )
235
+
236
+ async with i.connection(**kwargs) as connection:
237
+ kwargs["connection"] = connection
238
+
239
+ fields = await i.recv(name, timeout=timeout, **kwargs)
240
+ if fields:
241
+ try:
242
+ yield cls._hydrate(fields)
243
+
244
+ except ReleaseMessage as e:
245
+ await i.release(
246
+ name,
247
+ fields,
248
+ delay_seconds=e.delay_seconds,
249
+ **kwargs
250
+ )
251
+
252
+ except AckMessage as e:
253
+ await i.ack(name, fields, **kwargs)
254
+
255
+ except Exception as e:
256
+ if ack_on_recv:
257
+ await i.ack(name, fields, **kwargs)
258
+
259
+ else:
260
+ await i.release(name, fields, **kwargs)
261
+
262
+ raise
263
+
264
+ else:
265
+ await i.ack(name, fields, **kwargs)
266
+
267
+ else:
268
+ yield None
269
+
270
+ @classmethod
271
+ async def create(cls, *args, **kwargs):
272
+ """create an instance of cls with the passed in fields and send it off
273
+
274
+ Since this passed *args and **kwargs directly to .__init__, you can
275
+ override the .__init__ method and customize it and this method will
276
+ inherit the child class's changes. And the signature of this method
277
+ should always match .__init__
278
+
279
+ :param *args: list[Any], passed directly to .__init__
280
+ :param **kwargs: dict[str, Any], passed directly to .__init__
281
+ """
282
+ connection = kwargs.pop("connection", None)
283
+ instance = cls(*args, **kwargs)
284
+ await instance.send(connection=connection)
285
+ return instance
286
+
287
+ @classmethod
288
+ async def count(cls):
289
+ """how many messages total (approximately) are in the whole message
290
+ queue"""
291
+ n = cls.get_name()
292
+ return await cls.interface.count(n)
293
+
294
+ @classmethod
295
+ def _hydrate(cls, fields):
296
+ """This is used by the interface to populate an instance with
297
+ information received from the interface
298
+
299
+ :param fields: InterfaceMessage, the message freshly received from
300
+ the interface, see Interface.create_imessage()
301
+ """
302
+ message_class = cls
303
+ if cls is Message:
304
+ # When a generic Message instance is used to consume messages it
305
+ # will use the passed in classpath to create the correct Message
306
+ # child
307
+ rn = ReflectName(fields.get(cls._classpath_key))
308
+ message_class = rn.get_class()
309
+
310
+ instance = message_class()
311
+ instance._from_interface(fields)
312
+ instance._hydrate_fields = fields
313
+
314
+ return instance
315
+
316
+ def _to_interface(self):
317
+ """When sending a message to the interface this method will be called
318
+
319
+ :returns: dict, the fields
320
+ """
321
+ fields = {}
322
+ schema = self.schema
323
+ for field_name, rt in schema.items():
324
+ fields[field_name] = rt.cast(getattr(self, field_name))
325
+
326
+ if self._classpath_key not in fields:
327
+ fields[self._classpath_key] = ReflectClass(self).classpath
328
+
329
+ return fields
330
+
331
+ def _from_interface(self, fields):
332
+ """When receiving a message from the interface this method will
333
+ be called
334
+
335
+ you can see it in action with `._hydrate`
336
+
337
+ :param fields: dict, the fields received from the interface
338
+ """
339
+ for field_name in self.schema.keys():
340
+ if field_name in fields:
341
+ setattr(self, field_name, fields[field_name])
342
+
343
+ async def handle(self) -> bool|None:
344
+ """This method will be called from `.process` and can handle any
345
+ processing of the message, it should be defined in the child classes
346
+
347
+ :returns:
348
+ - if False, the message will be released back to be processed
349
+ again
350
+ - any other value is considered a success and the message is
351
+ acked/consumed
352
+ """
353
+ raise NotImplementedError()
354
+
@@ -0,0 +1,212 @@
1
+ Metadata-Version: 2.4
2
+ Name: morp
3
+ Version: 7.0.0
4
+ Summary: Send and receive messages without thinking about it
5
+ Author-email: Jay Marcyes <jay@marcyes.com>
6
+ License-Expression: MIT
7
+ Project-URL: Homepage, http://github.com/Jaymon/morp
8
+ Project-URL: Repository, https://github.com/Jaymon/morp
9
+ Classifier: Development Status :: 4 - Beta
10
+ Classifier: Environment :: Web Environment
11
+ Classifier: Intended Audience :: Developers
12
+ Classifier: Operating System :: OS Independent
13
+ Classifier: Topic :: Database
14
+ Classifier: Topic :: Software Development :: Libraries
15
+ Classifier: Topic :: Utilities
16
+ Classifier: Programming Language :: Python :: 3
17
+ Requires-Python: >=3.10
18
+ Description-Content-Type: text/markdown
19
+ License-File: LICENSE.txt
20
+ Requires-Dist: dsnparse
21
+ Requires-Dist: datatypes
22
+ Provides-Extra: tests
23
+ Requires-Dist: testdata; extra == "tests"
24
+ Provides-Extra: postgres
25
+ Requires-Dist: psycopg; extra == "postgres"
26
+ Provides-Extra: sqs
27
+ Requires-Dist: boto3; extra == "sqs"
28
+ Provides-Extra: encryption
29
+ Requires-Dist: cryptography; extra == "encryption"
30
+ Dynamic: license-file
31
+
32
+ # Morp
33
+
34
+ Simple message processing without really thinking about it. Morp can use dropfiles (simple text files), Postgres, and [Amazon SQS](http://aws.amazon.com/sqs/).
35
+
36
+
37
+ ## Installation
38
+
39
+ Use pip to install the latest stable version:
40
+
41
+ pip install morp
42
+
43
+ Morp only supports the dropfiles interface out of the box, you'll need to install certain dependencies depending on what interface you want to use:
44
+
45
+ pip install morp[sqs]
46
+ pip install morp[postgres]
47
+
48
+ To install the development version:
49
+
50
+ pip install -U "git+https://github.com/Jaymon/morp#egg=morp"
51
+
52
+
53
+ ## 1 Minute Getting Started
54
+
55
+ Send and receive a `Foo` message.
56
+
57
+ First, let's set our environment variable to use dropfiles (local files suitable for development and prototyping) interface:
58
+
59
+ export MORP_DSN=dropfile:${TMPDIR}
60
+
61
+ Then, let's create three files in our working directory:
62
+
63
+ * `tasks.py` - We'll define our `Message` classes here.
64
+ * `send.py` - We'll send messages from this script.
65
+ * `recv.py` - We'll receive messages from this script.
66
+
67
+
68
+ Let's create our `Message` class in `tasks.py`:
69
+
70
+ ```python
71
+ # tasks.py
72
+ from morp import Message
73
+
74
+ class Foo(Message):
75
+ some_field: int
76
+ some_other_field: str
77
+
78
+ def handle(self):
79
+ # this will be run when a Foo message is consumed
80
+ print(self.fields)
81
+ ```
82
+
83
+ Now, let's flesh out our `recv.py` file:
84
+
85
+ ```python
86
+ # recv.py
87
+
88
+ import asyncio
89
+
90
+ # import our Foo message class from our tasks.py file
91
+ from tasks import Foo
92
+
93
+ # Foo's `process` method will call `Foo.handle` for each Foo instance received
94
+ asyncio.run(Foo.process())
95
+ ```
96
+
97
+ And start it up:
98
+
99
+ ```
100
+ $ python recv.py
101
+ ```
102
+
103
+
104
+ Finally, let's send some messages by fleshing out `send.py`:
105
+
106
+ ```python
107
+ # send.py
108
+
109
+ import asyncio
110
+
111
+ from tasks import Foo
112
+
113
+ async def send_messages():
114
+ # create a message and send it manually
115
+ f = Foo()
116
+ f.some_field = 1
117
+ f.some_other_field = "one"
118
+ f.ignored_field = True
119
+ await f.send()
120
+
121
+ # quickly send a message
122
+ await Foo.create(
123
+ some_field=2,
124
+ some_other_field="two",
125
+ )
126
+
127
+ asyncio.run(send_messages())
128
+ ```
129
+
130
+ And running it in a separate shell from the shell running our `recv.py` script (it should send two messages):
131
+
132
+ ```
133
+ $ python send.py
134
+ ```
135
+
136
+ That's it! Our running `recv.py` script should've received the messages we sent when we ran our `send.py` script.
137
+
138
+
139
+ ## DSN
140
+
141
+ You configure your connection using a dsn in the form:
142
+
143
+ InterfaceName://username:password@host:port/path?param1=value1&param2=value2
144
+
145
+ So, to connect to [Amazon SQS](http://aws.amazon.com/sqs/), you would do:
146
+
147
+ sqs://${AWS_ACCESS_KEY_ID}:${AWS_SECRET_ACCESS_KEY}@
148
+
149
+ You can also override some default values like `region` and `read_lock`:
150
+
151
+ sqs://${AWS_ACCESS_KEY_ID}:${AWS_SECRET_ACCESS_KEY}@?region=${AWS_DEFAULT_REGION}&read_lock=120
152
+
153
+
154
+ ### Serializers
155
+
156
+ * `pickle` (default)
157
+ * `json`
158
+
159
+ ```
160
+ MORP_DSN="sqs://x:x@?serializer=json"
161
+ ```
162
+
163
+
164
+ ## Encryption
165
+
166
+ You might need to install some dependencies:
167
+
168
+ ```
169
+ pip install morp[encryption]
170
+ ```
171
+
172
+ If you would like to encrypt all your messages, you can pass in a `key` argument to your DSN and Morp will take care of encrypting and decrypting the messages for you transparently.
173
+
174
+ Let's just modify our DSN to pass in our key:
175
+
176
+ sqs://${AWS_ACCESS_KEY_ID}:${AWS_SECRET_ACCESS_KEY}@?key=jy4XWRuEsrH98RD2VeLG62uVLCPWpdUh
177
+
178
+ That's it, every message will now be encrypted on send and decrypted on receive. If you're using SQS you can also use [Amazon's key management service](https://github.com/Jaymon/morp/blob/master/docs/KMS.md) to handle the encryption for you.
179
+
180
+
181
+ ## Environment configuration
182
+
183
+ ### MORP_DISABLED
184
+
185
+ By default every message will be sent, if you just want to test functionality without actually sending the message you can set this environment variable to turn off all the queues.
186
+
187
+ MORP_DISABLED = 1 # queue is off
188
+ MORP_DISABLED = 0 # queue is on
189
+
190
+
191
+ ### MORP_PREFIX
192
+
193
+ If you would like to have your queue names prefixed with something (eg, `prod` or `dev`) then you can set this environment variable and it will be prefixed to the queue name.
194
+
195
+
196
+ ### MORP_DSN
197
+
198
+ Set this environment variable with your connection DSN so Morp can automatically configure itself when the interface is first requested.
199
+
200
+
201
+ ## FAQ
202
+
203
+ ### I would like to have multiple queues
204
+
205
+ By default, Morp will send any message from any `morp.Message` derived class to `Message.get_name()`, you can override this behavior by giving your child class a `._name` property:
206
+
207
+ ```python
208
+ from morp import Message
209
+
210
+ class childMsg(Message):
211
+ _name = "custom-queue-name"
212
+ ```
@@ -0,0 +1,15 @@
1
+ morp/__init__.py,sha256=2V8Qep-iSh73325gx-hqAwRJPh5CcpWb7uPhAB6iBCw,347
2
+ morp/compat.py,sha256=KPZYveHPyXYU4fKeU3fQuvBSFi-mcHK7Uqk9O_kNA-k,97
3
+ morp/config.py,sha256=kifJp-KL_tdDHeu5V17o25f95boQZtEpg3fR9RvOSio,4854
4
+ morp/exception.py,sha256=9CSZWYVNyqt7gxqApG4k3YXzrbmy_EJ_EPqw_SM8Wpk,1022
5
+ morp/message.py,sha256=B2ZLt83lzp-CN3dA0OIxzCegeKgYIwZ3t_FM41hW67w,11406
6
+ morp/interface/__init__.py,sha256=EMrMTF84Rp9V6KlnTaw4uYouLDf6jtb26CwGRX7zvts,2674
7
+ morp/interface/base.py,sha256=fi__yOjKw6J311BPoYAHo9RZ0Yi9MpJ5ZTUg7nZbE1M,14813
8
+ morp/interface/dropfile.py,sha256=oQhUVsTHIJlq_bR53SJ9W-5YZI7JOt_2FgEbx5Uc-AM,5191
9
+ morp/interface/postgres.py,sha256=Ov8NUPEhk63fXa5CZbXueKdmYXoIryfVZwdSQcJRnb8,12415
10
+ morp/interface/sqs.py,sha256=vvUdSVNqSGeM9nrMGUAPX1RVj9kZRMYet0xLht89ws4,14129
11
+ morp-7.0.0.dist-info/licenses/LICENSE.txt,sha256=NdwjhMrenM_sXvd3REQVjswgOw-pOITlnHCNLVp4AwI,1079
12
+ morp-7.0.0.dist-info/METADATA,sha256=gANIuXGUvMNlAKCZTbU6jZKCdsG3HU-Dt78N6sk9HT4,5586
13
+ morp-7.0.0.dist-info/WHEEL,sha256=wUyA8OaulRlbfwMtmQsvNngGrxQHAvkKcvRmdizlJi0,92
14
+ morp-7.0.0.dist-info/top_level.txt,sha256=GjC8bZE2FMSjNYMuYb-lrYy42OkOytWTV2pokuwpfDM,10
15
+ morp-7.0.0.dist-info/RECORD,,
@@ -0,0 +1,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (80.10.2)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1,21 @@
1
+ The MIT License (MIT)
2
+
3
+ Copyright (c) 2013+ Jay Marcyes
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,2 @@
1
+ dist
2
+ morp