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,351 @@
1
+ """Functions of the project, with sys.monitoring (PEP 669). Python 3.12+.
2
+
3
+ - One decision per code object: the first time a code object outside the
4
+ project starts, the callback returns DISABLE and it costs nothing after.
5
+ - Static metadata is redacted and serialised once per code object; enter and
6
+ exit events only add ids and times, as in src/runtime.mjs.
7
+ - Parentage: a ContextVar with the current node. Threads and asyncio tasks
8
+ each have their own context.
9
+ - PY_YIELD and PY_RESUME (or PY_THROW) are suspensions and resumptions, not
10
+ exits: the duration of a generator or coroutine includes its waits.
11
+ """
12
+ import ast
13
+ import itertools
14
+ import os
15
+ import sys
16
+ from time import perf_counter_ns
17
+
18
+ MONITORING = sys.monitoring
19
+ EVENTS = MONITORING.events
20
+ DISABLE = MONITORING.DISABLE
21
+ # 3 and 4 are not assigned to any kind of tool; 5 (optimizer) and 2
22
+ # (profiler, used by cProfile) only if they are free.
23
+ TOOL_IDS = (3, 4, 5, 2)
24
+ CO_OPTIMIZED = 0x0001
25
+ CO_ASYNC = 0x0080 | 0x0100 | 0x0200 # coroutine, iterable coroutine, async generator
26
+ CO_GENERATOR = 0x0020
27
+ CO_SUSPENDS = CO_GENERATOR | CO_ASYNC # generator, or any of those: it can be suspended
28
+ from .context import NULL, current, request_scope, scope # noqa: F401 (request_scope is used by the tests)
29
+ from .detail import Detail
30
+ from .jinja_map import Templates, is_template, read_table
31
+ from .project import ProjectFiles
32
+
33
+
34
+ class NoToolId(Exception):
35
+ pass
36
+
37
+
38
+ def free_tool_id():
39
+ for tool in TOOL_IDS:
40
+ try:
41
+ MONITORING.use_tool_id(tool, 'codetac')
42
+ return tool
43
+ except ValueError:
44
+ continue
45
+ raise NoToolId('outra ferramenta já está a observar o Python')
46
+
47
+
48
+ class Capture(object):
49
+ def __init__(self, writer, root):
50
+ self.writer = writer
51
+ self.root = os.path.realpath(root)
52
+ self.tool = free_tool_id()
53
+ self.infos = {} # code object -> serialised enter fragment, or None
54
+ self.project_file = ProjectFiles(self.root) # co_filename -> real path of a project file, or None
55
+ self.templates = Templates(self.root) # Jinja templates of the project
56
+ self.other_file = ProjectFiles(self.root, python=False) # co_filename -> a file of the project that is not .py
57
+ self.places = {} # real path -> {(first line, name): (line, endLine, column)}
58
+ self.modules = set() # real paths already announced with a `module` event
59
+ self.frames = {} # generators and coroutines: id(frame) -> [node id, context before, start, own context]
60
+ self.suspends = set() # code objects of generators and coroutines
61
+ # Synchronous generators of Jinja templates: consumed whole by the template's
62
+ # render (a C join), so nothing of the app runs between their yields. Following
63
+ # each yield cost milliseconds per page (one yield per piece of HTML); their
64
+ # context is set at the start and put back at the end only.
65
+ self.whole = set()
66
+ self.counter = itertools.count(1)
67
+ self.opaque_infos = {} # name -> serialised enter fragment of an opaque step
68
+ self.functions = 0
69
+ self.places_of = {} # code object -> (file, line, name), for detail on request
70
+ self.template_tables = {} # code object of a mapped template -> its line table
71
+ self.detail = Detail(self, writer, writer.directory)
72
+
73
+ def classify(self, code, get_frame=None):
74
+ info = None
75
+ try:
76
+ path = self.project_file(code.co_filename)
77
+ if path is None:
78
+ # Code compiled from a Jinja template of the project: mapped to
79
+ # the lines of the .html. The frame is asked for only then:
80
+ # materialising frames of any code (libraries, the interpreter
81
+ # shutting down) crashed CPython 3.12 at exit (stage 8).
82
+ if get_frame is not None and code.co_flags & CO_OPTIMIZED and self.other_file(code.co_filename) is not None:
83
+ frame = get_frame(2) # 0: classify, 1: py_start, 2: the code starting
84
+ if is_template(frame.f_globals):
85
+ info = self._template(code, frame.f_globals)
86
+ else:
87
+ if code.co_flags & CO_OPTIMIZED:
88
+ info = self._describe(code, path)
89
+ elif code.co_name == '<module>' and path not in self.modules:
90
+ # Module and class bodies are not functions.
91
+ self.modules.add(path)
92
+ self.writer.emit({'type': 'module', 'file': path, 'functions': _count_functions(code)})
93
+ except Exception:
94
+ info = None
95
+ self.infos[code] = info
96
+ return info
97
+
98
+ def _template(self, code, globals_):
99
+ found = self.templates.describe(code, globals_)
100
+ if not found:
101
+ return None
102
+ meta, limitation = found
103
+ if limitation is not None:
104
+ self.writer.emit(limitation)
105
+ self.functions += 1
106
+ if code.co_flags & CO_SUSPENDS:
107
+ self.suspends.add(code)
108
+ if code.co_flags & CO_SUSPENDS == CO_GENERATOR:
109
+ self.whole.add(code)
110
+ if meta['line'] is not None:
111
+ self.template_tables[code] = read_table(globals_.get('debug_info'))
112
+ self._place_for_detail(code, (meta['file'], meta['line'], meta['function']))
113
+ return self.writer.redact_fragment(meta)
114
+
115
+ def _place_for_detail(self, code, place):
116
+ self.places_of[code] = place
117
+ if self.detail.functions or self.detail.files:
118
+ self.detail.consider(code, place)
119
+
120
+ def _describe(self, code, path):
121
+ line, end_line, column = self._place(code, path)
122
+ self.functions += 1
123
+ if code.co_flags & CO_SUSPENDS:
124
+ self.suspends.add(code)
125
+ name = readable_name(code.co_qualname)
126
+ meta = {'function': name, 'file': path, 'line': line, 'endLine': end_line,
127
+ 'column': column, 'mapped': False, 'async': bool(code.co_flags & CO_ASYNC)}
128
+ self._place_for_detail(code, (path, line, name))
129
+ return self.writer.redact_fragment(meta)
130
+
131
+ def _place(self, code, path):
132
+ """The `def` line (co_firstlineno is the first decorator's), from the file's syntax tree."""
133
+ places = self.places.get(path)
134
+ if places is None:
135
+ places = self.places[path] = _read_places(path)
136
+ found = places.get((code.co_firstlineno, code.co_name))
137
+ if found:
138
+ return found
139
+ lines = [line for _, _, line in code.co_lines() if line is not None]
140
+ return code.co_firstlineno, max(lines) if lines else code.co_firstlineno, None
141
+
142
+ # Events -------------------------------------------------------------------------
143
+ # The hot path (stage 5). Per call: one ContextVar get and set on the way in
144
+ # and out, one clock reading and one tuple of values appended to the
145
+ # writer's queue each way (the writer makes the JSON lines in the batch). An ordinary function keeps its state in the context value itself
146
+ # (context.py); only generators and coroutines, which suspend and resume,
147
+ # use the table keyed by their frame.
148
+ def start(self):
149
+ self.events = (EVENTS.PY_START | EVENTS.PY_RETURN | EVENTS.PY_UNWIND
150
+ | EVENTS.PY_YIELD | EVENTS.PY_RESUME | EVENTS.PY_THROW)
151
+ self._bind()
152
+ MONITORING.set_events(self.tool, self.events)
153
+ # detalhe.json: now, and every 400 ms from the writer's thread.
154
+ self.detail.poll()
155
+ self.writer.periodic.append(self.detail.poll)
156
+ if hasattr(os, 'register_at_fork'):
157
+ # The writer has a new file, prefix and sequence in the child.
158
+ os.register_at_fork(after_in_child=self._bind)
159
+
160
+ def _bind(self):
161
+ tool, on = self.tool, MONITORING.register_callback
162
+ infos, frames, suspends, writer, whole = self.infos, self.frames, self.suspends, self.writer, self.whole
163
+ append, next_sequence, next_node = writer.queue.append, writer.sequence.__next__, self.counter.__next__
164
+ classify, get_frame, get, set_ = self.classify, sys._getframe, current.get, current.set
165
+ now, missing = perf_counter_ns, object()
166
+ # Functions with detail on request (usually none: one falsy check per call).
167
+ detailed, detail = self.detail.codes, self.detail
168
+
169
+ def py_start(code, offset):
170
+ try:
171
+ info = infos.get(code, missing)
172
+ if info is missing:
173
+ info = classify(code, get_frame)
174
+ if info is None:
175
+ return DISABLE
176
+ before = get()
177
+ started = now()
178
+ node = next_node()
179
+ append((1, next_sequence(), started, node, before[0], before[1], info))
180
+ if detailed and code in detailed:
181
+ detail.start(code, get_frame(1), node, before[1])
182
+ if code in suspends:
183
+ here = (node, before[1], None, 0, None)
184
+ frames[id(get_frame(1))] = [node, before, started, here]
185
+ set_(here)
186
+ else:
187
+ set_((node, before[1], before, started, code))
188
+ except Exception:
189
+ pass
190
+
191
+ def finish(code, error):
192
+ if code in suspends:
193
+ state = frames.pop(id(get_frame(2)), None)
194
+ if state is None:
195
+ return
196
+ set_(state[1])
197
+ ended = now()
198
+ append((0, next_sequence(), ended, state[0], state[3][1], error, ended - state[2]))
199
+ return
200
+ state = get()
201
+ # Only the call that is running: a call that started before the
202
+ # capture (or while it was paused) has no state of its own.
203
+ if state[4] is code:
204
+ set_(state[2])
205
+ ended = now()
206
+ append((0, next_sequence(), ended, state[0], state[1], error, ended - state[3]))
207
+
208
+ def py_return(code, offset, value):
209
+ try:
210
+ if infos.get(code) is None:
211
+ return DISABLE
212
+ if detailed and code in detailed:
213
+ detail.finish(get_frame(1), code, returned=value)
214
+ # The ordinary case inline (finish() below does the same): it is
215
+ # the most frequent event.
216
+ state = get()
217
+ if state[4] is code:
218
+ set_(state[2])
219
+ ended = now()
220
+ append((0, next_sequence(), ended, state[0], state[1], 'false', ended - state[3]))
221
+ elif code in suspends:
222
+ finish(code, 'false')
223
+ except Exception:
224
+ pass
225
+
226
+ def py_unwind(code, offset, exception):
227
+ # Cannot be disabled: called for every frame an exception leaves.
228
+ try:
229
+ if infos.get(code) is not None:
230
+ if detailed and code in detailed:
231
+ detail.finish(get_frame(1), code, exception=exception)
232
+ # Closing a suspended generator, or cancelling a coroutine (a
233
+ # client that went away), is not an error of the app.
234
+ kind = type(exception)
235
+ finish(code, 'false' if kind is GeneratorExit or kind.__name__ == 'CancelledError' else 'true')
236
+ except Exception:
237
+ pass
238
+
239
+ def py_yield(code, offset, value):
240
+ try:
241
+ if infos.get(code) is None or code in whole:
242
+ return DISABLE
243
+ state = frames.get(id(get_frame(1)))
244
+ if state is not None:
245
+ set_(state[1])
246
+ except Exception:
247
+ pass
248
+
249
+ def resumed():
250
+ state = frames.get(id(get_frame(2)))
251
+ if state is not None:
252
+ state[1] = get()
253
+ set_(state[3])
254
+
255
+ def py_resume(code, offset):
256
+ try:
257
+ if infos.get(code) is None or code in whole:
258
+ return DISABLE
259
+ resumed()
260
+ except Exception:
261
+ pass
262
+
263
+ def py_throw(code, offset, exception):
264
+ try:
265
+ if infos.get(code) is not None:
266
+ resumed()
267
+ except Exception:
268
+ pass
269
+
270
+ def line(code, number):
271
+ # Only the code objects with detail have LINE events (set_local_events).
272
+ try:
273
+ detail.line(get_frame(1), code, number)
274
+ except Exception:
275
+ pass
276
+
277
+ on(tool, EVENTS.PY_START, py_start)
278
+ on(tool, EVENTS.LINE, line)
279
+ on(tool, EVENTS.PY_RETURN, py_return)
280
+ on(tool, EVENTS.PY_UNWIND, py_unwind)
281
+ on(tool, EVENTS.PY_YIELD, py_yield)
282
+ on(tool, EVENTS.PY_RESUME, py_resume)
283
+ on(tool, EVENTS.PY_THROW, py_throw)
284
+
285
+ def opaque(self, name):
286
+ """A step the capture cannot see inside (compiled code, such as the validation of
287
+ pydantic-core): an enter now, with `opaque: true` and no file; returns end(error).
288
+ Functions of the project it calls (validators) are its children."""
289
+ writer = self.writer
290
+ info = self.opaque_infos.get(name)
291
+ if info is None:
292
+ info = self.opaque_infos[name] = writer.redact_fragment({
293
+ 'function': name, 'file': None, 'line': None, 'endLine': None, 'column': None,
294
+ 'mapped': False, 'async': False, 'opaque': True})
295
+ before = current.get()
296
+ node = next(self.counter)
297
+ started = perf_counter_ns()
298
+ writer.queue.append((1, next(writer.sequence), started, node, before[0], before[1], info))
299
+ token = current.set((node, before[1], None, 0, None))
300
+
301
+ def end(error):
302
+ current.reset(token)
303
+ ended = perf_counter_ns()
304
+ writer.queue.append((0, next(writer.sequence), ended, node, before[1], 'true' if error else 'false', ended - started))
305
+ return end
306
+
307
+ def pause(self):
308
+ MONITORING.set_events(self.tool, 0)
309
+
310
+ def resume(self):
311
+ MONITORING.set_events(self.tool, self.events)
312
+
313
+ def stop(self):
314
+ MONITORING.set_events(self.tool, 0)
315
+ MONITORING.free_tool_id(self.tool)
316
+
317
+
318
+ def readable_name(qualname):
319
+ """co_qualname without the noise: "deco.<locals>.wrapper" -> "deco.wrapper",
320
+ "total.<locals>.<genexpr>" -> "expressão geradora em total", lambdas alike."""
321
+ name = qualname.replace('.<locals>', '')
322
+ for inner, label in (('<genexpr>', 'expressão geradora'), ('<lambda>', 'lambda')):
323
+ if name == inner:
324
+ return label
325
+ if name.endswith('.' + inner):
326
+ return '%s em %s' % (label, name[:-len(inner) - 1])
327
+ return name
328
+
329
+
330
+ def _count_functions(code):
331
+ count = 0
332
+ for constant in code.co_consts:
333
+ if hasattr(constant, 'co_flags'):
334
+ count += bool(constant.co_flags & CO_OPTIMIZED) + _count_functions(constant)
335
+ return count
336
+
337
+
338
+ def _read_places(path):
339
+ places = {}
340
+ try:
341
+ with open(path, 'rb') as file:
342
+ tree = ast.parse(file.read(), path)
343
+ except (OSError, SyntaxError, ValueError):
344
+ return places
345
+ for node in ast.walk(tree):
346
+ if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)):
347
+ first = min([node.lineno] + [item.lineno for item in node.decorator_list])
348
+ places[(first, node.name)] = (node.lineno, node.end_lineno, node.col_offset + 1)
349
+ elif isinstance(node, ast.Lambda):
350
+ places.setdefault((node.lineno, '<lambda>'), (node.lineno, node.end_lineno, node.col_offset + 1))
351
+ return places
@@ -0,0 +1,50 @@
1
+ """The current node and the current request, like AsyncLocalStorage in the
2
+ Node runtime. Each thread and each asyncio task has its own context.
3
+
4
+ The value is a tuple (node, request, before, start, code):
5
+ - node: the number of the current call in this process, or None; request:
6
+ the request id as a JSON literal, or 'null';
7
+ - before, start, code: for an ordinary function being run, the value to put
8
+ back when it returns, its start time and its code object (capture.py keeps
9
+ the state of a call here instead of in a table: it costs less per call).
10
+ Generators and coroutines, which suspend, keep theirs in a table.
11
+
12
+ Keep it importable on old Pythons (3.8+): requests are recorded in the
13
+ minimal mode too.
14
+ """
15
+ import contextvars
16
+
17
+ NULL = 'null'
18
+
19
+ current = contextvars.ContextVar('codetac_node', default=(None, NULL, None, 0, None))
20
+
21
+
22
+ # After the response of its request (BackgroundTasks, the close() of a WSGI
23
+ # body), the request's literal carries the mark: the writer puts the literal
24
+ # where it puts "requestId", so enter and exit events get
25
+ # "requestId":"…","afterResponse":true at no extra cost per call.
26
+ AFTER = ',"afterResponse":true'
27
+
28
+
29
+ def after_response(literal):
30
+ return literal if literal == NULL or literal.endswith(AFTER) else literal + AFTER
31
+
32
+
33
+ def request_of(literal):
34
+ """(request id or None, after the response) from a request literal."""
35
+ if literal == NULL:
36
+ return None, False
37
+ after = literal.endswith(AFTER)
38
+ if after:
39
+ literal = literal[:-len(AFTER)]
40
+ return literal[1:-1], after
41
+
42
+
43
+ def scope(node, request):
44
+ """A value that only says where the calls that follow belong."""
45
+ return (node, request, None, 0, None)
46
+
47
+
48
+ def request_scope(request_json):
49
+ """Makes the calls that follow belong to a request; returns the token to reset."""
50
+ return current.set(scope(current.get()[0], request_json))