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/__init__.py +3 -0
- downshift/audit.py +188 -0
- downshift/cli.py +755 -0
- downshift/config.py +348 -0
- downshift/cost.py +212 -0
- downshift/decide.py +220 -0
- downshift/evalgen.py +220 -0
- downshift/evals.py +395 -0
- downshift/llm.py +171 -0
- downshift/py.typed +0 -0
- downshift/report.py +380 -0
- downshift/resolve.py +439 -0
- downshift/runner.py +312 -0
- downshift/scanner.py +310 -0
- downshift/schema.py +356 -0
- downshift/scorer.py +219 -0
- downshift-0.1.0.dev0.dist-info/METADATA +24 -0
- downshift-0.1.0.dev0.dist-info/RECORD +21 -0
- downshift-0.1.0.dev0.dist-info/WHEEL +4 -0
- downshift-0.1.0.dev0.dist-info/entry_points.txt +2 -0
- downshift-0.1.0.dev0.dist-info/licenses/LICENSE +21 -0
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
|