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.
@@ -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()