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,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))
|