DefStream 0.1.18__74-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 (75) hide show
  1. DefStream/Broker/Configuration.py +18 -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 +547 -0
  8. DefStream/Broker/Implements/RabbitMQ/Consumer.py +358 -0
  9. DefStream/Broker/Implements/RabbitMQ/Event.py +127 -0
  10. DefStream/Broker/Implements/RabbitMQ/Exchange.py +278 -0
  11. DefStream/Broker/Implements/RabbitMQ/Policies.py +9 -0
  12. DefStream/Broker/Implements/RabbitMQ/Queue.py +321 -0
  13. DefStream/Broker/Implements/RabbitMQ/Serializer.py +75 -0
  14. DefStream/Broker/Implements/RabbitMQ/Stream.py +181 -0
  15. DefStream/Broker/Implements/RabbitMQ/Subscription.py +74 -0
  16. DefStream/Broker/Implements/RabbitMQ/__init__.py +47 -0
  17. DefStream/Broker/Implements/Redis/Configuration.py +59 -0
  18. DefStream/Broker/Implements/Redis/Connection.py +265 -0
  19. DefStream/Broker/Implements/Redis/Consumer.py +364 -0
  20. DefStream/Broker/Implements/Redis/Event.py +191 -0
  21. DefStream/Broker/Implements/Redis/Queue.py +284 -0
  22. DefStream/Broker/Implements/Redis/Serializer.py +48 -0
  23. DefStream/Broker/Implements/Redis/Subscription.py +46 -0
  24. DefStream/Broker/Implements/Redis/__init__.py +29 -0
  25. DefStream/Broker/Implements/__init__.py +1 -0
  26. DefStream/Broker/Policies.py +25 -0
  27. DefStream/Broker/Priority.py +26 -0
  28. DefStream/Broker/Protocol.py +45 -0
  29. DefStream/Broker/__init__.py +59 -0
  30. DefStream/Descriptor/DataObject.py +409 -0
  31. DefStream/Descriptor/Types.py +248 -0
  32. DefStream/Descriptor/Value.py +188 -0
  33. DefStream/Descriptor/__init__.py +172 -0
  34. DefStream/Inspector/Extension.py +69 -0
  35. DefStream/Inspector/Extensions/Console.html +373 -0
  36. DefStream/Inspector/Extensions/Console.py +415 -0
  37. DefStream/Inspector/Extensions/Metrics.py +316 -0
  38. DefStream/Inspector/Extensions/Ping.py +107 -0
  39. DefStream/Inspector/Extensions/Specifications.py +176 -0
  40. DefStream/Inspector/Extensions/Status.html +206 -0
  41. DefStream/Inspector/Extensions/Status.py +124 -0
  42. DefStream/Inspector/Extensions/__init__.py +13 -0
  43. DefStream/Inspector/Inspector.py +347 -0
  44. DefStream/Inspector/Response.py +14 -0
  45. DefStream/Inspector/__init__.py +9 -0
  46. DefStream/Validator/Pattern.py +36 -0
  47. DefStream/Validator/Patterns/Array.py +30 -0
  48. DefStream/Validator/Patterns/Common.py +92 -0
  49. DefStream/Validator/Patterns/Compare.py +180 -0
  50. DefStream/Validator/Patterns/Condition.py +60 -0
  51. DefStream/Validator/Patterns/DataObject.py +85 -0
  52. DefStream/Validator/Patterns/DateTime.py +111 -0
  53. DefStream/Validator/Patterns/Number.py +43 -0
  54. DefStream/Validator/Patterns/Object.py +150 -0
  55. DefStream/Validator/Patterns/Size.py +110 -0
  56. DefStream/Validator/Patterns/String.py +85 -0
  57. DefStream/Validator/Patterns/Type.py +350 -0
  58. DefStream/Validator/Patterns/__init__.py +137 -0
  59. DefStream/Validator/Validate.py +29 -0
  60. DefStream/Validator/Validator.py +30 -0
  61. DefStream/Validator/__init__.py +11 -0
  62. DefStream/Worker/Executor.py +371 -0
  63. DefStream/Worker/Message.py +180 -0
  64. DefStream/Worker/Pool.py +245 -0
  65. DefStream/Worker/Run.py +357 -0
  66. DefStream/Worker/Specification.py +87 -0
  67. DefStream/Worker/Startup.py +174 -0
  68. DefStream/Worker/Worker.py +421 -0
  69. DefStream/Worker/__init__.py +47 -0
  70. DefStream/__init__.py +0 -0
  71. defstream-0.1.18.dist-info/METADATA +92 -0
  72. defstream-0.1.18.dist-info/RECORD +75 -0
  73. defstream-0.1.18.dist-info/WHEEL +6 -0
  74. defstream-0.1.18.dist-info/licenses/LICENSE +674 -0
  75. defstream-0.1.18.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
+ 'Console',
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 Console(Extension):
63
+ logger = getLogger('DefStream.Inspector.Extensions.Console')
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('Console 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('Console 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('Console 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('Console title must be a nonempty string')
100
+ if description is not None and not isinstance(
101
+ description,
102
+ str,
103
+ ):
104
+ raise TypeError('Console 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 Console HTML response')
116
+ page = files(__package__).joinpath('Console.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'\{\{console_(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 Console 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 Console 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 Console concurrent request limit exceeded')
193
+ return json_response(
194
+ '429 Too Many Requests',
195
+ {'error': 'Too many concurrent console requests'},
196
+ )
197
+ try:
198
+ self.logger.debug(
199
+ 'Handling Inspector Console 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 Console request failed: error=%s',
214
+ type(error).__name__,
215
+ )
216
+ return json_response(
217
+ '500 Internal Server Error',
218
+ {'error': 'Inspector console 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 Console task catalog')
228
+ producer = self.broker()
229
+ try:
230
+ if inspector.worker is not None and producer is inspector.worker.broker:
231
+ raise ValueError('Console 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 Console 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': 'Console 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 Console 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 Console 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('Console 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 Console 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 Console 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 Console 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 Console 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()