DefStream 0.1.2__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.
- DefStream/Broker/Configuration.py +18 -0
- DefStream/Broker/Connection.py +97 -0
- DefStream/Broker/Consumer.py +59 -0
- DefStream/Broker/Errors.py +14 -0
- DefStream/Broker/Event.py +35 -0
- DefStream/Broker/Implements/__init__.py +1 -0
- DefStream/Broker/Policies.py +25 -0
- DefStream/Broker/Protocol.py +45 -0
- DefStream/Broker/__init__.py +47 -0
- DefStream/Descriptor/DataObject.py +384 -0
- DefStream/Descriptor/Types.py +229 -0
- DefStream/Descriptor/Value.py +132 -0
- DefStream/Descriptor/__init__.py +172 -0
- DefStream/Inspector/Extension.py +69 -0
- DefStream/Inspector/Extensions/Console.html +373 -0
- DefStream/Inspector/Extensions/Console.py +396 -0
- DefStream/Inspector/Extensions/Dashboard.html +206 -0
- DefStream/Inspector/Extensions/Dashboard.py +114 -0
- DefStream/Inspector/Extensions/HealthCheck.py +92 -0
- DefStream/Inspector/Extensions/Metrics.py +281 -0
- DefStream/Inspector/Extensions/Specifications.py +160 -0
- DefStream/Inspector/Extensions/__init__.py +13 -0
- DefStream/Inspector/Inspector.py +216 -0
- DefStream/Inspector/Response.py +14 -0
- DefStream/Inspector/__init__.py +9 -0
- DefStream/Validator/Pattern.py +36 -0
- DefStream/Validator/Patterns/Array.py +30 -0
- DefStream/Validator/Patterns/Common.py +93 -0
- DefStream/Validator/Patterns/Compare.py +180 -0
- DefStream/Validator/Patterns/Condition.py +60 -0
- DefStream/Validator/Patterns/DataObject.py +85 -0
- DefStream/Validator/Patterns/DateTime.py +111 -0
- DefStream/Validator/Patterns/Number.py +43 -0
- DefStream/Validator/Patterns/Object.py +150 -0
- DefStream/Validator/Patterns/Size.py +110 -0
- DefStream/Validator/Patterns/String.py +85 -0
- DefStream/Validator/Patterns/Type.py +350 -0
- DefStream/Validator/Patterns/__init__.py +137 -0
- DefStream/Validator/Validate.py +29 -0
- DefStream/Validator/Validator.py +30 -0
- DefStream/Validator/__init__.py +11 -0
- DefStream/Worker/Executor.py +298 -0
- DefStream/Worker/Message.py +103 -0
- DefStream/Worker/Pool.py +203 -0
- DefStream/Worker/Run.py +187 -0
- DefStream/Worker/Specification.py +91 -0
- DefStream/Worker/Worker.py +321 -0
- DefStream/Worker/__init__.py +49 -0
- DefStream/__init__.py +0 -0
- defstream-0.1.2.dist-info/METADATA +1197 -0
- defstream-0.1.2.dist-info/RECORD +54 -0
- defstream-0.1.2.dist-info/WHEEL +5 -0
- defstream-0.1.2.dist-info/licenses/LICENSE +674 -0
- defstream-0.1.2.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
from html import escape
|
|
2
|
+
from importlib.resources import files
|
|
3
|
+
from json import dumps
|
|
4
|
+
from re import sub
|
|
5
|
+
from typing import (
|
|
6
|
+
Optional,
|
|
7
|
+
TYPE_CHECKING,
|
|
8
|
+
)
|
|
9
|
+
|
|
10
|
+
from ..Extension import Extension
|
|
11
|
+
from ..Response import Response
|
|
12
|
+
from .Metrics import Metrics
|
|
13
|
+
|
|
14
|
+
if TYPE_CHECKING:
|
|
15
|
+
from ..Inspector import Inspector
|
|
16
|
+
|
|
17
|
+
__all__ = (
|
|
18
|
+
'Dashboard',
|
|
19
|
+
)
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class Dashboard(Extension):
|
|
23
|
+
def __init__(
|
|
24
|
+
self,
|
|
25
|
+
path: str,
|
|
26
|
+
*,
|
|
27
|
+
title: str = 'DefStream',
|
|
28
|
+
description: Optional[str] = None,
|
|
29
|
+
):
|
|
30
|
+
super().__init__(path)
|
|
31
|
+
if not isinstance(
|
|
32
|
+
title,
|
|
33
|
+
str,
|
|
34
|
+
) or not title:
|
|
35
|
+
raise ValueError('Dashboard title must be a nonempty string')
|
|
36
|
+
if description is not None and not isinstance(
|
|
37
|
+
description,
|
|
38
|
+
str,
|
|
39
|
+
):
|
|
40
|
+
raise TypeError('Dashboard description must be a string or None')
|
|
41
|
+
self.title = title
|
|
42
|
+
self.description = description
|
|
43
|
+
self.metrics: Optional[Metrics] = None
|
|
44
|
+
|
|
45
|
+
def start(
|
|
46
|
+
self,
|
|
47
|
+
inspector: 'Inspector',
|
|
48
|
+
) -> None:
|
|
49
|
+
metrics = [
|
|
50
|
+
ext for ext in inspector.exts.values()
|
|
51
|
+
if isinstance(
|
|
52
|
+
ext,
|
|
53
|
+
Metrics,
|
|
54
|
+
)
|
|
55
|
+
]
|
|
56
|
+
if len(metrics) != 1:
|
|
57
|
+
raise ValueError('Dashboard requires exactly one registered Metrics extension')
|
|
58
|
+
self.metrics = metrics[0]
|
|
59
|
+
|
|
60
|
+
def handle(
|
|
61
|
+
self,
|
|
62
|
+
inspector: 'Inspector',
|
|
63
|
+
environ: dict,
|
|
64
|
+
) -> Response:
|
|
65
|
+
if environ.get('HTTP_ACCEPT') == 'application/json':
|
|
66
|
+
if self.metrics is None:
|
|
67
|
+
raise RuntimeError('Dashboard has not been started')
|
|
68
|
+
return Response(
|
|
69
|
+
'200 OK',
|
|
70
|
+
dumps(
|
|
71
|
+
self.metrics.snapshot(inspector),
|
|
72
|
+
ensure_ascii=False,
|
|
73
|
+
allow_nan=False,
|
|
74
|
+
).encode('utf-8'),
|
|
75
|
+
'application/json; charset=utf-8',
|
|
76
|
+
((
|
|
77
|
+
'X-Content-Type-Options',
|
|
78
|
+
'nosniff',
|
|
79
|
+
),),
|
|
80
|
+
)
|
|
81
|
+
return self(inspector)
|
|
82
|
+
|
|
83
|
+
def __call__(
|
|
84
|
+
self,
|
|
85
|
+
inspector: 'Inspector',
|
|
86
|
+
) -> Response:
|
|
87
|
+
values = {
|
|
88
|
+
'title': escape(self.title),
|
|
89
|
+
'description': escape(self.description) if self.description is not None else '',
|
|
90
|
+
}
|
|
91
|
+
page = sub(
|
|
92
|
+
r'\{\{dashboard_(title|description)\}\}',
|
|
93
|
+
lambda match: values[match[1]],
|
|
94
|
+
files(__package__).joinpath('Dashboard.html').read_text(encoding='utf-8'),
|
|
95
|
+
)
|
|
96
|
+
return Response(
|
|
97
|
+
'200 OK',
|
|
98
|
+
page.encode('utf-8'),
|
|
99
|
+
'text/html; charset=utf-8',
|
|
100
|
+
(
|
|
101
|
+
(
|
|
102
|
+
'Content-Security-Policy',
|
|
103
|
+
"default-src 'none'; script-src 'unsafe-inline'; style-src 'unsafe-inline'; connect-src 'self'; frame-ancestors 'none'; base-uri 'none'; form-action 'none'",
|
|
104
|
+
),
|
|
105
|
+
(
|
|
106
|
+
'X-Content-Type-Options',
|
|
107
|
+
'nosniff',
|
|
108
|
+
),
|
|
109
|
+
(
|
|
110
|
+
'Referrer-Policy',
|
|
111
|
+
'no-referrer',
|
|
112
|
+
),
|
|
113
|
+
),
|
|
114
|
+
)
|
|
@@ -0,0 +1,92 @@
|
|
|
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
|
+
'HealthCheck',
|
|
21
|
+
)
|
|
22
|
+
|
|
23
|
+
logger = getLogger(__name__)
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class HealthCheck(Extension):
|
|
27
|
+
def __init__(
|
|
28
|
+
self,
|
|
29
|
+
path: str,
|
|
30
|
+
timeout: float = 1,
|
|
31
|
+
):
|
|
32
|
+
super().__init__(path)
|
|
33
|
+
if isinstance(
|
|
34
|
+
timeout,
|
|
35
|
+
bool,
|
|
36
|
+
) or not isfinite(timeout) or timeout <= 0:
|
|
37
|
+
raise ValueError('HealthCheck probe timeout must be finite and positive')
|
|
38
|
+
self.timeout = timeout
|
|
39
|
+
self.lock = Lock()
|
|
40
|
+
self.probe: Optional[Event] = None
|
|
41
|
+
|
|
42
|
+
def state(
|
|
43
|
+
self,
|
|
44
|
+
inspector: 'Inspector',
|
|
45
|
+
) -> str:
|
|
46
|
+
worker = inspector.worker
|
|
47
|
+
if worker is None:
|
|
48
|
+
return 'starting'
|
|
49
|
+
if worker.finished.is_set():
|
|
50
|
+
return 'stopped'
|
|
51
|
+
if worker.stopping.is_set():
|
|
52
|
+
return 'stopping'
|
|
53
|
+
if not worker.running.is_set():
|
|
54
|
+
return 'starting'
|
|
55
|
+
return 'running'
|
|
56
|
+
|
|
57
|
+
def healthy(
|
|
58
|
+
self,
|
|
59
|
+
inspector: 'Inspector',
|
|
60
|
+
) -> bool:
|
|
61
|
+
if self.state(inspector) != 'running':
|
|
62
|
+
return False
|
|
63
|
+
worker = inspector.worker
|
|
64
|
+
if worker is None or worker.consumer is None:
|
|
65
|
+
return False
|
|
66
|
+
with self.lock:
|
|
67
|
+
if self.probe is None or self.probe.is_set():
|
|
68
|
+
self.probe = Event()
|
|
69
|
+
try:
|
|
70
|
+
worker.consumer.call(self.probe.set)
|
|
71
|
+
except Exception:
|
|
72
|
+
self.probe = None
|
|
73
|
+
logger.exception('HealthCheck could not schedule the consumer health probe')
|
|
74
|
+
return False
|
|
75
|
+
probe = self.probe
|
|
76
|
+
return probe.wait(self.timeout) and self.state(inspector) == 'running'
|
|
77
|
+
|
|
78
|
+
def __call__(
|
|
79
|
+
self,
|
|
80
|
+
inspector: 'Inspector',
|
|
81
|
+
) -> Response:
|
|
82
|
+
healthy = self.healthy(inspector)
|
|
83
|
+
state = 'running' if healthy else self.state(inspector)
|
|
84
|
+
if not healthy and state == 'running':
|
|
85
|
+
state = 'unresponsive'
|
|
86
|
+
return Response(
|
|
87
|
+
'200 OK' if healthy else '503 Service Unavailable',
|
|
88
|
+
dumps({
|
|
89
|
+
'status': state,
|
|
90
|
+
}).encode('utf-8'),
|
|
91
|
+
'application/json; charset=utf-8',
|
|
92
|
+
)
|
|
@@ -0,0 +1,281 @@
|
|
|
1
|
+
from dataclasses import (
|
|
2
|
+
dataclass,
|
|
3
|
+
field,
|
|
4
|
+
)
|
|
5
|
+
from math import floor
|
|
6
|
+
from threading import Lock
|
|
7
|
+
from time import monotonic
|
|
8
|
+
from typing import (
|
|
9
|
+
Dict,
|
|
10
|
+
Optional,
|
|
11
|
+
TypedDict,
|
|
12
|
+
TYPE_CHECKING,
|
|
13
|
+
)
|
|
14
|
+
from uuid import uuid4
|
|
15
|
+
|
|
16
|
+
from ..Extension import Extension
|
|
17
|
+
from ..Response import Response
|
|
18
|
+
|
|
19
|
+
if TYPE_CHECKING:
|
|
20
|
+
from ..Inspector import Inspector
|
|
21
|
+
|
|
22
|
+
__all__ = (
|
|
23
|
+
'Metrics',
|
|
24
|
+
)
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class WindowSnapshot(TypedDict):
|
|
28
|
+
success: int
|
|
29
|
+
failure: int
|
|
30
|
+
duration: float
|
|
31
|
+
max_duration: float
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
class TaskSnapshot(TypedDict):
|
|
35
|
+
active: int
|
|
36
|
+
success: int
|
|
37
|
+
failure: int
|
|
38
|
+
duration: float
|
|
39
|
+
oldest_active: float
|
|
40
|
+
windows: Dict[str, WindowSnapshot]
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
class MetricsSnapshot(TypedDict):
|
|
44
|
+
instance: str
|
|
45
|
+
sampled_at: float
|
|
46
|
+
uptime: float
|
|
47
|
+
running: bool
|
|
48
|
+
tasks: Dict[str, TaskSnapshot]
|
|
49
|
+
window_end: int
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
@dataclass
|
|
53
|
+
class TaskMetrics:
|
|
54
|
+
active: int = 0
|
|
55
|
+
success: int = 0
|
|
56
|
+
failure: int = 0
|
|
57
|
+
duration: float = 0
|
|
58
|
+
starts: Dict[float, int] = field(default_factory=dict)
|
|
59
|
+
buckets: Dict[int, WindowSnapshot] = field(default_factory=dict)
|
|
60
|
+
|
|
61
|
+
def prune(
|
|
62
|
+
self,
|
|
63
|
+
now: float,
|
|
64
|
+
) -> None:
|
|
65
|
+
for second in tuple(self.buckets):
|
|
66
|
+
if second < floor(now) - 60:
|
|
67
|
+
del self.buckets[second]
|
|
68
|
+
|
|
69
|
+
def window(
|
|
70
|
+
self,
|
|
71
|
+
end: int,
|
|
72
|
+
seconds: int,
|
|
73
|
+
) -> WindowSnapshot:
|
|
74
|
+
buckets = [
|
|
75
|
+
bucket for second, bucket in self.buckets.items()
|
|
76
|
+
if end - seconds <= second < end
|
|
77
|
+
]
|
|
78
|
+
return {
|
|
79
|
+
'success': sum(bucket['success'] for bucket in buckets),
|
|
80
|
+
'failure': sum(bucket['failure'] for bucket in buckets),
|
|
81
|
+
'duration': sum(bucket['duration'] for bucket in buckets),
|
|
82
|
+
'max_duration': max(
|
|
83
|
+
(bucket['max_duration'] for bucket in buckets),
|
|
84
|
+
default=0,
|
|
85
|
+
),
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def label(
|
|
90
|
+
value: str,
|
|
91
|
+
) -> str:
|
|
92
|
+
return value.replace(
|
|
93
|
+
'\\',
|
|
94
|
+
'\\\\',
|
|
95
|
+
).replace(
|
|
96
|
+
'\n',
|
|
97
|
+
'\\n',
|
|
98
|
+
).replace(
|
|
99
|
+
'"',
|
|
100
|
+
'\\"',
|
|
101
|
+
)
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
class Metrics(Extension):
|
|
105
|
+
def __init__(
|
|
106
|
+
self,
|
|
107
|
+
path: str,
|
|
108
|
+
):
|
|
109
|
+
super().__init__(path)
|
|
110
|
+
self.lock = Lock()
|
|
111
|
+
self.tasks: Dict[str, TaskMetrics] = {}
|
|
112
|
+
self.started_at: Optional[float] = None
|
|
113
|
+
self.instance = ''
|
|
114
|
+
|
|
115
|
+
def start(
|
|
116
|
+
self,
|
|
117
|
+
inspector: 'Inspector',
|
|
118
|
+
) -> None:
|
|
119
|
+
worker = inspector.worker
|
|
120
|
+
if worker is None:
|
|
121
|
+
raise RuntimeError('Metrics requires an attached Worker')
|
|
122
|
+
with self.lock:
|
|
123
|
+
self.started_at = monotonic()
|
|
124
|
+
self.instance = uuid4().hex
|
|
125
|
+
self.tasks = {
|
|
126
|
+
task.__module__ + '.' + task.__qualname__: TaskMetrics()
|
|
127
|
+
for task in worker.tasks
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
def begin(
|
|
131
|
+
self,
|
|
132
|
+
task: str,
|
|
133
|
+
) -> float:
|
|
134
|
+
with self.lock:
|
|
135
|
+
started = monotonic()
|
|
136
|
+
stats = self.tasks[task]
|
|
137
|
+
stats.active += 1
|
|
138
|
+
stats.starts[started] = stats.starts.get(
|
|
139
|
+
started,
|
|
140
|
+
0,
|
|
141
|
+
) + 1
|
|
142
|
+
return started
|
|
143
|
+
|
|
144
|
+
def end(
|
|
145
|
+
self,
|
|
146
|
+
task: str,
|
|
147
|
+
context: object,
|
|
148
|
+
success: Optional[bool],
|
|
149
|
+
) -> None:
|
|
150
|
+
if not isinstance(
|
|
151
|
+
context,
|
|
152
|
+
float,
|
|
153
|
+
):
|
|
154
|
+
raise TypeError('Metrics completion requires its submission timestamp')
|
|
155
|
+
with self.lock:
|
|
156
|
+
stats = self.tasks[task]
|
|
157
|
+
now = monotonic()
|
|
158
|
+
stats.starts[context] -= 1
|
|
159
|
+
if stats.starts[context] == 0:
|
|
160
|
+
del stats.starts[context]
|
|
161
|
+
stats.active -= 1
|
|
162
|
+
stats.prune(now)
|
|
163
|
+
if success is not None:
|
|
164
|
+
stats.success += int(success)
|
|
165
|
+
stats.failure += int(not success)
|
|
166
|
+
duration = now - context
|
|
167
|
+
stats.duration += duration
|
|
168
|
+
bucket = stats.buckets.setdefault(
|
|
169
|
+
floor(now),
|
|
170
|
+
{
|
|
171
|
+
'success': 0,
|
|
172
|
+
'failure': 0,
|
|
173
|
+
'duration': 0,
|
|
174
|
+
'max_duration': 0,
|
|
175
|
+
},
|
|
176
|
+
)
|
|
177
|
+
bucket['success'] += int(success)
|
|
178
|
+
bucket['failure'] += int(not success)
|
|
179
|
+
bucket['duration'] += duration
|
|
180
|
+
bucket['max_duration'] = max(
|
|
181
|
+
bucket['max_duration'],
|
|
182
|
+
duration,
|
|
183
|
+
)
|
|
184
|
+
|
|
185
|
+
def snapshot(
|
|
186
|
+
self,
|
|
187
|
+
inspector: 'Inspector',
|
|
188
|
+
) -> MetricsSnapshot:
|
|
189
|
+
worker = inspector.worker
|
|
190
|
+
with self.lock:
|
|
191
|
+
if self.started_at is None:
|
|
192
|
+
raise RuntimeError('Metrics has not been started')
|
|
193
|
+
sampled_at = monotonic()
|
|
194
|
+
for stats in self.tasks.values():
|
|
195
|
+
stats.prune(sampled_at)
|
|
196
|
+
return {
|
|
197
|
+
'instance': self.instance,
|
|
198
|
+
'sampled_at': sampled_at,
|
|
199
|
+
'uptime': sampled_at - self.started_at,
|
|
200
|
+
'running': worker is not None and worker.running.is_set() and not worker.stopping.is_set() and not worker.finished.is_set(),
|
|
201
|
+
'window_end': floor(sampled_at),
|
|
202
|
+
'tasks': {
|
|
203
|
+
task: {
|
|
204
|
+
'active': stats.active,
|
|
205
|
+
'success': stats.success,
|
|
206
|
+
'failure': stats.failure,
|
|
207
|
+
'duration': stats.duration,
|
|
208
|
+
'oldest_active': sampled_at - min(stats.starts) if stats.starts else 0,
|
|
209
|
+
'windows': {
|
|
210
|
+
str(seconds): stats.window(
|
|
211
|
+
floor(sampled_at),
|
|
212
|
+
seconds,
|
|
213
|
+
)
|
|
214
|
+
for seconds in (
|
|
215
|
+
1,
|
|
216
|
+
60,
|
|
217
|
+
)
|
|
218
|
+
},
|
|
219
|
+
}
|
|
220
|
+
for task, stats in self.tasks.items()
|
|
221
|
+
},
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
def render(
|
|
225
|
+
self,
|
|
226
|
+
inspector: 'Inspector',
|
|
227
|
+
) -> str:
|
|
228
|
+
snapshot = self.snapshot(inspector)
|
|
229
|
+
running = snapshot['running']
|
|
230
|
+
uptime = snapshot['uptime']
|
|
231
|
+
lines = [
|
|
232
|
+
'# HELP defstream_worker_running Worker consumer lifecycle is running; not a heartbeat probe.',
|
|
233
|
+
'# TYPE defstream_worker_running gauge',
|
|
234
|
+
f'defstream_worker_running {int(running)}',
|
|
235
|
+
'# HELP defstream_worker_uptime_seconds Seconds since Inspector startup.',
|
|
236
|
+
'# TYPE defstream_worker_uptime_seconds gauge',
|
|
237
|
+
f'defstream_worker_uptime_seconds {uptime}',
|
|
238
|
+
'# HELP defstream_tasks_active Submitted task executions not yet completed.',
|
|
239
|
+
'# TYPE defstream_tasks_active gauge',
|
|
240
|
+
'# HELP defstream_tasks_total Completed execution attempts including retries.',
|
|
241
|
+
'# TYPE defstream_tasks_total counter',
|
|
242
|
+
'# HELP defstream_task_duration_seconds End-to-end execution time including pool wait and hooks.',
|
|
243
|
+
'# TYPE defstream_task_duration_seconds summary',
|
|
244
|
+
'# HELP defstream_task_oldest_active_seconds Age of oldest submitted unfinished attempt including pool wait.',
|
|
245
|
+
'# TYPE defstream_task_oldest_active_seconds gauge',
|
|
246
|
+
'# HELP defstream_tasks_window Completed attempts in the last fully elapsed second or minute window.',
|
|
247
|
+
'# TYPE defstream_tasks_window gauge',
|
|
248
|
+
'# HELP defstream_task_window_duration_seconds_sum Total duration of attempts completed in the window.',
|
|
249
|
+
'# TYPE defstream_task_window_duration_seconds_sum gauge',
|
|
250
|
+
'# HELP defstream_task_window_duration_seconds_max Maximum duration of attempts completed in the window.',
|
|
251
|
+
'# TYPE defstream_task_window_duration_seconds_max gauge',
|
|
252
|
+
]
|
|
253
|
+
for task, stats in snapshot['tasks'].items():
|
|
254
|
+
labels = 'task="' + label(task) + '"'
|
|
255
|
+
lines.extend((
|
|
256
|
+
f'defstream_tasks_active{{{labels}}} {stats["active"]}',
|
|
257
|
+
f'defstream_tasks_total{{{labels},outcome="success"}} {stats["success"]}',
|
|
258
|
+
f'defstream_tasks_total{{{labels},outcome="failure"}} {stats["failure"]}',
|
|
259
|
+
f'defstream_task_duration_seconds_count{{{labels}}} {stats["success"] + stats["failure"]}',
|
|
260
|
+
f'defstream_task_duration_seconds_sum{{{labels}}} {stats["duration"]}',
|
|
261
|
+
f'defstream_task_oldest_active_seconds{{{labels}}} {stats["oldest_active"]}',
|
|
262
|
+
))
|
|
263
|
+
for seconds, window in stats['windows'].items():
|
|
264
|
+
window_labels = labels + ',window_seconds="' + seconds + '"'
|
|
265
|
+
lines.extend((
|
|
266
|
+
f'defstream_tasks_window{{{window_labels},outcome="success"}} {window["success"]}',
|
|
267
|
+
f'defstream_tasks_window{{{window_labels},outcome="failure"}} {window["failure"]}',
|
|
268
|
+
f'defstream_task_window_duration_seconds_sum{{{window_labels}}} {window["duration"]}',
|
|
269
|
+
f'defstream_task_window_duration_seconds_max{{{window_labels}}} {window["max_duration"]}',
|
|
270
|
+
))
|
|
271
|
+
return '\n'.join(lines) + '\n'
|
|
272
|
+
|
|
273
|
+
def __call__(
|
|
274
|
+
self,
|
|
275
|
+
inspector: 'Inspector',
|
|
276
|
+
) -> Response:
|
|
277
|
+
return Response(
|
|
278
|
+
'200 OK',
|
|
279
|
+
self.render(inspector).encode('utf-8'),
|
|
280
|
+
'text/plain; version=0.0.4; charset=utf-8',
|
|
281
|
+
)
|
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
from collections.abc import Mapping
|
|
2
|
+
from json import dumps
|
|
3
|
+
from re import fullmatch
|
|
4
|
+
from typing import (
|
|
5
|
+
TYPE_CHECKING,
|
|
6
|
+
Optional,
|
|
7
|
+
)
|
|
8
|
+
|
|
9
|
+
from ..Extension import Extension
|
|
10
|
+
from ..Response import Response
|
|
11
|
+
|
|
12
|
+
if TYPE_CHECKING:
|
|
13
|
+
from ..Inspector import Inspector
|
|
14
|
+
|
|
15
|
+
__all__ = (
|
|
16
|
+
'Specifications',
|
|
17
|
+
)
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def reference(
|
|
21
|
+
*parts: str,
|
|
22
|
+
) -> dict:
|
|
23
|
+
return {'$ref': '#/' + '/'.join(part.replace(
|
|
24
|
+
'~',
|
|
25
|
+
'~0',
|
|
26
|
+
).replace(
|
|
27
|
+
'/',
|
|
28
|
+
'~1',
|
|
29
|
+
) for part in parts)}
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
class Specifications(Extension):
|
|
33
|
+
def __init__(
|
|
34
|
+
self,
|
|
35
|
+
path: str,
|
|
36
|
+
version: str = '1.0.0',
|
|
37
|
+
description: Optional[str] = None,
|
|
38
|
+
format: str = 'application/json',
|
|
39
|
+
):
|
|
40
|
+
super().__init__(path)
|
|
41
|
+
if not isinstance(
|
|
42
|
+
version,
|
|
43
|
+
str,
|
|
44
|
+
) or not version:
|
|
45
|
+
raise ValueError('A document version is required')
|
|
46
|
+
self.version = version
|
|
47
|
+
self.description = description
|
|
48
|
+
if not isinstance(
|
|
49
|
+
format,
|
|
50
|
+
str,
|
|
51
|
+
) or fullmatch(
|
|
52
|
+
r"[!#$%&'+.^_`|~0-9A-Za-z-]+/[!#$%&'+.^_`|~0-9A-Za-z-]+(?:[ \t]*;[^\r\n]+)?",
|
|
53
|
+
format,
|
|
54
|
+
) is None:
|
|
55
|
+
raise ValueError('Specifications format must be a concrete media type')
|
|
56
|
+
self.format = format
|
|
57
|
+
|
|
58
|
+
def document(
|
|
59
|
+
self,
|
|
60
|
+
inspector: 'Inspector',
|
|
61
|
+
) -> dict:
|
|
62
|
+
worker = inspector.worker
|
|
63
|
+
if worker is None:
|
|
64
|
+
raise RuntimeError('Specifications requires an attached Worker')
|
|
65
|
+
info = {
|
|
66
|
+
'title': 'DefStream',
|
|
67
|
+
'version': self.version,
|
|
68
|
+
}
|
|
69
|
+
if self.description is not None:
|
|
70
|
+
info['description'] = self.description
|
|
71
|
+
channels = {}
|
|
72
|
+
operations = {}
|
|
73
|
+
for task in worker.tasks:
|
|
74
|
+
key = task.__name__
|
|
75
|
+
if key in operations:
|
|
76
|
+
raise ValueError('Duplicate task specification identifier: ' + key)
|
|
77
|
+
parameters = task.parameters
|
|
78
|
+
if isinstance(
|
|
79
|
+
parameters,
|
|
80
|
+
str,
|
|
81
|
+
):
|
|
82
|
+
queue = parameters
|
|
83
|
+
elif isinstance(
|
|
84
|
+
parameters,
|
|
85
|
+
Mapping,
|
|
86
|
+
):
|
|
87
|
+
queue = parameters.get('queue')
|
|
88
|
+
else:
|
|
89
|
+
queue = getattr(
|
|
90
|
+
parameters,
|
|
91
|
+
'queue',
|
|
92
|
+
None,
|
|
93
|
+
)
|
|
94
|
+
if not isinstance(
|
|
95
|
+
queue,
|
|
96
|
+
str,
|
|
97
|
+
) or not queue:
|
|
98
|
+
raise ValueError('Task specification requires a nonempty queue address: ' + key)
|
|
99
|
+
if queue in channels:
|
|
100
|
+
raise ValueError('Duplicate channel identifier: ' + queue)
|
|
101
|
+
message = task.request.document() if task.request is not None else {
|
|
102
|
+
'payload': {'type': 'object'},
|
|
103
|
+
}
|
|
104
|
+
message['name'] = task.__name__ + 'Request'
|
|
105
|
+
channels[queue] = {
|
|
106
|
+
'address': queue,
|
|
107
|
+
'messages': {'request': message},
|
|
108
|
+
}
|
|
109
|
+
operation = {
|
|
110
|
+
'action': 'receive',
|
|
111
|
+
'channel': reference(
|
|
112
|
+
'channels',
|
|
113
|
+
queue,
|
|
114
|
+
),
|
|
115
|
+
'messages': [reference(
|
|
116
|
+
'channels',
|
|
117
|
+
queue,
|
|
118
|
+
'messages',
|
|
119
|
+
'request',
|
|
120
|
+
)],
|
|
121
|
+
}
|
|
122
|
+
if task.request is not None and task.request.description is not None:
|
|
123
|
+
operation['description'] = task.request.description
|
|
124
|
+
if task.response is not None:
|
|
125
|
+
channels[queue]['messages']['response'] = task.response.document()
|
|
126
|
+
operation['reply'] = {
|
|
127
|
+
'channel': reference(
|
|
128
|
+
'channels',
|
|
129
|
+
queue,
|
|
130
|
+
),
|
|
131
|
+
'messages': [reference(
|
|
132
|
+
'channels',
|
|
133
|
+
queue,
|
|
134
|
+
'messages',
|
|
135
|
+
'response',
|
|
136
|
+
)],
|
|
137
|
+
}
|
|
138
|
+
operations[key] = operation
|
|
139
|
+
return {
|
|
140
|
+
'asyncapi': '3.1.0',
|
|
141
|
+
'info': info,
|
|
142
|
+
'defaultContentType': self.format,
|
|
143
|
+
'channels': channels,
|
|
144
|
+
'operations': operations,
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
def __call__(
|
|
148
|
+
self,
|
|
149
|
+
inspector: 'Inspector',
|
|
150
|
+
) -> Response:
|
|
151
|
+
return Response(
|
|
152
|
+
'200 OK',
|
|
153
|
+
dumps(
|
|
154
|
+
self.document(inspector),
|
|
155
|
+
ensure_ascii=False,
|
|
156
|
+
allow_nan=False,
|
|
157
|
+
indent=2,
|
|
158
|
+
).encode('utf-8'),
|
|
159
|
+
'application/json; charset=utf-8',
|
|
160
|
+
)
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
from .HealthCheck import HealthCheck
|
|
2
|
+
from .Metrics import Metrics
|
|
3
|
+
from .Specifications import Specifications
|
|
4
|
+
from .Console import Console
|
|
5
|
+
from .Dashboard import Dashboard
|
|
6
|
+
|
|
7
|
+
__all__ = (
|
|
8
|
+
'HealthCheck',
|
|
9
|
+
'Metrics',
|
|
10
|
+
'Specifications',
|
|
11
|
+
'Console',
|
|
12
|
+
'Dashboard',
|
|
13
|
+
)
|