@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.
Files changed (49) hide show
  1. package/.claude/settings.json +1 -0
  2. package/.claude/settings.local.json +38 -0
  3. package/bin/utopia.js +20 -0
  4. package/package.json +46 -0
  5. package/python/README.md +34 -0
  6. package/python/instrumenter/instrument.py +1148 -0
  7. package/python/pyproject.toml +32 -0
  8. package/python/setup.py +27 -0
  9. package/python/utopia_runtime/__init__.py +30 -0
  10. package/python/utopia_runtime/__pycache__/__init__.cpython-313.pyc +0 -0
  11. package/python/utopia_runtime/__pycache__/client.cpython-313.pyc +0 -0
  12. package/python/utopia_runtime/__pycache__/probe.cpython-313.pyc +0 -0
  13. package/python/utopia_runtime/client.py +31 -0
  14. package/python/utopia_runtime/probe.py +446 -0
  15. package/python/utopia_runtime.egg-info/PKG-INFO +59 -0
  16. package/python/utopia_runtime.egg-info/SOURCES.txt +10 -0
  17. package/python/utopia_runtime.egg-info/dependency_links.txt +1 -0
  18. package/python/utopia_runtime.egg-info/top_level.txt +1 -0
  19. package/scripts/publish-npm.sh +14 -0
  20. package/scripts/publish-pypi.sh +17 -0
  21. package/src/cli/commands/codex.ts +193 -0
  22. package/src/cli/commands/context.ts +188 -0
  23. package/src/cli/commands/destruct.ts +237 -0
  24. package/src/cli/commands/easter-eggs.ts +203 -0
  25. package/src/cli/commands/init.ts +505 -0
  26. package/src/cli/commands/instrument.ts +962 -0
  27. package/src/cli/commands/mcp.ts +16 -0
  28. package/src/cli/commands/serve.ts +194 -0
  29. package/src/cli/commands/status.ts +304 -0
  30. package/src/cli/commands/validate.ts +328 -0
  31. package/src/cli/index.ts +37 -0
  32. package/src/cli/utils/config.ts +54 -0
  33. package/src/graph/index.ts +687 -0
  34. package/src/instrumenter/javascript.ts +1798 -0
  35. package/src/mcp/index.ts +886 -0
  36. package/src/runtime/js/index.ts +518 -0
  37. package/src/runtime/js/package-lock.json +30 -0
  38. package/src/runtime/js/package.json +30 -0
  39. package/src/runtime/js/tsconfig.json +16 -0
  40. package/src/server/db/index.ts +26 -0
  41. package/src/server/db/schema.ts +45 -0
  42. package/src/server/index.ts +79 -0
  43. package/src/server/middleware/auth.ts +74 -0
  44. package/src/server/routes/admin.ts +36 -0
  45. package/src/server/routes/graph.ts +358 -0
  46. package/src/server/routes/probes.ts +286 -0
  47. package/src/types.ts +147 -0
  48. package/src/utopia-mode/index.ts +206 -0
  49. 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()