DefStream 0.1.22__90-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.
Files changed (73) hide show
  1. DefStream/Broker/Configuration.py +17 -0
  2. DefStream/Broker/Connection.py +141 -0
  3. DefStream/Broker/Consumer.py +59 -0
  4. DefStream/Broker/Errors.py +14 -0
  5. DefStream/Broker/Event.py +35 -0
  6. DefStream/Broker/Implements/RabbitMQ/Configuration.py +49 -0
  7. DefStream/Broker/Implements/RabbitMQ/Connection.py +550 -0
  8. DefStream/Broker/Implements/RabbitMQ/Consumer.py +416 -0
  9. DefStream/Broker/Implements/RabbitMQ/Event.py +146 -0
  10. DefStream/Broker/Implements/RabbitMQ/Exchange.py +277 -0
  11. DefStream/Broker/Implements/RabbitMQ/Queue.py +321 -0
  12. DefStream/Broker/Implements/RabbitMQ/Serializer.py +75 -0
  13. DefStream/Broker/Implements/RabbitMQ/Stream.py +181 -0
  14. DefStream/Broker/Implements/RabbitMQ/Subscription.py +90 -0
  15. DefStream/Broker/Implements/RabbitMQ/__init__.py +45 -0
  16. DefStream/Broker/Implements/Redis/Configuration.py +59 -0
  17. DefStream/Broker/Implements/Redis/Connection.py +265 -0
  18. DefStream/Broker/Implements/Redis/Consumer.py +368 -0
  19. DefStream/Broker/Implements/Redis/Event.py +212 -0
  20. DefStream/Broker/Implements/Redis/Queue.py +284 -0
  21. DefStream/Broker/Implements/Redis/Serializer.py +48 -0
  22. DefStream/Broker/Implements/Redis/Subscription.py +46 -0
  23. DefStream/Broker/Implements/Redis/__init__.py +29 -0
  24. DefStream/Broker/Implements/__init__.py +1 -0
  25. DefStream/Broker/Priority.py +26 -0
  26. DefStream/Broker/Protocol.py +54 -0
  27. DefStream/Broker/__init__.py +55 -0
  28. DefStream/Descriptor/DataObject.py +409 -0
  29. DefStream/Descriptor/Types.py +248 -0
  30. DefStream/Descriptor/Value.py +188 -0
  31. DefStream/Descriptor/__init__.py +172 -0
  32. DefStream/Inspector/Extension.py +69 -0
  33. DefStream/Inspector/Extensions/Metrics.py +316 -0
  34. DefStream/Inspector/Extensions/Ping.py +107 -0
  35. DefStream/Inspector/Extensions/Play.html +373 -0
  36. DefStream/Inspector/Extensions/Play.py +415 -0
  37. DefStream/Inspector/Extensions/Specifications.py +176 -0
  38. DefStream/Inspector/Extensions/Status.html +206 -0
  39. DefStream/Inspector/Extensions/Status.py +124 -0
  40. DefStream/Inspector/Extensions/__init__.py +13 -0
  41. DefStream/Inspector/Inspector.py +354 -0
  42. DefStream/Inspector/Response.py +14 -0
  43. DefStream/Inspector/__init__.py +9 -0
  44. DefStream/Validator/Pattern.py +36 -0
  45. DefStream/Validator/Patterns/Array.py +30 -0
  46. DefStream/Validator/Patterns/Common.py +92 -0
  47. DefStream/Validator/Patterns/Compare.py +180 -0
  48. DefStream/Validator/Patterns/Condition.py +60 -0
  49. DefStream/Validator/Patterns/DataObject.py +93 -0
  50. DefStream/Validator/Patterns/DateTime.py +111 -0
  51. DefStream/Validator/Patterns/Number.py +43 -0
  52. DefStream/Validator/Patterns/Object.py +158 -0
  53. DefStream/Validator/Patterns/Size.py +110 -0
  54. DefStream/Validator/Patterns/String.py +92 -0
  55. DefStream/Validator/Patterns/Type.py +355 -0
  56. DefStream/Validator/Patterns/__init__.py +137 -0
  57. DefStream/Validator/Validate.py +29 -0
  58. DefStream/Validator/Validator.py +30 -0
  59. DefStream/Validator/__init__.py +11 -0
  60. DefStream/Worker/Executor.py +525 -0
  61. DefStream/Worker/Message.py +180 -0
  62. DefStream/Worker/Pool.py +282 -0
  63. DefStream/Worker/Run.py +357 -0
  64. DefStream/Worker/Specification.py +87 -0
  65. DefStream/Worker/Startup.py +174 -0
  66. DefStream/Worker/Worker.py +571 -0
  67. DefStream/Worker/__init__.py +47 -0
  68. DefStream/__init__.py +0 -0
  69. defstream-0.1.22.dist-info/METADATA +92 -0
  70. defstream-0.1.22.dist-info/RECORD +73 -0
  71. defstream-0.1.22.dist-info/WHEEL +6 -0
  72. defstream-0.1.22.dist-info/licenses/LICENSE +674 -0
  73. defstream-0.1.22.dist-info/top_level.txt +1 -0
