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,333 @@
|
|
|
1
|
+
"""Detail on request (Phase 4 of the Node version, spec 3.8).
|
|
2
|
+
|
|
3
|
+
The panel writes .codetac/<recording>/detalhe.json: functions (file, line,
|
|
4
|
+
name) and whole files. For those functions only, and from their next call:
|
|
5
|
+
|
|
6
|
+
- the arguments, read from the frame when the call starts;
|
|
7
|
+
- the value returned, or the type of the exception;
|
|
8
|
+
- the lines that ran: LINE events are switched on for those code objects
|
|
9
|
+
only (sys.monitoring.set_local_events), so the rest of the app pays
|
|
10
|
+
nothing.
|
|
11
|
+
|
|
12
|
+
The `detail` event has the format of the Node runtime (src/runtime.mjs), so
|
|
13
|
+
the panel does not change. The file is read every 400 ms by the writer's
|
|
14
|
+
thread (no thread of its own: see writer.py on daemon threads at exit).
|
|
15
|
+
|
|
16
|
+
preview() never runs code of the app: no __repr__, __str__, properties or
|
|
17
|
+
__getattr__ of any type outside the standard library. Types are tested with
|
|
18
|
+
issubclass on the real type (isinstance may read an overridden __class__),
|
|
19
|
+
built-in containers are read through the methods of their base type, and
|
|
20
|
+
objects of the app through the C descriptor of their __dict__ only.
|
|
21
|
+
"""
|
|
22
|
+
import datetime
|
|
23
|
+
import decimal
|
|
24
|
+
import enum
|
|
25
|
+
import json
|
|
26
|
+
import math
|
|
27
|
+
import os
|
|
28
|
+
import sys
|
|
29
|
+
import types
|
|
30
|
+
import uuid
|
|
31
|
+
from pathlib import PurePath
|
|
32
|
+
|
|
33
|
+
from .redact import MARKER, is_sensitive_name
|
|
34
|
+
|
|
35
|
+
MONITORING = sys.monitoring
|
|
36
|
+
LINE = MONITORING.events.LINE
|
|
37
|
+
CO_VARARGS = 0x0004
|
|
38
|
+
CO_VARKEYWORDS = 0x0008
|
|
39
|
+
LIMITS = {'depth': 4, 'items': 20, 'keys': 30, 'nodes': 300, 'text': 2000}
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
# preview ----------------------------------------------------------------------------
|
|
43
|
+
|
|
44
|
+
_TYPE_DICT = type.__dict__['__dict__'] # a class's own namespace, without its metaclass
|
|
45
|
+
_TYPE_QUALNAME = type.__dict__['__qualname__']
|
|
46
|
+
_TYPE_MODULE = type.__dict__['__module__']
|
|
47
|
+
_TYPE_MRO = type.__dict__['__mro__']
|
|
48
|
+
_GETSET = types.GetSetDescriptorType
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def _class_name(cls):
|
|
52
|
+
try:
|
|
53
|
+
return _TYPE_QUALNAME.__get__(cls)
|
|
54
|
+
except Exception:
|
|
55
|
+
return '?'
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def _class_module(cls):
|
|
59
|
+
try:
|
|
60
|
+
module = _TYPE_MODULE.__get__(cls)
|
|
61
|
+
return module if isinstance(module, str) else ''
|
|
62
|
+
except Exception:
|
|
63
|
+
return ''
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def _instance_dict(item):
|
|
67
|
+
"""The instance's __dict__, through the C descriptor of the class that has one; None when
|
|
68
|
+
the class defines __dict__ itself (a property: app code) or has none (slots, C types)."""
|
|
69
|
+
for cls in _TYPE_MRO.__get__(type(item)):
|
|
70
|
+
found = _TYPE_DICT.__get__(cls).get('__dict__')
|
|
71
|
+
if found is None:
|
|
72
|
+
continue
|
|
73
|
+
if type(found) is _GETSET:
|
|
74
|
+
try:
|
|
75
|
+
value = found.__get__(item)
|
|
76
|
+
except Exception:
|
|
77
|
+
return None
|
|
78
|
+
return value if type(value) is dict else None
|
|
79
|
+
return None
|
|
80
|
+
return None
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def _query_keys(query):
|
|
84
|
+
keys = []
|
|
85
|
+
for part in query.split('&'):
|
|
86
|
+
key = part.split('=', 1)[0]
|
|
87
|
+
if key and key not in keys:
|
|
88
|
+
keys.append(key)
|
|
89
|
+
return keys
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def _path(path, query):
|
|
93
|
+
keys = _query_keys(query) if query else []
|
|
94
|
+
return path + ('?' + '&'.join('%s=…' % key for key in keys) if keys else '')
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def _http_summary(item, cls, fields):
|
|
98
|
+
"""Requests and responses of Starlette, werkzeug and Flask, from their own fields:
|
|
99
|
+
method, path without query values, header names; never header values or bodies."""
|
|
100
|
+
module = _class_module(cls)
|
|
101
|
+
if not module.startswith(('starlette.', 'fastapi.', 'werkzeug.', 'flask.')):
|
|
102
|
+
return None
|
|
103
|
+
name = _class_name(cls)
|
|
104
|
+
scope = fields.get('scope')
|
|
105
|
+
if type(scope) is dict and scope.get('type') in ('http', 'websocket'):
|
|
106
|
+
query = scope.get('query_string') or b''
|
|
107
|
+
headers = [key.decode('latin-1') for key, _ in list(scope.get('headers') or [])[:40] if type(key) is bytes]
|
|
108
|
+
return {'$classe': name, 'metodo': scope.get('method'), 'caminho': _path(str(scope.get('path', '')), query.decode('latin-1')),
|
|
109
|
+
'cabecalhos': headers}
|
|
110
|
+
environ = fields.get('environ')
|
|
111
|
+
if type(environ) is dict and 'REQUEST_METHOD' in environ:
|
|
112
|
+
headers = [key[5:].replace('_', '-').lower() for key in environ if type(key) is str and key.startswith('HTTP_')][:40]
|
|
113
|
+
return {'$classe': name, 'metodo': environ.get('REQUEST_METHOD'), 'caminho': _path(str(environ.get('PATH_INFO', '')),
|
|
114
|
+
str(environ.get('QUERY_STRING', ''))),
|
|
115
|
+
'cabecalhos': headers}
|
|
116
|
+
if 'status_code' in fields and ('raw_headers' in fields or 'body' in fields):
|
|
117
|
+
raw = fields.get('raw_headers') or []
|
|
118
|
+
return {'$classe': name, 'estado': fields.get('status_code') if type(fields.get('status_code')) is int else None,
|
|
119
|
+
'cabecalhos': [key.decode('latin-1') for key, _ in list(raw)[:40] if type(key) is bytes]}
|
|
120
|
+
if '_status_code' in fields or '_status' in fields:
|
|
121
|
+
code = fields.get('_status_code')
|
|
122
|
+
return {'$classe': name, 'estado': code if type(code) is int else None}
|
|
123
|
+
return {'$classe': name, '$resumido': True}
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def preview(value):
|
|
127
|
+
"""A readable copy for the detail view: plain JSON, bounded in depth, length and size.
|
|
128
|
+
Redaction happens when the event is emitted."""
|
|
129
|
+
nodes = [0]
|
|
130
|
+
seen = set()
|
|
131
|
+
|
|
132
|
+
def text(item):
|
|
133
|
+
return item if len(item) <= LIMITS['text'] else '%s[TRUNCATED %d]' % (item[:LIMITS['text']], len(item))
|
|
134
|
+
|
|
135
|
+
def walk(item, depth):
|
|
136
|
+
nodes[0] += 1
|
|
137
|
+
if nodes[0] > LIMITS['nodes']:
|
|
138
|
+
return '[…]'
|
|
139
|
+
cls = type(item)
|
|
140
|
+
if item is None or cls is bool:
|
|
141
|
+
return item
|
|
142
|
+
if cls is int:
|
|
143
|
+
return item if -2 ** 53 < item < 2 ** 53 else int.__repr__(item)
|
|
144
|
+
if cls is float:
|
|
145
|
+
return item if math.isfinite(item) else float.__repr__(item)
|
|
146
|
+
if cls is str:
|
|
147
|
+
return text(item)
|
|
148
|
+
if issubclass(cls, (bytes, bytearray, memoryview)):
|
|
149
|
+
return {'$tipo': 'bytes', 'bytes': len(item) if cls in (bytes, bytearray) else memoryview.__len__(item)}
|
|
150
|
+
if cls in (types.FunctionType, types.BuiltinFunctionType, types.MethodType):
|
|
151
|
+
return {'$tipo': 'função', 'nome': getattr(item, '__qualname__', None) if cls is not types.MethodType else '(método)'}
|
|
152
|
+
if issubclass(cls, type):
|
|
153
|
+
return {'$tipo': 'classe', 'nome': _class_name(item)}
|
|
154
|
+
if cls is types.ModuleType:
|
|
155
|
+
return {'$tipo': 'módulo'}
|
|
156
|
+
if cls in (types.GeneratorType, types.CoroutineType, types.AsyncGeneratorType):
|
|
157
|
+
return {'$tipo': {types.GeneratorType: 'gerador', types.CoroutineType: 'corrotina'}.get(cls, 'gerador assíncrono')}
|
|
158
|
+
if issubclass(cls, BaseException):
|
|
159
|
+
# The type only: the message comes from __str__, which may be the app's.
|
|
160
|
+
return {'$tipo': 'erro', 'nome': _class_name(cls)}
|
|
161
|
+
if cls in (datetime.datetime, datetime.date, datetime.time):
|
|
162
|
+
return {'$tipo': 'data', 'valor': cls.isoformat(item)}
|
|
163
|
+
if cls is decimal.Decimal:
|
|
164
|
+
return {'$tipo': 'decimal', 'valor': decimal.Decimal.__str__(item)}
|
|
165
|
+
if cls is uuid.UUID:
|
|
166
|
+
return {'$tipo': 'uuid', 'valor': uuid.UUID.__str__(item)}
|
|
167
|
+
if issubclass(cls, PurePath) and _class_module(cls).startswith('pathlib'):
|
|
168
|
+
return {'$tipo': 'caminho', 'valor': PurePath.__str__(item)}
|
|
169
|
+
key = id(item)
|
|
170
|
+
if key in seen:
|
|
171
|
+
return {'$tipo': 'circular'}
|
|
172
|
+
name = _class_name(cls)
|
|
173
|
+
if depth >= LIMITS['depth']:
|
|
174
|
+
return {'$tipo': name, '$resumido': True}
|
|
175
|
+
seen.add(key)
|
|
176
|
+
try:
|
|
177
|
+
if issubclass(cls, enum.Enum):
|
|
178
|
+
fields = _instance_dict(item) or {}
|
|
179
|
+
return {'$classe': name, 'nome': fields.get('_name_') if type(fields.get('_name_')) is str else None}
|
|
180
|
+
if issubclass(cls, dict):
|
|
181
|
+
out = {}
|
|
182
|
+
entries = list(dict.items(item))
|
|
183
|
+
for entry_key, entry in entries[:LIMITS['keys']]:
|
|
184
|
+
label = entry_key if type(entry_key) is str else walk(entry_key, depth + 1) if type(entry_key) in (int, float, bool) or entry_key is None else '[%s]' % _class_name(type(entry_key))
|
|
185
|
+
out[str(label)] = walk(entry, depth + 1)
|
|
186
|
+
if len(entries) > LIMITS['keys']:
|
|
187
|
+
out['$mais'] = len(entries) - LIMITS['keys']
|
|
188
|
+
if cls is not dict:
|
|
189
|
+
out = {'$classe': name, **out}
|
|
190
|
+
return out
|
|
191
|
+
if issubclass(cls, (list, tuple)):
|
|
192
|
+
base = list if issubclass(cls, list) else tuple
|
|
193
|
+
size = base.__len__(item)
|
|
194
|
+
out = [walk(base.__getitem__(item, index), depth + 1) for index in range(min(size, LIMITS['items']))]
|
|
195
|
+
if size > LIMITS['items']:
|
|
196
|
+
out.append({'$mais': size - LIMITS['items']})
|
|
197
|
+
return out
|
|
198
|
+
if issubclass(cls, (set, frozenset)):
|
|
199
|
+
base = set if issubclass(cls, set) else frozenset
|
|
200
|
+
values = []
|
|
201
|
+
for entry in base.__iter__(item):
|
|
202
|
+
if len(values) >= LIMITS['items']:
|
|
203
|
+
break
|
|
204
|
+
values.append(walk(entry, depth + 1))
|
|
205
|
+
return {'$tipo': 'conjunto', 'tamanho': base.__len__(item), 'valores': values}
|
|
206
|
+
fields = _instance_dict(item)
|
|
207
|
+
if fields is None:
|
|
208
|
+
# Slots, C types (connections, sockets, locks…): the class only.
|
|
209
|
+
return {'$classe': name, '$resumido': True}
|
|
210
|
+
summary = _http_summary(item, cls, fields)
|
|
211
|
+
if summary is not None:
|
|
212
|
+
return summary
|
|
213
|
+
out = {'$classe': name}
|
|
214
|
+
# Fields starting with "_" are internals (frameworks, ORMs), not the app's data.
|
|
215
|
+
keys = [field for field in fields if type(field) is str and not field.startswith('_')]
|
|
216
|
+
for field in keys[:LIMITS['keys']]:
|
|
217
|
+
out[field] = walk(fields[field], depth + 1)
|
|
218
|
+
if len(keys) > LIMITS['keys']:
|
|
219
|
+
out['$mais'] = len(keys) - LIMITS['keys']
|
|
220
|
+
return out
|
|
221
|
+
except Exception:
|
|
222
|
+
return {'$tipo': 'ilegível'}
|
|
223
|
+
finally:
|
|
224
|
+
seen.discard(key)
|
|
225
|
+
|
|
226
|
+
return walk(value, 0)
|
|
227
|
+
|
|
228
|
+
|
|
229
|
+
# Detail on request ------------------------------------------------------------------
|
|
230
|
+
|
|
231
|
+
def parameter_names(code):
|
|
232
|
+
"""In the order of the signature: positional, *args, keyword-only, **kwargs
|
|
233
|
+
(co_varnames keeps *args after the keyword-only ones)."""
|
|
234
|
+
names = code.co_varnames
|
|
235
|
+
positional, keyword = code.co_argcount, code.co_kwonlyargcount
|
|
236
|
+
varargs = (names[positional + keyword],) if code.co_flags & CO_VARARGS else ()
|
|
237
|
+
extra = positional + keyword + len(varargs)
|
|
238
|
+
varkw = (names[extra],) if code.co_flags & CO_VARKEYWORDS else ()
|
|
239
|
+
return names[:positional] + varargs + names[positional:positional + keyword] + varkw
|
|
240
|
+
|
|
241
|
+
|
|
242
|
+
def template_line(table, line):
|
|
243
|
+
"""The template line of a generated line (Template.get_corresponding_lineno)."""
|
|
244
|
+
for template, generated in reversed(table):
|
|
245
|
+
if generated <= line:
|
|
246
|
+
return template
|
|
247
|
+
return 1
|
|
248
|
+
|
|
249
|
+
|
|
250
|
+
class Detail(object):
|
|
251
|
+
def __init__(self, capture, writer, directory):
|
|
252
|
+
self.capture = capture
|
|
253
|
+
self.writer = writer
|
|
254
|
+
self.file = os.path.join(directory, 'detalhe.json')
|
|
255
|
+
self.stamp = None
|
|
256
|
+
self.functions = set() # "file:line:name"
|
|
257
|
+
self.files = set()
|
|
258
|
+
self.codes = {} # code object -> parameter names, for the functions with detail
|
|
259
|
+
self.calls = {} # id(frame) -> [args, lines, node, request literal, code]
|
|
260
|
+
|
|
261
|
+
# detalhe.json -----------------------------------------------------------------
|
|
262
|
+
def poll(self):
|
|
263
|
+
try:
|
|
264
|
+
stat = os.stat(self.file)
|
|
265
|
+
stamp = (stat.st_mtime_ns, stat.st_size)
|
|
266
|
+
except OSError:
|
|
267
|
+
stamp = None
|
|
268
|
+
if stamp == self.stamp:
|
|
269
|
+
return
|
|
270
|
+
self.stamp = stamp
|
|
271
|
+
spec = {}
|
|
272
|
+
if stamp is not None:
|
|
273
|
+
try:
|
|
274
|
+
with open(self.file, 'rb') as file:
|
|
275
|
+
spec = json.loads(file.read().decode('utf-8'))
|
|
276
|
+
except (OSError, ValueError):
|
|
277
|
+
spec = {}
|
|
278
|
+
functions = spec.get('functions') if isinstance(spec, dict) else None
|
|
279
|
+
files = spec.get('files') if isinstance(spec, dict) else None
|
|
280
|
+
self.functions = set('%s:%s:%s' % (item.get('file'), item.get('line'), item.get('function') or '')
|
|
281
|
+
for item in functions or [] if isinstance(item, dict))
|
|
282
|
+
self.files = set(item for item in files or [] if isinstance(item, str))
|
|
283
|
+
for code, place in list(self.capture.places_of.items()):
|
|
284
|
+
self.consider(code, place)
|
|
285
|
+
|
|
286
|
+
def wanted(self, place):
|
|
287
|
+
file, line, name = place
|
|
288
|
+
return file in self.files or '%s:%s:%s' % (file, line, name) in self.functions or '%s:%s:' % (file, line) in self.functions
|
|
289
|
+
|
|
290
|
+
def consider(self, code, place):
|
|
291
|
+
"""Switches detail on or off for one code object of the project."""
|
|
292
|
+
wanted = self.wanted(place)
|
|
293
|
+
tool = self.capture.tool
|
|
294
|
+
if wanted and code not in self.codes:
|
|
295
|
+
self.codes[code] = parameter_names(code)
|
|
296
|
+
MONITORING.set_local_events(tool, code, LINE)
|
|
297
|
+
elif not wanted and code in self.codes:
|
|
298
|
+
del self.codes[code]
|
|
299
|
+
MONITORING.set_local_events(tool, code, 0)
|
|
300
|
+
|
|
301
|
+
# Calls ------------------------------------------------------------------------
|
|
302
|
+
def start(self, code, frame, node, request):
|
|
303
|
+
try:
|
|
304
|
+
names = self.codes.get(code)
|
|
305
|
+
if names is None:
|
|
306
|
+
return
|
|
307
|
+
local = frame.f_locals
|
|
308
|
+
args = [{'name': name, 'value': MARKER if is_sensitive_name(name) else preview(local.get(name))} for name in names]
|
|
309
|
+
except Exception:
|
|
310
|
+
args = []
|
|
311
|
+
self.calls[id(frame)] = [args, set(), node, request, code]
|
|
312
|
+
|
|
313
|
+
def line(self, frame, code, line):
|
|
314
|
+
call = self.calls.get(id(frame))
|
|
315
|
+
if call is not None and call[4] is code:
|
|
316
|
+
table = self.capture.template_tables.get(code)
|
|
317
|
+
call[1].add(template_line(table, line) if table is not None else line)
|
|
318
|
+
|
|
319
|
+
def finish(self, frame, code, returned=None, exception=None):
|
|
320
|
+
call = self.calls.pop(id(frame), None)
|
|
321
|
+
if call is None or call[4] is not code:
|
|
322
|
+
return
|
|
323
|
+
args, lines, node, request, _ = call
|
|
324
|
+
from .context import request_of
|
|
325
|
+
request_id, _ = request_of(request)
|
|
326
|
+
event = {'type': 'detail', 'id': '%s:%d' % (self.writer.process, node), 'requestId': request_id,
|
|
327
|
+
'args': args, 'lines': sorted(lines)}
|
|
328
|
+
if exception is not None and type(exception) is not GeneratorExit:
|
|
329
|
+
event['threw'] = preview(exception)
|
|
330
|
+
else:
|
|
331
|
+
name = self.capture.places_of.get(code, (None, None, ''))[2]
|
|
332
|
+
event['returned'] = MARKER if is_sensitive_name(name.rsplit('.', 1)[-1]) else preview(returned)
|
|
333
|
+
self.writer.emit(event)
|
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
"""File boundaries (stage 7), as installFiles in src/boundaries.mjs: only
|
|
2
|
+
operations made directly by the project's code during a request.
|
|
3
|
+
|
|
4
|
+
boundary {kind: 'ficheiros', provider: 'sistema de ficheiros', library: 'builtins' | 'os',
|
|
5
|
+
function, operation, path}
|
|
6
|
+
|
|
7
|
+
- open() in a mode that writes (w, a, x, +): 'escrita'. pathlib's
|
|
8
|
+
write_text, write_bytes and open go through io.open;
|
|
9
|
+
- os.remove/unlink/rmdir ('remoção'), os.rename/replace ('mudança de
|
|
10
|
+
nome'), os.mkdir ('criação de pasta'; os.makedirs and Path.mkdir use it).
|
|
11
|
+
|
|
12
|
+
The first caller frame outside the captor and the standard library decides:
|
|
13
|
+
a library (site-packages, a virtual environment) is not the project. Reads
|
|
14
|
+
are not recorded (M66). The captor's own files and CodeTAC's data folder are
|
|
15
|
+
never boundaries; the captor writes with os.open/os.write, which are not
|
|
16
|
+
observed.
|
|
17
|
+
|
|
18
|
+
Keep it importable on old Pythons (3.8+): the minimal mode records boundaries.
|
|
19
|
+
"""
|
|
20
|
+
import builtins
|
|
21
|
+
import functools
|
|
22
|
+
import io
|
|
23
|
+
import os
|
|
24
|
+
import sys
|
|
25
|
+
import sysconfig
|
|
26
|
+
|
|
27
|
+
from .boundaries import _inside, start_boundary
|
|
28
|
+
from .context import NULL, current
|
|
29
|
+
from .project import CAPTOR, ProjectFiles
|
|
30
|
+
from .servers import safe_path
|
|
31
|
+
|
|
32
|
+
WRITES = set('wax+')
|
|
33
|
+
OPERATIONS = {'remove': 'remoção', 'unlink': 'remoção', 'rmdir': 'remoção', 'rename': 'mudança de nome',
|
|
34
|
+
'replace': 'mudança de nome', 'mkdir': 'criação de pasta'}
|
|
35
|
+
DEPTH = 12
|
|
36
|
+
# The captor's code, by its real path and by the path it was imported from.
|
|
37
|
+
CODE = tuple(set(folder + os.sep for folder in (CAPTOR, os.path.dirname(os.path.abspath(__file__)))))
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def _standard_library():
|
|
41
|
+
paths = sysconfig.get_paths()
|
|
42
|
+
folders = set()
|
|
43
|
+
for key in ('stdlib', 'platstdlib'):
|
|
44
|
+
if paths.get(key):
|
|
45
|
+
folders.add(os.path.realpath(paths[key]) + os.sep)
|
|
46
|
+
folders.add(os.path.abspath(paths[key]) + os.sep)
|
|
47
|
+
return tuple(folders)
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
class Files(object):
|
|
51
|
+
def __init__(self, root, data):
|
|
52
|
+
self.root = os.path.realpath(root)
|
|
53
|
+
self.project_file = ProjectFiles(self.root)
|
|
54
|
+
self.own = tuple(os.path.realpath(folder) + os.sep for folder in (CAPTOR, data))
|
|
55
|
+
self.stdlib = _standard_library()
|
|
56
|
+
|
|
57
|
+
def caller_is_project(self):
|
|
58
|
+
frame = sys._getframe(3)
|
|
59
|
+
for _ in range(DEPTH):
|
|
60
|
+
if frame is None:
|
|
61
|
+
return False
|
|
62
|
+
filename = frame.f_code.co_filename
|
|
63
|
+
if 'site-packages' in filename or 'dist-packages' in filename:
|
|
64
|
+
return False
|
|
65
|
+
if filename.startswith(CODE) or filename.startswith('<frozen') or filename.startswith(self.stdlib):
|
|
66
|
+
frame = frame.f_back
|
|
67
|
+
continue
|
|
68
|
+
return self.project_file(filename) is not None
|
|
69
|
+
return False
|
|
70
|
+
|
|
71
|
+
def describe(self, target):
|
|
72
|
+
if isinstance(target, int):
|
|
73
|
+
return '(descritor)'
|
|
74
|
+
path = os.fsdecode(os.fspath(target))
|
|
75
|
+
real = os.path.realpath(path)
|
|
76
|
+
if (real + os.sep).startswith(self.own):
|
|
77
|
+
return None
|
|
78
|
+
within = os.path.relpath(real, self.root)
|
|
79
|
+
if within != os.curdir and not within.startswith(os.pardir) and not os.path.isabs(within):
|
|
80
|
+
path = within
|
|
81
|
+
return safe_path(path)
|
|
82
|
+
|
|
83
|
+
def start(self, function, operation, target):
|
|
84
|
+
"""end(extra), or None when this operation is not a boundary."""
|
|
85
|
+
if current.get()[1] == NULL or _inside.get() or not self.caller_is_project():
|
|
86
|
+
return None
|
|
87
|
+
try:
|
|
88
|
+
path = self.describe(target)
|
|
89
|
+
except (TypeError, ValueError, OSError):
|
|
90
|
+
return None
|
|
91
|
+
if path is None:
|
|
92
|
+
return None
|
|
93
|
+
return start_boundary({'kind': 'ficheiros', 'provider': 'sistema de ficheiros',
|
|
94
|
+
'library': 'builtins' if function == 'open' else 'os',
|
|
95
|
+
'function': function, 'operation': operation, 'path': path})
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def _observe(files, function, operation, original):
|
|
99
|
+
@functools.wraps(original)
|
|
100
|
+
def wrapper(target, *args, **kwargs):
|
|
101
|
+
end = files.start(function, operation, target)
|
|
102
|
+
if end is None:
|
|
103
|
+
return original(target, *args, **kwargs)
|
|
104
|
+
try:
|
|
105
|
+
result = original(target, *args, **kwargs)
|
|
106
|
+
except BaseException:
|
|
107
|
+
end({'error': True})
|
|
108
|
+
raise
|
|
109
|
+
end()
|
|
110
|
+
return result
|
|
111
|
+
wrapper.__codetac__ = True
|
|
112
|
+
return wrapper
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def install(root, data):
|
|
116
|
+
if getattr(builtins.open, '__codetac__', False):
|
|
117
|
+
return
|
|
118
|
+
files = Files(root, data)
|
|
119
|
+
original_open = builtins.open
|
|
120
|
+
writing = _observe(files, 'open', 'escrita', original_open)
|
|
121
|
+
|
|
122
|
+
@functools.wraps(original_open)
|
|
123
|
+
def codetac_open(file, mode='r', *args, **kwargs):
|
|
124
|
+
if isinstance(mode, str) and WRITES.intersection(mode):
|
|
125
|
+
return writing(file, mode, *args, **kwargs)
|
|
126
|
+
return original_open(file, mode, *args, **kwargs)
|
|
127
|
+
|
|
128
|
+
codetac_open.__codetac__ = True
|
|
129
|
+
builtins.open = codetac_open
|
|
130
|
+
if io.open is original_open:
|
|
131
|
+
io.open = codetac_open
|
|
132
|
+
for name, operation in OPERATIONS.items():
|
|
133
|
+
original = getattr(os, name, None)
|
|
134
|
+
if original is not None and not getattr(original, '__codetac__', False):
|
|
135
|
+
setattr(os, name, _observe(files, name, operation, original))
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
"""What only a framework knows, when the dossier would otherwise have a gap.
|
|
2
|
+
|
|
3
|
+
- FastAPI: the body of a request is validated by pydantic-core, compiled
|
|
4
|
+
(Rust), where no Python function runs. The dossier shows an opaque step,
|
|
5
|
+
"validação do pedido (não observável)", around it: its time, whether it
|
|
6
|
+
failed (422), and any validators of the project as its children. The point
|
|
7
|
+
is fastapi.dependencies.utils.request_body_to_args, which solve_dependencies
|
|
8
|
+
looks up in its module at each call.
|
|
9
|
+
|
|
10
|
+
Keep it importable on old Pythons (3.8+); in the minimal mode, without
|
|
11
|
+
functions, nothing is recorded here.
|
|
12
|
+
"""
|
|
13
|
+
import functools
|
|
14
|
+
import inspect
|
|
15
|
+
|
|
16
|
+
from .hooks import register
|
|
17
|
+
|
|
18
|
+
VALIDATION = 'validação do pedido (não observável)'
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def _monitor():
|
|
22
|
+
import codetac_py
|
|
23
|
+
return codetac_py.monitor if codetac_py.role == 'app' else None
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def _patch_fastapi_dependencies(module):
|
|
27
|
+
original = module.request_body_to_args
|
|
28
|
+
if getattr(original, '__codetac_wrapped__', False):
|
|
29
|
+
return
|
|
30
|
+
|
|
31
|
+
def failed(result):
|
|
32
|
+
# (values, errors): errors is the list of validation errors.
|
|
33
|
+
try:
|
|
34
|
+
return bool(result[1])
|
|
35
|
+
except (TypeError, IndexError, KeyError):
|
|
36
|
+
return False
|
|
37
|
+
|
|
38
|
+
if inspect.iscoroutinefunction(original):
|
|
39
|
+
@functools.wraps(original)
|
|
40
|
+
async def request_body_to_args(*args, **kwargs):
|
|
41
|
+
monitor = _monitor()
|
|
42
|
+
if monitor is None:
|
|
43
|
+
return await original(*args, **kwargs)
|
|
44
|
+
end = monitor.opaque(VALIDATION)
|
|
45
|
+
error = True
|
|
46
|
+
try:
|
|
47
|
+
result = await original(*args, **kwargs)
|
|
48
|
+
error = failed(result)
|
|
49
|
+
return result
|
|
50
|
+
finally:
|
|
51
|
+
end(error)
|
|
52
|
+
else:
|
|
53
|
+
@functools.wraps(original)
|
|
54
|
+
def request_body_to_args(*args, **kwargs):
|
|
55
|
+
monitor = _monitor()
|
|
56
|
+
if monitor is None:
|
|
57
|
+
return original(*args, **kwargs)
|
|
58
|
+
end = monitor.opaque(VALIDATION)
|
|
59
|
+
error = True
|
|
60
|
+
try:
|
|
61
|
+
result = original(*args, **kwargs)
|
|
62
|
+
error = failed(result)
|
|
63
|
+
return result
|
|
64
|
+
finally:
|
|
65
|
+
end(error)
|
|
66
|
+
|
|
67
|
+
request_body_to_args.__codetac_wrapped__ = True
|
|
68
|
+
module.request_body_to_args = request_body_to_args
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def install():
|
|
72
|
+
register('fastapi.dependencies.utils', _patch_fastapi_dependencies)
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
"""Post-import hooks: a module of a library is patched right after it is
|
|
2
|
+
imported, before the code that imported it can bind its names. Used by
|
|
3
|
+
servers.py (servers) and boundaries.py (databases, HTTP...).
|
|
4
|
+
|
|
5
|
+
Keep it importable on old Pythons (3.8+).
|
|
6
|
+
"""
|
|
7
|
+
import sys
|
|
8
|
+
|
|
9
|
+
HOOKS = {}
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def register(name, hook):
|
|
13
|
+
"""Runs hook(module) when `name` is imported, or now if it already was."""
|
|
14
|
+
HOOKS.setdefault(name, []).append(hook)
|
|
15
|
+
module = sys.modules.get(name)
|
|
16
|
+
if module is not None: # imported before the capture started
|
|
17
|
+
_apply(hook, module)
|
|
18
|
+
_install()
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def _apply(hook, module):
|
|
22
|
+
try:
|
|
23
|
+
hook(module)
|
|
24
|
+
except Exception as error: # a version we do not know: it runs unobserved
|
|
25
|
+
sys.stderr.write('codeTAC: %s não foi preparado (%s: %s)\n' % (module.__name__, type(error).__name__, error))
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class _Loader(object):
|
|
29
|
+
"""Runs the real loader, then the hooks. Everything else is the real loader's."""
|
|
30
|
+
|
|
31
|
+
def __init__(self, loader, hooks):
|
|
32
|
+
self._loader = loader
|
|
33
|
+
self._hooks = hooks
|
|
34
|
+
|
|
35
|
+
def __getattr__(self, name):
|
|
36
|
+
return getattr(self._loader, name)
|
|
37
|
+
|
|
38
|
+
def create_module(self, spec):
|
|
39
|
+
return self._loader.create_module(spec)
|
|
40
|
+
|
|
41
|
+
def exec_module(self, module):
|
|
42
|
+
# The module keeps the real loader: nothing else sees this wrapper.
|
|
43
|
+
module.__loader__ = self._loader
|
|
44
|
+
if getattr(module, '__spec__', None) is not None:
|
|
45
|
+
module.__spec__.loader = self._loader
|
|
46
|
+
self._loader.exec_module(module)
|
|
47
|
+
for hook in self._hooks:
|
|
48
|
+
_apply(hook, module)
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
class _Finder(object):
|
|
52
|
+
def find_spec(self, name, path=None, target=None):
|
|
53
|
+
hooks = HOOKS.get(name)
|
|
54
|
+
if not hooks:
|
|
55
|
+
return None
|
|
56
|
+
for finder in sys.meta_path:
|
|
57
|
+
if finder is self or not hasattr(finder, 'find_spec'):
|
|
58
|
+
continue
|
|
59
|
+
spec = finder.find_spec(name, path, target)
|
|
60
|
+
if spec is not None:
|
|
61
|
+
if spec.loader is not None and hasattr(spec.loader, 'exec_module'):
|
|
62
|
+
spec.loader = _Loader(spec.loader, hooks)
|
|
63
|
+
return spec
|
|
64
|
+
return None
|
|
65
|
+
|
|
66
|
+
def invalidate_caches(self):
|
|
67
|
+
pass
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def _install():
|
|
71
|
+
if not any(isinstance(finder, _Finder) for finder in sys.meta_path):
|
|
72
|
+
sys.meta_path.insert(0, _Finder())
|