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/__init__.py +3 -0
- nabla/cli.py +121 -0
- nabla/contract.py +132 -0
- nabla/induce.py +176 -0
- nabla/profile.py +138 -0
- nabla/prompt_recon.py +542 -0
- nabla/recorder.py +609 -0
- nabla/scanner.py +453 -0
- nabla/schema_resolver.py +309 -0
- nabla/schemas/site_profile.schema.json +136 -0
- nabla_cli-0.1.0.dist-info/METADATA +82 -0
- nabla_cli-0.1.0.dist-info/RECORD +15 -0
- nabla_cli-0.1.0.dist-info/WHEEL +5 -0
- nabla_cli-0.1.0.dist-info/entry_points.txt +2 -0
- nabla_cli-0.1.0.dist-info/top_level.txt +1 -0
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
|