@@ -0,0 +1,415 @@
1
+ from hashlib import sha256
2
+ from hmac import compare_digest
3
+ from html import escape
4
+ from importlib.resources import files
5
+ from json import (
6
+ dumps,
7
+ loads,
8
+ )
9
+ from logging import getLogger
10
+ from math import isfinite
11
+ from re import sub
12
+ from threading import BoundedSemaphore
13
+ from time import monotonic
14
+ from typing import (
15
+ Optional,
16
+ TYPE_CHECKING,
17
+ )
18
+ from urllib.parse import urlsplit
19
+
20
+ from ...Broker.Connection import (
21
+ BrokerFactory,
22
+ Requestable,
23
+ )
24
+ from ...Broker.Errors import RemoteError
25
+ from ...Worker.Message import (
26
+ message_body,
27
+ request,
28
+ emit,
29
+ )
30
+ from ..Extension import Extension
31
+ from ..Response import Response
32
+ from .Specifications import Specifications
33
+
34
+ if TYPE_CHECKING:
35
+ from ..Inspector import Inspector
36
+
37
+ __all__ = (
38
+ 'Play',
39
+ )
40
+
41
+ def json_response(
42
+ status: str,
43
+ value: dict,
44
+ ) -> Response:
45
+ return Response(
46
+ status,
47
+ dumps(
48
+ value,
49
+ ensure_ascii=False,
50
+ allow_nan=False,
51
+ ).encode('utf-8'),
52
+ 'application/json; charset=utf-8',
53
+ (
54
+ (
55
+ 'X-Content-Type-Options',
56
+ 'nosniff',
57
+ ),
58
+ ),
59
+ )
60
+
61
+
62
+ class Play(Extension):
63
+ logger = getLogger('Worker.Inspector.Play')
64
+
65
+ methods = (
66
+ 'GET',
67
+ 'HEAD',
68
+ 'POST',
69
+ )
70
+
71
+ def __init__(
72
+ self,
73
+ path: str,
74
+ *,
75
+ broker: BrokerFactory,
76
+ token: Optional[str] = None,
77
+ timeout: float = 5,
78
+ title: str = 'DefStream',
79
+ description: Optional[str] = None,
80
+ ):
81
+ super().__init__(path)
82
+ if not callable(broker):
83
+ raise TypeError('Play broker must be a factory creating a new producer connection')
84
+ if token is not None and (not isinstance(
85
+ token,
86
+ str,
87
+ ) or len(token) < 16):
88
+ raise ValueError('Play token must contain at least 16 characters')
89
+ if isinstance(
90
+ timeout,
91
+ bool,
92
+ ) or not isfinite(timeout) or timeout <= 0:
93
+ raise ValueError('Play RPC timeout must be finite and positive')
94
+ self.broker = broker
95
+ if not isinstance(
96
+ title,
97
+ str,
98
+ ) or not title:
99
+ raise ValueError('Play title must be a nonempty string')
100
+ if description is not None and not isinstance(
101
+ description,
102
+ str,
103
+ ):
104
+ raise TypeError('Play description must be a string or None')
105
+ self.title = title
106
+ self.description = description
107
+ self.token_digest = sha256(token.encode('utf-8')).digest() if token is not None else None
108
+ self.timeout = timeout
109
+ self.slots = BoundedSemaphore(4)
110
+
111
+ def __call__(
112
+ self,
113
+ inspector: 'Inspector',
114
+ ) -> Response:
115
+ self.logger.debug('Building Inspector Play HTML response')
116
+ page = files(__package__).joinpath('Play.html').read_bytes()
117
+ values = {
118
+ 'title': escape(self.title),
119
+ 'description': escape(self.description) if self.description is not None else '',
120
+ }
121
+ page = sub(
122
+ r'\{\{play_(title|description)\}\}',
123
+ lambda match: values[match[1]],
124
+ page.decode('utf-8'),
125
+ ).encode('utf-8')
126
+ if self.token_digest is None:
127
+ page = page.replace(
128
+ b'data-auth-required="true"',
129
+ b'data-auth-required="false"',
130
+ )
131
+ return Response(
132
+ '200 OK',
133
+ page,
134
+ 'text/html; charset=utf-8',
135
+ (
136
+ (
137
+ 'Content-Security-Policy',
138
+ "default-src 'none'; script-src 'unsafe-inline'; style-src 'unsafe-inline'; connect-src 'self'; frame-ancestors 'none'; base-uri 'none'; form-action 'none'",
139
+ ),
140
+ (
141
+ 'X-Content-Type-Options',
142
+ 'nosniff',
143
+ ),
144
+ (
145
+ 'Referrer-Policy',
146
+ 'no-referrer',
147
+ ),
148
+ ),
149
+ )
150
+
151
+ def handle(
152
+ self,
153
+ inspector: 'Inspector',
154
+ environ: dict,
155
+ ) -> Response:
156
+ method = environ['REQUEST_METHOD']
157
+ if method in (
158
+ 'GET',
159
+ 'HEAD',
160
+ ) and environ.get('HTTP_ACCEPT') != 'application/json':
161
+ return self(inspector)
162
+ authorization = environ.get(
163
+ 'HTTP_AUTHORIZATION',
164
+ '',
165
+ )
166
+ if self.token_digest is not None and (not authorization.startswith('Bearer ') or not compare_digest(
167
+ sha256(authorization[7:].encode('utf-8')).digest(),
168
+ self.token_digest,
169
+ )):
170
+ self.logger.warning('Inspector Play rejected an unauthorized request')
171
+ return json_response(
172
+ '401 Unauthorized',
173
+ {'error': 'A valid Bearer token is required'},
174
+ )
175
+ origin = environ.get('HTTP_ORIGIN')
176
+ if origin is not None:
177
+ try:
178
+ parsed = urlsplit(origin)
179
+ valid_origin = parsed.scheme in (
180
+ 'http',
181
+ 'https',
182
+ ) and parsed.netloc == environ.get('HTTP_HOST')
183
+ except ValueError:
184
+ valid_origin = False
185
+ if not valid_origin:
186
+ self.logger.warning('Inspector Play rejected a cross-origin request')
187
+ return json_response(
188
+ '403 Forbidden',
189
+ {'error': 'Cross-origin requests are not allowed'},
190
+ )
191
+ if not self.slots.acquire(blocking=False):
192
+ self.logger.warning('Inspector Play concurrent request limit exceeded')
193
+ return json_response(
194
+ '429 Too Many Requests',
195
+ {'error': 'Too many concurrent Play requests'},
196
+ )
197
+ try:
198
+ self.logger.debug(
199
+ 'Handling Inspector Play request: method=%s',
200
+ method,
201
+ )
202
+ if method in (
203
+ 'GET',
204
+ 'HEAD',
205
+ ):
206
+ return self.catalog(inspector)
207
+ return self.execute(
208
+ inspector,
209
+ environ,
210
+ )
211
+ except Exception as error:
212
+ self.logger.error(
213
+ 'Inspector Play request failed: error=%s',
214
+ type(error).__name__,
215
+ )
216
+ return json_response(
217
+ '500 Internal Server Error',
218
+ {'error': 'Inspector Play failed; consult the worker logs'},
219
+ )
220
+ finally:
221
+ self.slots.release()
222
+
223
+ def catalog(
224
+ self,
225
+ inspector: 'Inspector',
226
+ ) -> Response:
227
+ self.logger.debug('Building Inspector Play task catalog')
228
+ producer = self.broker()
229
+ try:
230
+ if inspector.worker is not None and producer is inspector.worker.broker:
231
+ raise ValueError('Play requires a separate producer, not the Worker connection')
232
+ rpc = isinstance(
233
+ producer,
234
+ Requestable,
235
+ )
236
+ specifications = next(
237
+ (
238
+ extension for extension in inspector.exts.values()
239
+ if isinstance(
240
+ extension,
241
+ Specifications,
242
+ )
243
+ ),
244
+ None,
245
+ )
246
+ if specifications is None:
247
+ specifications = Specifications(self.path)
248
+ document = specifications.document(inspector)
249
+ self.logger.debug(
250
+ 'Inspector Play task catalog built: tasks=%d rpc=%s',
251
+ len(document['operations']),
252
+ rpc,
253
+ )
254
+ return json_response(
255
+ '200 OK',
256
+ {
257
+ 'specification': document,
258
+ 'rpc': rpc,
259
+ 'timeout': self.timeout,
260
+ },
261
+ )
262
+ finally:
263
+ if inspector.worker is None or producer is not inspector.worker.broker:
264
+ producer.close()
265
+
266
+ def execute(
267
+ self,
268
+ inspector: 'Inspector',
269
+ environ: dict,
270
+ ) -> Response:
271
+ worker = inspector.worker
272
+ if worker is None or not worker.running.is_set() or worker.stopping.is_set() or worker.finished.is_set():
273
+ return json_response(
274
+ '503 Service Unavailable',
275
+ {'error': 'Worker is not running'},
276
+ )
277
+ if environ.get(
278
+ 'CONTENT_TYPE',
279
+ '',
280
+ ).split(';')[0].strip().lower() != 'application/json':
281
+ return json_response(
282
+ '415 Unsupported Media Type',
283
+ {'error': 'Use application/json'},
284
+ )
285
+ length = environ.get(
286
+ 'CONTENT_LENGTH',
287
+ '',
288
+ )
289
+ if not length.isascii() or not length.isdecimal():
290
+ return json_response(
291
+ '411 Length Required',
292
+ {'error': 'A Content-Length is required'},
293
+ )
294
+ size = int(length)
295
+ if size > 65536:
296
+ return json_response(
297
+ '413 Payload Too Large',
298
+ {'error': 'Play requests must not exceed 64 KiB'},
299
+ )
300
+ raw = environ['wsgi.input'].read(size)
301
+ try:
302
+ if len(raw) != size:
303
+ raise ValueError('Incomplete request body')
304
+ payload = loads(raw)
305
+ if not isinstance(
306
+ payload,
307
+ dict,
308
+ ) or set(payload) != {
309
+ 'task',
310
+ 'mode',
311
+ 'body',
312
+ }:
313
+ raise ValueError('Expected task, mode and body fields')
314
+ if payload['mode'] not in (
315
+ 'send',
316
+ 'request',
317
+ ):
318
+ raise ValueError('Mode must be send or request')
319
+ body = message_body(payload['body'])
320
+ dumps(
321
+ body,
322
+ allow_nan=False,
323
+ )
324
+ task = next(
325
+ (
326
+ task for task in worker.tasks if task.name == payload['task']
327
+ ),
328
+ None,
329
+ )
330
+ if task is None:
331
+ raise ValueError('Unknown registered task')
332
+ if sum(task.name == payload['task'] for task in worker.tasks) != 1:
333
+ raise ValueError('Ambiguous registered task name')
334
+ except (
335
+ ValueError,
336
+ TypeError,
337
+ UnicodeDecodeError,
338
+ ) as error:
339
+ self.logger.warning(
340
+ 'Inspector Play rejected invalid input: error=%s message=%s',
341
+ type(error).__name__,
342
+ error,
343
+ )
344
+ return json_response(
345
+ '400 Bad Request',
346
+ {'error': str(error)},
347
+ )
348
+ started = monotonic()
349
+ self.logger.debug(
350
+ 'Inspector Play task request validated: task=%s mode=%s',
351
+ task.__module__ + '.' + task.__qualname__,
352
+ payload['mode'],
353
+ )
354
+ producer = self.broker()
355
+ if producer is worker.broker:
356
+ raise ValueError('Play requires a separate producer, not the Worker connection')
357
+ try:
358
+ if payload['mode'] == 'request' and not isinstance(
359
+ producer,
360
+ Requestable,
361
+ ):
362
+ self.logger.debug('Inspector Play request rejected: broker does not support RPC')
363
+ return json_response(
364
+ '501 Not Implemented',
365
+ {'error': 'This broker does not support RPC'},
366
+ )
367
+ producer.connect()
368
+ try:
369
+ if payload['mode'] == 'request':
370
+ result = request(
371
+ body,
372
+ task=task,
373
+ broker=producer,
374
+ timeout=self.timeout,
375
+ )
376
+ else:
377
+ result = emit(
378
+ body,
379
+ task=task,
380
+ broker=producer,
381
+ )
382
+ except TimeoutError:
383
+ self.logger.warning('Inspector Play RPC timed out')
384
+ return json_response(
385
+ '504 Gateway Timeout',
386
+ {'error': 'RPC timed out; delivered work is not cancelled'},
387
+ )
388
+ except RemoteError as error:
389
+ self.logger.warning(
390
+ 'Inspector Play received a remote task error: code=%s',
391
+ error.code,
392
+ )
393
+ return json_response(
394
+ '502 Bad Gateway',
395
+ {
396
+ 'error': str(error),
397
+ 'code': error.code,
398
+ },
399
+ )
400
+ self.logger.debug(
401
+ 'Inspector Play task request completed: task=%s mode=%s elapsed=%.6f',
402
+ task.__module__ + '.' + task.__qualname__,
403
+ payload['mode'],
404
+ monotonic() - started,
405
+ )
406
+ return json_response(
407
+ '200 OK',
408
+ {
409
+ 'mode': payload['mode'],
410
+ 'result': result,
411
+ 'elapsed': monotonic() - started,
412
+ },
413
+ )
414
+ finally:
415
+ producer.close()
@@ -0,0 +1,176 @@
1
+ from collections.abc import Mapping
2
+ from json import dumps
3
+ from logging import getLogger
4
+ from re import fullmatch
5
+ from typing import (
6
+ Any,
7
+ TYPE_CHECKING,
8
+ Optional,
9
+ )
10
+
11
+ from ..Extension import Extension
12
+ from ..Response import Response
13
+
14
+ if TYPE_CHECKING:
15
+ from ..Inspector import Inspector
16
+
17
+ __all__ = (
18
+ 'Specifications',
19
+ )
20
+
21
+
22
+ def reference(
23
+ *parts: str,
24
+ ) -> dict:
25
+ return {'$ref': '#/' + '/'.join(part.replace(
26
+ '~',
27
+ '~0',
28
+ ).replace(
29
+ '/',
30
+ '~1',
31
+ ) for part in parts)}
32
+
33
+
34
+ class Specifications(Extension):
35
+ logger = getLogger('Worker.Inspector.Specifications')
36
+
37
+ def __init__(
38
+ self,
39
+ path: str,
40
+ version: str = '1.0.0',
41
+ description: Optional[str] = None,
42
+ format: str = 'application/json',
43
+ ):
44
+ super().__init__(path)
45
+ if not isinstance(
46
+ version,
47
+ str,
48
+ ) or not version:
49
+ raise ValueError('A document version is required')
50
+ self.version = version
51
+ self.description = description
52
+ if not isinstance(
53
+ format,
54
+ str,
55
+ ) or fullmatch(
56
+ r"[!#$%&'+.^_`|~0-9A-Za-z-]+/[!#$%&'+.^_`|~0-9A-Za-z-]+(?:[ \t]*;[^\r\n]+)?",
57
+ format,
58
+ ) is None:
59
+ raise ValueError('Specifications format must be a concrete media type')
60
+ self.format = format
61
+
62
+ def document(
63
+ self,
64
+ inspector: 'Inspector',
65
+ ) -> dict:
66
+ self.logger.debug('Building Specifications AsyncAPI document')
67
+ worker = inspector.worker
68
+ if worker is None:
69
+ raise RuntimeError('Specifications requires an attached Worker')
70
+ info = {
71
+ 'title': 'DefStream',
72
+ 'version': self.version,
73
+ }
74
+ if self.description is not None:
75
+ info['description'] = self.description
76
+ channels: dict[str, Any] = {}
77
+ operations: dict[str, Any] = {}
78
+ for task in worker.tasks:
79
+ key = task.name
80
+ if key in operations:
81
+ raise ValueError('Duplicate task specification identifier: ' + key)
82
+ parameters = task.parameters
83
+ if isinstance(
84
+ parameters,
85
+ str,
86
+ ):
87
+ queue = parameters
88
+ elif isinstance(
89
+ parameters,
90
+ Mapping,
91
+ ):
92
+ queue = parameters.get('queue')
93
+ else:
94
+ queue = getattr(
95
+ parameters,
96
+ 'queue',
97
+ None,
98
+ )
99
+ if not isinstance(
100
+ queue,
101
+ str,
102
+ ) or not queue:
103
+ raise ValueError('Task specification requires a nonempty queue address: ' + key)
104
+ if queue in channels:
105
+ raise ValueError('Duplicate channel identifier: ' + queue)
106
+ message: dict[str, Any] = task.request.document() if task.request is not None else {
107
+ 'payload': {'type': 'object'},
108
+ }
109
+ message['name'] = task.__name__ + 'Request'
110
+ channels[queue] = {
111
+ 'address': queue,
112
+ 'messages': {'request': message},
113
+ }
114
+ operation = {
115
+ 'action': 'receive',
116
+ 'channel': reference(
117
+ 'channels',
118
+ queue,
119
+ ),
120
+ 'messages': [reference(
121
+ 'channels',
122
+ queue,
123
+ 'messages',
124
+ 'request',
125
+ )],
126
+ }
127
+ if task.description is not None:
128
+ operation['description'] = task.description
129
+ if task.response is not None:
130
+ channels[queue]['messages']['response'] = task.response.document()
131
+ operation['reply'] = {
132
+ 'channel': reference(
133
+ 'channels',
134
+ queue,
135
+ ),
136
+ 'messages': [reference(
137
+ 'channels',
138
+ queue,
139
+ 'messages',
140
+ 'response',
141
+ )],
142
+ }
143
+ operations[key] = operation
144
+ document = {
145
+ 'asyncapi': '3.1.0',
146
+ 'info': info,
147
+ 'defaultContentType': self.format,
148
+ 'channels': channels,
149
+ 'operations': operations,
150
+ }
151
+ self.logger.debug(
152
+ 'Specifications AsyncAPI document built: tasks=%d channels=%d',
153
+ len(operations),
154
+ len(channels),
155
+ )
156
+ return document
157
+
158
+ def __call__(
159
+ self,
160
+ inspector: 'Inspector',
161
+ ) -> Response:
162
+ body = dumps(
163
+ self.document(inspector),
164
+ ensure_ascii=False,
165
+ allow_nan=False,
166
+ indent=2,
167
+ ).encode('utf-8')
168
+ self.logger.debug(
169
+ 'Specifications HTTP response built: bytes=%d',
170
+ len(body),
171
+ )
172
+ return Response(
173
+ '200 OK',
174
+ body,
175
+ 'application/json; charset=utf-8',
176
+ )