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/prompt_recon.py
ADDED
|
@@ -0,0 +1,542 @@
|
|
|
1
|
+
"""T2 — prompt template reconstruction. See cli-plan.md T2.
|
|
2
|
+
|
|
3
|
+
Owner fills this module. Re-parses source from raw_site.file/span; does NOT
|
|
4
|
+
import scanner internals.
|
|
5
|
+
|
|
6
|
+
Approach: pure static backward dataflow over the AST. Starting from the
|
|
7
|
+
`messages=` argument of the SDK call, each message's `content` expression is
|
|
8
|
+
resolved to a template string by chasing Name references through a small
|
|
9
|
+
stack of "frames" -- one per function body currently being inlined:
|
|
10
|
+
|
|
11
|
+
* module-level constants (simple `NAME = <expr>` assignments) are inlined
|
|
12
|
+
directly;
|
|
13
|
+
* local assignments (`x = <expr>`) are inlined, with `<expr>` evaluated in
|
|
14
|
+
the same frame;
|
|
15
|
+
* calls to other functions defined in the same module are inlined by
|
|
16
|
+
binding the callee's parameters to the caller's argument expressions
|
|
17
|
+
(evaluated in the *caller's* frame) and resolving the callee's `return`
|
|
18
|
+
expression;
|
|
19
|
+
* a function parameter that has no binding (i.e. we've walked all the way
|
|
20
|
+
back to the function that directly contains -- or, for a wrapper site,
|
|
21
|
+
is one hop away from -- the SDK call) becomes a named `{slot}`;
|
|
22
|
+
* anything else (attribute access on an unknown object, calls to
|
|
23
|
+
functions we can't locate, subscripts, comprehensions, ...) becomes an
|
|
24
|
+
opaque `{UNRESOLVED_n}` slot and sets `partial=True`. This function never
|
|
25
|
+
raises; degenerate/unfamiliar code always degrades to a partial result.
|
|
26
|
+
|
|
27
|
+
For `wrapper_indirect` sites, `raw_site` describes the *caller* (the
|
|
28
|
+
reported site) and `raw_site.wrapper` describes where the SDK call actually
|
|
29
|
+
lives. We resolve the wrapper function's messages, but treat its own
|
|
30
|
+
parameters as bound to the caller's argument expressions at the one call
|
|
31
|
+
site (within `raw_site.symbol`) that invokes the wrapper -- i.e. exactly one
|
|
32
|
+
cross-module hop, as required.
|
|
33
|
+
"""
|
|
34
|
+
|
|
35
|
+
from __future__ import annotations
|
|
36
|
+
|
|
37
|
+
import ast
|
|
38
|
+
import re
|
|
39
|
+
from dataclasses import dataclass, field
|
|
40
|
+
from pathlib import Path
|
|
41
|
+
|
|
42
|
+
from .contract import PromptTemplate, RawSite, Slot
|
|
43
|
+
|
|
44
|
+
_FALLBACK_NAME = "UNRESOLVED_1"
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def reconstruct(raw_site: RawSite, repo_path: str) -> PromptTemplate:
|
|
48
|
+
"""Recover the prompt template with named {slot} markers.
|
|
49
|
+
|
|
50
|
+
Must: resolve messages back through assignments, f-strings, .format(),
|
|
51
|
+
%, concatenation, and function parameters (module + one call-hop);
|
|
52
|
+
split system vs user messages; mark unresolvable fragments as
|
|
53
|
+
{UNRESOLVED_n} slots and set partial=True instead of failing.
|
|
54
|
+
"""
|
|
55
|
+
try:
|
|
56
|
+
result = _reconstruct_inner(raw_site, repo_path)
|
|
57
|
+
if result is not None:
|
|
58
|
+
return result
|
|
59
|
+
except Exception:
|
|
60
|
+
pass
|
|
61
|
+
return _fallback_template()
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def _fallback_template() -> PromptTemplate:
|
|
65
|
+
return PromptTemplate(
|
|
66
|
+
template="{" + _FALLBACK_NAME + "}",
|
|
67
|
+
slots=[Slot(name=_FALLBACK_NAME, inferred_type="unresolved")],
|
|
68
|
+
system_prompt=None,
|
|
69
|
+
embedded_few_shot=[],
|
|
70
|
+
partial=True,
|
|
71
|
+
)
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
# ---------------------------------------------------------------------------
|
|
75
|
+
# Analysis state
|
|
76
|
+
# ---------------------------------------------------------------------------
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
class _Ctx:
|
|
80
|
+
def __init__(self) -> None:
|
|
81
|
+
self.partial = False
|
|
82
|
+
self.unresolved_count = 0
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
@dataclass
|
|
86
|
+
class _ModuleInfo:
|
|
87
|
+
path: Path
|
|
88
|
+
tree: ast.Module
|
|
89
|
+
module_consts: dict[str, ast.expr]
|
|
90
|
+
functions: dict[str, ast.FunctionDef | ast.AsyncFunctionDef]
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
@dataclass
|
|
94
|
+
class _Frame:
|
|
95
|
+
module: _ModuleInfo
|
|
96
|
+
func: ast.FunctionDef | ast.AsyncFunctionDef | None
|
|
97
|
+
env: dict[str, tuple[ast.expr, "_Frame"]] = field(default_factory=dict)
|
|
98
|
+
# is_root frames are those whose own parameters have no known caller
|
|
99
|
+
# binding -- their unbound parameters become the actual named slots.
|
|
100
|
+
is_root: bool = False
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
# ---------------------------------------------------------------------------
|
|
104
|
+
# Top-level driver
|
|
105
|
+
# ---------------------------------------------------------------------------
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def _reconstruct_inner(raw_site: RawSite, repo_path: str) -> PromptTemplate | None:
|
|
109
|
+
cache: dict[str, _ModuleInfo] = {}
|
|
110
|
+
ctx = _Ctx()
|
|
111
|
+
slots: dict[str, Slot] = {}
|
|
112
|
+
|
|
113
|
+
if raw_site.wrapper is not None:
|
|
114
|
+
wrapper_module = _load_module(repo_path, raw_site.wrapper.file, cache)
|
|
115
|
+
wrapper_func = _find_function(wrapper_module.tree, raw_site.wrapper.symbol)
|
|
116
|
+
if wrapper_func is None:
|
|
117
|
+
return None
|
|
118
|
+
target_call = _find_call(wrapper_func, raw_site.wrapper.span, raw_site.kind)
|
|
119
|
+
if target_call is None:
|
|
120
|
+
return None
|
|
121
|
+
|
|
122
|
+
caller_module = _load_module(repo_path, raw_site.file, cache)
|
|
123
|
+
caller_func = _find_function(caller_module.tree, raw_site.symbol)
|
|
124
|
+
if caller_func is None:
|
|
125
|
+
return None
|
|
126
|
+
caller_frame = _make_root_frame(caller_module, caller_func)
|
|
127
|
+
|
|
128
|
+
wrapper_name = _bare_name(raw_site.wrapper.symbol)
|
|
129
|
+
inner_call = _find_named_call(caller_func, wrapper_name)
|
|
130
|
+
if inner_call is None:
|
|
131
|
+
return None
|
|
132
|
+
message_frame = _make_callee_frame(wrapper_module, wrapper_func, inner_call, caller_frame)
|
|
133
|
+
else:
|
|
134
|
+
module = _load_module(repo_path, raw_site.file, cache)
|
|
135
|
+
func = _find_function(module.tree, raw_site.symbol)
|
|
136
|
+
if func is None:
|
|
137
|
+
return None
|
|
138
|
+
target_call = _find_call(func, raw_site.span, raw_site.kind)
|
|
139
|
+
if target_call is None:
|
|
140
|
+
return None
|
|
141
|
+
message_frame = _make_root_frame(module, func)
|
|
142
|
+
|
|
143
|
+
messages_node = _get_kw(target_call, "messages")
|
|
144
|
+
if messages_node is None or not isinstance(messages_node, ast.List):
|
|
145
|
+
return None
|
|
146
|
+
|
|
147
|
+
parsed: list[tuple[str, str]] = [
|
|
148
|
+
_resolve_message(elt, message_frame, slots, ctx) for elt in messages_node.elts
|
|
149
|
+
]
|
|
150
|
+
|
|
151
|
+
system_prompt: str | None = None
|
|
152
|
+
if parsed and parsed[0][0] == "system":
|
|
153
|
+
system_prompt = parsed[0][1]
|
|
154
|
+
parsed = parsed[1:]
|
|
155
|
+
|
|
156
|
+
embedded_few_shot: list[dict[str, str]] = []
|
|
157
|
+
if parsed:
|
|
158
|
+
*lead, last_msg = parsed
|
|
159
|
+
template = last_msg[1]
|
|
160
|
+
embedded_few_shot = [{"role": role, "content": content} for role, content in lead]
|
|
161
|
+
else:
|
|
162
|
+
template = _new_unresolved(slots, ctx)
|
|
163
|
+
|
|
164
|
+
return PromptTemplate(
|
|
165
|
+
template=template,
|
|
166
|
+
slots=list(slots.values()),
|
|
167
|
+
system_prompt=system_prompt,
|
|
168
|
+
embedded_few_shot=embedded_few_shot,
|
|
169
|
+
partial=ctx.partial,
|
|
170
|
+
)
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
# ---------------------------------------------------------------------------
|
|
174
|
+
# Module loading / AST lookups
|
|
175
|
+
# ---------------------------------------------------------------------------
|
|
176
|
+
|
|
177
|
+
|
|
178
|
+
def _load_module(repo_path: str, rel_file: str, cache: dict[str, _ModuleInfo]) -> _ModuleInfo:
|
|
179
|
+
if rel_file in cache:
|
|
180
|
+
return cache[rel_file]
|
|
181
|
+
full_path = Path(repo_path) / rel_file
|
|
182
|
+
source = full_path.read_text(encoding="utf-8")
|
|
183
|
+
tree = ast.parse(source, filename=str(full_path))
|
|
184
|
+
module_consts: dict[str, ast.expr] = {}
|
|
185
|
+
functions: dict[str, ast.FunctionDef | ast.AsyncFunctionDef] = {}
|
|
186
|
+
for stmt in tree.body:
|
|
187
|
+
if isinstance(stmt, ast.Assign) and len(stmt.targets) == 1 and isinstance(stmt.targets[0], ast.Name):
|
|
188
|
+
module_consts[stmt.targets[0].id] = stmt.value
|
|
189
|
+
elif isinstance(stmt, (ast.FunctionDef, ast.AsyncFunctionDef)):
|
|
190
|
+
functions[stmt.name] = stmt
|
|
191
|
+
info = _ModuleInfo(path=full_path, tree=tree, module_consts=module_consts, functions=functions)
|
|
192
|
+
cache[rel_file] = info
|
|
193
|
+
return info
|
|
194
|
+
|
|
195
|
+
|
|
196
|
+
def _bare_name(qualname: str) -> str:
|
|
197
|
+
name = qualname.strip()
|
|
198
|
+
if name.endswith("()"):
|
|
199
|
+
name = name[:-2]
|
|
200
|
+
return name.split(".")[-1]
|
|
201
|
+
|
|
202
|
+
|
|
203
|
+
def _find_function(tree: ast.AST, qualname: str) -> ast.FunctionDef | ast.AsyncFunctionDef | None:
|
|
204
|
+
target = _bare_name(qualname)
|
|
205
|
+
for node in ast.walk(tree):
|
|
206
|
+
if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)) and node.name == target:
|
|
207
|
+
return node
|
|
208
|
+
return None
|
|
209
|
+
|
|
210
|
+
|
|
211
|
+
def _find_call(func: ast.AST, span: tuple[int, int], kind: str) -> ast.Call | None:
|
|
212
|
+
candidates = [
|
|
213
|
+
node
|
|
214
|
+
for node in ast.walk(func)
|
|
215
|
+
if isinstance(node, ast.Call) and isinstance(node.func, ast.Attribute) and node.func.attr == kind
|
|
216
|
+
]
|
|
217
|
+
if not candidates:
|
|
218
|
+
return None
|
|
219
|
+
lo, hi = span
|
|
220
|
+
for node in candidates:
|
|
221
|
+
node_lo = node.lineno
|
|
222
|
+
node_hi = getattr(node, "end_lineno", node.lineno)
|
|
223
|
+
if node_lo <= hi and node_hi >= lo:
|
|
224
|
+
return node
|
|
225
|
+
return candidates[0]
|
|
226
|
+
|
|
227
|
+
|
|
228
|
+
def _find_named_call(func: ast.AST, name: str) -> ast.Call | None:
|
|
229
|
+
for node in ast.walk(func):
|
|
230
|
+
if isinstance(node, ast.Call):
|
|
231
|
+
f = node.func
|
|
232
|
+
if isinstance(f, ast.Name) and f.id == name:
|
|
233
|
+
return node
|
|
234
|
+
if isinstance(f, ast.Attribute) and f.attr == name:
|
|
235
|
+
return node
|
|
236
|
+
return None
|
|
237
|
+
|
|
238
|
+
|
|
239
|
+
def _get_kw(call: ast.Call, name: str) -> ast.expr | None:
|
|
240
|
+
for kw in call.keywords:
|
|
241
|
+
if kw.arg == name:
|
|
242
|
+
return kw.value
|
|
243
|
+
return None
|
|
244
|
+
|
|
245
|
+
|
|
246
|
+
# ---------------------------------------------------------------------------
|
|
247
|
+
# Scope walking (stays within one function; does not descend into nested
|
|
248
|
+
# defs/classes/lambdas, so their locals don't leak into the outer scope)
|
|
249
|
+
# ---------------------------------------------------------------------------
|
|
250
|
+
|
|
251
|
+
|
|
252
|
+
def _scope_statements(stmts: list[ast.stmt]):
|
|
253
|
+
for stmt in stmts:
|
|
254
|
+
yield stmt
|
|
255
|
+
for _, field_val in ast.iter_fields(stmt):
|
|
256
|
+
if isinstance(field_val, list):
|
|
257
|
+
for item in field_val:
|
|
258
|
+
if isinstance(item, ast.stmt) and not isinstance(
|
|
259
|
+
item, (ast.FunctionDef, ast.AsyncFunctionDef, ast.ClassDef)
|
|
260
|
+
):
|
|
261
|
+
yield from _scope_statements([item])
|
|
262
|
+
elif isinstance(field_val, ast.stmt) and not isinstance(
|
|
263
|
+
field_val, (ast.FunctionDef, ast.AsyncFunctionDef, ast.ClassDef)
|
|
264
|
+
):
|
|
265
|
+
yield from _scope_statements([field_val])
|
|
266
|
+
|
|
267
|
+
|
|
268
|
+
def _collect_local_assigns(func: ast.FunctionDef | ast.AsyncFunctionDef) -> dict[str, ast.expr]:
|
|
269
|
+
out: dict[str, ast.expr] = {}
|
|
270
|
+
for stmt in _scope_statements(func.body):
|
|
271
|
+
if isinstance(stmt, ast.Assign) and len(stmt.targets) == 1 and isinstance(stmt.targets[0], ast.Name):
|
|
272
|
+
out[stmt.targets[0].id] = stmt.value
|
|
273
|
+
elif isinstance(stmt, ast.AnnAssign) and isinstance(stmt.target, ast.Name) and stmt.value is not None:
|
|
274
|
+
out[stmt.target.id] = stmt.value
|
|
275
|
+
return out
|
|
276
|
+
|
|
277
|
+
|
|
278
|
+
def _find_return_expr(func: ast.FunctionDef | ast.AsyncFunctionDef) -> ast.expr | None:
|
|
279
|
+
result: ast.expr | None = None
|
|
280
|
+
for stmt in _scope_statements(func.body):
|
|
281
|
+
if isinstance(stmt, ast.Return) and stmt.value is not None:
|
|
282
|
+
result = stmt.value
|
|
283
|
+
return result
|
|
284
|
+
|
|
285
|
+
|
|
286
|
+
def _param_names(func: ast.FunctionDef | ast.AsyncFunctionDef) -> list[str]:
|
|
287
|
+
a = func.args
|
|
288
|
+
return [p.arg for p in a.posonlyargs] + [p.arg for p in a.args] + [p.arg for p in a.kwonlyargs]
|
|
289
|
+
|
|
290
|
+
|
|
291
|
+
# ---------------------------------------------------------------------------
|
|
292
|
+
# Frame construction
|
|
293
|
+
# ---------------------------------------------------------------------------
|
|
294
|
+
|
|
295
|
+
|
|
296
|
+
def _make_root_frame(module: _ModuleInfo, func: ast.FunctionDef | ast.AsyncFunctionDef) -> _Frame:
|
|
297
|
+
frame = _Frame(module=module, func=func, env={}, is_root=True)
|
|
298
|
+
for name, val in _collect_local_assigns(func).items():
|
|
299
|
+
frame.env[name] = (val, frame)
|
|
300
|
+
return frame
|
|
301
|
+
|
|
302
|
+
|
|
303
|
+
def _make_callee_frame(
|
|
304
|
+
module: _ModuleInfo,
|
|
305
|
+
func: ast.FunctionDef | ast.AsyncFunctionDef,
|
|
306
|
+
call_node: ast.Call,
|
|
307
|
+
calling_frame: _Frame,
|
|
308
|
+
) -> _Frame:
|
|
309
|
+
frame = _Frame(module=module, func=func, env={}, is_root=False)
|
|
310
|
+
a = func.args
|
|
311
|
+
positional_params = [p.arg for p in a.posonlyargs] + [p.arg for p in a.args]
|
|
312
|
+
kwonly_params = [p.arg for p in a.kwonlyargs]
|
|
313
|
+
|
|
314
|
+
for i, arg_node in enumerate(call_node.args):
|
|
315
|
+
if i < len(positional_params):
|
|
316
|
+
frame.env[positional_params[i]] = (arg_node, calling_frame)
|
|
317
|
+
for kw in call_node.keywords:
|
|
318
|
+
if kw.arg and (kw.arg in positional_params or kw.arg in kwonly_params):
|
|
319
|
+
frame.env[kw.arg] = (kw.value, calling_frame)
|
|
320
|
+
|
|
321
|
+
if a.defaults:
|
|
322
|
+
default_params = positional_params[-len(a.defaults):]
|
|
323
|
+
for p, d in zip(default_params, a.defaults):
|
|
324
|
+
frame.env.setdefault(p, (d, frame))
|
|
325
|
+
for p, d in zip(kwonly_params, a.kw_defaults):
|
|
326
|
+
if d is not None:
|
|
327
|
+
frame.env.setdefault(p, (d, frame))
|
|
328
|
+
|
|
329
|
+
for name, val in _collect_local_assigns(func).items():
|
|
330
|
+
frame.env.setdefault(name, (val, frame))
|
|
331
|
+
return frame
|
|
332
|
+
|
|
333
|
+
|
|
334
|
+
# ---------------------------------------------------------------------------
|
|
335
|
+
# Expression resolution
|
|
336
|
+
# ---------------------------------------------------------------------------
|
|
337
|
+
|
|
338
|
+
|
|
339
|
+
def _escape(text: str) -> str:
|
|
340
|
+
return text.replace("{", "{{").replace("}", "}}")
|
|
341
|
+
|
|
342
|
+
|
|
343
|
+
def _new_slot(name: str, slots: dict[str, Slot]) -> str:
|
|
344
|
+
if name not in slots:
|
|
345
|
+
slots[name] = Slot(name=name)
|
|
346
|
+
return "{" + name + "}"
|
|
347
|
+
|
|
348
|
+
|
|
349
|
+
def _new_unresolved(slots: dict[str, Slot], ctx: _Ctx) -> str:
|
|
350
|
+
ctx.partial = True
|
|
351
|
+
ctx.unresolved_count += 1
|
|
352
|
+
name = f"UNRESOLVED_{ctx.unresolved_count}"
|
|
353
|
+
slots[name] = Slot(name=name, inferred_type="unresolved")
|
|
354
|
+
return "{" + name + "}"
|
|
355
|
+
|
|
356
|
+
|
|
357
|
+
def _resolve_message(elt: ast.expr, frame: _Frame, slots: dict[str, Slot], ctx: _Ctx) -> tuple[str, str]:
|
|
358
|
+
if not isinstance(elt, ast.Dict):
|
|
359
|
+
return ("unknown", _new_unresolved(slots, ctx))
|
|
360
|
+
role = "unknown"
|
|
361
|
+
content_node: ast.expr | None = None
|
|
362
|
+
for k, v in zip(elt.keys, elt.values):
|
|
363
|
+
if isinstance(k, ast.Constant) and k.value == "role":
|
|
364
|
+
role = v.value if isinstance(v, ast.Constant) and isinstance(v.value, str) else "unknown"
|
|
365
|
+
elif isinstance(k, ast.Constant) and k.value == "content":
|
|
366
|
+
content_node = v
|
|
367
|
+
if content_node is None:
|
|
368
|
+
return (role, _new_unresolved(slots, ctx))
|
|
369
|
+
return (role, _resolve_expr(content_node, frame, slots, ctx))
|
|
370
|
+
|
|
371
|
+
|
|
372
|
+
def _resolve_expr(node: ast.expr, frame: _Frame, slots: dict[str, Slot], ctx: _Ctx, depth: int = 0) -> str:
|
|
373
|
+
if depth > 60:
|
|
374
|
+
return _new_unresolved(slots, ctx)
|
|
375
|
+
|
|
376
|
+
if isinstance(node, ast.Constant):
|
|
377
|
+
return _escape(node.value) if isinstance(node.value, str) else _escape(str(node.value))
|
|
378
|
+
|
|
379
|
+
if isinstance(node, ast.JoinedStr):
|
|
380
|
+
parts = []
|
|
381
|
+
for v in node.values:
|
|
382
|
+
if isinstance(v, ast.Constant):
|
|
383
|
+
parts.append(_escape(v.value) if isinstance(v.value, str) else _escape(str(v.value)))
|
|
384
|
+
elif isinstance(v, ast.FormattedValue):
|
|
385
|
+
parts.append(_resolve_expr(v.value, frame, slots, ctx, depth + 1))
|
|
386
|
+
else:
|
|
387
|
+
parts.append(_new_unresolved(slots, ctx))
|
|
388
|
+
return "".join(parts)
|
|
389
|
+
|
|
390
|
+
if isinstance(node, ast.Name):
|
|
391
|
+
return _resolve_name(node.id, frame, slots, ctx, depth)
|
|
392
|
+
|
|
393
|
+
if isinstance(node, ast.BinOp):
|
|
394
|
+
if isinstance(node.op, ast.Add):
|
|
395
|
+
return _resolve_expr(node.left, frame, slots, ctx, depth + 1) + _resolve_expr(
|
|
396
|
+
node.right, frame, slots, ctx, depth + 1
|
|
397
|
+
)
|
|
398
|
+
if isinstance(node.op, ast.Mod):
|
|
399
|
+
raw = _raw_literal(node.left, frame)
|
|
400
|
+
if raw is not None:
|
|
401
|
+
return _apply_percent(raw, node.right, frame, slots, ctx)
|
|
402
|
+
return _new_unresolved(slots, ctx)
|
|
403
|
+
|
|
404
|
+
if isinstance(node, ast.Call):
|
|
405
|
+
return _resolve_call(node, frame, slots, ctx, depth)
|
|
406
|
+
|
|
407
|
+
return _new_unresolved(slots, ctx)
|
|
408
|
+
|
|
409
|
+
|
|
410
|
+
def _resolve_name(name: str, frame: _Frame, slots: dict[str, Slot], ctx: _Ctx, depth: int) -> str:
|
|
411
|
+
if name in frame.env:
|
|
412
|
+
val_node, val_frame = frame.env[name]
|
|
413
|
+
return _resolve_expr(val_node, val_frame, slots, ctx, depth + 1)
|
|
414
|
+
if name in frame.module.module_consts:
|
|
415
|
+
val_node = frame.module.module_consts[name]
|
|
416
|
+
mod_frame = _Frame(module=frame.module, func=None, env={}, is_root=False)
|
|
417
|
+
return _resolve_expr(val_node, mod_frame, slots, ctx, depth + 1)
|
|
418
|
+
if frame.func is not None and frame.is_root and name in _param_names(frame.func):
|
|
419
|
+
return _new_slot(name, slots)
|
|
420
|
+
return _new_unresolved(slots, ctx)
|
|
421
|
+
|
|
422
|
+
|
|
423
|
+
def _resolve_call(node: ast.Call, frame: _Frame, slots: dict[str, Slot], ctx: _Ctx, depth: int) -> str:
|
|
424
|
+
func = node.func
|
|
425
|
+
|
|
426
|
+
if isinstance(func, ast.Attribute):
|
|
427
|
+
if func.attr in ("strip", "lstrip", "rstrip") and not node.args and not node.keywords:
|
|
428
|
+
inner = _resolve_expr(func.value, frame, slots, ctx, depth + 1)
|
|
429
|
+
if func.attr == "strip":
|
|
430
|
+
return inner.strip()
|
|
431
|
+
if func.attr == "lstrip":
|
|
432
|
+
return inner.lstrip()
|
|
433
|
+
return inner.rstrip()
|
|
434
|
+
if func.attr == "format":
|
|
435
|
+
raw = _raw_literal(func.value, frame)
|
|
436
|
+
if raw is not None:
|
|
437
|
+
return _apply_format(raw, node.args, node.keywords, frame, slots, ctx)
|
|
438
|
+
return _new_unresolved(slots, ctx)
|
|
439
|
+
|
|
440
|
+
if isinstance(func, ast.Name) and func.id in frame.module.functions:
|
|
441
|
+
callee = frame.module.functions[func.id]
|
|
442
|
+
callee_frame = _make_callee_frame(frame.module, callee, node, frame)
|
|
443
|
+
ret = _find_return_expr(callee)
|
|
444
|
+
if ret is None:
|
|
445
|
+
return _new_unresolved(slots, ctx)
|
|
446
|
+
return _resolve_expr(ret, callee_frame, slots, ctx, depth + 1)
|
|
447
|
+
|
|
448
|
+
return _new_unresolved(slots, ctx)
|
|
449
|
+
|
|
450
|
+
|
|
451
|
+
def _raw_literal(node: ast.expr, frame: _Frame, depth: int = 0) -> str | None:
|
|
452
|
+
"""Best-effort *unescaped* literal string, for use as a .format()/%
|
|
453
|
+
base template (whose braces/`%s` are meaningful, not literal text).
|
|
454
|
+
Returns None if it doesn't bottom out at a plain string constant."""
|
|
455
|
+
if depth > 20:
|
|
456
|
+
return None
|
|
457
|
+
if isinstance(node, ast.Constant) and isinstance(node.value, str):
|
|
458
|
+
return node.value
|
|
459
|
+
if isinstance(node, ast.Name):
|
|
460
|
+
if node.id in frame.env:
|
|
461
|
+
val_node, val_frame = frame.env[node.id]
|
|
462
|
+
return _raw_literal(val_node, val_frame, depth + 1)
|
|
463
|
+
if node.id in frame.module.module_consts:
|
|
464
|
+
val_node = frame.module.module_consts[node.id]
|
|
465
|
+
mod_frame = _Frame(module=frame.module, func=None, env={}, is_root=False)
|
|
466
|
+
return _raw_literal(val_node, mod_frame, depth + 1)
|
|
467
|
+
return None
|
|
468
|
+
|
|
469
|
+
|
|
470
|
+
_FORMAT_FIELD_RE = re.compile(r"\{([^{}]*)\}")
|
|
471
|
+
|
|
472
|
+
|
|
473
|
+
def _apply_format(
|
|
474
|
+
raw: str,
|
|
475
|
+
call_args: list[ast.expr],
|
|
476
|
+
call_keywords: list[ast.keyword],
|
|
477
|
+
frame: _Frame,
|
|
478
|
+
slots: dict[str, Slot],
|
|
479
|
+
ctx: _Ctx,
|
|
480
|
+
) -> str:
|
|
481
|
+
kwmap = {kw.arg: kw.value for kw in call_keywords if kw.arg}
|
|
482
|
+
pos_args = list(call_args)
|
|
483
|
+
auto_idx = 0
|
|
484
|
+
out: list[str] = []
|
|
485
|
+
last = 0
|
|
486
|
+
for m in _FORMAT_FIELD_RE.finditer(raw):
|
|
487
|
+
out.append(_escape(raw[last:m.start()]))
|
|
488
|
+
last = m.end()
|
|
489
|
+
field = m.group(1).split("!", 1)[0].split(":", 1)[0]
|
|
490
|
+
arg_node: ast.expr | None
|
|
491
|
+
if field == "":
|
|
492
|
+
arg_node = pos_args[auto_idx] if auto_idx < len(pos_args) else None
|
|
493
|
+
auto_idx += 1
|
|
494
|
+
elif field.isdigit():
|
|
495
|
+
idx = int(field)
|
|
496
|
+
arg_node = pos_args[idx] if idx < len(pos_args) else None
|
|
497
|
+
else:
|
|
498
|
+
arg_node = kwmap.get(field)
|
|
499
|
+
if arg_node is None:
|
|
500
|
+
out.append(_new_unresolved(slots, ctx))
|
|
501
|
+
else:
|
|
502
|
+
out.append(_resolve_expr(arg_node, frame, slots, ctx))
|
|
503
|
+
out.append(_escape(raw[last:]))
|
|
504
|
+
return "".join(out)
|
|
505
|
+
|
|
506
|
+
|
|
507
|
+
_PERCENT_RE = re.compile(r"%(\(([^)]+)\))?[-+#0 ]*\d*(\.\d+)?[a-zA-Z%]")
|
|
508
|
+
|
|
509
|
+
|
|
510
|
+
def _apply_percent(raw: str, right: ast.expr, frame: _Frame, slots: dict[str, Slot], ctx: _Ctx) -> str:
|
|
511
|
+
positional: list[ast.expr] | None = None
|
|
512
|
+
dict_map: dict[str, ast.expr] = {}
|
|
513
|
+
if isinstance(right, ast.Tuple):
|
|
514
|
+
positional = list(right.elts)
|
|
515
|
+
elif isinstance(right, ast.Dict):
|
|
516
|
+
for k, v in zip(right.keys, right.values):
|
|
517
|
+
if isinstance(k, ast.Constant):
|
|
518
|
+
dict_map[k.value] = v
|
|
519
|
+
else:
|
|
520
|
+
positional = [right]
|
|
521
|
+
|
|
522
|
+
out: list[str] = []
|
|
523
|
+
last = 0
|
|
524
|
+
idx = 0
|
|
525
|
+
for m in _PERCENT_RE.finditer(raw):
|
|
526
|
+
out.append(_escape(raw[last:m.start()]))
|
|
527
|
+
last = m.end()
|
|
528
|
+
if m.group(0) == "%%":
|
|
529
|
+
out.append("%")
|
|
530
|
+
continue
|
|
531
|
+
name = m.group(2)
|
|
532
|
+
if name is not None:
|
|
533
|
+
arg_node = dict_map.get(name)
|
|
534
|
+
else:
|
|
535
|
+
arg_node = positional[idx] if positional is not None and idx < len(positional) else None
|
|
536
|
+
idx += 1
|
|
537
|
+
if arg_node is None:
|
|
538
|
+
out.append(_new_unresolved(slots, ctx))
|
|
539
|
+
else:
|
|
540
|
+
out.append(_resolve_expr(arg_node, frame, slots, ctx))
|
|
541
|
+
out.append(_escape(raw[last:]))
|
|
542
|
+
return "".join(out)
|