downshift 0.1.0.dev0__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.
downshift/resolve.py ADDED
@@ -0,0 +1,439 @@
1
+ """Static resolution of model names and prompt templates.
2
+
3
+ Follows variables, imports, os.getenv defaults, dict lookups and parameter
4
+ defaults across the scanned modules. Anything that is only known at runtime
5
+ is reported as unresolved, never guessed.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import ast
11
+ from dataclasses import dataclass, field
12
+ from typing import TypeAlias
13
+
14
+ from downshift.schema import ModelRef, PromptMessage
15
+
16
+ FunctionNode: TypeAlias = ast.FunctionDef | ast.AsyncFunctionDef
17
+
18
+ MAX_DEPTH = 10
19
+ ENV_GETTERS = frozenset({"os.getenv", "os.environ.get", "environ.get", "getenv"})
20
+ ENV_MAPPINGS = frozenset({"os.environ", "environ"})
21
+ MUTATING_METHODS = frozenset({"append", "extend", "insert"})
22
+
23
+
24
+ # --- module index -------------------------------------------------------------
25
+
26
+
27
+ def module_dotted(rel: str) -> str:
28
+ """supportdesk/triage.py -> supportdesk.triage, pkg/__init__.py -> pkg."""
29
+ path = rel[:-3] if rel.endswith(".py") else rel
30
+ parts = path.split("/")
31
+ if parts[-1] == "__init__":
32
+ parts = parts[:-1]
33
+ return ".".join(parts)
34
+
35
+
36
+ def _import_base(dotted: str, module: str | None, level: int, is_package: bool) -> str:
37
+ if level == 0:
38
+ return module or ""
39
+ parts = dotted.split(".") if is_package else dotted.split(".")[:-1]
40
+ parts = parts[: max(len(parts) - (level - 1), 0)]
41
+ if module:
42
+ parts.append(module)
43
+ return ".".join(parts)
44
+
45
+
46
+ @dataclass
47
+ class Module:
48
+ """One parsed file plus its module-level assignments and imports."""
49
+
50
+ rel: str
51
+ dotted: str
52
+ tree: ast.Module
53
+ assigns: dict[str, ast.expr] = field(default_factory=dict)
54
+ imports: dict[str, tuple[str, str | None]] = field(default_factory=dict)
55
+
56
+ @classmethod
57
+ def build(cls, tree: ast.Module, rel: str) -> Module:
58
+ module = cls(rel=rel, dotted=module_dotted(rel), tree=tree)
59
+ is_package = rel.endswith("__init__.py")
60
+ for stmt in tree.body:
61
+ if isinstance(stmt, ast.Assign):
62
+ for target in stmt.targets:
63
+ if isinstance(target, ast.Name):
64
+ module.assigns[target.id] = stmt.value
65
+ elif (
66
+ isinstance(stmt, ast.AnnAssign)
67
+ and isinstance(stmt.target, ast.Name)
68
+ and stmt.value is not None
69
+ ):
70
+ module.assigns[stmt.target.id] = stmt.value
71
+ elif isinstance(stmt, ast.ImportFrom):
72
+ base = _import_base(module.dotted, stmt.module, stmt.level, is_package)
73
+ for alias in stmt.names:
74
+ module.imports[alias.asname or alias.name] = (base, alias.name)
75
+ elif isinstance(stmt, ast.Import):
76
+ for alias in stmt.names:
77
+ if alias.asname:
78
+ module.imports[alias.asname] = (alias.name, None)
79
+ else:
80
+ top = alias.name.split(".")[0]
81
+ module.imports[top] = (top, None)
82
+ return module
83
+
84
+
85
+ class ModuleIndex:
86
+ """Looks modules up by dotted name, tolerating a different package root."""
87
+
88
+ def __init__(self, modules: list[Module]) -> None:
89
+ self.modules = modules
90
+ self._by_dotted = {m.dotted: m for m in modules}
91
+
92
+ def find(self, dotted: str) -> Module | None:
93
+ if dotted in self._by_dotted:
94
+ return self._by_dotted[dotted]
95
+ matches = [m for name, m in self._by_dotted.items() if name.endswith("." + dotted)]
96
+ return matches[0] if len(matches) == 1 else None
97
+
98
+
99
+ # --- resolution ---------------------------------------------------------------
100
+
101
+
102
+ @dataclass(frozen=True)
103
+ class Ctx:
104
+ """Where an expression lives: its module, enclosing function, and line."""
105
+
106
+ module: Module
107
+ func: FunctionNode | None
108
+ line: int
109
+
110
+
111
+ @dataclass
112
+ class _Trail:
113
+ """What happened while following a reference, used to label the source."""
114
+
115
+ hops: int = 0
116
+ dict_lookup: bool = False
117
+ param_default: bool = False
118
+ env_var: str | None = None
119
+ defined_in: str | None = None
120
+
121
+
122
+ class Resolver:
123
+ def __init__(self, index: ModuleIndex) -> None:
124
+ self.index = index
125
+
126
+ # model -------------------------------------------------------------------
127
+
128
+ def resolve_model(self, call: ast.Call, ctx: Ctx) -> ModelRef:
129
+ value = keyword_arg(call, "model")
130
+ if value is not None:
131
+ return self._model_from(value, ctx, ast.unparse(value), _Trail())
132
+
133
+ for kw in call.keywords:
134
+ if kw.arg is None:
135
+ expression = "**" + ast.unparse(kw.value)
136
+ trail = _Trail()
137
+ target = self._follow(kw.value, ctx, trail, 0)
138
+ if target is not None and isinstance(target[0], ast.Dict):
139
+ model_expr = _dict_get(target[0], "model")
140
+ if model_expr is not None:
141
+ trail.dict_lookup = True
142
+ ref = self._model_from(model_expr, target[1], expression, trail)
143
+ if ref.resolved:
144
+ return ref
145
+ return ModelRef(value=None, source="kwargs", expression=expression)
146
+ return ModelRef(value=None, source="missing", expression="")
147
+
148
+ def _model_from(self, value: ast.expr, ctx: Ctx, expression: str, trail: _Trail) -> ModelRef:
149
+ result = self._string(value, ctx, trail, 0)
150
+ if trail.env_var is not None:
151
+ source = "env_default" if result is not None else "env"
152
+ elif result is None:
153
+ source = "dynamic"
154
+ elif trail.dict_lookup:
155
+ source = "dict_lookup"
156
+ elif trail.param_default:
157
+ source = "parameter_default"
158
+ elif trail.hops == 0:
159
+ source = "literal"
160
+ else:
161
+ source = "constant"
162
+ return ModelRef(
163
+ value=result,
164
+ source=source,
165
+ expression=expression,
166
+ env_var=trail.env_var,
167
+ defined_in=trail.defined_in if result is not None else None,
168
+ )
169
+
170
+ def _string(self, expr: ast.expr, ctx: Ctx, trail: _Trail, depth: int) -> str | None:
171
+ if depth > MAX_DEPTH:
172
+ return None
173
+ if isinstance(expr, ast.Constant):
174
+ if isinstance(expr.value, str):
175
+ trail.defined_in = ctx.module.rel
176
+ return expr.value
177
+ return None
178
+ if isinstance(expr, (ast.Name, ast.Attribute)):
179
+ target = self._definition(expr, ctx, trail, depth)
180
+ if target is None:
181
+ return None
182
+ trail.hops += 1
183
+ return self._string(target[0], target[1], trail, depth + 1)
184
+ if isinstance(expr, ast.Call) and _dotted_name(expr.func) in ENV_GETTERS:
185
+ if expr.args:
186
+ trail.env_var = _constant_str(expr.args[0])
187
+ default = expr.args[1] if len(expr.args) > 1 else keyword_arg(expr, "default")
188
+ if default is None:
189
+ return None
190
+ return self._string(default, ctx, trail, depth + 1)
191
+ if isinstance(expr, ast.Subscript):
192
+ key = _constant_str(expr.slice)
193
+ if _dotted_name(expr.value) in ENV_MAPPINGS:
194
+ trail.env_var = key
195
+ return None
196
+ target = self._follow(expr.value, ctx, trail, depth + 1)
197
+ if key is None or target is None or not isinstance(target[0], ast.Dict):
198
+ return None
199
+ value = _dict_get(target[0], key)
200
+ if value is None:
201
+ return None
202
+ trail.dict_lookup = True
203
+ return self._string(value, target[1], trail, depth + 1)
204
+ return None
205
+
206
+ # prompts -----------------------------------------------------------------
207
+
208
+ def resolve_messages(self, call: ast.Call, api: str, ctx: Ctx) -> list[PromptMessage] | None:
209
+ """Recover the prompt template, or None if the messages are built at runtime."""
210
+ messages: list[PromptMessage] = []
211
+ system = keyword_arg(call, "system", "instructions")
212
+ if system is not None:
213
+ messages.append(self._message("system", system, ctx))
214
+
215
+ body = keyword_arg(call, "messages", "input")
216
+ if body is None:
217
+ return messages or None
218
+ if isinstance(body, ast.Name) and ctx.func is not None and _is_mutated(ctx.func, body.id):
219
+ return None
220
+
221
+ target = self._follow(body, ctx, _Trail(), 0)
222
+ if target is None:
223
+ return None
224
+ node, node_ctx = target
225
+ if isinstance(node, ast.List):
226
+ for element in node.elts:
227
+ if not isinstance(element, ast.Dict):
228
+ return None
229
+ role_expr = _dict_get(element, "role")
230
+ content_expr = _dict_get(element, "content")
231
+ role = _constant_str(role_expr) if role_expr is not None else None
232
+ if role is None or content_expr is None:
233
+ return None
234
+ messages.append(self._message(role, content_expr, node_ctx))
235
+ return messages
236
+ if api == "openai.responses":
237
+ messages.append(self._message("user", body, ctx))
238
+ return messages
239
+ return None
240
+
241
+ def _message(self, role: str, expr: ast.expr, ctx: Ctx) -> PromptMessage:
242
+ content, resolved = self._template(expr, ctx, 0, inside=False)
243
+ return PromptMessage(role=role, content=content, resolved=resolved)
244
+
245
+ def _template(self, expr: ast.expr, ctx: Ctx, depth: int, inside: bool) -> tuple[str, bool]:
246
+ """Render a prompt expression as text, with runtime values as {placeholders}.
247
+
248
+ inside=True means we are inside a larger template, where any runtime value
249
+ is just a placeholder. At the top level, an opaque value means the whole
250
+ prompt is unknown.
251
+ """
252
+ placeholder = "{" + ast.unparse(expr) + "}"
253
+ if depth > MAX_DEPTH:
254
+ return placeholder, False
255
+ if isinstance(expr, ast.Constant) and isinstance(expr.value, str):
256
+ return expr.value, True
257
+ if isinstance(expr, ast.JoinedStr):
258
+ parts: list[str] = []
259
+ for value in expr.values:
260
+ if isinstance(value, ast.Constant) and isinstance(value.value, str):
261
+ parts.append(value.value)
262
+ elif isinstance(value, ast.FormattedValue):
263
+ parts.append("{" + ast.unparse(value.value) + "}")
264
+ return "".join(parts), True
265
+ if isinstance(expr, ast.BinOp) and isinstance(expr.op, ast.Add):
266
+ left, left_ok = self._template(expr.left, ctx, depth + 1, inside=True)
267
+ right, right_ok = self._template(expr.right, ctx, depth + 1, inside=True)
268
+ return left + right, left_ok and right_ok
269
+ if inside:
270
+ return placeholder, True
271
+ if isinstance(expr, ast.Name) and ctx.func is not None:
272
+ is_param, _ = _parameter(ctx.func, expr.id)
273
+ if is_param and _local_assignment(ctx.func, expr.id, ctx.line) is None:
274
+ return placeholder, True
275
+ if isinstance(expr, (ast.Name, ast.Attribute)):
276
+ target = self._definition(expr, ctx, _Trail(), depth)
277
+ if target is not None:
278
+ return self._template(target[0], target[1], depth + 1, inside=False)
279
+ return placeholder, False
280
+
281
+ # following references ----------------------------------------------------
282
+
283
+ def _follow(
284
+ self, expr: ast.expr, ctx: Ctx, trail: _Trail, depth: int
285
+ ) -> tuple[ast.expr, Ctx] | None:
286
+ """Follow names until reaching the expression that defines them."""
287
+ while isinstance(expr, (ast.Name, ast.Attribute)):
288
+ if depth > MAX_DEPTH:
289
+ return None
290
+ target = self._definition(expr, ctx, trail, depth)
291
+ if target is None:
292
+ return None
293
+ expr, ctx = target
294
+ depth += 1
295
+ return expr, ctx
296
+
297
+ def _definition(
298
+ self, expr: ast.expr, ctx: Ctx, trail: _Trail, depth: int
299
+ ) -> tuple[ast.expr, Ctx] | None:
300
+ if isinstance(expr, ast.Name):
301
+ if ctx.func is not None:
302
+ local = _local_assignment(ctx.func, expr.id, ctx.line)
303
+ if local is not None:
304
+ return local, ctx
305
+ is_param, default = _parameter(ctx.func, expr.id)
306
+ if is_param:
307
+ if default is None:
308
+ return None
309
+ trail.param_default = True
310
+ return default, Ctx(ctx.module, None, 0)
311
+ return self._module_name(ctx.module, expr.id, depth)
312
+ if isinstance(expr, ast.Attribute) and isinstance(expr.value, ast.Name):
313
+ module = self._module_alias(ctx.module, expr.value.id)
314
+ if module is not None:
315
+ return self._module_name(module, expr.attr, depth)
316
+ return None
317
+
318
+ def _module_name(self, module: Module, name: str, depth: int) -> tuple[ast.expr, Ctx] | None:
319
+ if depth > MAX_DEPTH:
320
+ return None
321
+ if name in module.assigns:
322
+ return module.assigns[name], Ctx(module, None, 0)
323
+ imported = module.imports.get(name)
324
+ if imported is None:
325
+ return None
326
+ base, attr = imported
327
+ if attr is None:
328
+ return None
329
+ target = self.index.find(base)
330
+ if target is None or target is module:
331
+ return None
332
+ return self._module_name(target, attr, depth + 1)
333
+
334
+ def _module_alias(self, module: Module, alias: str) -> Module | None:
335
+ imported = module.imports.get(alias)
336
+ if imported is None:
337
+ return None
338
+ base, attr = imported
339
+ return self.index.find(base if attr is None else f"{base}.{attr}")
340
+
341
+
342
+ # --- small ast helpers --------------------------------------------------------
343
+
344
+
345
+ def keyword_arg(call: ast.Call, *names: str) -> ast.expr | None:
346
+ for kw in call.keywords:
347
+ if kw.arg is not None and kw.arg in names:
348
+ return kw.value
349
+ return None
350
+
351
+
352
+ def _dotted_name(node: ast.expr) -> str | None:
353
+ parts: list[str] = []
354
+ while isinstance(node, ast.Attribute):
355
+ parts.append(node.attr)
356
+ node = node.value
357
+ if isinstance(node, ast.Name):
358
+ parts.append(node.id)
359
+ return ".".join(reversed(parts))
360
+ return None
361
+
362
+
363
+ def _constant_str(node: ast.expr | None) -> str | None:
364
+ if isinstance(node, ast.Constant) and isinstance(node.value, str):
365
+ return node.value
366
+ return None
367
+
368
+
369
+ def _dict_get(node: ast.Dict, key: str) -> ast.expr | None:
370
+ for k, v in zip(node.keys, node.values, strict=True):
371
+ if k is not None and _constant_str(k) == key:
372
+ return v
373
+ return None
374
+
375
+
376
+ def _parameter(func: FunctionNode, name: str) -> tuple[bool, ast.expr | None]:
377
+ """Return (is a parameter, its default expression or None)."""
378
+ args = func.args
379
+ positional = [*args.posonlyargs, *args.args]
380
+ defaults: list[ast.expr | None] = [None] * (len(positional) - len(args.defaults))
381
+ defaults.extend(args.defaults)
382
+ for arg, default in zip(positional, defaults, strict=True):
383
+ if arg.arg == name:
384
+ return True, default
385
+ for arg, kw_default in zip(args.kwonlyargs, args.kw_defaults, strict=True):
386
+ if arg.arg == name:
387
+ return True, kw_default
388
+ for special in (args.vararg, args.kwarg):
389
+ if special is not None and special.arg == name:
390
+ return True, None
391
+ return False, None
392
+
393
+
394
+ def _local_assignment(func: FunctionNode, name: str, line: int) -> ast.expr | None:
395
+ """The last assignment to name inside func before the given line."""
396
+ best: tuple[int, ast.expr] | None = None
397
+ for node in ast.walk(func):
398
+ targets: list[ast.expr]
399
+ value: ast.expr | None
400
+ if isinstance(node, ast.Assign):
401
+ targets, value = node.targets, node.value
402
+ elif isinstance(node, ast.AnnAssign):
403
+ targets, value = [node.target], node.value
404
+ else:
405
+ continue
406
+ if value is None or node.lineno >= line:
407
+ continue
408
+ if any(isinstance(t, ast.Name) and t.id == name for t in targets) and (
409
+ best is None or node.lineno > best[0]
410
+ ):
411
+ best = (node.lineno, value)
412
+ return best[1] if best else None
413
+
414
+
415
+ def _is_mutated(func: FunctionNode, name: str) -> bool:
416
+ """True if a list/dict variable is changed after creation (append, +=, x[k] = v)."""
417
+ for node in ast.walk(func):
418
+ if (
419
+ isinstance(node, ast.Call)
420
+ and isinstance(node.func, ast.Attribute)
421
+ and isinstance(node.func.value, ast.Name)
422
+ and node.func.value.id == name
423
+ and node.func.attr in MUTATING_METHODS
424
+ ):
425
+ return True
426
+ if (
427
+ isinstance(node, ast.AugAssign)
428
+ and isinstance(node.target, ast.Name)
429
+ and node.target.id == name
430
+ ):
431
+ return True
432
+ if (
433
+ isinstance(node, ast.Subscript)
434
+ and isinstance(node.ctx, ast.Store)
435
+ and isinstance(node.value, ast.Name)
436
+ and node.value.id == name
437
+ ):
438
+ return True
439
+ return False