DefStream 0.1.28__123-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 (55) hide show
  1. DefStream/Broker/Configuration.py +17 -0
  2. DefStream/Broker/Connection.py +176 -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/__init__.py +1 -0
  7. DefStream/Broker/Priority.py +26 -0
  8. DefStream/Broker/Protocol.py +54 -0
  9. DefStream/Broker/__init__.py +61 -0
  10. DefStream/Descriptor/DataObject.py +409 -0
  11. DefStream/Descriptor/Types.py +248 -0
  12. DefStream/Descriptor/Value.py +188 -0
  13. DefStream/Descriptor/__init__.py +172 -0
  14. DefStream/Inspector/Extension.py +69 -0
  15. DefStream/Inspector/Extensions/Metrics.py +316 -0
  16. DefStream/Inspector/Extensions/Ping.py +107 -0
  17. DefStream/Inspector/Extensions/Play.html +373 -0
  18. DefStream/Inspector/Extensions/Play.py +415 -0
  19. DefStream/Inspector/Extensions/Specifications.py +176 -0
  20. DefStream/Inspector/Extensions/Status.html +206 -0
  21. DefStream/Inspector/Extensions/Status.py +124 -0
  22. DefStream/Inspector/Extensions/__init__.py +13 -0
  23. DefStream/Inspector/Inspector.py +357 -0
  24. DefStream/Inspector/Response.py +14 -0
  25. DefStream/Inspector/__init__.py +9 -0
  26. DefStream/Validator/Pattern.py +36 -0
  27. DefStream/Validator/Patterns/Array.py +30 -0
  28. DefStream/Validator/Patterns/Common.py +92 -0
  29. DefStream/Validator/Patterns/Compare.py +180 -0
  30. DefStream/Validator/Patterns/Condition.py +60 -0
  31. DefStream/Validator/Patterns/DataObject.py +93 -0
  32. DefStream/Validator/Patterns/DateTime.py +111 -0
  33. DefStream/Validator/Patterns/Number.py +43 -0
  34. DefStream/Validator/Patterns/Object.py +158 -0
  35. DefStream/Validator/Patterns/Size.py +110 -0
  36. DefStream/Validator/Patterns/String.py +92 -0
  37. DefStream/Validator/Patterns/Type.py +355 -0
  38. DefStream/Validator/Patterns/__init__.py +137 -0
  39. DefStream/Validator/Validate.py +29 -0
  40. DefStream/Validator/Validator.py +30 -0
  41. DefStream/Validator/__init__.py +11 -0
  42. DefStream/Worker/Executor.py +525 -0
  43. DefStream/Worker/Message.py +260 -0
  44. DefStream/Worker/Pool.py +282 -0
  45. DefStream/Worker/Run.py +357 -0
  46. DefStream/Worker/Specification.py +87 -0
  47. DefStream/Worker/Startup.py +174 -0
  48. DefStream/Worker/Worker.py +571 -0
  49. DefStream/Worker/__init__.py +47 -0
  50. DefStream/__init__.py +0 -0
  51. defstream-0.1.28.dist-info/METADATA +94 -0
  52. defstream-0.1.28.dist-info/RECORD +55 -0
  53. defstream-0.1.28.dist-info/WHEEL +6 -0
  54. defstream-0.1.28.dist-info/licenses/LICENSE +674 -0
  55. defstream-0.1.28.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 &amp; 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,13 @@
