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/base.py ADDED
@@ -0,0 +1,441 @@
1
+ from contextlib import asynccontextmanager, AbstractAsyncContextManager
2
+ import json
3
+ from typing import Any, Self
4
+ from collections.abc import Mapping
5
+
6
+ from datatypes import Datetime, logging
7
+
8
+ try:
9
+ from cryptography.fernet import Fernet
10
+ except ImportError:
11
+ Fernet = None
12
+
13
+ from ..compat import *
14
+ from ..exception import InterfaceError
15
+ from ..config import Connection
16
+
17
+
18
+ logger = logging.getLogger(__name__)
19
+
20
+
21
+ class InterfaceABC(object):
22
+ """This abstract base class containing all the methods that need to be
23
+ implemented in a child interface class.
24
+
25
+ Child classes should extend Interface (which extends this class). Interface
26
+ contains the public API for using the interface, these methods are broken
27
+ out from Interface just for convenience of seeing all the methods that must
28
+ be implemented
29
+ """
30
+ async def _connect(self, connection_config: Connection) -> None:
31
+ raise NotImplementedError()
32
+
33
+ async def _get_connection(self) -> Any:
34
+ """Returns a connection
35
+
36
+ usually the body of this is as simple as `return self._connection` but
37
+ needs to be implemented because the interfaces can be much more
38
+ convoluted in how it creates and manages the connection
39
+
40
+ :returns: Any, the interface connection instance
41
+ """
42
+ raise NotImplementedError()
43
+
44
+ async def _close(self) -> None:
45
+ raise NotImplementedError()
46
+
47
+ async def _send(
48
+ self,
49
+ name: str,
50
+ connection: Any,
51
+ body: bytes,
52
+ **kwargs,
53
+ ) -> tuple[str, Any]:
54
+ """similar to self.send() but this takes a body, which is the message
55
+ completely encoded and ready to be sent by the backend
56
+
57
+ :returns: tuple[str, Any], (_id, raw) where _id is the message unique
58
+ id and raw is the returned receipt from the backend
59
+ """
60
+ raise NotImplementedError()
61
+
62
+ async def _count(self, name: str, connection: Any, **kwargs) -> int:
63
+ """count how many messages are currently in the queue
64
+
65
+ :returns: int, the rough count, depending on the backend this might not
66
+ be exact
67
+ """
68
+ raise NotImplementedError()
69
+
70
+ async def _recv(
71
+ self,
72
+ name: str,
73
+ connection: Any,
74
+ **kwargs,
75
+ ) -> tuple[str, bytes, Any]:
76
+ """
77
+ :returns: (_id, body, raw) where body is the body that
78
+ was originally passed into send, raw is the untouched object
79
+ returned from recv, and _id is the unique id of the message
80
+ """
81
+ raise NotImplementedError()
82
+
83
+ async def _ack(
84
+ self,
85
+ name: str,
86
+ connection: Any,
87
+ fields: Mapping,
88
+ **kwargs,
89
+ ) -> None:
90
+ raise NotImplementedError()
91
+
92
+ async def _release(
93
+ self,
94
+ name: str,
95
+ connection: Any,
96
+ fields: Mapping,
97
+ **kwargs,
98
+ ) -> None:
99
+ raise NotImplementedError()
100
+
101
+ async def _clear(self, name: str, connection: Any, **kwargs) -> None:
102
+ raise NotImplementedError()
103
+
104
+ async def _delete(self, name: str, connection: Any, **kwargs) -> None:
105
+ raise NotImplementedError()
106
+
107
+
108
+ class Interface(InterfaceABC):
109
+ """base class for interfaces to messaging"""
110
+
111
+ connected = False
112
+ """true if a connection has been established, false otherwise"""
113
+
114
+ connection_config = None
115
+ """a config.Connection() instance"""
116
+
117
+ def __init__(self, connection_config: Connection|None = None):
118
+ self.connection_config = connection_config
119
+
120
+ async def connect(self, connection_config: Connection|None = None) -> bool:
121
+ """connect to the interface
122
+
123
+ this will set the raw db connection to self.connection
124
+ """
125
+ if self.connected:
126
+ return self.connected
127
+
128
+ if connection_config:
129
+ self.connection_config = connection_config
130
+
131
+ try:
132
+ self.connected = False
133
+ await self._connect(self.connection_config)
134
+ self.connected = True
135
+ logger.debug("Connected to %s interface", self.__class__.__name__)
136
+
137
+ except Exception as e:
138
+ raise self._raise_error(e)
139
+
140
+ return self.connected
141
+
142
+ async def close(self) -> None:
143
+ """
144
+ close an open connection
145
+ """
146
+ if not self.connected:
147
+ return;
148
+
149
+ await self._close()
150
+ self.connected = False
151
+ logger.debug(
152
+ "Closed Connection to %s interface", self.__class__.__name__,
153
+ )
154
+
155
+ @asynccontextmanager
156
+ async def connection(
157
+ self,
158
+ connection: Any = None,
159
+ **kwargs,
160
+ ) -> AbstractAsyncContextManager:
161
+ try:
162
+ if not connection:
163
+ if not self.connected:
164
+ await self.connect()
165
+
166
+ connection = await self._get_connection()
167
+
168
+ yield connection
169
+
170
+ except Exception as e:
171
+ self._raise_error(e)
172
+
173
+ def _fields_to_body(self, fields: Mapping) -> bytes:
174
+ """This will prepare the fields passed from Message to Interface.send
175
+
176
+ prepare a message to be sent over the backend
177
+
178
+ :param fields: dict, all the fields that will be sent to the backend
179
+ :returns: bytes, the fully encoded fields
180
+ """
181
+ serializer = self.connection_config.serializer
182
+ if serializer == "pickle":
183
+ ret = pickle.dumps(fields, pickle.HIGHEST_PROTOCOL)
184
+
185
+ elif serializer == "json":
186
+ ret = ByteString(json.dumps(fields))
187
+
188
+ key = self.connection_config.key
189
+ if key:
190
+ if Fernet is None:
191
+ logger.warning(
192
+ "Cannot encrypt because of missing dependencies",
193
+ )
194
+
195
+ else:
196
+ logger.debug("Encrypting fields")
197
+ f = Fernet(key)
198
+ ret = f.encrypt(ret)
199
+
200
+ return ret
201
+
202
+ def _send_to_fields(self, _id: str, fields: Mapping, raw: Any) -> Mapping:
203
+ """This creates the value that is returned from .send()
204
+
205
+ :param _id: str, the unique id of the message, usually created by the
206
+ backend
207
+ :param fields: dict, the fields that were passed to .send()
208
+ :param raw: mixed: whatever the backend returned after sending body
209
+ :returns: dict: the fields populated with additional keys. If the key
210
+ begins with an underscore then that usually means it was populated
211
+ internally
212
+ """
213
+ fields["_id"] = _id
214
+ fields["_raw_send"] = raw
215
+ return fields
216
+
217
+ async def send(self, name: str, fields: Mapping, **kwargs) -> Mapping:
218
+ """send an interface message to the message queue
219
+
220
+ :param name: str, the queue name
221
+ :param fields: dict, the fields to send to the queue name
222
+ :param **kwargs: anything else, this gets passed to self.connection()
223
+ :returns: dict, see .send_to_fields() for what this returns
224
+ """
225
+ if not fields:
226
+ raise ValueError("No fields to send")
227
+
228
+ async with self.connection(**kwargs) as connection:
229
+ kwargs["connection"] = connection
230
+ _id, raw = await self._send(
231
+ name=name,
232
+ body=self._fields_to_body(fields),
233
+ **kwargs
234
+ )
235
+ logger.debug(
236
+ "Message %s sent to %s -- %s",
237
+ _id,
238
+ name,
239
+ fields,
240
+ )
241
+ return self._send_to_fields(_id, fields, raw)
242
+
243
+ async def count(self, name: str, **kwargs) -> int:
244
+ """count how many messages are in queue name
245
+
246
+ :returns: int, a rough count of the messages in the queue, this is
247
+ backend dependent and might not be completely accurate
248
+ """
249
+ async with self.connection(**kwargs) as connection:
250
+ kwargs["connection"] = connection
251
+ return int(await self._count(name, **kwargs))
252
+
253
+ def _body_to_fields(self, body: bytes) -> Mapping:
254
+ """This will prepare the body returned from the backend to be passed
255
+ to Message
256
+
257
+ This turns a backend body back to the original fields
258
+
259
+ :param body: bytes, the body returned from the backend that needs to be
260
+ converted back into a dict
261
+ :returns: dict, the fields of the original message
262
+ """
263
+ key = self.connection_config.key
264
+ if key:
265
+ if Fernet is None:
266
+ logger.warning(
267
+ "Skipping decrypt because of missing dependencies",
268
+ )
269
+ ret = body
270
+
271
+ else:
272
+ logger.debug("Decoding encrypted body")
273
+ f = Fernet(key)
274
+ ret = f.decrypt(ByteString(body))
275
+
276
+ else:
277
+ ret = body
278
+
279
+ serializer = self.connection_config.serializer
280
+ if serializer == "pickle":
281
+ ret = pickle.loads(ret)
282
+
283
+ elif serializer == "json":
284
+ ret = json.loads(ret)
285
+
286
+ return ret
287
+
288
+ def _recv_to_fields(self, _id: str, body: bytes, raw: Any) -> Mapping:
289
+ """This creates the value that is returned from .recv()
290
+
291
+ :param _id: str, the unique id of the message, usually created by the
292
+ backend
293
+ :param body: bytes, the backend message body
294
+ :param raw: mixed: whatever the backend fetched from its receive method
295
+ :returns: dict: the fields populated with additional keys. If the key
296
+ begins with an underscore then that usually means it was populated
297
+ internally
298
+ """
299
+ fields = self._body_to_fields(body)
300
+ fields["_id"] = _id
301
+ fields["_raw_recv"] = raw
302
+ fields.setdefault("_count", 0)
303
+ fields["_count"] += 1
304
+ return fields
305
+
306
+ async def recv(
307
+ self,
308
+ name: str,
309
+ timeout: float|int = 0.0,
310
+ **kwargs,
311
+ ) -> Mapping:
312
+ """receive a message from queue name
313
+
314
+ :param name: str, the queue name
315
+ :param timeout: integer, seconds to try and receive a message before
316
+ returning None, 0 means no timeout or max timeout if the interface
317
+ requires a timeout
318
+ :returns: dict, the fields that were sent via .send populated with
319
+ additional keys (additional keys will usually be prefixed with an
320
+ underscore), it will return None if it failed to fetch (ie, timeout
321
+ or error)
322
+ """
323
+ async with self.connection(**kwargs) as connection:
324
+ kwargs["connection"] = connection
325
+ _id, body, raw = await self._recv(
326
+ name,
327
+ timeout=timeout,
328
+ **kwargs
329
+ )
330
+ fields = self._recv_to_fields(_id, body, raw) if body else None
331
+ if fields:
332
+ logger.log_for(
333
+ debug=(
334
+ "Message %s received from %s -- %s",
335
+ _id,
336
+ name,
337
+ fields
338
+ ),
339
+ info=(
340
+ "Message %s recceived from %s -- %s",
341
+ _id,
342
+ name,
343
+ fields.keys(),
344
+ ),
345
+ )
346
+
347
+ return fields
348
+
349
+ async def ack(self, name: str, fields: Mapping, **kwargs) -> None:
350
+ """this will acknowledge that the interface message was received
351
+ successfully
352
+
353
+ :param name: str, the queue name
354
+ :param fields: dict, these are the fields returned from .recv that have
355
+ additional fields that the backend will most likely need to ack the
356
+ message
357
+ """
358
+ async with self.connection(**kwargs) as connection:
359
+ kwargs["connection"] = connection
360
+ await self._ack(name, fields=fields, **kwargs)
361
+ logger.debug("Message %s acked from %s", fields["_id"], name)
362
+
363
+ async def release(self, name: str, fields: Mapping, **kwargs) -> None:
364
+ """release the message back into the queue, this is usually for when
365
+ processing the message has failed and so a new attempt to process the
366
+ message should be made
367
+
368
+ :param name: str, the queue name
369
+ :param fields: dict, these are the fields returned from .recv that have
370
+ additional fields that the backend will most likely need to release
371
+ the message
372
+ """
373
+ async with self.connection(**kwargs) as connection:
374
+ kwargs["connection"] = connection
375
+ delay_seconds = max(kwargs.pop('delay_seconds', 0), 0)
376
+ count = fields.get("_count", 0)
377
+
378
+ if delay_seconds == 0:
379
+ if count:
380
+ max_timeout = self.connection_config.options.get(
381
+ "max_timeout"
382
+ )
383
+ backoff = self.connection_config.options.get(
384
+ "backoff_multiplier"
385
+ )
386
+ amplifier = self.connection_config.options.get(
387
+ "backoff_amplifier",
388
+ count
389
+ )
390
+ delay_seconds = min(
391
+ max_timeout,
392
+ (count * backoff) * amplifier
393
+ )
394
+
395
+ await self._release(
396
+ name,
397
+ fields=fields,
398
+ delay_seconds=delay_seconds,
399
+ **kwargs
400
+ )
401
+ logger.debug(
402
+ "Message %s released back to %s count %s, with delay %ss",
403
+ fields["_id"],
404
+ name,
405
+ count,
406
+ delay_seconds,
407
+ )
408
+
409
+ async def unsafe_clear(self, name: str, **kwargs) -> None:
410
+ """clear the queue name, clearing the queue removes all the messages
411
+ from the queue but doesn't delete the actual queue
412
+
413
+ :param name: str, the queue name to clear
414
+ """
415
+ async with self.connection(**kwargs) as connection:
416
+ kwargs["connection"] = connection
417
+ await self._clear(name, **kwargs)
418
+ logger.debug("Messages cleared from %s", name)
419
+
420
+ async def unsafe_delete(self, name: str, **kwargs) -> None:
421
+ """delete the queue, this removes messages and the queue
422
+
423
+ :param name: str, the queue name to delete
424
+ """
425
+ async with self.connection(**kwargs) as connection:
426
+ kwargs["connection"] = connection
427
+ await self._delete(name, **kwargs)
428
+ logger.debug("Queue %s deleted", name)
429
+
430
+ def _raise_error(self, e: BaseException) -> None:
431
+ """this is just a wrapper to make the passed in exception an
432
+ InterfaceError"""
433
+ if (
434
+ isinstance(e, InterfaceError)
435
+ or hasattr(builtins, e.__class__.__name__)
436
+ ):
437
+ raise e
438
+
439
+ else:
440
+ raise InterfaceError(e) from e
441
+
@@ -0,0 +1,160 @@
1
+ from contextlib import contextmanager
2
+ import fcntl
3
+ import errno
4
+ import time
5
+ import uuid
6
+
7
+ from datatypes import (
8
+ Dirpath,
9
+ Filepath,
10
+ )
11
+
12
+ from ..compat import *
13
+ from .base import Interface
14
+
15
+
16
+ class Dropfile(Interface):
17
+ """Dropfile interface using local files as messages, great for quick
18
+ prototyping or passing messages from the frontend to the backend on the
19
+ same machine
20
+ """
21
+ _connection = None
22
+
23
+ @contextmanager
24
+ def queue(self, name, connection, **kwargs):
25
+ yield connection.child_dir(name, touch=True)
26
+
27
+ async def _connect(self, connection_config):
28
+ self._connection = Dirpath(
29
+ connection_config.path,
30
+ __name__.split(".", 1)[0],
31
+ "queue",
32
+ touch=True
33
+ )
34
+
35
+ async def _get_connection(self):
36
+ return self._connection
37
+
38
+ async def _close(self):
39
+ pass
40
+
41
+ async def _send(self, name, connection, body, **kwargs):
42
+ with self.queue(name, connection) as queue:
43
+ now = time.time_ns()
44
+ _id = uuid.uuid4().hex
45
+
46
+ if delay_seconds := kwargs.get('delay_seconds', 0):
47
+ now += (delay_seconds * 1000000000)
48
+
49
+ message = queue.child_file(f"{now}-{_id}-1.txt")
50
+ message.write_bytes(body)
51
+ return _id, message
52
+
53
+ async def _count(self, name, connection, **kwargs):
54
+ with self.queue(name, connection) as queue:
55
+ return queue.files().count()
56
+
57
+ def _recv_to_fields(self, _id, body, raw):
58
+ fields = super()._recv_to_fields(_id, body, raw)
59
+ fields["_count"] = raw._count
60
+ fields["_body"] = body
61
+ #fields["_created"] = raw.stat[9]
62
+ return fields
63
+
64
+ async def _recv(self, name, connection, **kwargs):
65
+ _id = body = raw = None
66
+ timeout = kwargs.get('timeout', 0.0)
67
+ count = 0.0
68
+
69
+ with self.queue(name, connection) as queue:
70
+ while count <= timeout:
71
+ now = time.time_ns()
72
+ for message in queue.files().sort():
73
+ parts = message.fileroot.split("-")
74
+ then = int(parts[0])
75
+ if now > then:
76
+ fp = message.open("rb+")
77
+ try:
78
+ fcntl.flock(fp, fcntl.LOCK_EX | fcntl.LOCK_NB)
79
+
80
+ body = fp.read()
81
+ if body:
82
+ _id = parts[1]
83
+ message.fp = fp
84
+ message._count = int(parts[2])
85
+ raw = message
86
+ break
87
+
88
+ else:
89
+ # looks like another process got to this
90
+ # message first, so try and clean it up
91
+ self._cleanup(fp, message, truncate=False)
92
+
93
+ except OSError as e:
94
+ pass
95
+
96
+ if body:
97
+ break
98
+
99
+ else:
100
+ count += 0.1
101
+ if count < timeout:
102
+ time.sleep(0.1)
103
+
104
+ return _id, body, raw
105
+
106
+ async def _ack(self, name, connection, fields, **kwargs):
107
+ message = fields["_raw_recv"]
108
+ self._cleanup(message.fp, message)
109
+
110
+ async def _release(self, name, connection, fields, **kwargs):
111
+ delay_seconds = kwargs.get('delay_seconds', 0)
112
+
113
+ _id = fields["_id"]
114
+ message = fields["_raw_recv"]
115
+ body = fields["_body"]
116
+ fp = message.fp
117
+
118
+ if delay_seconds:
119
+ now = time.time_ns() + (delay_seconds * 1000000000)
120
+
121
+ # let's copy the file body to the future and then delete the old
122
+ # message, sadly, because we've got a lock on the file we can't
123
+ # move or copy it, so we're just going to create a new file
124
+ count = fields["_count"] + 1
125
+ dest = Filepath(message.dirname, f"{now}-{_id}-{count}.txt")
126
+ dest.write_bytes(body)
127
+ self._cleanup(fp, message)
128
+
129
+ else:
130
+ # release the message back into the queue
131
+ self._cleanup(fp, message, truncate=False, delete=False)
132
+
133
+ async def _clear(self, name, connection, **kwargs):
134
+ with self.queue(name, connection) as queue:
135
+ queue.clear()
136
+
137
+ async def _delete(self, name, connection, **kwargs):
138
+ with self.queue(name, connection) as queue:
139
+ queue.delete()
140
+
141
+ def _cleanup(self, fp, message, **kwargs):
142
+ # clear the message so other get() requests will move on (this is the
143
+ # one thing we can do with an exclusive lock to tell other processes
144
+ # we have already looked at the file, I wish we could delete under an
145
+ # exclusive lock)
146
+ try:
147
+ if kwargs.get("truncate", True):
148
+ fp.truncate()
149
+
150
+ fcntl.flock(fp, fcntl.LOCK_UN)
151
+ fp.close()
152
+
153
+ except ValueError:
154
+ # .truncate - ValueError: truncate of closed file
155
+ # .flock - ValueError: I/O operation on closed file
156
+ pass
157
+
158
+ if kwargs.get("delete", True):
159
+ message.delete()
160
+