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.
- DefStream/Broker/Configuration.py +17 -0
- DefStream/Broker/Connection.py +141 -0
- DefStream/Broker/Consumer.py +59 -0
- DefStream/Broker/Errors.py +14 -0
- DefStream/Broker/Event.py +35 -0
- DefStream/Broker/Implements/RabbitMQ/Configuration.py +49 -0
- DefStream/Broker/Implements/RabbitMQ/Connection.py +550 -0
- DefStream/Broker/Implements/RabbitMQ/Consumer.py +416 -0
- DefStream/Broker/Implements/RabbitMQ/Event.py +146 -0
- DefStream/Broker/Implements/RabbitMQ/Exchange.py +277 -0
- DefStream/Broker/Implements/RabbitMQ/Queue.py +321 -0
- DefStream/Broker/Implements/RabbitMQ/Serializer.py +75 -0
- DefStream/Broker/Implements/RabbitMQ/Stream.py +181 -0
- DefStream/Broker/Implements/RabbitMQ/Subscription.py +90 -0
- DefStream/Broker/Implements/RabbitMQ/__init__.py +45 -0
- DefStream/Broker/Implements/Redis/Configuration.py +59 -0
- DefStream/Broker/Implements/Redis/Connection.py +265 -0
- DefStream/Broker/Implements/Redis/Consumer.py +368 -0
- DefStream/Broker/Implements/Redis/Event.py +212 -0
- DefStream/Broker/Implements/Redis/Queue.py +284 -0
- DefStream/Broker/Implements/Redis/Serializer.py +48 -0
- DefStream/Broker/Implements/Redis/Subscription.py +46 -0
- DefStream/Broker/Implements/Redis/__init__.py +29 -0
- DefStream/Broker/Implements/__init__.py +1 -0
- DefStream/Broker/Priority.py +26 -0
- DefStream/Broker/Protocol.py +54 -0
- DefStream/Broker/__init__.py +55 -0
- DefStream/Descriptor/DataObject.py +409 -0
- DefStream/Descriptor/Types.py +248 -0
- DefStream/Descriptor/Value.py +188 -0
- DefStream/Descriptor/__init__.py +172 -0
- DefStream/Inspector/Extension.py +69 -0
- DefStream/Inspector/Extensions/Metrics.py +316 -0
- DefStream/Inspector/Extensions/Ping.py +107 -0
- DefStream/Inspector/Extensions/Play.html +373 -0
- DefStream/Inspector/Extensions/Play.py +415 -0
- DefStream/Inspector/Extensions/Specifications.py +176 -0
- DefStream/Inspector/Extensions/Status.html +206 -0
- DefStream/Inspector/Extensions/Status.py +124 -0
- DefStream/Inspector/Extensions/__init__.py +13 -0
- DefStream/Inspector/Inspector.py +354 -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 +92 -0
- DefStream/Validator/Patterns/Compare.py +180 -0
- DefStream/Validator/Patterns/Condition.py +60 -0
- DefStream/Validator/Patterns/DataObject.py +93 -0
- DefStream/Validator/Patterns/DateTime.py +111 -0
- DefStream/Validator/Patterns/Number.py +43 -0
- DefStream/Validator/Patterns/Object.py +158 -0
- DefStream/Validator/Patterns/Size.py +110 -0
- DefStream/Validator/Patterns/String.py +92 -0
- DefStream/Validator/Patterns/Type.py +355 -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 +525 -0
- DefStream/Worker/Message.py +180 -0
- DefStream/Worker/Pool.py +282 -0
- DefStream/Worker/Run.py +357 -0
- DefStream/Worker/Specification.py +87 -0
- DefStream/Worker/Startup.py +174 -0
- DefStream/Worker/Worker.py +571 -0
- DefStream/Worker/__init__.py +47 -0
- DefStream/__init__.py +0 -0
- defstream-0.1.22.dist-info/METADATA +92 -0
- defstream-0.1.22.dist-info/RECORD +73 -0
- defstream-0.1.22.dist-info/WHEEL +6 -0
- defstream-0.1.22.dist-info/licenses/LICENSE +674 -0
- defstream-0.1.22.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,206 @@
|
|
|
1
|
+
<!doctype html>
|
|
2
|
+
<html lang="en">
|
|
3
|
+
<head>
|
|
4
|
+
<meta charset="utf-8">
|
|
5
|
+
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
6
|
+
<title>{{status_title}}</title>
|
|
7
|
+
<style>
|
|
8
|
+
:root { color-scheme: light; font: 14px/1.55 system-ui, -apple-system, sans-serif; color: #263343; background: #f7f8fa; }
|
|
9
|
+
* { box-sizing: border-box; }
|
|
10
|
+
body { margin: 0; }
|
|
11
|
+
header { background: #fff; border-bottom: 1px solid #e1e5eb; }
|
|
12
|
+
.heading, main { max-width: 1440px; margin: auto; padding: 24px 32px; }
|
|
13
|
+
.heading { display: flex; align-items: center; justify-content: space-between; gap: 16px; }
|
|
14
|
+
h1 { margin: 0; font-size: 22px; letter-spacing: -.6px; overflow-wrap: anywhere; }
|
|
15
|
+
h2 { margin: 0; font-size: 15px; }
|
|
16
|
+
p { margin: 6px 0 0; }
|
|
17
|
+
.subtitle, .muted { color: #657184; font-size: 12px; }
|
|
18
|
+
.subtitle:empty { display: none; }
|
|
19
|
+
.badge { padding: 5px 10px; border: 1px solid #c6dfcf; border-radius: 5px; color: #286f40; background: #f0faf3; font-size: 11px; white-space: nowrap; }
|
|
20
|
+
.toolbar { display: flex; align-items: center; justify-content: space-between; gap: 16px; margin-bottom: 20px; }
|
|
21
|
+
#notice { margin: 0; font-size: 12px; color: #536374; }
|
|
22
|
+
#notice[data-state="error"] { color: #ab303d; }
|
|
23
|
+
button { padding: 8px 14px; border: 1px solid #cdd5df; border-radius: 5px; font: 600 12px system-ui, sans-serif; background: #fff; color: #38485b; cursor: pointer; white-space: nowrap; }
|
|
24
|
+
button:hover { background: #edf3f8; }
|
|
25
|
+
select { padding: 8px; border: 1px solid #cdd5df; border-radius: 5px; background: #fff; color: #38485b; }
|
|
26
|
+
.controls { display: flex; flex-wrap: wrap; gap: 10px; align-items: center; }
|
|
27
|
+
:focus-visible { outline: 3px solid #89b8ff; outline-offset: 3px; }
|
|
28
|
+
.cards { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 16px; margin-bottom: 24px; }
|
|
29
|
+
.card, .panel { min-width: 0; background: #fff; border: 1px solid #e1e5eb; border-radius: 7px; }
|
|
30
|
+
.card { padding: 20px; }
|
|
31
|
+
.label { font-size: 12px; color: #657184; }
|
|
32
|
+
.value { margin-top: 8px; font-size: 28px; font-weight: 650; line-height: 1.3; font-variant-numeric: tabular-nums; overflow-wrap: anywhere; }
|
|
33
|
+
.rate { color: #27834c; }
|
|
34
|
+
.failure { color: #b33b45; }
|
|
35
|
+
.hint { color: #768295; font-size: 11px; margin-top: 8px; }
|
|
36
|
+
.panel-heading { display: flex; flex-wrap: wrap; align-items: center; justify-content: space-between; gap: 10px; padding: 18px 20px; border-bottom: 1px solid #e1e5eb; }
|
|
37
|
+
.table-scroll { overflow: auto; }
|
|
38
|
+
table { width: 100%; border-collapse: collapse; font-size: 12px; }
|
|
39
|
+
th { background: #f7f9fb; color: #657184; font-weight: 600; white-space: nowrap; }
|
|
40
|
+
th, td { padding: 13px 18px; text-align: right; border-bottom: 1px solid #edf0f4; font-variant-numeric: tabular-nums; }
|
|
41
|
+
th:first-child, td:first-child { text-align: left; }
|
|
42
|
+
td:first-child { max-width: 400px; overflow-wrap: anywhere; font: 12px/1.7 Consolas, monospace; }
|
|
43
|
+
tr:last-child td { border-bottom: 0; }
|
|
44
|
+
#empty { padding: 24px; color: #657184; font-size: 12px; }
|
|
45
|
+
.notes { margin-top: 20px; color: #657184; font-size: 12px; }
|
|
46
|
+
.stale .cards, .stale .panel { opacity: .55; }
|
|
47
|
+
[hidden] { display: none !important; }
|
|
48
|
+
@media(max-width: 800px) {
|
|
49
|
+
.heading, main { padding: 20px 16px; }
|
|
50
|
+
.cards { grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 10px; }
|
|
51
|
+
.card { padding: 16px; }
|
|
52
|
+
.value { font-size: 24px; }
|
|
53
|
+
}
|
|
54
|
+
@media(max-width: 420px) {
|
|
55
|
+
.cards { grid-template-columns: 1fr; }
|
|
56
|
+
.heading { align-items: start; }
|
|
57
|
+
}
|
|
58
|
+
</style>
|
|
59
|
+
</head>
|
|
60
|
+
<body>
|
|
61
|
+
<header><div class="heading">
|
|
62
|
+
<div><h1>{{status_title}}</h1><p class="subtitle">{{status_description}}</p></div>
|
|
63
|
+
<span class="badge">LIVE DASHBOARD</span>
|
|
64
|
+
</div></header>
|
|
65
|
+
<main>
|
|
66
|
+
<div class="toolbar">
|
|
67
|
+
<p id="notice" role="status" aria-live="polite">Connecting to worker...</p>
|
|
68
|
+
<div class="controls">
|
|
69
|
+
<label for="window">View</label><select id="window"><option value="1">Last second</option><option value="60">Last minute</option></select>
|
|
70
|
+
<button id="pause" type="button" aria-pressed="false">Pause updates</button>
|
|
71
|
+
</div>
|
|
72
|
+
</div>
|
|
73
|
+
<div class="cards">
|
|
74
|
+
<section class="card"><div class="label">Window completions</div><div id="rate" class="value rate">--</div><p id="window-hint" class="hint">Completed in the last fully elapsed second</p></section>
|
|
75
|
+
<section class="card"><div class="label">In flight</div><div id="active" class="value">--</div><p class="hint">Submitted, not yet completed</p></section>
|
|
76
|
+
<section class="card"><div class="label">Worker lifecycle</div><div id="state" class="value">--</div><p class="hint">Lifecycle only, not a health probe</p></section>
|
|
77
|
+
<section class="card"><div class="label">Succeeded</div><div id="success" class="value">--</div><p class="hint">Since worker startup</p></section>
|
|
78
|
+
<section class="card"><div class="label">Failed</div><div id="failure" class="value failure">--</div><p class="hint">Includes failed retry attempts</p></section>
|
|
79
|
+
<section class="card"><div class="label">Uptime</div><div id="uptime" class="value">--</div><p class="hint">Since Metrics initialization</p></section>
|
|
80
|
+
<section class="card"><div class="label">Window success / failure</div><div id="window-outcomes" class="value">--</div><p class="hint">Completed attempts in selected window</p></section>
|
|
81
|
+
<section class="card"><div class="label">Window failure rate</div><div id="failure-rate" class="value">--</div><p class="hint">Failed / completed attempts</p></section>
|
|
82
|
+
<section class="card"><div class="label">Window average time</div><div id="average" class="value">--</div><p class="hint">Includes pool wait and hooks</p></section>
|
|
83
|
+
<section class="card"><div class="label">Window maximum time</div><div id="maximum" class="value">--</div><p class="hint">Slowest completion in selected window</p></section>
|
|
84
|
+
<section class="card"><div class="label">Oldest in flight</div><div id="oldest" class="value">--</div><p class="hint">Elapsed since submission, including pool wait</p></section>
|
|
85
|
+
</div>
|
|
86
|
+
<section class="panel" aria-labelledby="tasks-title">
|
|
87
|
+
<div class="panel-heading"><h2 id="tasks-title">Task activity</h2><span class="muted">Current rates & cumulative totals</span></div>
|
|
88
|
+
<div class="table-scroll"><table>
|
|
89
|
+
<thead><tr><th scope="col">Task</th><th scope="col">In flight</th><th scope="col">Total success</th><th scope="col">Total failure</th><th id="rate-heading" scope="col">Tasks / sec</th><th scope="col">Window avg.</th><th scope="col">Window max.</th><th scope="col">Window success</th><th scope="col">Window failure</th><th scope="col">Failure rate</th><th scope="col">Oldest in flight</th></tr></thead>
|
|
90
|
+
<tbody id="tasks"></tbody>
|
|
91
|
+
</table></div>
|
|
92
|
+
<p id="empty" hidden>No tasks registered.</p>
|
|
93
|
+
</section>
|
|
94
|
+
<p class="notes">Refreshes about every second. Windows end at the last fully elapsed monotonic second (less than one second behind live totals).
|
|
95
|
+
Last minute counts actual completions in 60 seconds, not a multiplied per-second estimate. Startup windows include only data since startup.
|
|
96
|
+
Counts represent execution attempts, not unique messages or confirmed ACKs. Average time includes pool wait and hooks.
|
|
97
|
+
This page shows one worker only, with no persistent history.</p>
|
|
98
|
+
</main>
|
|
99
|
+
<script>
|
|
100
|
+
'use strict';
|
|
101
|
+
const el = id => document.getElementById(id);
|
|
102
|
+
const number = new Intl.NumberFormat(undefined, { maximumFractionDigits: 2 });
|
|
103
|
+
let latest = null;
|
|
104
|
+
let timer = null;
|
|
105
|
+
let pending = false;
|
|
106
|
+
let paused = false;
|
|
107
|
+
const milliseconds = value => number.format(value * 1000) + ' ms';
|
|
108
|
+
const failureRate = window => window.success + window.failure ? number.format(window.failure / (window.success + window.failure) * 100) + '%' : '--';
|
|
109
|
+
function duration(seconds) {
|
|
110
|
+
const total = Math.floor(seconds);
|
|
111
|
+
return [Math.floor(total / 3600), Math.floor(total % 3600 / 60), total % 60].map(value => String(value).padStart(2, '0')).join(':');
|
|
112
|
+
}
|
|
113
|
+
function render(snapshot) {
|
|
114
|
+
const seconds = el('window').value || '1';
|
|
115
|
+
const unit = seconds === '60' ? 'min' : 'sec';
|
|
116
|
+
const totals = { active: 0, success: 0, failure: 0 };
|
|
117
|
+
const window = { success: 0, failure: 0, duration: 0, max_duration: 0 };
|
|
118
|
+
let oldest = 0;
|
|
119
|
+
const rows = Object.entries(snapshot.tasks).map(([name, task]) => {
|
|
120
|
+
for (const key of Object.keys(totals)) totals[key] += task[key];
|
|
121
|
+
const period = task.windows[seconds];
|
|
122
|
+
for (const key of ['success', 'failure', 'duration']) window[key] += period[key];
|
|
123
|
+
window.max_duration = Math.max(window.max_duration, period.max_duration);
|
|
124
|
+
oldest = Math.max(oldest, task.oldest_active);
|
|
125
|
+
const count = period.success + period.failure;
|
|
126
|
+
const row = document.createElement('tr');
|
|
127
|
+
const values = [name, number.format(task.active), number.format(task.success), number.format(task.failure),
|
|
128
|
+
number.format(count), count ? milliseconds(period.duration / count) : '--',
|
|
129
|
+
count ? milliseconds(period.max_duration) : '--', number.format(period.success), number.format(period.failure),
|
|
130
|
+
failureRate(period), task.active ? milliseconds(task.oldest_active) : '--'];
|
|
131
|
+
values.forEach((value, index) => {
|
|
132
|
+
const cell = document.createElement('td');
|
|
133
|
+
if (index >= 4) cell.className = 'task-rate';
|
|
134
|
+
cell.textContent = value;
|
|
135
|
+
row.append(cell);
|
|
136
|
+
});
|
|
137
|
+
return row;
|
|
138
|
+
});
|
|
139
|
+
el('tasks').replaceChildren(...rows);
|
|
140
|
+
el('empty').hidden = rows.length > 0;
|
|
141
|
+
for (const key of Object.keys(totals)) el(key).textContent = number.format(totals[key]);
|
|
142
|
+
const count = window.success + window.failure;
|
|
143
|
+
el('rate').textContent = number.format(count) + ' /' + unit;
|
|
144
|
+
el('window-hint').textContent = 'Completed in the last fully elapsed ' + (seconds === '60' ? '60 seconds' : 'second');
|
|
145
|
+
el('rate-heading').textContent = 'Tasks / ' + unit;
|
|
146
|
+
el('window-outcomes').textContent = number.format(window.success) + ' / ' + number.format(window.failure);
|
|
147
|
+
el('failure-rate').textContent = failureRate(window);
|
|
148
|
+
el('average').textContent = count ? milliseconds(window.duration / count) : '--';
|
|
149
|
+
el('maximum').textContent = count ? milliseconds(window.max_duration) : '--';
|
|
150
|
+
el('oldest').textContent = totals.active ? milliseconds(oldest) : '--';
|
|
151
|
+
el('state').textContent = snapshot.running ? 'Running' : 'Not running';
|
|
152
|
+
el('uptime').textContent = duration(snapshot.uptime);
|
|
153
|
+
latest = snapshot;
|
|
154
|
+
}
|
|
155
|
+
function stale() {
|
|
156
|
+
latest = null;
|
|
157
|
+
document.body.classList.add('stale');
|
|
158
|
+
for (const id of ['rate', 'window-outcomes', 'failure-rate', 'average', 'maximum', 'oldest']) el(id).textContent = '--';
|
|
159
|
+
for (const cell of document.querySelectorAll('.task-rate')) cell.textContent = '--';
|
|
160
|
+
}
|
|
161
|
+
el('window').onchange = () => { if (latest) render(latest); };
|
|
162
|
+
async function refresh() {
|
|
163
|
+
if (pending || paused || document.hidden) return;
|
|
164
|
+
pending = true;
|
|
165
|
+
const controller = new AbortController();
|
|
166
|
+
const timeout = setTimeout(() => controller.abort(), 5000);
|
|
167
|
+
try {
|
|
168
|
+
const response = await fetch(window.location.pathname, {
|
|
169
|
+
headers: { Accept: 'application/json' }, credentials: 'omit', redirect: 'error', cache: 'no-store', signal: controller.signal
|
|
170
|
+
});
|
|
171
|
+
if (!response.ok) throw new Error('HTTP ' + response.status);
|
|
172
|
+
const snapshot = await response.json();
|
|
173
|
+
if (paused || document.hidden) return;
|
|
174
|
+
render(snapshot);
|
|
175
|
+
document.body.classList.remove('stale');
|
|
176
|
+
el('notice').dataset.state = 'ready';
|
|
177
|
+
el('notice').textContent = (paused ? 'Paused. Last updated ' : 'Live. Updated ') + new Date().toLocaleTimeString();
|
|
178
|
+
} catch (error) {
|
|
179
|
+
stale();
|
|
180
|
+
el('notice').dataset.state = 'error';
|
|
181
|
+
el('notice').textContent = 'Disconnected - displayed totals are stale. ' + (error.name === 'AbortError' ? 'Request timed out.' : error.message);
|
|
182
|
+
} finally {
|
|
183
|
+
clearTimeout(timeout);
|
|
184
|
+
pending = false;
|
|
185
|
+
if (!paused && !document.hidden) timer = setTimeout(refresh, 1000);
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
el('pause').onclick = () => {
|
|
189
|
+
paused = !paused;
|
|
190
|
+
clearTimeout(timer);
|
|
191
|
+
el('pause').textContent = paused ? 'Resume updates' : 'Pause updates';
|
|
192
|
+
el('pause').setAttribute('aria-pressed', String(paused));
|
|
193
|
+
if (paused) {
|
|
194
|
+
stale();
|
|
195
|
+
el('notice').textContent = 'Paused - displayed data is not updating.';
|
|
196
|
+
} else refresh();
|
|
197
|
+
};
|
|
198
|
+
document.addEventListener('visibilitychange', () => {
|
|
199
|
+
clearTimeout(timer);
|
|
200
|
+
if (document.hidden) stale();
|
|
201
|
+
if (!document.hidden) refresh();
|
|
202
|
+
});
|
|
203
|
+
refresh();
|
|
204
|
+
</script>
|
|
205
|
+
</body>
|
|
206
|
+
</html>
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
from html import escape
|
|
2
|
+
from importlib.resources import files
|
|
3
|
+
from json import dumps
|
|
4
|
+
from logging import getLogger
|
|
5
|
+
from re import sub
|
|
6
|
+
from typing import (
|
|
7
|
+
Optional,
|
|
8
|
+
TYPE_CHECKING,
|
|
9
|
+
)
|
|
10
|
+
|
|
11
|
+
from ..Extension import Extension
|
|
12
|
+
from ..Response import Response
|
|
13
|
+
from .Metrics import Metrics
|
|
14
|
+
|
|
15
|
+
if TYPE_CHECKING:
|
|
16
|
+
from ..Inspector import Inspector
|
|
17
|
+
|
|
18
|
+
__all__ = (
|
|
19
|
+
'Status',
|
|
20
|
+
)
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class Status(Extension):
|
|
24
|
+
logger = getLogger('Worker.Inspector.Status')
|
|
25
|
+
|
|
26
|
+
def __init__(
|
|
27
|
+
self,
|
|
28
|
+
path: str,
|
|
29
|
+
*,
|
|
30
|
+
title: str = 'DefStream',
|
|
31
|
+
description: Optional[str] = None,
|
|
32
|
+
):
|
|
33
|
+
super().__init__(path)
|
|
34
|
+
if not isinstance(
|
|
35
|
+
title,
|
|
36
|
+
str,
|
|
37
|
+
) or not title:
|
|
38
|
+
raise ValueError('Status title must be a nonempty string')
|
|
39
|
+
if description is not None and not isinstance(
|
|
40
|
+
description,
|
|
41
|
+
str,
|
|
42
|
+
):
|
|
43
|
+
raise TypeError('Status description must be a string or None')
|
|
44
|
+
self.title = title
|
|
45
|
+
self.description = description
|
|
46
|
+
self.metrics: Optional[Metrics] = None
|
|
47
|
+
|
|
48
|
+
def start(
|
|
49
|
+
self,
|
|
50
|
+
inspector: 'Inspector',
|
|
51
|
+
) -> None:
|
|
52
|
+
metrics = [
|
|
53
|
+
ext for ext in inspector.exts.values()
|
|
54
|
+
if isinstance(
|
|
55
|
+
ext,
|
|
56
|
+
Metrics,
|
|
57
|
+
)
|
|
58
|
+
]
|
|
59
|
+
if len(metrics) != 1:
|
|
60
|
+
raise ValueError('Status requires exactly one registered Metrics extension')
|
|
61
|
+
self.metrics = metrics[0]
|
|
62
|
+
self.logger.debug(
|
|
63
|
+
'Status Metrics extension attached: path=%s',
|
|
64
|
+
self.metrics.path,
|
|
65
|
+
)
|
|
66
|
+
|
|
67
|
+
def handle(
|
|
68
|
+
self,
|
|
69
|
+
inspector: 'Inspector',
|
|
70
|
+
environ: dict,
|
|
71
|
+
) -> Response:
|
|
72
|
+
if environ.get('HTTP_ACCEPT') == 'application/json':
|
|
73
|
+
if self.metrics is None:
|
|
74
|
+
raise RuntimeError('Status has not been started')
|
|
75
|
+
self.logger.debug('Building Status JSON response')
|
|
76
|
+
return Response(
|
|
77
|
+
'200 OK',
|
|
78
|
+
dumps(
|
|
79
|
+
self.metrics.snapshot(inspector),
|
|
80
|
+
ensure_ascii=False,
|
|
81
|
+
allow_nan=False,
|
|
82
|
+
).encode('utf-8'),
|
|
83
|
+
'application/json; charset=utf-8',
|
|
84
|
+
((
|
|
85
|
+
'X-Content-Type-Options',
|
|
86
|
+
'nosniff',
|
|
87
|
+
),),
|
|
88
|
+
)
|
|
89
|
+
self.logger.debug('Status HTML response requested')
|
|
90
|
+
return self(inspector)
|
|
91
|
+
|
|
92
|
+
def __call__(
|
|
93
|
+
self,
|
|
94
|
+
inspector: 'Inspector',
|
|
95
|
+
) -> Response:
|
|
96
|
+
self.logger.debug('Reading Status HTML resource')
|
|
97
|
+
values = {
|
|
98
|
+
'title': escape(self.title),
|
|
99
|
+
'description': escape(self.description) if self.description is not None else '',
|
|
100
|
+
}
|
|
101
|
+
page = sub(
|
|
102
|
+
r'\{\{status_(title|description)\}\}',
|
|
103
|
+
lambda match: values[match[1]],
|
|
104
|
+
files(__package__).joinpath('Status.html').read_text(encoding='utf-8'),
|
|
105
|
+
)
|
|
106
|
+
return Response(
|
|
107
|
+
'200 OK',
|
|
108
|
+
page.encode('utf-8'),
|
|
109
|
+
'text/html; charset=utf-8',
|
|
110
|
+
(
|
|
111
|
+
(
|
|
112
|
+
'Content-Security-Policy',
|
|
113
|
+
"default-src 'none'; script-src 'unsafe-inline'; style-src 'unsafe-inline'; connect-src 'self'; frame-ancestors 'none'; base-uri 'none'; form-action 'none'",
|
|
114
|
+
),
|
|
115
|
+
(
|
|
116
|
+
'X-Content-Type-Options',
|
|
117
|
+
'nosniff',
|
|
118
|
+
),
|
|
119
|
+
(
|
|
120
|
+
'Referrer-Policy',
|
|
121
|
+
'no-referrer',
|
|
122
|
+
),
|
|
123
|
+
),
|
|
124
|
+
)
|
|
@@ -0,0 +1,354 @@
|
|
|
1
|
+
from logging import getLogger
|
|
2
|
+
from socketserver import ThreadingMixIn
|
|
3
|
+
from sys import exc_info
|
|
4
|
+
from threading import Thread
|
|
5
|
+
from time import monotonic
|
|
6
|
+
from traceback import extract_tb
|
|
7
|
+
from typing import (
|
|
8
|
+
Callable,
|
|
9
|
+
Dict,
|
|
10
|
+
Iterable,
|
|
11
|
+
Optional,
|
|
12
|
+
Sequence,
|
|
13
|
+
TYPE_CHECKING,
|
|
14
|
+
Tuple,
|
|
15
|
+
IO,
|
|
16
|
+
cast,
|
|
17
|
+
)
|
|
18
|
+
from wsgiref.simple_server import (
|
|
19
|
+
WSGIRequestHandler,
|
|
20
|
+
ServerHandler,
|
|
21
|
+
WSGIServer,
|
|
22
|
+
make_server,
|
|
23
|
+
)
|
|
24
|
+
|
|
25
|
+
from .Extension import Extension
|
|
26
|
+
from .Response import Response
|
|
27
|
+
|
|
28
|
+
if TYPE_CHECKING:
|
|
29
|
+
from ..Worker.Worker import Worker
|
|
30
|
+
|
|
31
|
+
__all__ = (
|
|
32
|
+
'Inspector',
|
|
33
|
+
)
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
class Server(
|
|
37
|
+
ThreadingMixIn,
|
|
38
|
+
WSGIServer,
|
|
39
|
+
):
|
|
40
|
+
logger = getLogger('Worker.Inspector')
|
|
41
|
+
|
|
42
|
+
daemon_threads = True
|
|
43
|
+
block_on_close = False
|
|
44
|
+
|
|
45
|
+
def get_request(self):
|
|
46
|
+
connection, address = super().get_request()
|
|
47
|
+
connection.settimeout(5)
|
|
48
|
+
return connection, address
|
|
49
|
+
|
|
50
|
+
def handle_error(
|
|
51
|
+
self,
|
|
52
|
+
request,
|
|
53
|
+
client_address,
|
|
54
|
+
) -> None:
|
|
55
|
+
_, error, traceback = exc_info()
|
|
56
|
+
self.logger.error(
|
|
57
|
+
'HTTP connection handling failed: error_type=%s\n%s',
|
|
58
|
+
type(error).__name__,
|
|
59
|
+
''.join(
|
|
60
|
+
f' {frame.filename}:{frame.lineno} ({frame.name})\n'
|
|
61
|
+
for frame in extract_tb(traceback)
|
|
62
|
+
),
|
|
63
|
+
)
|
|
64
|
+
return
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
class InspectorServerHandler(ServerHandler):
|
|
68
|
+
request_handler: 'RequestHandler'
|
|
69
|
+
|
|
70
|
+
def log_exception(
|
|
71
|
+
self,
|
|
72
|
+
exc_info,
|
|
73
|
+
) -> None:
|
|
74
|
+
_, error, traceback = exc_info
|
|
75
|
+
self.request_handler.logger.error(
|
|
76
|
+
'WSGI request handling failed: error_type=%s\n%s',
|
|
77
|
+
type(error).__name__,
|
|
78
|
+
''.join(
|
|
79
|
+
f' {frame.filename}:{frame.lineno} ({frame.name})\n'
|
|
80
|
+
for frame in extract_tb(traceback)
|
|
81
|
+
),
|
|
82
|
+
)
|
|
83
|
+
return
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
class RequestHandler(WSGIRequestHandler):
|
|
87
|
+
logger = getLogger('Worker.Inspector')
|
|
88
|
+
|
|
89
|
+
def handle(self) -> None:
|
|
90
|
+
self.started_at = monotonic()
|
|
91
|
+
self.path = ''
|
|
92
|
+
self.raw_requestline = self.rfile.readline(65537)
|
|
93
|
+
if len(self.raw_requestline) > 65536:
|
|
94
|
+
self.requestline = ''
|
|
95
|
+
self.request_version = ''
|
|
96
|
+
self.command = ''
|
|
97
|
+
self.send_error(414)
|
|
98
|
+
return
|
|
99
|
+
if not self.raw_requestline:
|
|
100
|
+
self.close_connection = True
|
|
101
|
+
return
|
|
102
|
+
if not self.parse_request():
|
|
103
|
+
return
|
|
104
|
+
self.logger.debug(
|
|
105
|
+
'HTTP request received: method=%s path=%s',
|
|
106
|
+
self.command,
|
|
107
|
+
self.path.partition('?')[0],
|
|
108
|
+
)
|
|
109
|
+
handler = InspectorServerHandler(
|
|
110
|
+
self.rfile,
|
|
111
|
+
cast(IO[bytes], self.wfile),
|
|
112
|
+
self.get_stderr(),
|
|
113
|
+
self.get_environ(),
|
|
114
|
+
multithread=True,
|
|
115
|
+
)
|
|
116
|
+
handler.request_handler = self
|
|
117
|
+
application = cast(WSGIServer, self.server).get_app()
|
|
118
|
+
if application is None:
|
|
119
|
+
raise RuntimeError('Inspector WSGI application is not initialized')
|
|
120
|
+
handler.run(application)
|
|
121
|
+
|
|
122
|
+
def log_request(
|
|
123
|
+
self,
|
|
124
|
+
code='-',
|
|
125
|
+
size='-',
|
|
126
|
+
) -> None:
|
|
127
|
+
self.logger.info(
|
|
128
|
+
'HTTP response sent: method=%s path=%s status=%s bytes=%s duration=%s',
|
|
129
|
+
self.command,
|
|
130
|
+
self.path.partition('?')[0],
|
|
131
|
+
code,
|
|
132
|
+
size if size is not None else '-',
|
|
133
|
+
monotonic() - self.started_at,
|
|
134
|
+
)
|
|
135
|
+
return
|
|
136
|
+
|
|
137
|
+
def log_message(
|
|
138
|
+
self,
|
|
139
|
+
format: str,
|
|
140
|
+
*args,
|
|
141
|
+
) -> None:
|
|
142
|
+
self.logger.warning('HTTP protocol error or request timeout')
|
|
143
|
+
return
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
class Inspector:
|
|
147
|
+
logger = getLogger('Worker.Inspector')
|
|
148
|
+
|
|
149
|
+
def __init__(
|
|
150
|
+
self,
|
|
151
|
+
host: str = '127.0.0.1',
|
|
152
|
+
port: int = 9999,
|
|
153
|
+
exts: Sequence[Extension] = (),
|
|
154
|
+
):
|
|
155
|
+
if isinstance(
|
|
156
|
+
port,
|
|
157
|
+
bool,
|
|
158
|
+
) or not isinstance(
|
|
159
|
+
port,
|
|
160
|
+
int,
|
|
161
|
+
) or not 0 <= port <= 65535:
|
|
162
|
+
raise ValueError('Inspector port must be between 0 and 65535')
|
|
163
|
+
self.host = host
|
|
164
|
+
self.port = port
|
|
165
|
+
self.exts: Dict[str, Extension] = {}
|
|
166
|
+
for ext in exts:
|
|
167
|
+
if not isinstance(
|
|
168
|
+
ext,
|
|
169
|
+
Extension,
|
|
170
|
+
):
|
|
171
|
+
raise TypeError('Inspector extensions must inherit Extension')
|
|
172
|
+
if ext.path in self.exts:
|
|
173
|
+
raise ValueError('Duplicate Inspector path: ' + ext.path)
|
|
174
|
+
self.exts[ext.path] = ext
|
|
175
|
+
self.logger.debug(
|
|
176
|
+
'Inspector extension registered: class=%s path=%s',
|
|
177
|
+
type(ext).__name__,
|
|
178
|
+
ext.path,
|
|
179
|
+
)
|
|
180
|
+
self.worker: Optional['Worker'] = None
|
|
181
|
+
self.server: Optional[WSGIServer] = None
|
|
182
|
+
self.thread: Optional[Thread] = None
|
|
183
|
+
|
|
184
|
+
def start(
|
|
185
|
+
self,
|
|
186
|
+
worker: 'Worker',
|
|
187
|
+
) -> None:
|
|
188
|
+
if self.worker is not None:
|
|
189
|
+
raise RuntimeError('Inspector can only be attached and started once')
|
|
190
|
+
self.worker = worker
|
|
191
|
+
for ext in self.exts.values():
|
|
192
|
+
self.logger.debug(
|
|
193
|
+
'Starting Inspector extension: class=%s path=%s',
|
|
194
|
+
type(ext).__name__,
|
|
195
|
+
ext.path,
|
|
196
|
+
)
|
|
197
|
+
try:
|
|
198
|
+
ext.start(self)
|
|
199
|
+
except Exception as error:
|
|
200
|
+
self.logger.error(
|
|
201
|
+
'Inspector extension startup failed: class=%s path=%s error=%s',
|
|
202
|
+
type(ext).__name__,
|
|
203
|
+
ext.path,
|
|
204
|
+
type(error).__name__,
|
|
205
|
+
)
|
|
206
|
+
raise
|
|
207
|
+
try:
|
|
208
|
+
server = make_server(
|
|
209
|
+
self.host,
|
|
210
|
+
self.port,
|
|
211
|
+
self,
|
|
212
|
+
server_class=Server,
|
|
213
|
+
handler_class=RequestHandler,
|
|
214
|
+
)
|
|
215
|
+
except Exception as error:
|
|
216
|
+
self.logger.error(
|
|
217
|
+
'Inspector HTTP server binding failed: error=%s',
|
|
218
|
+
type(error).__name__,
|
|
219
|
+
)
|
|
220
|
+
raise
|
|
221
|
+
self.server = server
|
|
222
|
+
thread = Thread(
|
|
223
|
+
target=self.serve,
|
|
224
|
+
daemon=True,
|
|
225
|
+
)
|
|
226
|
+
self.thread = thread
|
|
227
|
+
try:
|
|
228
|
+
thread.start()
|
|
229
|
+
except BaseException:
|
|
230
|
+
self.logger.error('Inspector HTTP server thread startup failed')
|
|
231
|
+
server.server_close()
|
|
232
|
+
raise
|
|
233
|
+
self.logger.info(
|
|
234
|
+
'Inspector HTTP server started: host=%s port=%s',
|
|
235
|
+
server.server_address[0],
|
|
236
|
+
server.server_port,
|
|
237
|
+
)
|
|
238
|
+
|
|
239
|
+
def serve(self) -> None:
|
|
240
|
+
server = self.server
|
|
241
|
+
if server is None:
|
|
242
|
+
raise RuntimeError('Inspector server is not initialized')
|
|
243
|
+
try:
|
|
244
|
+
server.serve_forever(poll_interval=0.1)
|
|
245
|
+
except Exception as error:
|
|
246
|
+
self.logger.error(
|
|
247
|
+
'Inspector HTTP server failed: error=%s',
|
|
248
|
+
type(error).__name__,
|
|
249
|
+
)
|
|
250
|
+
if self.worker is not None:
|
|
251
|
+
self.worker.stop()
|
|
252
|
+
raise
|
|
253
|
+
|
|
254
|
+
def stop(self) -> None:
|
|
255
|
+
server = self.server
|
|
256
|
+
thread = self.thread
|
|
257
|
+
if server is not None:
|
|
258
|
+
try:
|
|
259
|
+
if thread is not None and thread.is_alive():
|
|
260
|
+
self.logger.info('Stopping Inspector HTTP server')
|
|
261
|
+
server.shutdown()
|
|
262
|
+
thread.join()
|
|
263
|
+
finally:
|
|
264
|
+
server.server_close()
|
|
265
|
+
self.logger.info('Inspector HTTP server stopped')
|
|
266
|
+
|
|
267
|
+
def begin(
|
|
268
|
+
self,
|
|
269
|
+
task: str,
|
|
270
|
+
) -> Tuple[Tuple[Extension, object], ...]:
|
|
271
|
+
return tuple(
|
|
272
|
+
(
|
|
273
|
+
ext,
|
|
274
|
+
ext.begin(task),
|
|
275
|
+
)
|
|
276
|
+
for ext in self.exts.values()
|
|
277
|
+
)
|
|
278
|
+
|
|
279
|
+
def end(
|
|
280
|
+
self,
|
|
281
|
+
task: str,
|
|
282
|
+
contexts: Tuple[Tuple[Extension, object], ...],
|
|
283
|
+
success: Optional[bool],
|
|
284
|
+
) -> None:
|
|
285
|
+
for ext, context in contexts:
|
|
286
|
+
ext.end(
|
|
287
|
+
task,
|
|
288
|
+
context,
|
|
289
|
+
success,
|
|
290
|
+
)
|
|
291
|
+
|
|
292
|
+
def __call__(
|
|
293
|
+
self,
|
|
294
|
+
environ: dict,
|
|
295
|
+
start_response: Callable,
|
|
296
|
+
) -> Iterable[bytes]:
|
|
297
|
+
method = environ['REQUEST_METHOD']
|
|
298
|
+
environ['wsgi.multithread'] = True
|
|
299
|
+
headers = []
|
|
300
|
+
ext = self.exts.get(environ['PATH_INFO'])
|
|
301
|
+
self.logger.debug(
|
|
302
|
+
'Routing Inspector request: method=%s path=%r extension=%s',
|
|
303
|
+
method,
|
|
304
|
+
environ['PATH_INFO'],
|
|
305
|
+
type(ext).__name__ if ext is not None else 'none',
|
|
306
|
+
)
|
|
307
|
+
methods = ext.methods if ext is not None else (
|
|
308
|
+
'GET',
|
|
309
|
+
'HEAD',
|
|
310
|
+
)
|
|
311
|
+
if method not in methods:
|
|
312
|
+
response = Response(
|
|
313
|
+
'405 Method Not Allowed',
|
|
314
|
+
b'Method not allowed\n',
|
|
315
|
+
'text/plain; charset=utf-8',
|
|
316
|
+
)
|
|
317
|
+
headers.append((
|
|
318
|
+
'Allow',
|
|
319
|
+
', '.join(methods),
|
|
320
|
+
))
|
|
321
|
+
else:
|
|
322
|
+
response = ext.handle(
|
|
323
|
+
self,
|
|
324
|
+
environ,
|
|
325
|
+
) if ext is not None else Response(
|
|
326
|
+
'404 Not Found',
|
|
327
|
+
b'Not found\n',
|
|
328
|
+
'text/plain; charset=utf-8',
|
|
329
|
+
)
|
|
330
|
+
headers.extend(response.headers)
|
|
331
|
+
headers.extend((
|
|
332
|
+
(
|
|
333
|
+
'Content-Type',
|
|
334
|
+
response.content_type,
|
|
335
|
+
),
|
|
336
|
+
(
|
|
337
|
+
'Content-Length',
|
|
338
|
+
str(len(response.body)),
|
|
339
|
+
),
|
|
340
|
+
(
|
|
341
|
+
'Cache-Control',
|
|
342
|
+
'no-store',
|
|
343
|
+
),
|
|
344
|
+
))
|
|
345
|
+
self.logger.debug(
|
|
346
|
+
'Inspector response built: status=%s bytes=%d',
|
|
347
|
+
response.status,
|
|
348
|
+
len(response.body),
|
|
349
|
+
)
|
|
350
|
+
start_response(
|
|
351
|
+
response.status,
|
|
352
|
+
headers,
|
|
353
|
+
)
|
|
354
|
+
return [b'' if method == 'HEAD' else response.body]
|