1
+ from .Ping import Ping
2
+ from .Metrics import Metrics
3
+ from .Specifications import Specifications
4
+ from .Play import Play
5
+ from .Status import Status
6
+
7
+ __all__ = (
8
+ 'Ping',
9
+ 'Metrics',
10
+ 'Specifications',
11
+ 'Play',
12
+ 'Status',
13
+ )
@@ -0,0 +1,357 @@
1
+ from logging import getLogger
2
+ from socket import timeout as SocketTimeout
3
+ from socketserver import ThreadingMixIn
4
+ from sys import exc_info
5
+ from threading import Thread
6
+ from time import monotonic
7
+ from traceback import extract_tb
8
+ from typing import (
9
+ Callable,
10
+ Dict,
11
+ Iterable,
12
+ Optional,
13
+ Sequence,
14
+ TYPE_CHECKING,
15
+ Tuple,
16
+ IO,
17
+ cast,
18
+ )
19
+ from wsgiref.simple_server import (
20
+ WSGIRequestHandler,
21
+ ServerHandler,
22
+ WSGIServer,
23
+ make_server,
24
+ )
25
+
26
+ from .Extension import Extension
27
+ from .Response import Response
28
+
29
+ if TYPE_CHECKING:
30
+ from ..Worker.Worker import Worker
31
+
32
+ __all__ = (
33
+ 'Inspector',
34
+ )
35
+
36
+
37
+ class Server(
38
+ ThreadingMixIn,
39
+ WSGIServer,
40
+ ):
41
+ logger = getLogger('Worker.Inspector')
42
+
43
+ daemon_threads = True
44
+ block_on_close = False
45
+
46
+ def get_request(self):
47
+ connection, address = super().get_request()
48
+ connection.settimeout(5)
49
+ return connection, address
50
+
51
+ def handle_error(
52
+ self,
53
+ request,
54
+ client_address,
55
+ ) -> None:
56
+ _, error, traceback = exc_info()
57
+ log = self.logger.warning if isinstance(error, (TimeoutError, SocketTimeout, ConnectionError)) else self.logger.error
58
+ log(
59
+ 'HTTP connection handling failed: error_type=%s\n%s',
60
+ type(error).__name__,
61
+ ''.join(
62
+ f' {frame.filename}:{frame.lineno} ({frame.name})\n'
63
+ for frame in extract_tb(traceback)
64
+ ),
65
+ )
66
+ return
67
+
68
+
69
+ class InspectorServerHandler(ServerHandler):
70
+ request_handler: 'RequestHandler'
71
+
72
+ def log_exception(
73
+ self,
74
+ exc_info,
75
+ ) -> None:
76
+ _, error, traceback = exc_info
77
+ log = self.request_handler.logger.warning if isinstance(error, (TimeoutError, SocketTimeout, ConnectionError)) else self.request_handler.logger.error
78
+ log(
79
+ 'WSGI request handling failed: error_type=%s\n%s',
80
+ type(error).__name__,
81
+ ''.join(
82
+ f' {frame.filename}:{frame.lineno} ({frame.name})\n'
83
+ for frame in extract_tb(traceback)
84
+ ),
85
+ )
86
+ return
87
+
88
+
89
+ class RequestHandler(WSGIRequestHandler):
90
+ logger = getLogger('Worker.Inspector')
91
+
92
+ def handle(self) -> None:
93
+ self.started_at = monotonic()
94
+ self.path = ''
95
+ self.raw_requestline = self.rfile.readline(65537)
96
+ if len(self.raw_requestline) > 65536:
97
+ self.requestline = ''
98
+ self.request_version = ''
99
+ self.command = ''
100
+ self.send_error(414)
101
+ return
102
+ if not self.raw_requestline:
103
+ self.close_connection = True
104
+ return
105
+ if not self.parse_request():
106
+ return
107
+ self.logger.debug(
108
+ 'HTTP request received: method=%s path=%s',
109
+ self.command,
110
+ self.path.partition('?')[0],
111
+ )
112
+ handler = InspectorServerHandler(
113
+ self.rfile,
114
+ cast(IO[bytes], self.wfile),
115
+ self.get_stderr(),
116
+ self.get_environ(),
117
+ multithread=True,
118
+ )
119
+ handler.request_handler = self
120
+ application = cast(WSGIServer, self.server).get_app()
121
+ if application is None:
122
+ raise RuntimeError('Inspector WSGI application is not initialized')
123
+ handler.run(application)
124
+
125
+ def log_request(
126
+ self,
127
+ code='-',
128
+ size='-',
129
+ ) -> None:
130
+ self.logger.info(
131
+ 'HTTP response sent: method=%s path=%s status=%s bytes=%s duration=%s',
132
+ self.command,
133
+ self.path.partition('?')[0],
134
+ code,
135
+ size if size is not None else '-',
136
+ monotonic() - self.started_at,
137
+ )
138
+ return
139
+
140
+ def log_message(
141
+ self,
142
+ format: str,
143
+ *args,
144
+ ) -> None:
145
+ self.logger.warning('HTTP protocol error or request timeout')
146
+ return
147
+
148
+
149
+ class Inspector:
150
+ logger = getLogger('Worker.Inspector')
151
+
152
+ def __init__(
153
+ self,
154
+ host: str = '127.0.0.1',
155
+ port: int = 9999,
156
+ exts: Sequence[Extension] = (),
157
+ ):
158
+ if isinstance(
159
+ port,
160
+ bool,
161
+ ) or not isinstance(
162
+ port,
163
+ int,
164
+ ) or not 0 <= port <= 65535:
165
+ raise ValueError('Inspector port must be between 0 and 65535')
166
+ self.host = host
167
+ self.port = port
168
+ self.exts: Dict[str, Extension] = {}
169
+ for ext in exts:
170
+ if not isinstance(
171
+ ext,
172
+ Extension,
173
+ ):
174
+ raise TypeError('Inspector extensions must inherit Extension')
175
+ if ext.path in self.exts:
176
+ raise ValueError('Duplicate Inspector path: ' + ext.path)
177
+ self.exts[ext.path] = ext
178
+ self.logger.debug(
179
+ 'Inspector extension registered: class=%s path=%s',
180
+ type(ext).__name__,
181
+ ext.path,
182
+ )
183
+ self.worker: Optional['Worker'] = None
184
+ self.server: Optional[WSGIServer] = None
185
+ self.thread: Optional[Thread] = None
186
+
187
+ def start(
188
+ self,
189
+ worker: 'Worker',
190
+ ) -> None:
191
+ if self.worker is not None:
192
+ raise RuntimeError('Inspector can only be attached and started once')
193
+ self.worker = worker
194
+ for ext in self.exts.values():
195
+ self.logger.debug(
196
+ 'Starting Inspector extension: class=%s path=%s',
197
+ type(ext).__name__,
198
+ ext.path,
199
+ )
200
+ try:
201
+ ext.start(self)
202
+ except Exception as error:
203
+ self.logger.error(
204
+ 'Inspector extension startup failed: class=%s path=%s error=%s',
205
+ type(ext).__name__,
206
+ ext.path,
207
+ type(error).__name__,
208
+ )
209
+ raise
210
+ try:
211
+ server = make_server(
212
+ self.host,
213
+ self.port,
214
+ self,
215
+ server_class=Server,
216
+ handler_class=RequestHandler,
217
+ )
218
+ except Exception as error:
219
+ self.logger.error(
220
+ 'Inspector HTTP server binding failed: error=%s',
221
+ type(error).__name__,
222
+ )
223
+ raise
224
+ self.server = server
225
+ thread = Thread(
226
+ target=self.serve,
227
+ daemon=True,
228
+ )
229
+ self.thread = thread
230
+ try:
231
+ thread.start()
232
+ except BaseException:
233
+ self.logger.error('Inspector HTTP server thread startup failed')
234
+ server.server_close()
235
+ raise
236
+ self.logger.info(
237
+ 'Inspector HTTP server started: host=%s port=%s',
238
+ server.server_address[0],
239
+ server.server_port,
240
+ )
241
+
242
+ def serve(self) -> None:
243
+ server = self.server
244
+ if server is None:
245
+ raise RuntimeError('Inspector server is not initialized')
246
+ try:
247
+ server.serve_forever(poll_interval=0.1)
248
+ except Exception as error:
249
+ self.logger.error(
250
+ 'Inspector HTTP server failed: error=%s',
251
+ type(error).__name__,
252
+ )
253
+ if self.worker is not None:
254
+ self.worker.stop()
255
+ raise
256
+
257
+ def stop(self) -> None:
258
+ server = self.server
259
+ thread = self.thread
260
+ if server is not None:
261
+ try:
262
+ if thread is not None and thread.is_alive():
263
+ self.logger.info('Stopping Inspector HTTP server')
264
+ server.shutdown()
265
+ thread.join()
266
+ finally:
267
+ server.server_close()
268
+ self.logger.info('Inspector HTTP server stopped')
269
+
270
+ def begin(
271
+ self,
272
+ task: str,
273
+ ) -> Tuple[Tuple[Extension, object], ...]:
274
+ return tuple(
275
+ (
276
+ ext,
277
+ ext.begin(task),
278
+ )
279
+ for ext in self.exts.values()
280
+ )
281
+
282
+ def end(
283
+ self,
284
+ task: str,
285
+ contexts: Tuple[Tuple[Extension, object], ...],
286
+ success: Optional[bool],
287
+ ) -> None:
288
+ for ext, context in contexts:
289
+ ext.end(
290
+ task,
291
+ context,
292
+ success,
293
+ )
294
+
295
+ def __call__(
296
+ self,
297
+ environ: dict,
298
+ start_response: Callable,
299
+ ) -> Iterable[bytes]:
300
+ method = environ['REQUEST_METHOD']
301
+ environ['wsgi.multithread'] = True
302
+ headers = []
303
+ ext = self.exts.get(environ['PATH_INFO'])
304
+ self.logger.debug(
305
+ 'Routing Inspector request: method=%s path=%r extension=%s',
306
+ method,
307
+ environ['PATH_INFO'],
308
+ type(ext).__name__ if ext is not None else 'none',
309
+ )
310
+ methods = ext.methods if ext is not None else (
311
+ 'GET',
312
+ 'HEAD',
313
+ )
314
+ if method not in methods:
315
+ response = Response(
316
+ '405 Method Not Allowed',
317
+ b'Method not allowed\n',
318
+ 'text/plain; charset=utf-8',
319
+ )
320
+ headers.append((
321
+ 'Allow',
322
+ ', '.join(methods),
323
+ ))
324
+ else:
325
+ response = ext.handle(
326
+ self,
327
+ environ,
328
+ ) if ext is not None else Response(
329
+ '404 Not Found',
330
+ b'Not found\n',
331
+ 'text/plain; charset=utf-8',
332
+ )
333
+ headers.extend(response.headers)
334
+ headers.extend((
335
+ (
336
+ 'Content-Type',
337
+ response.content_type,
338
+ ),
339
+ (
340
+ 'Content-Length',
341
+ str(len(response.body)),
342
+ ),
343
+ (
344
+ 'Cache-Control',
345
+ 'no-store',
346
+ ),
347
+ ))
348
+ self.logger.debug(
349
+ 'Inspector response built: status=%s bytes=%d',
350
+ response.status,
351
+ len(response.body),
352
+ )
353
+ start_response(
354
+ response.status,
355
+ headers,
356
+ )
357
+ return [b'' if method == 'HEAD' else response.body]