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,316 @@
1
+ from dataclasses import (
2
+ dataclass,
3
+ field,
4
+ )
5
+ from logging import getLogger
6
+ from math import floor
7
+ from threading import Lock
8
+ from time import monotonic
9
+ from typing import (
10
+ Dict,
11
+ Optional,
12
+ TypedDict,
13
+ TYPE_CHECKING,
14
+ )
15
+ from uuid import uuid4
16
+
17
+ from ..Extension import Extension
18
+ from ..Response import Response
19
+
20
+ if TYPE_CHECKING:
21
+ from ..Inspector import Inspector
22
+
23
+ __all__ = (
24
+ 'Metrics',
25
+ )
26
+
27
+
28
+ class WindowSnapshot(TypedDict):
29
+ success: int
30
+ failure: int
31
+ duration: float
32
+ max_duration: float
33
+
34
+
35
+ class TaskSnapshot(TypedDict):
36
+ active: int
37
+ success: int
38
+ failure: int
39
+ duration: float
40
+ oldest_active: float
41
+ windows: Dict[str, WindowSnapshot]
42
+
43
+
44
+ class MetricsSnapshot(TypedDict):
45
+ instance: str
46
+ sampled_at: float
47
+ uptime: float
48
+ running: bool
49
+ tasks: Dict[str, TaskSnapshot]
50
+ window_end: int
51
+
52
+
53
+ @dataclass
54
+ class TaskMetrics:
55
+ active: int = 0
56
+ success: int = 0
57
+ failure: int = 0
58
+ duration: float = 0
59
+ starts: Dict[float, int] = field(default_factory=dict)
60
+ buckets: Dict[int, WindowSnapshot] = field(default_factory=dict)
61
+
62
+ def prune(
63
+ self,
64
+ now: float,
65
+ ) -> None:
66
+ for second in tuple(self.buckets):
67
+ if second < floor(now) - 60:
68
+ del self.buckets[second]
69
+
70
+ def window(
71
+ self,
72
+ end: int,
73
+ seconds: int,
74
+ ) -> WindowSnapshot:
75
+ buckets = [
76
+ bucket for second, bucket in self.buckets.items()
77
+ if end - seconds <= second < end
78
+ ]
79
+ return {
80
+ 'success': sum(bucket['success'] for bucket in buckets),
81
+ 'failure': sum(bucket['failure'] for bucket in buckets),
82
+ 'duration': sum(bucket['duration'] for bucket in buckets),
83
+ 'max_duration': max(
84
+ (bucket['max_duration'] for bucket in buckets),
85
+ default=0,
86
+ ),
87
+ }
88
+
89
+
90
+ def label(
91
+ value: str,
92
+ ) -> str:
93
+ return value.replace(
94
+ '\\',
95
+ '\\\\',
96
+ ).replace(
97
+ '\n',
98
+ '\\n',
99
+ ).replace(
100
+ '"',
101
+ '\\"',
102
+ )
103
+
104
+
105
+ class Metrics(Extension):
106
+ logger = getLogger('DefStream.Inspector.Extensions.Metrics')
107
+
108
+ def __init__(
109
+ self,
110
+ path: str,
111
+ ):
112
+ super().__init__(path)
113
+ self.lock = Lock()
114
+ self.tasks: Dict[str, TaskMetrics] = {}
115
+ self.started_at: Optional[float] = None
116
+ self.instance = ''
117
+
118
+ def start(
119
+ self,
120
+ inspector: 'Inspector',
121
+ ) -> None:
122
+ worker = inspector.worker
123
+ if worker is None:
124
+ raise RuntimeError('Metrics requires an attached Worker')
125
+ tasks = {}
126
+ for task in worker.tasks:
127
+ if task.name in tasks:
128
+ raise ValueError('Duplicate Metrics task name: ' + task.name)
129
+ tasks[task.name] = TaskMetrics()
130
+ with self.lock:
131
+ self.started_at = monotonic()
132
+ self.instance = uuid4().hex
133
+ self.tasks = tasks
134
+ self.logger.info(
135
+ 'Metrics collection initialized: path=%s tasks=%d',
136
+ self.path,
137
+ len(self.tasks),
138
+ )
139
+
140
+ def begin(
141
+ self,
142
+ task: str,
143
+ ) -> float:
144
+ with self.lock:
145
+ started = monotonic()
146
+ stats = self.tasks[task]
147
+ stats.active += 1
148
+ stats.starts[started] = stats.starts.get(
149
+ started,
150
+ 0,
151
+ ) + 1
152
+ self.logger.debug(
153
+ 'Metrics task observation started: task=%s',
154
+ task,
155
+ )
156
+ return started
157
+
158
+ def end(
159
+ self,
160
+ task: str,
161
+ context: object,
162
+ success: Optional[bool],
163
+ ) -> None:
164
+ if not isinstance(
165
+ context,
166
+ float,
167
+ ):
168
+ raise TypeError('Metrics completion requires its submission timestamp')
169
+ with self.lock:
170
+ stats = self.tasks[task]
171
+ now = monotonic()
172
+ stats.starts[context] -= 1
173
+ if stats.starts[context] == 0:
174
+ del stats.starts[context]
175
+ stats.active -= 1
176
+ stats.prune(now)
177
+ if success is not None:
178
+ stats.success += int(success)
179
+ stats.failure += int(not success)
180
+ duration = now - context
181
+ stats.duration += duration
182
+ bucket = stats.buckets.setdefault(
183
+ floor(now),
184
+ {
185
+ 'success': 0,
186
+ 'failure': 0,
187
+ 'duration': 0,
188
+ 'max_duration': 0,
189
+ },
190
+ )
191
+ bucket['success'] += int(success)
192
+ bucket['failure'] += int(not success)
193
+ bucket['duration'] += duration
194
+ bucket['max_duration'] = max(
195
+ bucket['max_duration'],
196
+ duration,
197
+ )
198
+ else:
199
+ duration = now - context
200
+ self.logger.debug(
201
+ 'Metrics task observation completed: task=%s success=%s duration=%.6f active=%d',
202
+ task,
203
+ success,
204
+ duration,
205
+ stats.active,
206
+ )
207
+
208
+ def snapshot(
209
+ self,
210
+ inspector: 'Inspector',
211
+ ) -> MetricsSnapshot:
212
+ worker = inspector.worker
213
+ with self.lock:
214
+ if self.started_at is None:
215
+ raise RuntimeError('Metrics has not been started')
216
+ sampled_at = monotonic()
217
+ for stats in self.tasks.values():
218
+ stats.prune(sampled_at)
219
+ return {
220
+ 'instance': self.instance,
221
+ 'sampled_at': sampled_at,
222
+ 'uptime': sampled_at - self.started_at,
223
+ 'running': worker is not None and worker.running.is_set() and not worker.stopping.is_set() and not worker.finished.is_set(),
224
+ 'window_end': floor(sampled_at),
225
+ 'tasks': {
226
+ task: {
227
+ 'active': stats.active,
228
+ 'success': stats.success,
229
+ 'failure': stats.failure,
230
+ 'duration': stats.duration,
231
+ 'oldest_active': sampled_at - min(stats.starts) if stats.starts else 0,
232
+ 'windows': {
233
+ str(seconds): stats.window(
234
+ floor(sampled_at),
235
+ seconds,
236
+ )
237
+ for seconds in (
238
+ 1,
239
+ 60,
240
+ )
241
+ },
242
+ }
243
+ for task, stats in self.tasks.items()
244
+ },
245
+ }
246
+ self.logger.debug(
247
+ 'Metrics snapshot built: tasks=%d running=%s',
248
+ len(snapshot['tasks']),
249
+ snapshot['running'],
250
+ )
251
+ return snapshot
252
+
253
+ def render(
254
+ self,
255
+ inspector: 'Inspector',
256
+ ) -> str:
257
+ self.logger.debug('Rendering Metrics in Prometheus format')
258
+ snapshot = self.snapshot(inspector)
259
+ running = snapshot['running']
260
+ uptime = snapshot['uptime']
261
+ lines = [
262
+ '# HELP defstream_worker_running Worker consumer lifecycle is running; not a heartbeat probe.',
263
+ '# TYPE defstream_worker_running gauge',
264
+ f'defstream_worker_running {int(running)}',
265
+ '# HELP defstream_worker_uptime_seconds Seconds since Inspector startup.',
266
+ '# TYPE defstream_worker_uptime_seconds gauge',
267
+ f'defstream_worker_uptime_seconds {uptime}',
268
+ '# HELP defstream_tasks_active Submitted task executions not yet completed.',
269
+ '# TYPE defstream_tasks_active gauge',
270
+ '# HELP defstream_tasks_total Completed execution attempts including retries.',
271
+ '# TYPE defstream_tasks_total counter',
272
+ '# HELP defstream_task_duration_seconds End-to-end execution time including pool wait and hooks.',
273
+ '# TYPE defstream_task_duration_seconds summary',
274
+ '# HELP defstream_task_oldest_active_seconds Age of oldest submitted unfinished attempt including pool wait.',
275
+ '# TYPE defstream_task_oldest_active_seconds gauge',
276
+ '# HELP defstream_tasks_window Completed attempts in the last fully elapsed second or minute window.',
277
+ '# TYPE defstream_tasks_window gauge',
278
+ '# HELP defstream_task_window_duration_seconds_sum Total duration of attempts completed in the window.',
279
+ '# TYPE defstream_task_window_duration_seconds_sum gauge',
280
+ '# HELP defstream_task_window_duration_seconds_max Maximum duration of attempts completed in the window.',
281
+ '# TYPE defstream_task_window_duration_seconds_max gauge',
282
+ ]
283
+ for task, stats in snapshot['tasks'].items():
284
+ labels = 'task="' + label(task) + '"'
285
+ lines.extend((
286
+ f'defstream_tasks_active{{{labels}}} {stats["active"]}',
287
+ f'defstream_tasks_total{{{labels},outcome="success"}} {stats["success"]}',
288
+ f'defstream_tasks_total{{{labels},outcome="failure"}} {stats["failure"]}',
289
+ f'defstream_task_duration_seconds_count{{{labels}}} {stats["success"] + stats["failure"]}',
290
+ f'defstream_task_duration_seconds_sum{{{labels}}} {stats["duration"]}',
291
+ f'defstream_task_oldest_active_seconds{{{labels}}} {stats["oldest_active"]}',
292
+ ))
293
+ for seconds, window in stats['windows'].items():
294
+ window_labels = labels + ',window_seconds="' + seconds + '"'
295
+ lines.extend((
296
+ f'defstream_tasks_window{{{window_labels},outcome="success"}} {window["success"]}',
297
+ f'defstream_tasks_window{{{window_labels},outcome="failure"}} {window["failure"]}',
298
+ f'defstream_task_window_duration_seconds_sum{{{window_labels}}} {window["duration"]}',
299
+ f'defstream_task_window_duration_seconds_max{{{window_labels}}} {window["max_duration"]}',
300
+ ))
301
+ return '\n'.join(lines) + '\n'
302
+
303
+ def __call__(
304
+ self,
305
+ inspector: 'Inspector',
306
+ ) -> Response:
307
+ body = self.render(inspector).encode('utf-8')
308
+ self.logger.debug(
309
+ 'Metrics HTTP response built: bytes=%d',
310
+ len(body),
311
+ )
312
+ return Response(
313
+ '200 OK',
314
+ body,
315
+ 'text/plain; version=0.0.4; charset=utf-8',
316
+ )
@@ -0,0 +1,107 @@
1
+ from json import dumps
2
+ from logging import getLogger
3
+ from math import isfinite
4
+ from threading import (
5
+ Event,
6
+ Lock,
7
+ )
8
+ from typing import (
9
+ Optional,
10
+ TYPE_CHECKING,
11
+ )
12
+
13
+ from ..Extension import Extension
14
+ from ..Response import Response
15
+
16
+ if TYPE_CHECKING:
17
+ from ..Inspector import Inspector
18
+
19
+ __all__ = (
20
+ 'Ping',
21
+ )
22
+
23
+ class Ping(Extension):
24
+ logger = getLogger('DefStream.Inspector.Extensions.Ping')
25
+
26
+ def __init__(
27
+ self,
28
+ path: str,
29
+ timeout: float = 1,
30
+ ):
31
+ super().__init__(path)
32
+ if isinstance(
33
+ timeout,
34
+ bool,
35
+ ) or not isfinite(timeout) or timeout <= 0:
36
+ raise ValueError('Ping probe timeout must be finite and positive')
37
+ self.timeout = timeout
38
+ self.lock = Lock()
39
+ self.probe: Optional[Event] = None
40
+
41
+ def state(
42
+ self,
43
+ inspector: 'Inspector',
44
+ ) -> str:
45
+ worker = inspector.worker
46
+ if worker is None:
47
+ return 'starting'
48
+ if worker.finished.is_set():
49
+ return 'stopped'
50
+ if worker.stopping.is_set():
51
+ return 'stopping'
52
+ if not worker.running.is_set():
53
+ return 'starting'
54
+ return 'running'
55
+
56
+ def healthy(
57
+ self,
58
+ inspector: 'Inspector',
59
+ ) -> bool:
60
+ if self.state(inspector) != 'running':
61
+ return False
62
+ worker = inspector.worker
63
+ if worker is None or worker.consumer is None:
64
+ return False
65
+ with self.lock:
66
+ if self.probe is None or self.probe.is_set():
67
+ self.probe = Event()
68
+ try:
69
+ self.logger.debug('Scheduling Ping Consumer probe')
70
+ worker.consumer.call(self.probe.set)
71
+ except Exception as error:
72
+ self.probe = None
73
+ self.logger.error(
74
+ 'Ping Consumer probe scheduling failed: error=%s',
75
+ type(error).__name__,
76
+ )
77
+ return False
78
+ probe = self.probe
79
+ responded = probe.wait(self.timeout)
80
+ healthy = responded and self.state(inspector) == 'running'
81
+ self.logger.debug(
82
+ 'Ping Consumer probe completed: responded=%s healthy=%s',
83
+ responded,
84
+ healthy,
85
+ )
86
+ return healthy
87
+
88
+ def __call__(
89
+ self,
90
+ inspector: 'Inspector',
91
+ ) -> Response:
92
+ healthy = self.healthy(inspector)
93
+ state = 'running' if healthy else self.state(inspector)
94
+ if not healthy and state == 'running':
95
+ state = 'unresponsive'
96
+ self.logger.debug(
97
+ 'Building Ping response: status=%s state=%s',
98
+ '200' if healthy else '503',
99
+ state,
100
+ )
101
+ return Response(
102
+ '200 OK' if healthy else '503 Service Unavailable',
103
+ dumps({
104
+ 'status': state,
105
+ }).encode('utf-8'),
106
+ 'application/json; charset=utf-8',
107
+ )
@@ -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('DefStream.Inspector.Extensions.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
+ )