@utopia-ai/cli 0.1.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/.claude/settings.json +1 -0
- package/.claude/settings.local.json +38 -0
- package/bin/utopia.js +20 -0
- package/package.json +46 -0
- package/python/README.md +34 -0
- package/python/instrumenter/instrument.py +1148 -0
- package/python/pyproject.toml +32 -0
- package/python/setup.py +27 -0
- package/python/utopia_runtime/__init__.py +30 -0
- package/python/utopia_runtime/__pycache__/__init__.cpython-313.pyc +0 -0
- package/python/utopia_runtime/__pycache__/client.cpython-313.pyc +0 -0
- package/python/utopia_runtime/__pycache__/probe.cpython-313.pyc +0 -0
- package/python/utopia_runtime/client.py +31 -0
- package/python/utopia_runtime/probe.py +446 -0
- package/python/utopia_runtime.egg-info/PKG-INFO +59 -0
- package/python/utopia_runtime.egg-info/SOURCES.txt +10 -0
- package/python/utopia_runtime.egg-info/dependency_links.txt +1 -0
- package/python/utopia_runtime.egg-info/top_level.txt +1 -0
- package/scripts/publish-npm.sh +14 -0
- package/scripts/publish-pypi.sh +17 -0
- package/src/cli/commands/codex.ts +193 -0
- package/src/cli/commands/context.ts +188 -0
- package/src/cli/commands/destruct.ts +237 -0
- package/src/cli/commands/easter-eggs.ts +203 -0
- package/src/cli/commands/init.ts +505 -0
- package/src/cli/commands/instrument.ts +962 -0
- package/src/cli/commands/mcp.ts +16 -0
- package/src/cli/commands/serve.ts +194 -0
- package/src/cli/commands/status.ts +304 -0
- package/src/cli/commands/validate.ts +328 -0
- package/src/cli/index.ts +37 -0
- package/src/cli/utils/config.ts +54 -0
- package/src/graph/index.ts +687 -0
- package/src/instrumenter/javascript.ts +1798 -0
- package/src/mcp/index.ts +886 -0
- package/src/runtime/js/index.ts +518 -0
- package/src/runtime/js/package-lock.json +30 -0
- package/src/runtime/js/package.json +30 -0
- package/src/runtime/js/tsconfig.json +16 -0
- package/src/server/db/index.ts +26 -0
- package/src/server/db/schema.ts +45 -0
- package/src/server/index.ts +79 -0
- package/src/server/middleware/auth.ts +74 -0
- package/src/server/routes/admin.ts +36 -0
- package/src/server/routes/graph.ts +358 -0
- package/src/server/routes/probes.ts +286 -0
- package/src/types.ts +147 -0
- package/src/utopia-mode/index.ts +206 -0
- package/tsconfig.json +19 -0
|
@@ -0,0 +1,1148 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""
|
|
3
|
+
Utopia Python AST Instrumenter
|
|
4
|
+
|
|
5
|
+
Instruments Python source files by injecting observability probes via AST
|
|
6
|
+
transformation. Supports error, database, API, and infrastructure probes.
|
|
7
|
+
|
|
8
|
+
Usage:
|
|
9
|
+
python instrument.py instrument <file_path> [options]
|
|
10
|
+
python instrument.py validate <file_path> [options]
|
|
11
|
+
|
|
12
|
+
Options:
|
|
13
|
+
--probe-types error,database,api,infra Comma-separated probe types (default: all)
|
|
14
|
+
--utopia-mode Enable function probes
|
|
15
|
+
--dry-run Print transformed code without writing
|
|
16
|
+
--output-json Output results as JSON
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
import argparse
|
|
20
|
+
import ast
|
|
21
|
+
import copy
|
|
22
|
+
import json
|
|
23
|
+
import os
|
|
24
|
+
import sys
|
|
25
|
+
import textwrap
|
|
26
|
+
from typing import Any
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
# ---------------------------------------------------------------------------
|
|
30
|
+
# Constants
|
|
31
|
+
# ---------------------------------------------------------------------------
|
|
32
|
+
|
|
33
|
+
ALL_PROBE_TYPES = {"error", "database", "api", "infra"}
|
|
34
|
+
|
|
35
|
+
ENTRY_POINT_NAMES = {"main.py", "app.py", "wsgi.py", "asgi.py", "manage.py", "__main__.py"}
|
|
36
|
+
|
|
37
|
+
DB_CALL_PATTERNS: dict[str, dict[str, str]] = {
|
|
38
|
+
# attr_name -> {method -> library}
|
|
39
|
+
"execute": {"cursor": "dbapi", "session": "sqlalchemy", "conn": "asyncpg"},
|
|
40
|
+
"executemany": {"cursor": "dbapi"},
|
|
41
|
+
"query": {"session": "sqlalchemy"},
|
|
42
|
+
"fetch": {"conn": "asyncpg"},
|
|
43
|
+
"fetchrow": {"conn": "asyncpg"},
|
|
44
|
+
"fetchval": {"conn": "asyncpg"},
|
|
45
|
+
"find": {"collection": "pymongo"},
|
|
46
|
+
"find_one": {"collection": "pymongo"},
|
|
47
|
+
"insert_one": {"collection": "pymongo"},
|
|
48
|
+
"insert_many": {"collection": "pymongo"},
|
|
49
|
+
"update_one": {"collection": "pymongo"},
|
|
50
|
+
"update_many": {"collection": "pymongo"},
|
|
51
|
+
"delete_one": {"collection": "pymongo"},
|
|
52
|
+
"delete_many": {"collection": "pymongo"},
|
|
53
|
+
"replace_one": {"collection": "pymongo"},
|
|
54
|
+
"aggregate": {"collection": "pymongo"},
|
|
55
|
+
"count_documents": {"collection": "pymongo"},
|
|
56
|
+
"filter": {"objects": "django"},
|
|
57
|
+
"get": {"objects": "django"},
|
|
58
|
+
"create": {"objects": "django"},
|
|
59
|
+
"bulk_create": {"objects": "django"},
|
|
60
|
+
"exclude": {"objects": "django"},
|
|
61
|
+
"all": {"objects": "django"},
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
API_METHODS = {"get", "post", "put", "patch", "delete", "head", "options"}
|
|
65
|
+
|
|
66
|
+
API_CALLERS = {"requests", "httpx", "client", "session", "aiohttp"}
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
# ---------------------------------------------------------------------------
|
|
70
|
+
# Helpers
|
|
71
|
+
# ---------------------------------------------------------------------------
|
|
72
|
+
|
|
73
|
+
def _make_import_inline(module: str) -> ast.Call:
|
|
74
|
+
"""Build ``__import__('<module>')``."""
|
|
75
|
+
return ast.Call(
|
|
76
|
+
func=ast.Name(id="__import__", ctx=ast.Load()),
|
|
77
|
+
args=[ast.Constant(value=module)],
|
|
78
|
+
keywords=[],
|
|
79
|
+
)
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def _make_file_expr() -> ast.IfExp:
|
|
83
|
+
"""Build ``__file__ if '__file__' in dir() else '<unknown>'``."""
|
|
84
|
+
return ast.IfExp(
|
|
85
|
+
test=ast.Compare(
|
|
86
|
+
left=ast.Constant(value="__file__"),
|
|
87
|
+
ops=[ast.In()],
|
|
88
|
+
comparators=[
|
|
89
|
+
ast.Call(func=ast.Name(id="dir", ctx=ast.Load()), args=[], keywords=[])
|
|
90
|
+
],
|
|
91
|
+
),
|
|
92
|
+
body=ast.Name(id="__file__", ctx=ast.Load()),
|
|
93
|
+
orelse=ast.Constant(value="<unknown>"),
|
|
94
|
+
)
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def _attr_chain(node: ast.expr) -> list[str]:
|
|
98
|
+
"""Return the chain of attribute names for dotted access, e.g. ['db', 'session', 'execute']."""
|
|
99
|
+
parts: list[str] = []
|
|
100
|
+
while isinstance(node, ast.Attribute):
|
|
101
|
+
parts.append(node.attr)
|
|
102
|
+
node = node.value
|
|
103
|
+
if isinstance(node, ast.Name):
|
|
104
|
+
parts.append(node.id)
|
|
105
|
+
parts.reverse()
|
|
106
|
+
return parts
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def _enclosing_function(ancestors: list[ast.AST]) -> str:
|
|
110
|
+
for node in reversed(ancestors):
|
|
111
|
+
if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)):
|
|
112
|
+
return node.name
|
|
113
|
+
return "<module>"
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def _is_utopia_try(node: ast.stmt) -> bool:
|
|
117
|
+
"""Return True if *node* is a try block we already injected."""
|
|
118
|
+
if not isinstance(node, ast.Try):
|
|
119
|
+
return False
|
|
120
|
+
src = ast.dump(node)
|
|
121
|
+
return "utopia" in src.lower()
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
def _function_has_utopia_wrap(func_node: ast.FunctionDef | ast.AsyncFunctionDef) -> bool:
|
|
125
|
+
"""Check whether the function body is already a single utopia try/except."""
|
|
126
|
+
body = func_node.body
|
|
127
|
+
# skip docstrings
|
|
128
|
+
start = 0
|
|
129
|
+
if (
|
|
130
|
+
body
|
|
131
|
+
and isinstance(body[0], ast.Expr)
|
|
132
|
+
and isinstance(body[0].value, ast.Constant)
|
|
133
|
+
and isinstance(body[0].value.value, str)
|
|
134
|
+
):
|
|
135
|
+
start = 1
|
|
136
|
+
remaining = body[start:]
|
|
137
|
+
if len(remaining) == 1 and _is_utopia_try(remaining[0]):
|
|
138
|
+
return True
|
|
139
|
+
return False
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
# ---------------------------------------------------------------------------
|
|
143
|
+
# AST Transformer
|
|
144
|
+
# ---------------------------------------------------------------------------
|
|
145
|
+
|
|
146
|
+
class UtopiaTransformer(ast.NodeTransformer):
|
|
147
|
+
"""Walk the AST and inject probes according to *probe_types*."""
|
|
148
|
+
|
|
149
|
+
def __init__(
|
|
150
|
+
self,
|
|
151
|
+
filepath: str,
|
|
152
|
+
probe_types: set[str],
|
|
153
|
+
utopia_mode: bool = False,
|
|
154
|
+
) -> None:
|
|
155
|
+
super().__init__()
|
|
156
|
+
self.filepath = filepath
|
|
157
|
+
self.probe_types = probe_types
|
|
158
|
+
self.utopia_mode = utopia_mode
|
|
159
|
+
self.probes_added: list[dict[str, Any]] = []
|
|
160
|
+
self.errors: list[str] = []
|
|
161
|
+
self._ancestor_stack: list[ast.AST] = []
|
|
162
|
+
self._needs_utopia_import = False
|
|
163
|
+
|
|
164
|
+
# -- helpers --
|
|
165
|
+
|
|
166
|
+
def _record(self, probe_type: str, line: int, **extra: Any) -> None:
|
|
167
|
+
entry: dict[str, Any] = {"type": probe_type, "line": line}
|
|
168
|
+
entry.update(extra)
|
|
169
|
+
self.probes_added.append(entry)
|
|
170
|
+
self._needs_utopia_import = True
|
|
171
|
+
|
|
172
|
+
def _current_function(self) -> str:
|
|
173
|
+
return _enclosing_function(self._ancestor_stack)
|
|
174
|
+
|
|
175
|
+
# -- visitor plumbing --
|
|
176
|
+
|
|
177
|
+
def _visit_children(self, node: ast.AST) -> ast.AST:
|
|
178
|
+
self._ancestor_stack.append(node)
|
|
179
|
+
self.generic_visit(node)
|
|
180
|
+
self._ancestor_stack.pop()
|
|
181
|
+
return node
|
|
182
|
+
|
|
183
|
+
# ------------------------------------------------------------------
|
|
184
|
+
# Error probes (wrap function bodies)
|
|
185
|
+
# ------------------------------------------------------------------
|
|
186
|
+
|
|
187
|
+
def _wrap_function_body(self, node: ast.FunctionDef | ast.AsyncFunctionDef) -> ast.AST:
|
|
188
|
+
if _function_has_utopia_wrap(node):
|
|
189
|
+
return self._visit_children(node)
|
|
190
|
+
|
|
191
|
+
original_body = node.body
|
|
192
|
+
# Preserve leading docstring outside the try so tools can still find it
|
|
193
|
+
docstring_stmts: list[ast.stmt] = []
|
|
194
|
+
remaining_body = list(original_body)
|
|
195
|
+
if (
|
|
196
|
+
remaining_body
|
|
197
|
+
and isinstance(remaining_body[0], ast.Expr)
|
|
198
|
+
and isinstance(remaining_body[0].value, ast.Constant)
|
|
199
|
+
and isinstance(remaining_body[0].value.value, str)
|
|
200
|
+
):
|
|
201
|
+
docstring_stmts.append(remaining_body.pop(0))
|
|
202
|
+
|
|
203
|
+
if not remaining_body:
|
|
204
|
+
return self._visit_children(node)
|
|
205
|
+
|
|
206
|
+
line = node.lineno
|
|
207
|
+
|
|
208
|
+
# Build the input_data dict from function params
|
|
209
|
+
param_names: list[str] = []
|
|
210
|
+
for arg in node.args.args:
|
|
211
|
+
if arg.arg != "self" and arg.arg != "cls":
|
|
212
|
+
param_names.append(arg.arg)
|
|
213
|
+
for arg in node.args.posonlyargs:
|
|
214
|
+
if arg.arg != "self" and arg.arg != "cls":
|
|
215
|
+
param_names.append(arg.arg)
|
|
216
|
+
for arg in node.args.kwonlyargs:
|
|
217
|
+
param_names.append(arg.arg)
|
|
218
|
+
if node.args.vararg:
|
|
219
|
+
param_names.append(node.args.vararg.arg)
|
|
220
|
+
if node.args.kwarg:
|
|
221
|
+
param_names.append(node.args.kwarg.arg)
|
|
222
|
+
|
|
223
|
+
# {param: repr(param) for each param}
|
|
224
|
+
input_dict_keys: list[ast.expr] = []
|
|
225
|
+
input_dict_values: list[ast.expr] = []
|
|
226
|
+
for p in param_names:
|
|
227
|
+
input_dict_keys.append(ast.Constant(value=p))
|
|
228
|
+
input_dict_values.append(
|
|
229
|
+
ast.Call(
|
|
230
|
+
func=ast.Name(id="repr", ctx=ast.Load()),
|
|
231
|
+
args=[ast.Name(id=p, ctx=ast.Load())],
|
|
232
|
+
keywords=[],
|
|
233
|
+
)
|
|
234
|
+
)
|
|
235
|
+
input_data_node = ast.Dict(keys=input_dict_keys, values=input_dict_values)
|
|
236
|
+
|
|
237
|
+
# except block
|
|
238
|
+
err_name = "__utopia_err"
|
|
239
|
+
mod_alias = "__utopia_mod"
|
|
240
|
+
tb_alias = "__utopia_tb"
|
|
241
|
+
|
|
242
|
+
import_runtime = ast.Assign(
|
|
243
|
+
targets=[ast.Name(id=mod_alias, ctx=ast.Store())],
|
|
244
|
+
value=_make_import_inline("utopia_runtime"),
|
|
245
|
+
lineno=line,
|
|
246
|
+
)
|
|
247
|
+
import_tb = ast.Assign(
|
|
248
|
+
targets=[ast.Name(id=tb_alias, ctx=ast.Store())],
|
|
249
|
+
value=_make_import_inline("traceback"),
|
|
250
|
+
lineno=line,
|
|
251
|
+
)
|
|
252
|
+
report_call = ast.Expr(
|
|
253
|
+
value=ast.Call(
|
|
254
|
+
func=ast.Attribute(
|
|
255
|
+
value=ast.Name(id=mod_alias, ctx=ast.Load()),
|
|
256
|
+
attr="report_error",
|
|
257
|
+
ctx=ast.Load(),
|
|
258
|
+
),
|
|
259
|
+
args=[],
|
|
260
|
+
keywords=[
|
|
261
|
+
ast.keyword(arg="file", value=_make_file_expr()),
|
|
262
|
+
ast.keyword(arg="line", value=ast.Constant(value=line)),
|
|
263
|
+
ast.keyword(arg="function_name", value=ast.Constant(value=node.name)),
|
|
264
|
+
ast.keyword(
|
|
265
|
+
arg="error_type",
|
|
266
|
+
value=ast.Attribute(
|
|
267
|
+
value=ast.Call(
|
|
268
|
+
func=ast.Name(id="type", ctx=ast.Load()),
|
|
269
|
+
args=[ast.Name(id=err_name, ctx=ast.Load())],
|
|
270
|
+
keywords=[],
|
|
271
|
+
),
|
|
272
|
+
attr="__name__",
|
|
273
|
+
ctx=ast.Load(),
|
|
274
|
+
),
|
|
275
|
+
),
|
|
276
|
+
ast.keyword(
|
|
277
|
+
arg="message",
|
|
278
|
+
value=ast.Call(
|
|
279
|
+
func=ast.Name(id="str", ctx=ast.Load()),
|
|
280
|
+
args=[ast.Name(id=err_name, ctx=ast.Load())],
|
|
281
|
+
keywords=[],
|
|
282
|
+
),
|
|
283
|
+
),
|
|
284
|
+
ast.keyword(
|
|
285
|
+
arg="stack",
|
|
286
|
+
value=ast.Call(
|
|
287
|
+
func=ast.Attribute(
|
|
288
|
+
value=ast.Name(id=tb_alias, ctx=ast.Load()),
|
|
289
|
+
attr="format_exc",
|
|
290
|
+
ctx=ast.Load(),
|
|
291
|
+
),
|
|
292
|
+
args=[],
|
|
293
|
+
keywords=[],
|
|
294
|
+
),
|
|
295
|
+
),
|
|
296
|
+
ast.keyword(arg="input_data", value=input_data_node),
|
|
297
|
+
],
|
|
298
|
+
)
|
|
299
|
+
)
|
|
300
|
+
raise_stmt = ast.Raise()
|
|
301
|
+
|
|
302
|
+
handler = ast.ExceptHandler(
|
|
303
|
+
type=ast.Name(id="Exception", ctx=ast.Load()),
|
|
304
|
+
name=err_name,
|
|
305
|
+
body=[import_runtime, import_tb, report_call, raise_stmt],
|
|
306
|
+
)
|
|
307
|
+
|
|
308
|
+
try_node = ast.Try(
|
|
309
|
+
body=remaining_body,
|
|
310
|
+
handlers=[handler],
|
|
311
|
+
orelse=[],
|
|
312
|
+
finalbody=[],
|
|
313
|
+
)
|
|
314
|
+
|
|
315
|
+
node.body = docstring_stmts + [try_node]
|
|
316
|
+
self._record("error", line, function_name=node.name)
|
|
317
|
+
|
|
318
|
+
# Now visit the children inside the body (important for nested functions)
|
|
319
|
+
return self._visit_children(node)
|
|
320
|
+
|
|
321
|
+
# ------------------------------------------------------------------
|
|
322
|
+
# Function probes (utopia mode)
|
|
323
|
+
# ------------------------------------------------------------------
|
|
324
|
+
|
|
325
|
+
def _wrap_function_probe(self, node: ast.FunctionDef | ast.AsyncFunctionDef) -> ast.AST:
|
|
326
|
+
"""Add timing + arg/return capture around entire function body."""
|
|
327
|
+
line = node.lineno
|
|
328
|
+
|
|
329
|
+
# Build param repr dict
|
|
330
|
+
param_names: list[str] = []
|
|
331
|
+
for arg in node.args.args + node.args.posonlyargs + node.args.kwonlyargs:
|
|
332
|
+
if arg.arg not in ("self", "cls"):
|
|
333
|
+
param_names.append(arg.arg)
|
|
334
|
+
if node.args.vararg:
|
|
335
|
+
param_names.append(node.args.vararg.arg)
|
|
336
|
+
if node.args.kwarg:
|
|
337
|
+
param_names.append(node.args.kwarg.arg)
|
|
338
|
+
|
|
339
|
+
args_dict_keys = [ast.Constant(value=p) for p in param_names]
|
|
340
|
+
args_dict_values = [
|
|
341
|
+
ast.Call(
|
|
342
|
+
func=ast.Name(id="repr", ctx=ast.Load()),
|
|
343
|
+
args=[ast.Name(id=p, ctx=ast.Load())],
|
|
344
|
+
keywords=[],
|
|
345
|
+
)
|
|
346
|
+
for p in param_names
|
|
347
|
+
]
|
|
348
|
+
args_data_node = ast.Dict(keys=args_dict_keys, values=args_dict_values)
|
|
349
|
+
|
|
350
|
+
# We wrap the body in:
|
|
351
|
+
# __utopia_fn_t0 = __import__('time').perf_counter()
|
|
352
|
+
# try:
|
|
353
|
+
# <body>
|
|
354
|
+
# finally:
|
|
355
|
+
# __utopia_fn_dur = __import__('time').perf_counter() - __utopia_fn_t0
|
|
356
|
+
# __import__('utopia_runtime').report_function(...)
|
|
357
|
+
|
|
358
|
+
timer_start = ast.Assign(
|
|
359
|
+
targets=[ast.Name(id="__utopia_fn_t0", ctx=ast.Store())],
|
|
360
|
+
value=ast.Call(
|
|
361
|
+
func=ast.Attribute(
|
|
362
|
+
value=_make_import_inline("time"),
|
|
363
|
+
attr="perf_counter",
|
|
364
|
+
ctx=ast.Load(),
|
|
365
|
+
),
|
|
366
|
+
args=[],
|
|
367
|
+
keywords=[],
|
|
368
|
+
),
|
|
369
|
+
lineno=line,
|
|
370
|
+
)
|
|
371
|
+
|
|
372
|
+
timer_end = ast.Assign(
|
|
373
|
+
targets=[ast.Name(id="__utopia_fn_dur", ctx=ast.Store())],
|
|
374
|
+
value=ast.BinOp(
|
|
375
|
+
left=ast.Call(
|
|
376
|
+
func=ast.Attribute(
|
|
377
|
+
value=_make_import_inline("time"),
|
|
378
|
+
attr="perf_counter",
|
|
379
|
+
ctx=ast.Load(),
|
|
380
|
+
),
|
|
381
|
+
args=[],
|
|
382
|
+
keywords=[],
|
|
383
|
+
),
|
|
384
|
+
op=ast.Sub(),
|
|
385
|
+
right=ast.Name(id="__utopia_fn_t0", ctx=ast.Load()),
|
|
386
|
+
),
|
|
387
|
+
lineno=line,
|
|
388
|
+
)
|
|
389
|
+
|
|
390
|
+
report_fn_call = ast.Expr(
|
|
391
|
+
value=ast.Call(
|
|
392
|
+
func=ast.Attribute(
|
|
393
|
+
value=_make_import_inline("utopia_runtime"),
|
|
394
|
+
attr="report_function",
|
|
395
|
+
ctx=ast.Load(),
|
|
396
|
+
),
|
|
397
|
+
args=[],
|
|
398
|
+
keywords=[
|
|
399
|
+
ast.keyword(arg="file", value=_make_file_expr()),
|
|
400
|
+
ast.keyword(arg="line", value=ast.Constant(value=line)),
|
|
401
|
+
ast.keyword(arg="function_name", value=ast.Constant(value=node.name)),
|
|
402
|
+
ast.keyword(arg="args", value=args_data_node),
|
|
403
|
+
ast.keyword(arg="duration", value=ast.Name(id="__utopia_fn_dur", ctx=ast.Load())),
|
|
404
|
+
],
|
|
405
|
+
)
|
|
406
|
+
)
|
|
407
|
+
|
|
408
|
+
# Preserve docstring
|
|
409
|
+
docstring_stmts: list[ast.stmt] = []
|
|
410
|
+
remaining_body = list(node.body)
|
|
411
|
+
if (
|
|
412
|
+
remaining_body
|
|
413
|
+
and isinstance(remaining_body[0], ast.Expr)
|
|
414
|
+
and isinstance(remaining_body[0].value, ast.Constant)
|
|
415
|
+
and isinstance(remaining_body[0].value.value, str)
|
|
416
|
+
):
|
|
417
|
+
docstring_stmts.append(remaining_body.pop(0))
|
|
418
|
+
|
|
419
|
+
if not remaining_body:
|
|
420
|
+
return self._visit_children(node)
|
|
421
|
+
|
|
422
|
+
try_finally = ast.Try(
|
|
423
|
+
body=remaining_body,
|
|
424
|
+
handlers=[],
|
|
425
|
+
orelse=[],
|
|
426
|
+
finalbody=[timer_end, report_fn_call],
|
|
427
|
+
)
|
|
428
|
+
|
|
429
|
+
node.body = docstring_stmts + [timer_start, try_finally]
|
|
430
|
+
self._record("function", line, function_name=node.name)
|
|
431
|
+
return self._visit_children(node)
|
|
432
|
+
|
|
433
|
+
# ------------------------------------------------------------------
|
|
434
|
+
# Database probes
|
|
435
|
+
# ------------------------------------------------------------------
|
|
436
|
+
|
|
437
|
+
def _is_db_call(self, node: ast.Call) -> tuple[str, str, str] | None:
|
|
438
|
+
"""Return (method, receiver_hint, library) if *node* is a recognised DB call, else None."""
|
|
439
|
+
func = node.func
|
|
440
|
+
if not isinstance(func, ast.Attribute):
|
|
441
|
+
return None
|
|
442
|
+
method = func.attr
|
|
443
|
+
patterns = DB_CALL_PATTERNS.get(method)
|
|
444
|
+
if patterns is None:
|
|
445
|
+
return None
|
|
446
|
+
|
|
447
|
+
chain = _attr_chain(func.value)
|
|
448
|
+
if not chain:
|
|
449
|
+
return None
|
|
450
|
+
|
|
451
|
+
# Check for db.session.execute style (last element of chain matches key)
|
|
452
|
+
for receiver, lib in patterns.items():
|
|
453
|
+
if chain[-1] == receiver or (len(chain) >= 2 and chain[-1] == receiver):
|
|
454
|
+
return method, receiver, lib
|
|
455
|
+
# Also match if the chain contains the receiver anywhere
|
|
456
|
+
if receiver in chain:
|
|
457
|
+
return method, receiver, lib
|
|
458
|
+
|
|
459
|
+
# Fallback: match any Name receiver against known patterns
|
|
460
|
+
if isinstance(func.value, ast.Name) and func.value.id in patterns:
|
|
461
|
+
return method, func.value.id, patterns[func.value.id]
|
|
462
|
+
|
|
463
|
+
return None
|
|
464
|
+
|
|
465
|
+
def _make_db_probe_stmts(
|
|
466
|
+
self, node: ast.Call, method: str, receiver: str, lib: str, line: int
|
|
467
|
+
) -> list[ast.stmt]:
|
|
468
|
+
"""Build the timing + report statements for a DB call."""
|
|
469
|
+
# first_arg repr for query
|
|
470
|
+
query_node: ast.expr
|
|
471
|
+
if node.args:
|
|
472
|
+
query_node = ast.Call(
|
|
473
|
+
func=ast.Name(id="repr", ctx=ast.Load()),
|
|
474
|
+
args=[node.args[0]],
|
|
475
|
+
keywords=[],
|
|
476
|
+
)
|
|
477
|
+
else:
|
|
478
|
+
query_node = ast.Constant(value=None)
|
|
479
|
+
|
|
480
|
+
timer_start = ast.Assign(
|
|
481
|
+
targets=[ast.Name(id="__utopia_db_t0", ctx=ast.Store())],
|
|
482
|
+
value=ast.Call(
|
|
483
|
+
func=ast.Attribute(
|
|
484
|
+
value=_make_import_inline("time"),
|
|
485
|
+
attr="perf_counter",
|
|
486
|
+
ctx=ast.Load(),
|
|
487
|
+
),
|
|
488
|
+
args=[],
|
|
489
|
+
keywords=[],
|
|
490
|
+
),
|
|
491
|
+
lineno=line,
|
|
492
|
+
)
|
|
493
|
+
timer_end = ast.Assign(
|
|
494
|
+
targets=[ast.Name(id="__utopia_db_dur", ctx=ast.Store())],
|
|
495
|
+
value=ast.BinOp(
|
|
496
|
+
left=ast.Call(
|
|
497
|
+
func=ast.Attribute(
|
|
498
|
+
value=_make_import_inline("time"),
|
|
499
|
+
attr="perf_counter",
|
|
500
|
+
ctx=ast.Load(),
|
|
501
|
+
),
|
|
502
|
+
args=[],
|
|
503
|
+
keywords=[],
|
|
504
|
+
),
|
|
505
|
+
op=ast.Sub(),
|
|
506
|
+
right=ast.Name(id="__utopia_db_t0", ctx=ast.Load()),
|
|
507
|
+
),
|
|
508
|
+
lineno=line,
|
|
509
|
+
)
|
|
510
|
+
report_call = ast.Expr(
|
|
511
|
+
value=ast.Call(
|
|
512
|
+
func=ast.Attribute(
|
|
513
|
+
value=_make_import_inline("utopia_runtime"),
|
|
514
|
+
attr="report_db",
|
|
515
|
+
ctx=ast.Load(),
|
|
516
|
+
),
|
|
517
|
+
args=[],
|
|
518
|
+
keywords=[
|
|
519
|
+
ast.keyword(arg="file", value=_make_file_expr()),
|
|
520
|
+
ast.keyword(arg="line", value=ast.Constant(value=line)),
|
|
521
|
+
ast.keyword(arg="function_name", value=ast.Constant(value=self._current_function())),
|
|
522
|
+
ast.keyword(arg="operation", value=ast.Constant(value=method)),
|
|
523
|
+
ast.keyword(arg="query", value=query_node),
|
|
524
|
+
ast.keyword(arg="duration", value=ast.Name(id="__utopia_db_dur", ctx=ast.Load())),
|
|
525
|
+
ast.keyword(
|
|
526
|
+
arg="connection_info",
|
|
527
|
+
value=ast.Dict(
|
|
528
|
+
keys=[ast.Constant(value="type")],
|
|
529
|
+
values=[ast.Constant(value=lib)],
|
|
530
|
+
),
|
|
531
|
+
),
|
|
532
|
+
],
|
|
533
|
+
)
|
|
534
|
+
)
|
|
535
|
+
return [timer_start], [timer_end, report_call]
|
|
536
|
+
|
|
537
|
+
# ------------------------------------------------------------------
|
|
538
|
+
# API probes
|
|
539
|
+
# ------------------------------------------------------------------
|
|
540
|
+
|
|
541
|
+
def _is_api_call(self, node: ast.Call) -> tuple[str, str] | None:
|
|
542
|
+
"""Return (http_method, library_hint) if *node* is a recognised HTTP call, else None."""
|
|
543
|
+
func = node.func
|
|
544
|
+
if not isinstance(func, ast.Attribute):
|
|
545
|
+
return None
|
|
546
|
+
method = func.attr.lower()
|
|
547
|
+
if method not in API_METHODS:
|
|
548
|
+
return None
|
|
549
|
+
chain = _attr_chain(func.value)
|
|
550
|
+
if not chain:
|
|
551
|
+
# bare Name e.g. requests.get
|
|
552
|
+
if isinstance(func.value, ast.Name) and func.value.id.lower() in API_CALLERS:
|
|
553
|
+
return method, func.value.id
|
|
554
|
+
return None
|
|
555
|
+
# Check if any part of the chain matches known callers
|
|
556
|
+
for part in chain:
|
|
557
|
+
if part.lower() in API_CALLERS:
|
|
558
|
+
return method, part
|
|
559
|
+
return None
|
|
560
|
+
|
|
561
|
+
def _make_api_probe_stmts(
|
|
562
|
+
self, node: ast.Call, http_method: str, lib_hint: str, line: int
|
|
563
|
+
) -> tuple[list[ast.stmt], list[ast.stmt]]:
|
|
564
|
+
"""Build the timing + report statements for an API call."""
|
|
565
|
+
url_node: ast.expr
|
|
566
|
+
if node.args:
|
|
567
|
+
url_node = node.args[0]
|
|
568
|
+
else:
|
|
569
|
+
# look for url= keyword
|
|
570
|
+
url_kw = next((kw for kw in node.keywords if kw.arg == "url"), None)
|
|
571
|
+
url_node = url_kw.value if url_kw else ast.Constant(value="<unknown>")
|
|
572
|
+
|
|
573
|
+
timer_start = ast.Assign(
|
|
574
|
+
targets=[ast.Name(id="__utopia_api_t0", ctx=ast.Store())],
|
|
575
|
+
value=ast.Call(
|
|
576
|
+
func=ast.Attribute(
|
|
577
|
+
value=_make_import_inline("time"),
|
|
578
|
+
attr="perf_counter",
|
|
579
|
+
ctx=ast.Load(),
|
|
580
|
+
),
|
|
581
|
+
args=[],
|
|
582
|
+
keywords=[],
|
|
583
|
+
),
|
|
584
|
+
lineno=line,
|
|
585
|
+
)
|
|
586
|
+
timer_end = ast.Assign(
|
|
587
|
+
targets=[ast.Name(id="__utopia_api_dur", ctx=ast.Store())],
|
|
588
|
+
value=ast.BinOp(
|
|
589
|
+
left=ast.Call(
|
|
590
|
+
func=ast.Attribute(
|
|
591
|
+
value=_make_import_inline("time"),
|
|
592
|
+
attr="perf_counter",
|
|
593
|
+
ctx=ast.Load(),
|
|
594
|
+
),
|
|
595
|
+
args=[],
|
|
596
|
+
keywords=[],
|
|
597
|
+
),
|
|
598
|
+
op=ast.Sub(),
|
|
599
|
+
right=ast.Name(id="__utopia_api_t0", ctx=ast.Load()),
|
|
600
|
+
),
|
|
601
|
+
lineno=line,
|
|
602
|
+
)
|
|
603
|
+
|
|
604
|
+
# Try to get status_code from the result variable if assigned
|
|
605
|
+
# We'll use a helper: getattr(__utopia_api_res, 'status_code', None)
|
|
606
|
+
status_code_node = ast.Call(
|
|
607
|
+
func=ast.Name(id="getattr", ctx=ast.Load()),
|
|
608
|
+
args=[
|
|
609
|
+
ast.Name(id="__utopia_api_res", ctx=ast.Load()),
|
|
610
|
+
ast.Constant(value="status_code"),
|
|
611
|
+
ast.Constant(value=None),
|
|
612
|
+
],
|
|
613
|
+
keywords=[],
|
|
614
|
+
)
|
|
615
|
+
|
|
616
|
+
report_call = ast.Expr(
|
|
617
|
+
value=ast.Call(
|
|
618
|
+
func=ast.Attribute(
|
|
619
|
+
value=_make_import_inline("utopia_runtime"),
|
|
620
|
+
attr="report_api",
|
|
621
|
+
ctx=ast.Load(),
|
|
622
|
+
),
|
|
623
|
+
args=[],
|
|
624
|
+
keywords=[
|
|
625
|
+
ast.keyword(arg="file", value=_make_file_expr()),
|
|
626
|
+
ast.keyword(arg="line", value=ast.Constant(value=line)),
|
|
627
|
+
ast.keyword(arg="function_name", value=ast.Constant(value=self._current_function())),
|
|
628
|
+
ast.keyword(arg="method", value=ast.Constant(value=http_method.upper())),
|
|
629
|
+
ast.keyword(
|
|
630
|
+
arg="url",
|
|
631
|
+
value=ast.Call(
|
|
632
|
+
func=ast.Name(id="str", ctx=ast.Load()),
|
|
633
|
+
args=[copy.deepcopy(url_node)],
|
|
634
|
+
keywords=[],
|
|
635
|
+
),
|
|
636
|
+
),
|
|
637
|
+
ast.keyword(arg="status_code", value=status_code_node),
|
|
638
|
+
ast.keyword(arg="duration", value=ast.Name(id="__utopia_api_dur", ctx=ast.Load())),
|
|
639
|
+
],
|
|
640
|
+
)
|
|
641
|
+
)
|
|
642
|
+
return [timer_start], [timer_end, report_call]
|
|
643
|
+
|
|
644
|
+
# ------------------------------------------------------------------
|
|
645
|
+
# Statement-level visitor (for DB / API probes in Assign / Expr)
|
|
646
|
+
# ------------------------------------------------------------------
|
|
647
|
+
|
|
648
|
+
def _visit_stmt_list(self, stmts: list[ast.stmt]) -> list[ast.stmt]:
|
|
649
|
+
"""Process a list of statements, injecting DB/API probes where needed."""
|
|
650
|
+
new_stmts: list[ast.stmt] = []
|
|
651
|
+
for stmt in stmts:
|
|
652
|
+
injected = False
|
|
653
|
+
|
|
654
|
+
# --- Assign: result = some_call(...) ---
|
|
655
|
+
if isinstance(stmt, ast.Assign) and len(stmt.targets) == 1:
|
|
656
|
+
call_node = stmt.value
|
|
657
|
+
if isinstance(call_node, ast.Await):
|
|
658
|
+
call_node = call_node.value
|
|
659
|
+
if isinstance(call_node, ast.Call):
|
|
660
|
+
if "database" in self.probe_types:
|
|
661
|
+
db_info = self._is_db_call(call_node)
|
|
662
|
+
if db_info:
|
|
663
|
+
method, receiver, lib = db_info
|
|
664
|
+
pre, post = self._make_db_probe_stmts(call_node, method, receiver, lib, stmt.lineno)
|
|
665
|
+
new_stmts.extend(pre)
|
|
666
|
+
new_stmts.append(stmt)
|
|
667
|
+
new_stmts.extend(post)
|
|
668
|
+
self._record("database", stmt.lineno, function_name=self._current_function(), operation=method)
|
|
669
|
+
injected = True
|
|
670
|
+
if not injected and "api" in self.probe_types:
|
|
671
|
+
api_info = self._is_api_call(call_node)
|
|
672
|
+
if api_info:
|
|
673
|
+
http_method, lib_hint = api_info
|
|
674
|
+
pre, post = self._make_api_probe_stmts(call_node, http_method, lib_hint, stmt.lineno)
|
|
675
|
+
# Rewrite: __utopia_api_res = <call>; original_target = __utopia_api_res
|
|
676
|
+
res_assign = ast.Assign(
|
|
677
|
+
targets=[ast.Name(id="__utopia_api_res", ctx=ast.Store())],
|
|
678
|
+
value=stmt.value,
|
|
679
|
+
lineno=stmt.lineno,
|
|
680
|
+
)
|
|
681
|
+
copy_assign = ast.Assign(
|
|
682
|
+
targets=stmt.targets,
|
|
683
|
+
value=ast.Name(id="__utopia_api_res", ctx=ast.Load()),
|
|
684
|
+
lineno=stmt.lineno,
|
|
685
|
+
)
|
|
686
|
+
new_stmts.extend(pre)
|
|
687
|
+
new_stmts.append(res_assign)
|
|
688
|
+
new_stmts.extend(post)
|
|
689
|
+
new_stmts.append(copy_assign)
|
|
690
|
+
self._record("api", stmt.lineno, function_name=self._current_function(), method=http_method.upper())
|
|
691
|
+
injected = True
|
|
692
|
+
|
|
693
|
+
# --- Expr: bare call ---
|
|
694
|
+
elif isinstance(stmt, ast.Expr):
|
|
695
|
+
call_node = stmt.value
|
|
696
|
+
if isinstance(call_node, ast.Await):
|
|
697
|
+
call_node = call_node.value
|
|
698
|
+
if isinstance(call_node, ast.Call):
|
|
699
|
+
if "database" in self.probe_types:
|
|
700
|
+
db_info = self._is_db_call(call_node)
|
|
701
|
+
if db_info:
|
|
702
|
+
method, receiver, lib = db_info
|
|
703
|
+
pre, post = self._make_db_probe_stmts(call_node, method, receiver, lib, stmt.lineno)
|
|
704
|
+
new_stmts.extend(pre)
|
|
705
|
+
new_stmts.append(stmt)
|
|
706
|
+
new_stmts.extend(post)
|
|
707
|
+
self._record("database", stmt.lineno, function_name=self._current_function(), operation=method)
|
|
708
|
+
injected = True
|
|
709
|
+
if not injected and "api" in self.probe_types:
|
|
710
|
+
api_info = self._is_api_call(call_node)
|
|
711
|
+
if api_info:
|
|
712
|
+
http_method, lib_hint = api_info
|
|
713
|
+
pre, post = self._make_api_probe_stmts(call_node, http_method, lib_hint, stmt.lineno)
|
|
714
|
+
# Wrap into assignment so we can read status_code
|
|
715
|
+
res_assign = ast.Assign(
|
|
716
|
+
targets=[ast.Name(id="__utopia_api_res", ctx=ast.Store())],
|
|
717
|
+
value=stmt.value,
|
|
718
|
+
lineno=stmt.lineno,
|
|
719
|
+
)
|
|
720
|
+
new_stmts.extend(pre)
|
|
721
|
+
new_stmts.append(res_assign)
|
|
722
|
+
new_stmts.extend(post)
|
|
723
|
+
self._record("api", stmt.lineno, function_name=self._current_function(), method=http_method.upper())
|
|
724
|
+
injected = True
|
|
725
|
+
|
|
726
|
+
if not injected:
|
|
727
|
+
new_stmts.append(stmt)
|
|
728
|
+
|
|
729
|
+
return new_stmts
|
|
730
|
+
|
|
731
|
+
# ------------------------------------------------------------------
|
|
732
|
+
# Infra probes
|
|
733
|
+
# ------------------------------------------------------------------
|
|
734
|
+
|
|
735
|
+
def _make_infra_probe(self) -> list[ast.stmt]:
|
|
736
|
+
"""Build the __utopia_detect_infra function + call."""
|
|
737
|
+
# We build the function as a string and parse it, for readability.
|
|
738
|
+
code = textwrap.dedent("""\
|
|
739
|
+
def __utopia_detect_infra():
|
|
740
|
+
import os
|
|
741
|
+
__import__('utopia_runtime').report_infra(
|
|
742
|
+
file=__file__ if '__file__' in dir() else '<unknown>',
|
|
743
|
+
line=0,
|
|
744
|
+
provider='aws' if os.environ.get('AWS_REGION') else 'gcp' if os.environ.get('GOOGLE_CLOUD_PROJECT') else 'vercel' if os.environ.get('VERCEL') else 'other',
|
|
745
|
+
region=os.environ.get('AWS_REGION') or os.environ.get('GOOGLE_CLOUD_REGION') or os.environ.get('VERCEL_REGION'),
|
|
746
|
+
env_vars={k: v for k, v in os.environ.items() if not any(s in k.upper() for s in ('KEY', 'SECRET', 'TOKEN', 'PASSWORD', 'CRED'))}
|
|
747
|
+
)
|
|
748
|
+
__utopia_detect_infra()
|
|
749
|
+
""")
|
|
750
|
+
tree = ast.parse(code)
|
|
751
|
+
return tree.body # [FunctionDef, Expr(call)]
|
|
752
|
+
|
|
753
|
+
# ------------------------------------------------------------------
|
|
754
|
+
# Top-level visit dispatcher
|
|
755
|
+
# ------------------------------------------------------------------
|
|
756
|
+
|
|
757
|
+
def visit_Module(self, node: ast.Module) -> ast.Module:
|
|
758
|
+
# First, generically visit so all children are transformed
|
|
759
|
+
self._ancestor_stack.append(node)
|
|
760
|
+
|
|
761
|
+
# Visit all function defs for error / function probes
|
|
762
|
+
node = self._visit_module_body(node)
|
|
763
|
+
|
|
764
|
+
self._ancestor_stack.pop()
|
|
765
|
+
|
|
766
|
+
# Inject infra probe for entry-point files
|
|
767
|
+
if "infra" in self.probe_types:
|
|
768
|
+
basename = os.path.basename(self.filepath)
|
|
769
|
+
if basename in ENTRY_POINT_NAMES:
|
|
770
|
+
infra_stmts = self._make_infra_probe()
|
|
771
|
+
# Insert after the last import/from-import at top of module
|
|
772
|
+
insert_idx = 0
|
|
773
|
+
for i, stmt in enumerate(node.body):
|
|
774
|
+
if isinstance(stmt, (ast.Import, ast.ImportFrom)):
|
|
775
|
+
insert_idx = i + 1
|
|
776
|
+
for s in reversed(infra_stmts):
|
|
777
|
+
node.body.insert(insert_idx, s)
|
|
778
|
+
self._record("infra", 0, function_name="<module>")
|
|
779
|
+
|
|
780
|
+
return node
|
|
781
|
+
|
|
782
|
+
def _visit_module_body(self, node: ast.Module) -> ast.Module:
|
|
783
|
+
"""Recursively visit the module, handling function wrapping and statement probes."""
|
|
784
|
+
new_body: list[ast.stmt] = []
|
|
785
|
+
for stmt in node.body:
|
|
786
|
+
stmt = self._visit_node(stmt)
|
|
787
|
+
new_body.append(stmt)
|
|
788
|
+
node.body = new_body
|
|
789
|
+
|
|
790
|
+
# Now do statement-level DB/API injection on the module body
|
|
791
|
+
if "database" in self.probe_types or "api" in self.probe_types:
|
|
792
|
+
node.body = self._visit_stmt_list(node.body)
|
|
793
|
+
|
|
794
|
+
return node
|
|
795
|
+
|
|
796
|
+
def _visit_node(self, node: ast.AST) -> ast.AST:
|
|
797
|
+
"""Visit a single node, dispatching to the appropriate handler."""
|
|
798
|
+
if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)):
|
|
799
|
+
return self._visit_function(node)
|
|
800
|
+
elif isinstance(node, ast.ClassDef):
|
|
801
|
+
return self._visit_classdef(node)
|
|
802
|
+
elif isinstance(node, (ast.If, ast.For, ast.While, ast.With, ast.AsyncFor, ast.AsyncWith)):
|
|
803
|
+
return self._visit_compound(node)
|
|
804
|
+
elif isinstance(node, ast.Try):
|
|
805
|
+
return self._visit_try(node)
|
|
806
|
+
return node
|
|
807
|
+
|
|
808
|
+
def _visit_function(self, node: ast.FunctionDef | ast.AsyncFunctionDef) -> ast.AST:
|
|
809
|
+
"""Visit a function definition: recurse into body, then apply probes."""
|
|
810
|
+
# First recurse into nested definitions
|
|
811
|
+
new_body: list[ast.stmt] = []
|
|
812
|
+
for stmt in node.body:
|
|
813
|
+
stmt = self._visit_node(stmt)
|
|
814
|
+
new_body.append(stmt)
|
|
815
|
+
node.body = new_body
|
|
816
|
+
|
|
817
|
+
# Inject DB/API probes at statement level inside the function
|
|
818
|
+
if "database" in self.probe_types or "api" in self.probe_types:
|
|
819
|
+
self._ancestor_stack.append(node)
|
|
820
|
+
node.body = self._visit_stmt_list(node.body)
|
|
821
|
+
self._ancestor_stack.pop()
|
|
822
|
+
|
|
823
|
+
# Wrap with error probe
|
|
824
|
+
if "error" in self.probe_types:
|
|
825
|
+
node = self._wrap_function_body(node)
|
|
826
|
+
|
|
827
|
+
# Wrap with function probe (utopia mode)
|
|
828
|
+
if self.utopia_mode:
|
|
829
|
+
node = self._wrap_function_probe(node)
|
|
830
|
+
|
|
831
|
+
return node
|
|
832
|
+
|
|
833
|
+
def _visit_classdef(self, node: ast.ClassDef) -> ast.ClassDef:
|
|
834
|
+
new_body: list[ast.stmt] = []
|
|
835
|
+
self._ancestor_stack.append(node)
|
|
836
|
+
for stmt in node.body:
|
|
837
|
+
stmt = self._visit_node(stmt)
|
|
838
|
+
new_body.append(stmt)
|
|
839
|
+
node.body = new_body
|
|
840
|
+
if "database" in self.probe_types or "api" in self.probe_types:
|
|
841
|
+
node.body = self._visit_stmt_list(node.body)
|
|
842
|
+
self._ancestor_stack.pop()
|
|
843
|
+
return node
|
|
844
|
+
|
|
845
|
+
def _visit_compound(self, node: ast.AST) -> ast.AST:
|
|
846
|
+
"""Visit compound statements (if/for/while/with) recursively."""
|
|
847
|
+
self._ancestor_stack.append(node)
|
|
848
|
+
for field_name in ("body", "orelse", "finalbody"):
|
|
849
|
+
body = getattr(node, field_name, None)
|
|
850
|
+
if body and isinstance(body, list):
|
|
851
|
+
new_body = []
|
|
852
|
+
for stmt in body:
|
|
853
|
+
stmt = self._visit_node(stmt)
|
|
854
|
+
new_body.append(stmt)
|
|
855
|
+
setattr(node, field_name, new_body)
|
|
856
|
+
if "database" in self.probe_types or "api" in self.probe_types:
|
|
857
|
+
setattr(node, field_name, self._visit_stmt_list(getattr(node, field_name)))
|
|
858
|
+
self._ancestor_stack.pop()
|
|
859
|
+
return node
|
|
860
|
+
|
|
861
|
+
def _visit_try(self, node: ast.Try) -> ast.Try:
|
|
862
|
+
self._ancestor_stack.append(node)
|
|
863
|
+
for field_name in ("body", "orelse", "finalbody"):
|
|
864
|
+
body = getattr(node, field_name, None)
|
|
865
|
+
if body and isinstance(body, list):
|
|
866
|
+
new_body = []
|
|
867
|
+
for stmt in body:
|
|
868
|
+
stmt = self._visit_node(stmt)
|
|
869
|
+
new_body.append(stmt)
|
|
870
|
+
setattr(node, field_name, new_body)
|
|
871
|
+
if "database" in self.probe_types or "api" in self.probe_types:
|
|
872
|
+
setattr(node, field_name, self._visit_stmt_list(getattr(node, field_name)))
|
|
873
|
+
for handler in node.handlers:
|
|
874
|
+
if handler.body:
|
|
875
|
+
new_body = []
|
|
876
|
+
for stmt in handler.body:
|
|
877
|
+
stmt = self._visit_node(stmt)
|
|
878
|
+
new_body.append(stmt)
|
|
879
|
+
handler.body = new_body
|
|
880
|
+
if "database" in self.probe_types or "api" in self.probe_types:
|
|
881
|
+
handler.body = self._visit_stmt_list(handler.body)
|
|
882
|
+
self._ancestor_stack.pop()
|
|
883
|
+
return node
|
|
884
|
+
|
|
885
|
+
|
|
886
|
+
# ---------------------------------------------------------------------------
|
|
887
|
+
# Top-level instrumentation logic
|
|
888
|
+
# ---------------------------------------------------------------------------
|
|
889
|
+
|
|
890
|
+
def _add_top_imports(tree: ast.Module) -> None:
|
|
891
|
+
"""Ensure ``import utopia_runtime`` is at the top of the module if needed."""
|
|
892
|
+
has_utopia_import = False
|
|
893
|
+
for node in ast.walk(tree):
|
|
894
|
+
if isinstance(node, ast.Import):
|
|
895
|
+
for alias in node.names:
|
|
896
|
+
if alias.name == "utopia_runtime":
|
|
897
|
+
has_utopia_import = True
|
|
898
|
+
elif isinstance(node, ast.ImportFrom):
|
|
899
|
+
if node.module and node.module.startswith("utopia_runtime"):
|
|
900
|
+
has_utopia_import = True
|
|
901
|
+
# We rely on inline __import__ calls, so a top-level import is optional
|
|
902
|
+
# but nice for readability. Only add if transformer flagged need.
|
|
903
|
+
# Actually, we do NOT add top-level imports -- the injected code uses
|
|
904
|
+
# __import__() for isolation and to avoid polluting the namespace.
|
|
905
|
+
|
|
906
|
+
|
|
907
|
+
def instrument_file(
|
|
908
|
+
filepath: str,
|
|
909
|
+
probe_types: set[str],
|
|
910
|
+
utopia_mode: bool = False,
|
|
911
|
+
dry_run: bool = False,
|
|
912
|
+
) -> dict[str, Any]:
|
|
913
|
+
"""Instrument a single Python file. Returns a result dict."""
|
|
914
|
+
result: dict[str, Any] = {
|
|
915
|
+
"success": False,
|
|
916
|
+
"file": filepath,
|
|
917
|
+
"probes_added": [],
|
|
918
|
+
"errors": [],
|
|
919
|
+
}
|
|
920
|
+
|
|
921
|
+
# Read source
|
|
922
|
+
try:
|
|
923
|
+
with open(filepath, "r", encoding="utf-8") as f:
|
|
924
|
+
source = f.read()
|
|
925
|
+
except Exception as exc:
|
|
926
|
+
result["errors"].append(f"Failed to read file: {exc}")
|
|
927
|
+
return result
|
|
928
|
+
|
|
929
|
+
# Parse
|
|
930
|
+
try:
|
|
931
|
+
tree = ast.parse(source, filename=filepath)
|
|
932
|
+
except SyntaxError as exc:
|
|
933
|
+
result["errors"].append(f"Syntax error: {exc}")
|
|
934
|
+
return result
|
|
935
|
+
|
|
936
|
+
# Transform
|
|
937
|
+
try:
|
|
938
|
+
transformer = UtopiaTransformer(filepath, probe_types, utopia_mode=utopia_mode)
|
|
939
|
+
new_tree = transformer.visit_Module(tree)
|
|
940
|
+
ast.fix_missing_locations(new_tree)
|
|
941
|
+
result["probes_added"] = transformer.probes_added
|
|
942
|
+
result["errors"] = transformer.errors
|
|
943
|
+
except Exception as exc:
|
|
944
|
+
result["errors"].append(f"Transformation error: {exc}")
|
|
945
|
+
return result
|
|
946
|
+
|
|
947
|
+
# Generate code
|
|
948
|
+
try:
|
|
949
|
+
new_source = ast.unparse(new_tree)
|
|
950
|
+
except Exception as exc:
|
|
951
|
+
result["errors"].append(f"Code generation error: {exc}")
|
|
952
|
+
return result
|
|
953
|
+
|
|
954
|
+
# Write or return
|
|
955
|
+
if dry_run:
|
|
956
|
+
result["code"] = new_source
|
|
957
|
+
else:
|
|
958
|
+
try:
|
|
959
|
+
with open(filepath, "w", encoding="utf-8") as f:
|
|
960
|
+
f.write(new_source)
|
|
961
|
+
except Exception as exc:
|
|
962
|
+
result["errors"].append(f"Failed to write file: {exc}")
|
|
963
|
+
return result
|
|
964
|
+
|
|
965
|
+
result["success"] = True
|
|
966
|
+
return result
|
|
967
|
+
|
|
968
|
+
|
|
969
|
+
def validate_file(filepath: str) -> dict[str, Any]:
|
|
970
|
+
"""Validate that a file has been properly instrumented."""
|
|
971
|
+
result: dict[str, Any] = {
|
|
972
|
+
"success": False,
|
|
973
|
+
"file": filepath,
|
|
974
|
+
"probes_found": [],
|
|
975
|
+
"errors": [],
|
|
976
|
+
}
|
|
977
|
+
|
|
978
|
+
try:
|
|
979
|
+
with open(filepath, "r", encoding="utf-8") as f:
|
|
980
|
+
source = f.read()
|
|
981
|
+
except Exception as exc:
|
|
982
|
+
result["errors"].append(f"Failed to read file: {exc}")
|
|
983
|
+
return result
|
|
984
|
+
|
|
985
|
+
try:
|
|
986
|
+
tree = ast.parse(source, filename=filepath)
|
|
987
|
+
except SyntaxError as exc:
|
|
988
|
+
result["errors"].append(f"Syntax error in instrumented file: {exc}")
|
|
989
|
+
return result
|
|
990
|
+
|
|
991
|
+
# Walk the tree looking for utopia probe markers
|
|
992
|
+
for node in ast.walk(tree):
|
|
993
|
+
# Look for __import__('utopia_runtime') calls
|
|
994
|
+
if isinstance(node, ast.Call):
|
|
995
|
+
if (
|
|
996
|
+
isinstance(node.func, ast.Name)
|
|
997
|
+
and node.func.id == "__import__"
|
|
998
|
+
and node.args
|
|
999
|
+
and isinstance(node.args[0], ast.Constant)
|
|
1000
|
+
and node.args[0].value == "utopia_runtime"
|
|
1001
|
+
):
|
|
1002
|
+
# Find what method is being called on it
|
|
1003
|
+
pass
|
|
1004
|
+
|
|
1005
|
+
# Look for utopia variable names
|
|
1006
|
+
if isinstance(node, ast.Name) and node.id.startswith("__utopia_"):
|
|
1007
|
+
result["probes_found"].append(
|
|
1008
|
+
{"type": "variable", "name": node.id, "line": getattr(node, "lineno", 0)}
|
|
1009
|
+
)
|
|
1010
|
+
|
|
1011
|
+
# Look for try/except with utopia report calls
|
|
1012
|
+
if isinstance(node, ast.Try):
|
|
1013
|
+
for handler in node.handlers:
|
|
1014
|
+
if handler.name and handler.name.startswith("__utopia_"):
|
|
1015
|
+
result["probes_found"].append(
|
|
1016
|
+
{"type": "error_handler", "name": handler.name, "line": getattr(handler, "lineno", 0)}
|
|
1017
|
+
)
|
|
1018
|
+
|
|
1019
|
+
# Look for __utopia_detect_infra
|
|
1020
|
+
if isinstance(node, ast.FunctionDef) and node.name == "__utopia_detect_infra":
|
|
1021
|
+
result["probes_found"].append(
|
|
1022
|
+
{"type": "infra", "name": node.name, "line": getattr(node, "lineno", 0)}
|
|
1023
|
+
)
|
|
1024
|
+
|
|
1025
|
+
# Check that the file at least compiles
|
|
1026
|
+
try:
|
|
1027
|
+
compile(source, filepath, "exec")
|
|
1028
|
+
except Exception as exc:
|
|
1029
|
+
result["errors"].append(f"Instrumented file does not compile: {exc}")
|
|
1030
|
+
return result
|
|
1031
|
+
|
|
1032
|
+
result["success"] = True
|
|
1033
|
+
return result
|
|
1034
|
+
|
|
1035
|
+
|
|
1036
|
+
# ---------------------------------------------------------------------------
|
|
1037
|
+
# CLI
|
|
1038
|
+
# ---------------------------------------------------------------------------
|
|
1039
|
+
|
|
1040
|
+
def build_parser() -> argparse.ArgumentParser:
|
|
1041
|
+
parser = argparse.ArgumentParser(
|
|
1042
|
+
prog="instrument",
|
|
1043
|
+
description="Utopia Python AST Instrumenter - inject observability probes into Python source files",
|
|
1044
|
+
)
|
|
1045
|
+
subparsers = parser.add_subparsers(dest="command", required=True)
|
|
1046
|
+
|
|
1047
|
+
# instrument
|
|
1048
|
+
instr_parser = subparsers.add_parser("instrument", help="Instrument a Python file")
|
|
1049
|
+
instr_parser.add_argument("file_path", help="Path to the Python file to instrument")
|
|
1050
|
+
instr_parser.add_argument(
|
|
1051
|
+
"--probe-types",
|
|
1052
|
+
default="error,database,api,infra",
|
|
1053
|
+
help="Comma-separated list of probe types (default: error,database,api,infra)",
|
|
1054
|
+
)
|
|
1055
|
+
instr_parser.add_argument(
|
|
1056
|
+
"--utopia-mode",
|
|
1057
|
+
action="store_true",
|
|
1058
|
+
help="Enable function probes for detailed tracing",
|
|
1059
|
+
)
|
|
1060
|
+
instr_parser.add_argument(
|
|
1061
|
+
"--dry-run",
|
|
1062
|
+
action="store_true",
|
|
1063
|
+
help="Print transformed code to stdout without writing to file",
|
|
1064
|
+
)
|
|
1065
|
+
instr_parser.add_argument(
|
|
1066
|
+
"--output-json",
|
|
1067
|
+
action="store_true",
|
|
1068
|
+
help="Output results as JSON",
|
|
1069
|
+
)
|
|
1070
|
+
|
|
1071
|
+
# validate
|
|
1072
|
+
val_parser = subparsers.add_parser("validate", help="Validate an instrumented file")
|
|
1073
|
+
val_parser.add_argument("file_path", help="Path to the instrumented Python file")
|
|
1074
|
+
val_parser.add_argument(
|
|
1075
|
+
"--output-json",
|
|
1076
|
+
action="store_true",
|
|
1077
|
+
help="Output results as JSON",
|
|
1078
|
+
)
|
|
1079
|
+
|
|
1080
|
+
return parser
|
|
1081
|
+
|
|
1082
|
+
|
|
1083
|
+
def main() -> None:
|
|
1084
|
+
parser = build_parser()
|
|
1085
|
+
args = parser.parse_args()
|
|
1086
|
+
|
|
1087
|
+
if args.command == "instrument":
|
|
1088
|
+
filepath = os.path.abspath(args.file_path)
|
|
1089
|
+
if not os.path.isfile(filepath):
|
|
1090
|
+
print(f"Error: file not found: {filepath}", file=sys.stderr)
|
|
1091
|
+
sys.exit(1)
|
|
1092
|
+
|
|
1093
|
+
probe_types_raw = {t.strip() for t in args.probe_types.split(",")}
|
|
1094
|
+
invalid = probe_types_raw - ALL_PROBE_TYPES
|
|
1095
|
+
if invalid:
|
|
1096
|
+
print(f"Error: unknown probe types: {', '.join(sorted(invalid))}", file=sys.stderr)
|
|
1097
|
+
sys.exit(1)
|
|
1098
|
+
|
|
1099
|
+
result = instrument_file(
|
|
1100
|
+
filepath,
|
|
1101
|
+
probe_types=probe_types_raw,
|
|
1102
|
+
utopia_mode=args.utopia_mode,
|
|
1103
|
+
dry_run=args.dry_run,
|
|
1104
|
+
)
|
|
1105
|
+
|
|
1106
|
+
if args.output_json:
|
|
1107
|
+
print(json.dumps(result, indent=2))
|
|
1108
|
+
else:
|
|
1109
|
+
if result["success"]:
|
|
1110
|
+
action = "Would instrument" if args.dry_run else "Instrumented"
|
|
1111
|
+
print(f"{action} {filepath}")
|
|
1112
|
+
for probe in result["probes_added"]:
|
|
1113
|
+
print(f" [{probe['type']}] line {probe['line']}: {probe.get('function_name', '')}")
|
|
1114
|
+
if args.dry_run and "code" in result:
|
|
1115
|
+
print("\n--- Transformed code ---")
|
|
1116
|
+
print(result["code"])
|
|
1117
|
+
else:
|
|
1118
|
+
print(f"Failed to instrument {filepath}", file=sys.stderr)
|
|
1119
|
+
for err in result["errors"]:
|
|
1120
|
+
print(f" Error: {err}", file=sys.stderr)
|
|
1121
|
+
sys.exit(1)
|
|
1122
|
+
|
|
1123
|
+
elif args.command == "validate":
|
|
1124
|
+
filepath = os.path.abspath(args.file_path)
|
|
1125
|
+
if not os.path.isfile(filepath):
|
|
1126
|
+
print(f"Error: file not found: {filepath}", file=sys.stderr)
|
|
1127
|
+
sys.exit(1)
|
|
1128
|
+
|
|
1129
|
+
result = validate_file(filepath)
|
|
1130
|
+
|
|
1131
|
+
if args.output_json:
|
|
1132
|
+
print(json.dumps(result, indent=2))
|
|
1133
|
+
else:
|
|
1134
|
+
if result["success"]:
|
|
1135
|
+
print(f"Validated {filepath}")
|
|
1136
|
+
for probe in result["probes_found"]:
|
|
1137
|
+
print(f" [{probe['type']}] line {probe['line']}: {probe.get('name', '')}")
|
|
1138
|
+
if not result["probes_found"]:
|
|
1139
|
+
print(" No probes found in file")
|
|
1140
|
+
else:
|
|
1141
|
+
print(f"Validation failed for {filepath}", file=sys.stderr)
|
|
1142
|
+
for err in result["errors"]:
|
|
1143
|
+
print(f" Error: {err}", file=sys.stderr)
|
|
1144
|
+
sys.exit(1)
|
|
1145
|
+
|
|
1146
|
+
|
|
1147
|
+
if __name__ == "__main__":
|
|
1148
|
+
main()
|