nabla-cli 0.1.0__py3-none-any.whl

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.
nabla/scanner.py ADDED
@@ -0,0 +1,453 @@
1
+ """T1 — call-site detection + classification. See cli-plan.md T1.
2
+
3
+ Pure `ast` static analysis. No LLM, no network, no imports of sibling
4
+ nabla modules besides `contract`.
5
+
6
+ Algorithm, in two passes over every ``*.py`` file under the repo:
7
+
8
+ 1. For every function definition (module-level function or class method,
9
+ at any nesting depth) find the SDK calls made *directly* in its body
10
+ (not inside a nested function/class/lambda — those get their own
11
+ entry when we reach them), and the calls it makes to other
12
+ project-local functions (resolved through same-file definitions or
13
+ ``import`` / ``from ... import`` statements, one hop).
14
+
15
+ 2. A function that contains an SDK call directly is a "wrapper
16
+ candidate". If nothing in the repo calls it, it *is* the site. If
17
+ one or more project functions call it, each caller becomes its own
18
+ site (flagged ``wrapper_indirect``) and the wrapper itself is not
19
+ reported — *unless* the function owns its prompt template (literal
20
+ or module-constant message content in the call's ``messages``). A
21
+ template-owning function is the site even when called from elsewhere:
22
+ its callers pass slot data, not templates.
23
+ """
24
+
25
+ from __future__ import annotations
26
+
27
+ import ast
28
+ import os
29
+ import posixpath
30
+ from dataclasses import dataclass, field
31
+
32
+ from .contract import (
33
+ FLAG_ASYNC,
34
+ FLAG_MULTI_TURN,
35
+ FLAG_N_GT_1,
36
+ FLAG_STREAMING,
37
+ FLAG_VISION,
38
+ FLAG_WRAPPER_INDIRECT,
39
+ RawSite,
40
+ RawSiteRef,
41
+ )
42
+
43
+ _SKIP_DIR_NAMES = {".git", "node_modules", "__pycache__", ".venv", "venv"}
44
+
45
+ _VISION_MARKERS = {"image_url", "input_image"}
46
+
47
+ Span = tuple[int, int]
48
+ FuncKey = tuple[str, str] # (repo-relative file, qualname)
49
+
50
+
51
+ @dataclass
52
+ class _SdkCallInfo:
53
+ node: ast.Call
54
+ kind: str
55
+ flags: list[str]
56
+ owns_template: bool = False
57
+
58
+
59
+ @dataclass
60
+ class _FileImports:
61
+ # local name -> (target repo-relative file, target function name)
62
+ names: dict[str, FuncKey] = field(default_factory=dict)
63
+ # local module alias -> target repo-relative file
64
+ modules: dict[str, str] = field(default_factory=dict)
65
+
66
+
67
+ def scan(repo_path: str) -> list[RawSite]:
68
+ """Find every OpenAI chat-completions call site under repo_path.
69
+
70
+ Resolves within-module client aliases, enumerates wrapper callers
71
+ (one hop) as distinct sites, detects flags (streaming/async/
72
+ multi_turn/vision/n_gt_1/wrapper_indirect), and returns a
73
+ deterministic ordering (sorted by file, then span).
74
+ """
75
+ repo_abspath = os.path.abspath(repo_path)
76
+ files = _discover_python_files(repo_abspath)
77
+
78
+ parsed: dict[str, ast.Module] = {}
79
+ for relpath, abspath in files:
80
+ with open(abspath, "r", encoding="utf-8") as f:
81
+ source = f.read()
82
+ parsed[relpath] = ast.parse(source, filename=abspath)
83
+
84
+ top_level_funcs: dict[str, dict[str, ast.AST]] = {
85
+ relpath: {
86
+ stmt.name: stmt
87
+ for stmt in tree.body
88
+ if isinstance(stmt, (ast.FunctionDef, ast.AsyncFunctionDef))
89
+ }
90
+ for relpath, tree in parsed.items()
91
+ }
92
+
93
+ imports: dict[str, _FileImports] = {
94
+ relpath: _resolve_imports(relpath, tree, top_level_funcs)
95
+ for relpath, tree in parsed.items()
96
+ }
97
+
98
+ direct_sdk: dict[FuncKey, list[_SdkCallInfo]] = {}
99
+ callee_calls: dict[FuncKey, list[tuple[ast.Call, FuncKey]]] = {}
100
+
101
+ for relpath, tree in parsed.items():
102
+ module_str_consts = {
103
+ stmt.targets[0].id
104
+ for stmt in tree.body
105
+ if isinstance(stmt, ast.Assign)
106
+ and len(stmt.targets) == 1
107
+ and isinstance(stmt.targets[0], ast.Name)
108
+ and isinstance(stmt.value, ast.Constant)
109
+ and isinstance(stmt.value.value, str)
110
+ }
111
+ for func_node, qualname in _iter_all_funcdefs(tree):
112
+ is_async = isinstance(func_node, ast.AsyncFunctionDef)
113
+ local_assigns: dict[str, ast.AST] = {}
114
+ sdk_calls_here: list[_SdkCallInfo] = []
115
+ callee_here: list[tuple[ast.Call, FuncKey]] = []
116
+
117
+ for n in _bounded_walk(func_node):
118
+ if (
119
+ isinstance(n, ast.Assign)
120
+ and len(n.targets) == 1
121
+ and isinstance(n.targets[0], ast.Name)
122
+ ):
123
+ local_assigns[n.targets[0].id] = n.value
124
+ if isinstance(n, ast.Call):
125
+ kind = _match_sdk_call(n)
126
+ if kind is not None:
127
+ flags = _call_flags(n, is_async, local_assigns)
128
+ sdk_calls_here.append(_SdkCallInfo(
129
+ node=n, kind=kind, flags=flags,
130
+ owns_template=_owns_template(n, module_str_consts),
131
+ ))
132
+ else:
133
+ target = _resolve_callee(n, relpath, top_level_funcs, imports)
134
+ if target is not None:
135
+ callee_here.append((n, target))
136
+
137
+ key = (relpath, qualname)
138
+ if sdk_calls_here:
139
+ direct_sdk[key] = sdk_calls_here
140
+ if callee_here:
141
+ callee_calls[key] = callee_here
142
+
143
+ wrapper_callers: dict[FuncKey, list[tuple[str, str, ast.Call]]] = {}
144
+ for (caller_file, caller_func), calls in callee_calls.items():
145
+ for call_node, target_key in calls:
146
+ if target_key in direct_sdk:
147
+ wrapper_callers.setdefault(target_key, []).append(
148
+ (caller_file, caller_func, call_node)
149
+ )
150
+
151
+ sites: list[RawSite] = []
152
+ for key, sdk_calls in direct_sdk.items():
153
+ file, func = key
154
+ callers = wrapper_callers.get(key)
155
+ # A template-owning function is never demoted to a wrapper: its
156
+ # callers supply slot values, not prompt structure.
157
+ if callers and all(c.owns_template for c in sdk_calls):
158
+ callers = None
159
+ if callers:
160
+ for sdk_call in sdk_calls:
161
+ wrapper_ref = RawSiteRef(file=file, span=_span(sdk_call.node), symbol=func)
162
+ for caller_file, caller_func, call_node in callers:
163
+ site_flags = sorted(set(sdk_call.flags) | {FLAG_WRAPPER_INDIRECT})
164
+ sites.append(
165
+ RawSite(
166
+ file=caller_file,
167
+ span=_span(call_node),
168
+ symbol=caller_func,
169
+ kind=sdk_call.kind,
170
+ flags=site_flags,
171
+ wrapper=wrapper_ref,
172
+ )
173
+ )
174
+ else:
175
+ for sdk_call in sdk_calls:
176
+ sites.append(
177
+ RawSite(
178
+ file=file,
179
+ span=_span(sdk_call.node),
180
+ symbol=func,
181
+ kind=sdk_call.kind,
182
+ flags=sorted(sdk_call.flags),
183
+ wrapper=None,
184
+ )
185
+ )
186
+
187
+ sites.sort(key=lambda s: (s.file, s.span))
188
+ return sites
189
+
190
+
191
+ # --------------------------------------------------------------------------
192
+ # Repo discovery
193
+ # --------------------------------------------------------------------------
194
+
195
+
196
+ def _discover_python_files(repo_abspath: str) -> list[tuple[str, str]]:
197
+ results: list[tuple[str, str]] = []
198
+ for dirpath, dirnames, filenames in os.walk(repo_abspath):
199
+ if "pyvenv.cfg" in filenames:
200
+ dirnames[:] = []
201
+ continue
202
+ dirnames[:] = [d for d in dirnames if d not in _SKIP_DIR_NAMES]
203
+ for fname in filenames:
204
+ if fname.endswith(".py"):
205
+ abspath = os.path.join(dirpath, fname)
206
+ relpath = os.path.relpath(abspath, repo_abspath).replace(os.sep, "/")
207
+ results.append((relpath, abspath))
208
+ results.sort()
209
+ return results
210
+
211
+
212
+ # --------------------------------------------------------------------------
213
+ # Scope-bounded traversal
214
+ # --------------------------------------------------------------------------
215
+
216
+
217
+ def _bounded_walk(func_node: ast.AST):
218
+ """Yield every descendant in func_node's body without crossing into a
219
+ nested function/class/lambda scope (that scope is walked separately
220
+ when we reach it as its own top-level entry from `_iter_all_funcdefs`)."""
221
+
222
+ def walk(n: ast.AST):
223
+ yield n
224
+ if isinstance(n, (ast.FunctionDef, ast.AsyncFunctionDef, ast.ClassDef, ast.Lambda)):
225
+ return
226
+ for child in ast.iter_child_nodes(n):
227
+ yield from walk(child)
228
+
229
+ for stmt in func_node.body:
230
+ yield from walk(stmt)
231
+
232
+
233
+ def _iter_all_funcdefs(tree: ast.Module):
234
+ """Yield (func_node, qualname) for every function/method defined
235
+ anywhere in the module. qualname is "Class.method" for methods
236
+ (one level), else the bare function name."""
237
+
238
+ def walk(node: ast.AST, class_stack: list[str]):
239
+ for child in ast.iter_child_nodes(node):
240
+ if isinstance(child, ast.ClassDef):
241
+ yield from walk(child, class_stack + [child.name])
242
+ elif isinstance(child, (ast.FunctionDef, ast.AsyncFunctionDef)):
243
+ qualname = ".".join(class_stack + [child.name])
244
+ yield (child, qualname)
245
+ yield from walk(child, class_stack)
246
+ else:
247
+ yield from walk(child, class_stack)
248
+
249
+ yield from walk(tree, [])
250
+
251
+
252
+ # --------------------------------------------------------------------------
253
+ # SDK call matching + flag detection
254
+ # --------------------------------------------------------------------------
255
+
256
+
257
+ def _match_sdk_call(call: ast.Call) -> str | None:
258
+ """Match `<base...>.chat.completions.create(...)` -> "create" or
259
+ `<base...>.beta.chat.completions.parse(...)` -> "parse", regardless
260
+ of what `<base...>` resolves to (client alias name, `self.client`,
261
+ etc.)."""
262
+ func = call.func
263
+ if not isinstance(func, ast.Attribute):
264
+ return None
265
+
266
+ if func.attr == "create":
267
+ completions = func.value
268
+ if isinstance(completions, ast.Attribute) and completions.attr == "completions":
269
+ chat = completions.value
270
+ if isinstance(chat, ast.Attribute) and chat.attr == "chat":
271
+ return "create"
272
+ return None
273
+
274
+ if func.attr == "parse":
275
+ completions = func.value
276
+ if isinstance(completions, ast.Attribute) and completions.attr == "completions":
277
+ chat = completions.value
278
+ if isinstance(chat, ast.Attribute) and chat.attr == "chat":
279
+ beta = chat.value
280
+ if isinstance(beta, ast.Attribute) and beta.attr == "beta":
281
+ return "parse"
282
+ return None
283
+
284
+ return None
285
+
286
+
287
+ def _get_kwarg(call: ast.Call, name: str) -> ast.AST | None:
288
+ for kw in call.keywords:
289
+ if kw.arg == name:
290
+ return kw.value
291
+ return None
292
+
293
+
294
+ def _call_flags(call: ast.Call, is_async: bool, local_assigns: dict[str, ast.AST]) -> list[str]:
295
+ flags: list[str] = []
296
+ if is_async:
297
+ flags.append(FLAG_ASYNC)
298
+
299
+ stream_val = _get_kwarg(call, "stream")
300
+ if isinstance(stream_val, ast.Constant) and stream_val.value is True:
301
+ flags.append(FLAG_STREAMING)
302
+
303
+ n_val = _get_kwarg(call, "n")
304
+ if (
305
+ isinstance(n_val, ast.Constant)
306
+ and isinstance(n_val.value, int)
307
+ and not isinstance(n_val.value, bool)
308
+ and n_val.value >= 2
309
+ ):
310
+ flags.append(FLAG_N_GT_1)
311
+
312
+ messages_val = _get_kwarg(call, "messages")
313
+ if messages_val is not None:
314
+ if _is_multi_turn(messages_val, local_assigns):
315
+ flags.append(FLAG_MULTI_TURN)
316
+ if _has_vision_content(messages_val):
317
+ flags.append(FLAG_VISION)
318
+
319
+ return flags
320
+
321
+
322
+ def _owns_template(call: ast.Call, module_str_consts: set[str]) -> bool:
323
+ """True when the SDK call's ``messages`` literal carries template text of
324
+ its own — a string-literal content value, a module-level string constant,
325
+ or an f-string with non-trivial literal fragments. Distinguishes a
326
+ template-owning site (e.g. a fixed system prompt + one data parameter)
327
+ from a generic wrapper whose entire prompt arrives via parameters."""
328
+ messages = _get_kwarg(call, "messages")
329
+ if not isinstance(messages, ast.List):
330
+ return False
331
+ for elt in messages.elts:
332
+ if not isinstance(elt, ast.Dict):
333
+ continue
334
+ for k, v in zip(elt.keys, elt.values):
335
+ if not (isinstance(k, ast.Constant) and k.value == "content"):
336
+ continue
337
+ if isinstance(v, ast.Constant) and isinstance(v.value, str) and v.value.strip():
338
+ return True
339
+ if isinstance(v, ast.Name) and v.id in module_str_consts:
340
+ return True
341
+ if isinstance(v, ast.JoinedStr) and any(
342
+ isinstance(part, ast.Constant)
343
+ and isinstance(part.value, str)
344
+ and part.value.strip()
345
+ for part in v.values
346
+ ):
347
+ return True
348
+ return False
349
+
350
+
351
+ def _is_multi_turn(value_node: ast.AST, local_assigns: dict[str, ast.AST]) -> bool:
352
+ """True when `messages` is derived from a parameter or accumulated
353
+ history rather than being a list literal built in-function."""
354
+ if isinstance(value_node, ast.List):
355
+ return False
356
+ if isinstance(value_node, ast.Name):
357
+ assigned = local_assigns.get(value_node.id)
358
+ if assigned is None:
359
+ # Not locally assigned -> comes from an outer scope (a
360
+ # parameter, module global, etc.).
361
+ return True
362
+ return not isinstance(assigned, ast.List)
363
+ return True
364
+
365
+
366
+ def _has_vision_content(node: ast.AST) -> bool:
367
+ for n in ast.walk(node):
368
+ if isinstance(n, ast.Constant) and isinstance(n.value, str) and n.value in _VISION_MARKERS:
369
+ return True
370
+ return False
371
+
372
+
373
+ def _span(node: ast.AST) -> Span:
374
+ return (node.lineno, node.end_lineno)
375
+
376
+
377
+ # --------------------------------------------------------------------------
378
+ # Import resolution (same-repo, one hop)
379
+ # --------------------------------------------------------------------------
380
+
381
+
382
+ def _resolve_imports(
383
+ relpath: str,
384
+ tree: ast.Module,
385
+ top_level_funcs: dict[str, dict[str, ast.AST]],
386
+ ) -> _FileImports:
387
+ result = _FileImports()
388
+ file_dir = posixpath.dirname(relpath)
389
+
390
+ for stmt in tree.body:
391
+ if isinstance(stmt, ast.ImportFrom):
392
+ module_parts = stmt.module.split(".") if stmt.module else []
393
+ if stmt.level == 0:
394
+ candidate = "/".join(module_parts) + ".py"
395
+ else:
396
+ target_dir = file_dir
397
+ for _ in range(stmt.level - 1):
398
+ target_dir = posixpath.dirname(target_dir)
399
+ parts = [p for p in [target_dir, *module_parts] if p]
400
+ candidate = "/".join(parts) + ".py"
401
+
402
+ if candidate in top_level_funcs:
403
+ for alias in stmt.names:
404
+ orig_name = alias.name
405
+ local_name = alias.asname or alias.name
406
+ if orig_name in top_level_funcs[candidate]:
407
+ result.names[local_name] = (candidate, orig_name)
408
+ else:
409
+ # Might be a submodule/name we can't resolve
410
+ # (e.g. `from . import module`); ignore.
411
+ pass
412
+ # Track the module itself too, for `from .llm_utils import
413
+ # x` used elsewhere as attribute access — not needed here.
414
+
415
+ elif isinstance(stmt, ast.Import):
416
+ for alias in stmt.names:
417
+ module_parts = alias.name.split(".")
418
+ candidate = "/".join(module_parts) + ".py"
419
+ if candidate in top_level_funcs:
420
+ local_name = alias.asname or module_parts[0]
421
+ result.modules[local_name] = candidate
422
+
423
+ return result
424
+
425
+
426
+ def _resolve_callee(
427
+ call: ast.Call,
428
+ relpath: str,
429
+ top_level_funcs: dict[str, dict[str, ast.AST]],
430
+ imports: dict[str, _FileImports],
431
+ ) -> FuncKey | None:
432
+ """Resolve a call to another project-local function, one hop, via
433
+ same-file definitions or this file's imports. Returns None for
434
+ anything else (external libraries, dynamic dispatch, etc.)."""
435
+ func = call.func
436
+ file_imports = imports.get(relpath, _FileImports())
437
+
438
+ if isinstance(func, ast.Name):
439
+ name = func.id
440
+ if name in top_level_funcs.get(relpath, {}):
441
+ return (relpath, name)
442
+ if name in file_imports.names:
443
+ return file_imports.names[name]
444
+ return None
445
+
446
+ if isinstance(func, ast.Attribute) and isinstance(func.value, ast.Name):
447
+ base = func.value.id
448
+ target_file = file_imports.modules.get(base)
449
+ if target_file is not None and func.attr in top_level_funcs.get(target_file, {}):
450
+ return (target_file, func.attr)
451
+ return None
452
+
453
+ return None