codetac 0.1.0 → 0.2.0
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.
- package/package.json +10 -5
- package/readme.md +81 -14
- package/src/ai.mjs +4 -2
- package/src/boundaries.mjs +12 -9
- package/src/browser/bar.js +5 -3
- package/src/cli.mjs +251 -33
- package/src/detect-python.mjs +286 -0
- package/src/detect.mjs +21 -6
- package/src/diagnose.mjs +35 -5
- package/src/digest.mjs +18 -4
- package/src/page.mjs +19 -7
- package/src/panel.mjs +56 -14
- package/src/python/codetac_py/__init__.py +88 -0
- package/src/python/codetac_py/boundaries.py +435 -0
- package/src/python/codetac_py/capture.py +351 -0
- package/src/python/codetac_py/context.py +50 -0
- package/src/python/codetac_py/detail.py +333 -0
- package/src/python/codetac_py/files.py +135 -0
- package/src/python/codetac_py/frameworks.py +72 -0
- package/src/python/codetac_py/hooks.py +72 -0
- package/src/python/codetac_py/jinja_map.py +132 -0
- package/src/python/codetac_py/network.py +704 -0
- package/src/python/codetac_py/page.py +513 -0
- package/src/python/codetac_py/project.py +60 -0
- package/src/python/codetac_py/redact.py +104 -0
- package/src/python/codetac_py/servers.py +514 -0
- package/src/python/codetac_py/sitecustomize.py +62 -0
- package/src/python/codetac_py/writer.py +237 -0
- package/src/python/probe.py +99 -0
- package/src/recording.mjs +22 -4
- package/src/runtime.mjs +13 -1
- package/src/sentences.mjs +4 -0
- package/src/store.mjs +61 -9
|
@@ -0,0 +1,514 @@
|
|
|
1
|
+
"""Requests as the unit, at the server (protocol level, never the framework).
|
|
2
|
+
|
|
3
|
+
A meta path finder patches a server module right after it is imported,
|
|
4
|
+
before the code that imported it can bind its names:
|
|
5
|
+
|
|
6
|
+
- the WSGI or ASGI callable the server runs is wrapped: each HTTP request
|
|
7
|
+
becomes a `request` / `request-end` pair, and everything it causes carries
|
|
8
|
+
its id (see context.py). Lifespan and websocket pass untouched;
|
|
9
|
+
- the processes that only supervise the one that serves are recognised: the
|
|
10
|
+
reloader of werkzeug (run_simple with the reloader, outside the child with
|
|
11
|
+
WERKZEUG_RUN_MAIN=true), the supervisor of uvicorn --reload/--workers and
|
|
12
|
+
fastapi dev (Config.bind_socket), and the gunicorn arbiter;
|
|
13
|
+
- the real port (`listening`): socket.listen, and the sockets uvicorn
|
|
14
|
+
reports after starting (uvloop opens them in C).
|
|
15
|
+
|
|
16
|
+
The events are those of the Node runtime (src/boundaries.mjs, runtime.mjs).
|
|
17
|
+
Keep it importable on old Pythons (3.8+): the minimal mode records requests.
|
|
18
|
+
"""
|
|
19
|
+
import functools
|
|
20
|
+
import itertools
|
|
21
|
+
import json
|
|
22
|
+
import os
|
|
23
|
+
import socket
|
|
24
|
+
import sys
|
|
25
|
+
import time
|
|
26
|
+
from email.utils import parsedate_to_datetime
|
|
27
|
+
from urllib.parse import parse_qsl, quote, unquote, urlsplit
|
|
28
|
+
|
|
29
|
+
from . import page
|
|
30
|
+
from .context import after_response, current, scope
|
|
31
|
+
from .hooks import register
|
|
32
|
+
|
|
33
|
+
INTERNAL_HEADER = page.INTERNAL_HEADER # CodeTAC's own requests (src/page.mjs)
|
|
34
|
+
_requests = itertools.count(1)
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def _codetac():
|
|
38
|
+
import codetac_py
|
|
39
|
+
return codetac_py
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
# Requests --------------------------------------------------------------------------
|
|
43
|
+
|
|
44
|
+
def safe_path(path):
|
|
45
|
+
"""src/boundaries.mjs safePath: long opaque segments (tokens in paths) are hidden."""
|
|
46
|
+
segments = []
|
|
47
|
+
for segment in path.split('/'):
|
|
48
|
+
plain = unquote(segment)
|
|
49
|
+
opaque = (len(plain) >= 20 and all(c.isascii() and (c.isalnum() or c in '_.~-') for c in plain)
|
|
50
|
+
and any(c.isdigit() for c in plain) and any(c.isascii() and c.isalpha() for c in plain))
|
|
51
|
+
segments.append('[REDACTED]' if opaque else segment)
|
|
52
|
+
return '/'.join(segments)
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def split_target(target):
|
|
56
|
+
"""Path without query values, and the query's keys (src/boundaries.mjs splitUrl)."""
|
|
57
|
+
try:
|
|
58
|
+
parts = urlsplit(target)
|
|
59
|
+
except ValueError:
|
|
60
|
+
return '[inválido]', []
|
|
61
|
+
keys = []
|
|
62
|
+
for key, _ in parse_qsl(parts.query, keep_blank_values=True):
|
|
63
|
+
if key not in keys:
|
|
64
|
+
keys.append(key)
|
|
65
|
+
return safe_path(parts.path or '/'), keys
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def cookie_names(values):
|
|
69
|
+
"""Set-Cookie headers -> [{name, cleared}], names only (src/runtime.mjs cookieNames)."""
|
|
70
|
+
found = {}
|
|
71
|
+
for value in values:
|
|
72
|
+
pair, _, rest = value.partition(';')
|
|
73
|
+
name = pair.split('=', 1)[0].strip()
|
|
74
|
+
if not name:
|
|
75
|
+
continue
|
|
76
|
+
max_age = expires = None
|
|
77
|
+
for attribute in rest.split(';'):
|
|
78
|
+
key, _, text = attribute.strip().partition('=')
|
|
79
|
+
if key.lower() == 'max-age' and max_age is None:
|
|
80
|
+
try:
|
|
81
|
+
max_age = int(text.strip())
|
|
82
|
+
except ValueError:
|
|
83
|
+
pass
|
|
84
|
+
elif key.lower() == 'expires' and expires is None and text.strip():
|
|
85
|
+
expires = text.strip()
|
|
86
|
+
cleared = False
|
|
87
|
+
if max_age is not None:
|
|
88
|
+
cleared = max_age <= 0
|
|
89
|
+
elif expires:
|
|
90
|
+
try:
|
|
91
|
+
cleared = parsedate_to_datetime(expires).timestamp() < time.time()
|
|
92
|
+
except (TypeError, ValueError, IndexError):
|
|
93
|
+
cleared = False
|
|
94
|
+
found[name] = {'name': name, 'cleared': cleared}
|
|
95
|
+
return list(found.values()) or None
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def cross_origin(origin, host):
|
|
99
|
+
"""(origin, host) when the Origin header names another origin than the Host, else None."""
|
|
100
|
+
if not origin or not host or origin == 'null':
|
|
101
|
+
return None
|
|
102
|
+
try:
|
|
103
|
+
parts = urlsplit(origin)
|
|
104
|
+
except ValueError:
|
|
105
|
+
return None
|
|
106
|
+
if parts.scheme not in ('http', 'https') or not parts.netloc or parts.netloc == host:
|
|
107
|
+
return None
|
|
108
|
+
return '%s://%s' % (parts.scheme, parts.netloc), host
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
class Unit(object):
|
|
112
|
+
"""One incoming HTTP request."""
|
|
113
|
+
|
|
114
|
+
def __init__(self, method, target, action=None, origin=None, host=None):
|
|
115
|
+
writer = _codetac().writer
|
|
116
|
+
self.writer = writer
|
|
117
|
+
self.id = '%s:r%d' % (writer.process, next(_requests))
|
|
118
|
+
self.json = json.dumps(self.id)
|
|
119
|
+
path, keys = split_target(target)
|
|
120
|
+
node = current.get()[0]
|
|
121
|
+
event = {'type': 'request', 'requestId': self.id, 'parentId': None if node is None else '%s:%d' % (writer.process, node),
|
|
122
|
+
'method': method, 'path': path, 'queryKeys': keys, 'at': int(time.time() * 1000)}
|
|
123
|
+
# A page of another origin calling this server (a frontend on another
|
|
124
|
+
# port without a proxy): the origin and the host (scheme, name and port
|
|
125
|
+
# only) let the store make a probable link to the browser's request.
|
|
126
|
+
cross = cross_origin(origin, host)
|
|
127
|
+
if cross:
|
|
128
|
+
event['origin'], event['host'] = cross
|
|
129
|
+
# The browser action that caused it, when the page script marked it.
|
|
130
|
+
if action:
|
|
131
|
+
event['action'] = action['action']
|
|
132
|
+
if action['actionRequest'] is not None:
|
|
133
|
+
event['actionRequest'] = action['actionRequest']
|
|
134
|
+
writer.emit(event)
|
|
135
|
+
self.start = time.perf_counter_ns()
|
|
136
|
+
self.status = None
|
|
137
|
+
self.cookies = None
|
|
138
|
+
self.done = False
|
|
139
|
+
|
|
140
|
+
def enter(self):
|
|
141
|
+
return current.set(scope(current.get()[0], self.json))
|
|
142
|
+
|
|
143
|
+
def enter_after(self):
|
|
144
|
+
"""What runs from now on happens after the response (afterResponse: true)."""
|
|
145
|
+
return current.set(scope(current.get()[0], after_response(self.json)))
|
|
146
|
+
|
|
147
|
+
def end(self, aborted):
|
|
148
|
+
if self.done:
|
|
149
|
+
return
|
|
150
|
+
self.done = True
|
|
151
|
+
event = {'type': 'request-end', 'requestId': self.id, 'status': self.status, 'aborted': bool(aborted),
|
|
152
|
+
'durationNs': time.perf_counter_ns() - self.start}
|
|
153
|
+
if self.cookies:
|
|
154
|
+
event['cookies'] = self.cookies
|
|
155
|
+
self.writer.emit(event)
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
# WSGI ------------------------------------------------------------------------------
|
|
159
|
+
|
|
160
|
+
def _wsgi_target(environ):
|
|
161
|
+
target = environ.get('REQUEST_URI') or environ.get('RAW_URI')
|
|
162
|
+
if not target:
|
|
163
|
+
path = (environ.get('SCRIPT_NAME', '') + environ.get('PATH_INFO', '')).encode('latin-1', 'replace')
|
|
164
|
+
target = quote(path) + ('?' + environ['QUERY_STRING'] if environ.get('QUERY_STRING') else '')
|
|
165
|
+
return target
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
class _Body(object):
|
|
169
|
+
"""The response body, iterated inside the request. The request ends when the body has
|
|
170
|
+
been given whole, like 'finish' in Node: servers may call close() much later (werkzeug
|
|
171
|
+
first drains the socket, which a browser keeps open after a POST). What close() runs
|
|
172
|
+
(Flask's teardown) still belongs to the request, after its end; a body not given
|
|
173
|
+
whole ends, aborted, on close()."""
|
|
174
|
+
|
|
175
|
+
def __init__(self, result, unit):
|
|
176
|
+
self.result = result
|
|
177
|
+
self.unit = unit
|
|
178
|
+
self.iterator = None
|
|
179
|
+
self.finished = False
|
|
180
|
+
|
|
181
|
+
def __iter__(self):
|
|
182
|
+
return self
|
|
183
|
+
|
|
184
|
+
def __next__(self):
|
|
185
|
+
token = self.unit.enter()
|
|
186
|
+
try:
|
|
187
|
+
if self.iterator is None:
|
|
188
|
+
self.iterator = iter(self.result)
|
|
189
|
+
return next(self.iterator)
|
|
190
|
+
except StopIteration:
|
|
191
|
+
self.finished = True
|
|
192
|
+
self.unit.end(False)
|
|
193
|
+
raise
|
|
194
|
+
finally:
|
|
195
|
+
current.reset(token)
|
|
196
|
+
|
|
197
|
+
def close(self):
|
|
198
|
+
try:
|
|
199
|
+
close = getattr(self.result, 'close', None)
|
|
200
|
+
if close is not None:
|
|
201
|
+
token = self.unit.enter_after() if self.unit.done else self.unit.enter()
|
|
202
|
+
try:
|
|
203
|
+
close()
|
|
204
|
+
finally:
|
|
205
|
+
current.reset(token)
|
|
206
|
+
finally:
|
|
207
|
+
self.unit.end(not self.finished)
|
|
208
|
+
|
|
209
|
+
|
|
210
|
+
def wrap_wsgi(app):
|
|
211
|
+
if app is None or getattr(app, '__codetac_wrapped__', False):
|
|
212
|
+
return app
|
|
213
|
+
|
|
214
|
+
def codetac_wsgi(environ, start_response):
|
|
215
|
+
# CodeTAC's own routes never reach the app.
|
|
216
|
+
if environ.get('PATH_INFO', '').startswith(page.PREFIX):
|
|
217
|
+
return page.wsgi_own_route(environ, start_response)
|
|
218
|
+
if environ.get('HTTP_' + INTERNAL_HEADER.upper().replace('-', '_')):
|
|
219
|
+
return app(environ, start_response)
|
|
220
|
+
document = None
|
|
221
|
+
try:
|
|
222
|
+
method = environ.get('REQUEST_METHOD', 'GET')
|
|
223
|
+
target = _wsgi_target(environ)
|
|
224
|
+
if page.enabled() and page.wants_page(method, environ.get('HTTP_SEC_FETCH_DEST'), environ.get('HTTP_ACCEPT')):
|
|
225
|
+
# Pages are asked for uncompressed, so the tag can go in.
|
|
226
|
+
environ.pop('HTTP_ACCEPT_ENCODING', None)
|
|
227
|
+
document = page.WsgiPage(start_response)
|
|
228
|
+
start_response = document.start
|
|
229
|
+
page.emit_page(split_target(target)[0])
|
|
230
|
+
unit = Unit(method, target, page.action_of(environ.get('HTTP_' + page.ACTION_HEADER.upper().replace('-', '_')),
|
|
231
|
+
environ.get('HTTP_SEC_FETCH_MODE'), environ.get('HTTP_COOKIE')),
|
|
232
|
+
environ.get('HTTP_ORIGIN'), environ.get('HTTP_HOST'))
|
|
233
|
+
except Exception:
|
|
234
|
+
return app(environ, start_response)
|
|
235
|
+
|
|
236
|
+
def start(status, headers, exc_info=None):
|
|
237
|
+
try:
|
|
238
|
+
unit.status = int(str(status).split(' ', 1)[0])
|
|
239
|
+
unit.cookies = cookie_names([value for key, value in headers if key.lower() == 'set-cookie'])
|
|
240
|
+
except Exception:
|
|
241
|
+
pass
|
|
242
|
+
if exc_info is None:
|
|
243
|
+
return start_response(status, headers)
|
|
244
|
+
return start_response(status, headers, exc_info)
|
|
245
|
+
|
|
246
|
+
token = unit.enter()
|
|
247
|
+
try:
|
|
248
|
+
result = app(environ, start)
|
|
249
|
+
except BaseException:
|
|
250
|
+
# The server answers 500 on its own.
|
|
251
|
+
unit.status = unit.status or 500
|
|
252
|
+
unit.end(False)
|
|
253
|
+
raise
|
|
254
|
+
finally:
|
|
255
|
+
current.reset(token)
|
|
256
|
+
return _Body(result if document is None else document.body(result), unit)
|
|
257
|
+
|
|
258
|
+
codetac_wsgi.__codetac_wrapped__ = True
|
|
259
|
+
_copy_names(app, codetac_wsgi)
|
|
260
|
+
return codetac_wsgi
|
|
261
|
+
|
|
262
|
+
|
|
263
|
+
def _copy_names(app, wrapper):
|
|
264
|
+
# Not functools.wraps: the app is often an object (Flask, FastAPI) whose
|
|
265
|
+
# whole __dict__ it would copy.
|
|
266
|
+
for name in ('__name__', '__qualname__', '__doc__'):
|
|
267
|
+
try:
|
|
268
|
+
setattr(wrapper, name, getattr(app, name))
|
|
269
|
+
except (AttributeError, TypeError):
|
|
270
|
+
pass
|
|
271
|
+
|
|
272
|
+
|
|
273
|
+
# ASGI ------------------------------------------------------------------------------
|
|
274
|
+
|
|
275
|
+
def _asgi_target(scope):
|
|
276
|
+
path = scope.get('raw_path') or quote(scope.get('root_path', '') + scope.get('path', '/')).encode()
|
|
277
|
+
if isinstance(path, bytes):
|
|
278
|
+
path = path.decode('latin-1')
|
|
279
|
+
query = scope.get('query_string') or b''
|
|
280
|
+
return path + ('?' + query.decode('latin-1') if query else '')
|
|
281
|
+
|
|
282
|
+
|
|
283
|
+
def wrap_asgi(app):
|
|
284
|
+
if app is None or getattr(app, '__codetac_wrapped__', False):
|
|
285
|
+
return app
|
|
286
|
+
|
|
287
|
+
async def codetac_asgi(scope, receive, send):
|
|
288
|
+
# Lifespan and websocket pass untouched.
|
|
289
|
+
if scope.get('type') != 'http':
|
|
290
|
+
return await app(scope, receive, send)
|
|
291
|
+
# CodeTAC's own routes never reach the app.
|
|
292
|
+
if scope.get('path', '').startswith(page.PREFIX):
|
|
293
|
+
return await page.asgi_own_route(scope, receive, send)
|
|
294
|
+
headers = {}
|
|
295
|
+
for key, value in scope.get('headers') or []:
|
|
296
|
+
headers.setdefault(key.decode('latin-1').lower(), value.decode('latin-1'))
|
|
297
|
+
if INTERNAL_HEADER in headers:
|
|
298
|
+
return await app(scope, receive, send)
|
|
299
|
+
try:
|
|
300
|
+
method = scope.get('method', 'GET')
|
|
301
|
+
target = _asgi_target(scope)
|
|
302
|
+
if page.enabled() and page.wants_page(method, headers.get('sec-fetch-dest'), headers.get('accept')):
|
|
303
|
+
# Pages are asked for uncompressed, so the tag can go in.
|
|
304
|
+
scope = dict(scope, headers=[(key, value) for key, value in scope.get('headers') or []
|
|
305
|
+
if key.lower() != b'accept-encoding'])
|
|
306
|
+
send = page.AsgiPage(send)
|
|
307
|
+
page.emit_page(split_target(target)[0])
|
|
308
|
+
unit = Unit(method, target, page.action_of(headers.get(page.ACTION_HEADER), headers.get('sec-fetch-mode'), headers.get('cookie')),
|
|
309
|
+
headers.get('origin'), headers.get('host'))
|
|
310
|
+
except Exception:
|
|
311
|
+
return await app(scope, receive, send)
|
|
312
|
+
|
|
313
|
+
async def codetac_send(message):
|
|
314
|
+
kind = message.get('type')
|
|
315
|
+
if kind == 'http.response.start':
|
|
316
|
+
try:
|
|
317
|
+
unit.status = message.get('status')
|
|
318
|
+
unit.cookies = cookie_names([value.decode('latin-1') for key, value in message.get('headers') or []
|
|
319
|
+
if key.lower() == b'set-cookie'])
|
|
320
|
+
except Exception:
|
|
321
|
+
pass
|
|
322
|
+
await send(message)
|
|
323
|
+
if kind == 'http.response.body' and not message.get('more_body', False) and not unit.done:
|
|
324
|
+
unit.end(False)
|
|
325
|
+
# Work after the response (BackgroundTasks) runs on in this task.
|
|
326
|
+
unit.enter_after()
|
|
327
|
+
|
|
328
|
+
token = unit.enter()
|
|
329
|
+
try:
|
|
330
|
+
# Work after the response (BackgroundTasks) still belongs to the request.
|
|
331
|
+
await app(scope, receive, codetac_send)
|
|
332
|
+
finally:
|
|
333
|
+
current.reset(token)
|
|
334
|
+
unit.end(True)
|
|
335
|
+
|
|
336
|
+
codetac_asgi.__codetac_wrapped__ = True
|
|
337
|
+
_copy_names(app, codetac_asgi)
|
|
338
|
+
return codetac_asgi
|
|
339
|
+
|
|
340
|
+
|
|
341
|
+
# listening -------------------------------------------------------------------------
|
|
342
|
+
|
|
343
|
+
_announced = set()
|
|
344
|
+
# The server that runs the app, for `codetac diagnostico`: the first one loaded,
|
|
345
|
+
# in this order (gunicorn with the uvicorn worker loads both).
|
|
346
|
+
_SERVERS = ('gunicorn', 'uvicorn', 'hypercorn', 'granian', 'waitress', 'werkzeug', 'wsgiref.simple_server')
|
|
347
|
+
|
|
348
|
+
|
|
349
|
+
def server_name():
|
|
350
|
+
for name in _SERVERS:
|
|
351
|
+
if name in sys.modules:
|
|
352
|
+
return name.split('.')[0]
|
|
353
|
+
return None
|
|
354
|
+
|
|
355
|
+
|
|
356
|
+
def announce(sock):
|
|
357
|
+
"""One `listening` per port and address of this process."""
|
|
358
|
+
try:
|
|
359
|
+
if sock.family in (socket.AF_INET, socket.AF_INET6) and sock.type == socket.SOCK_STREAM:
|
|
360
|
+
address = sock.getsockname()
|
|
361
|
+
key = (os.getpid(), address[0], address[1])
|
|
362
|
+
if key not in _announced:
|
|
363
|
+
_announced.add(key)
|
|
364
|
+
event = {'type': 'listening', 'port': address[1], 'address': address[0]}
|
|
365
|
+
server = server_name()
|
|
366
|
+
if server:
|
|
367
|
+
event['server'] = server
|
|
368
|
+
_codetac().writer.emit(event)
|
|
369
|
+
except Exception:
|
|
370
|
+
pass
|
|
371
|
+
|
|
372
|
+
|
|
373
|
+
def _install_listening():
|
|
374
|
+
original = socket.socket.listen
|
|
375
|
+
if getattr(original, '__codetac_wrapped__', False):
|
|
376
|
+
return
|
|
377
|
+
|
|
378
|
+
@functools.wraps(original)
|
|
379
|
+
def listen(self, *args):
|
|
380
|
+
result = original(self, *args)
|
|
381
|
+
announce(self)
|
|
382
|
+
return result
|
|
383
|
+
|
|
384
|
+
listen.__codetac_wrapped__ = True
|
|
385
|
+
socket.socket.listen = listen
|
|
386
|
+
|
|
387
|
+
|
|
388
|
+
# Servers ---------------------------------------------------------------------------
|
|
389
|
+
|
|
390
|
+
def _mark(server):
|
|
391
|
+
_codetac().mark_supervisor(server)
|
|
392
|
+
|
|
393
|
+
|
|
394
|
+
def _patch_werkzeug_serving(module):
|
|
395
|
+
run_simple = module.run_simple
|
|
396
|
+
|
|
397
|
+
@functools.wraps(run_simple)
|
|
398
|
+
def codetac_run_simple(*args, **kwargs):
|
|
399
|
+
use_reloader = kwargs.get('use_reloader', args[3] if len(args) > 3 else False)
|
|
400
|
+
if use_reloader and os.environ.get('WERKZEUG_RUN_MAIN') != 'true':
|
|
401
|
+
_mark('werkzeug')
|
|
402
|
+
return run_simple(*args, **kwargs)
|
|
403
|
+
|
|
404
|
+
module.run_simple = codetac_run_simple
|
|
405
|
+
# run_simple and make_server both build a BaseWSGIServer.
|
|
406
|
+
init = module.BaseWSGIServer.__init__
|
|
407
|
+
|
|
408
|
+
@functools.wraps(init)
|
|
409
|
+
def codetac_init(self, *args, **kwargs):
|
|
410
|
+
init(self, *args, **kwargs)
|
|
411
|
+
self.app = wrap_wsgi(self.app)
|
|
412
|
+
|
|
413
|
+
module.BaseWSGIServer.__init__ = codetac_init
|
|
414
|
+
|
|
415
|
+
|
|
416
|
+
def _patch_uvicorn_config(module):
|
|
417
|
+
bind_socket = module.Config.bind_socket
|
|
418
|
+
|
|
419
|
+
@functools.wraps(bind_socket)
|
|
420
|
+
def codetac_bind_socket(self, *args, **kwargs):
|
|
421
|
+
_mark('uvicorn')
|
|
422
|
+
return bind_socket(self, *args, **kwargs)
|
|
423
|
+
|
|
424
|
+
module.Config.bind_socket = codetac_bind_socket
|
|
425
|
+
# The app uvicorn serves, whatever the worker: after load(), loaded_app is
|
|
426
|
+
# the app with uvicorn's own middlewares (WSGI apps are already adapted).
|
|
427
|
+
load = module.Config.load
|
|
428
|
+
|
|
429
|
+
@functools.wraps(load)
|
|
430
|
+
def codetac_load(self, *args, **kwargs):
|
|
431
|
+
load(self, *args, **kwargs)
|
|
432
|
+
self.loaded_app = wrap_asgi(self.loaded_app)
|
|
433
|
+
|
|
434
|
+
module.Config.load = codetac_load
|
|
435
|
+
|
|
436
|
+
|
|
437
|
+
def _patch_uvicorn_server(module):
|
|
438
|
+
# With uvloop the port is opened in C, without socket.listen: the server
|
|
439
|
+
# says which sockets it listens on once it has started.
|
|
440
|
+
startup = module.Server.startup
|
|
441
|
+
|
|
442
|
+
@functools.wraps(startup)
|
|
443
|
+
async def codetac_startup(self, *args, **kwargs):
|
|
444
|
+
result = await startup(self, *args, **kwargs)
|
|
445
|
+
for server in getattr(self, 'servers', None) or []:
|
|
446
|
+
for sock in getattr(server, 'sockets', None) or []:
|
|
447
|
+
announce(sock)
|
|
448
|
+
return result
|
|
449
|
+
|
|
450
|
+
module.Server.startup = codetac_startup
|
|
451
|
+
|
|
452
|
+
|
|
453
|
+
def _patch_gunicorn_arbiter(module):
|
|
454
|
+
run = module.Arbiter.run
|
|
455
|
+
|
|
456
|
+
@functools.wraps(run)
|
|
457
|
+
def codetac_run(self, *args, **kwargs):
|
|
458
|
+
_mark('gunicorn')
|
|
459
|
+
return run(self, *args, **kwargs)
|
|
460
|
+
|
|
461
|
+
module.Arbiter.run = codetac_run
|
|
462
|
+
|
|
463
|
+
|
|
464
|
+
def _patch_gunicorn_worker(module):
|
|
465
|
+
load_wsgi = module.Worker.load_wsgi
|
|
466
|
+
|
|
467
|
+
@functools.wraps(load_wsgi)
|
|
468
|
+
def codetac_load_wsgi(self, *args, **kwargs):
|
|
469
|
+
load_wsgi(self, *args, **kwargs)
|
|
470
|
+
# gunicorn's own workers run WSGI; the ASGI ones (uvicorn-worker) go
|
|
471
|
+
# through uvicorn's Config.load.
|
|
472
|
+
if type(self).__module__.startswith('gunicorn.workers.'):
|
|
473
|
+
self.wsgi = wrap_wsgi(self.wsgi)
|
|
474
|
+
|
|
475
|
+
module.Worker.load_wsgi = codetac_load_wsgi
|
|
476
|
+
|
|
477
|
+
|
|
478
|
+
def _patch_waitress_server(module):
|
|
479
|
+
create_server = module.create_server
|
|
480
|
+
|
|
481
|
+
@functools.wraps(create_server)
|
|
482
|
+
def codetac_create_server(application, *args, **kwargs):
|
|
483
|
+
return create_server(wrap_wsgi(application), *args, **kwargs)
|
|
484
|
+
|
|
485
|
+
module.create_server = codetac_create_server
|
|
486
|
+
|
|
487
|
+
|
|
488
|
+
def _patch_wsgiref(module):
|
|
489
|
+
set_app = module.WSGIServer.set_app
|
|
490
|
+
|
|
491
|
+
@functools.wraps(set_app)
|
|
492
|
+
def codetac_set_app(self, application):
|
|
493
|
+
return set_app(self, wrap_wsgi(application))
|
|
494
|
+
|
|
495
|
+
module.WSGIServer.set_app = codetac_set_app
|
|
496
|
+
|
|
497
|
+
|
|
498
|
+
SERVER_HOOKS = {
|
|
499
|
+
'werkzeug.serving': _patch_werkzeug_serving,
|
|
500
|
+
'uvicorn.config': _patch_uvicorn_config,
|
|
501
|
+
'uvicorn.server': _patch_uvicorn_server,
|
|
502
|
+
'gunicorn.arbiter': _patch_gunicorn_arbiter,
|
|
503
|
+
'gunicorn.workers.base': _patch_gunicorn_worker,
|
|
504
|
+
'waitress.server': _patch_waitress_server,
|
|
505
|
+
'wsgiref.simple_server': _patch_wsgiref,
|
|
506
|
+
}
|
|
507
|
+
|
|
508
|
+
|
|
509
|
+
|
|
510
|
+
|
|
511
|
+
def install_hooks():
|
|
512
|
+
_install_listening()
|
|
513
|
+
for name, hook in SERVER_HOOKS.items():
|
|
514
|
+
register(name, hook)
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
"""Entry point. Python imports it at startup because the codetac command puts
|
|
2
|
+
this folder first in PYTHONPATH.
|
|
3
|
+
|
|
4
|
+
1. This folder leaves sys.path at once, so the other files here (capture.py,
|
|
5
|
+
page.py...) never shadow the app's own modules of the same name.
|
|
6
|
+
2. Without CODETAC_RUN (for example any subprocess that inherits PYTHONPATH)
|
|
7
|
+
the capture does nothing.
|
|
8
|
+
3. The sitecustomize this file shadows is looked up further along sys.path and
|
|
9
|
+
run, so environments that rely on it keep working. usercustomize is not
|
|
10
|
+
shadowed (there is none here): site.py imports it as usual.
|
|
11
|
+
|
|
12
|
+
Keep it importable on old Pythons (3.8+).
|
|
13
|
+
"""
|
|
14
|
+
import importlib.util
|
|
15
|
+
import os
|
|
16
|
+
import sys
|
|
17
|
+
from importlib.machinery import PathFinder
|
|
18
|
+
|
|
19
|
+
HERE = os.path.realpath(os.path.dirname(os.path.abspath(__file__)))
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def _is_here(entry):
|
|
23
|
+
try:
|
|
24
|
+
return os.path.realpath(entry or os.curdir) == HERE
|
|
25
|
+
except (TypeError, ValueError):
|
|
26
|
+
return False
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def _leave_sys_path():
|
|
30
|
+
sys.path[:] = [entry for entry in sys.path if not _is_here(entry)]
|
|
31
|
+
for entry in list(sys.path_importer_cache):
|
|
32
|
+
if _is_here(entry):
|
|
33
|
+
del sys.path_importer_cache[entry]
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def _start_capture():
|
|
37
|
+
spec = importlib.util.spec_from_file_location(
|
|
38
|
+
'codetac_py', os.path.join(HERE, '__init__.py'), submodule_search_locations=[HERE])
|
|
39
|
+
package = importlib.util.module_from_spec(spec)
|
|
40
|
+
sys.modules['codetac_py'] = package
|
|
41
|
+
try:
|
|
42
|
+
spec.loader.exec_module(package)
|
|
43
|
+
package.start()
|
|
44
|
+
except Exception as error: # the app must start even if the capture cannot
|
|
45
|
+
sys.modules.pop('codetac_py', None)
|
|
46
|
+
sys.stderr.write('codeTAC: captura desligada (%s: %s)\n' % (type(error).__name__, error))
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def _chain_original():
|
|
50
|
+
spec = PathFinder.find_spec('sitecustomize', sys.path)
|
|
51
|
+
if spec is None or spec.loader is None:
|
|
52
|
+
return
|
|
53
|
+
original = importlib.util.module_from_spec(spec)
|
|
54
|
+
sys.modules['sitecustomize'] = original
|
|
55
|
+
# Errors propagate to site.py, which reports them as it would have.
|
|
56
|
+
spec.loader.exec_module(original)
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
_leave_sys_path()
|
|
60
|
+
if os.environ.get('CODETAC_RUN'):
|
|
61
|
+
_start_capture()
|
|
62
|
+
_chain_original()
|