errpath 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.
- errpath/__init__.py +19 -0
- errpath/__main__.py +4 -0
- errpath/_pydantic.py +102 -0
- errpath/analyse.py +1035 -0
- errpath/cli.py +141 -0
- errpath/diagram.py +135 -0
- errpath/ir.py +274 -0
- errpath/machine.py +165 -0
- errpath/py.typed +0 -0
- errpath/result.py +254 -0
- errpath/review.py +517 -0
- errpath/safe.py +129 -0
- errpath-0.1.0.dist-info/METADATA +622 -0
- errpath-0.1.0.dist-info/RECORD +17 -0
- errpath-0.1.0.dist-info/WHEEL +4 -0
- errpath-0.1.0.dist-info/entry_points.txt +2 -0
- errpath-0.1.0.dist-info/licenses/LICENSE +21 -0
errpath/analyse.py
ADDED
|
@@ -0,0 +1,1035 @@
|
|
|
1
|
+
"""AST walker: Python Result railways → Report IR."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import ast
|
|
6
|
+
import builtins
|
|
7
|
+
from collections.abc import Iterable, Iterator
|
|
8
|
+
from dataclasses import replace
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
from typing import TypeGuard
|
|
11
|
+
|
|
12
|
+
from errpath.ir import (
|
|
13
|
+
CATCH_ANY,
|
|
14
|
+
NO_PANIC,
|
|
15
|
+
NO_SWALLOW,
|
|
16
|
+
SCHEMA_VERSION,
|
|
17
|
+
Arm,
|
|
18
|
+
Block,
|
|
19
|
+
Branch,
|
|
20
|
+
Catch,
|
|
21
|
+
Confidence,
|
|
22
|
+
Coverage,
|
|
23
|
+
ErrorSource,
|
|
24
|
+
Fail,
|
|
25
|
+
Finding,
|
|
26
|
+
Function,
|
|
27
|
+
Jump,
|
|
28
|
+
Loop,
|
|
29
|
+
Node,
|
|
30
|
+
Panic,
|
|
31
|
+
ParseFailure,
|
|
32
|
+
Recover,
|
|
33
|
+
Report,
|
|
34
|
+
Returns,
|
|
35
|
+
ReturnsOther,
|
|
36
|
+
ReturnsResult,
|
|
37
|
+
Scope,
|
|
38
|
+
Step,
|
|
39
|
+
Succeed,
|
|
40
|
+
Swallow,
|
|
41
|
+
Unanalysed,
|
|
42
|
+
error_members,
|
|
43
|
+
escaping,
|
|
44
|
+
)
|
|
45
|
+
|
|
46
|
+
_TryNode = ast.Try | ast.TryStar # try/except* has the same fields as try/except
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def _ann_name(node: ast.expr | None) -> str | None:
|
|
50
|
+
if node is None:
|
|
51
|
+
return None
|
|
52
|
+
if isinstance(node, ast.Name):
|
|
53
|
+
return node.id
|
|
54
|
+
if isinstance(node, ast.Attribute):
|
|
55
|
+
return node.attr
|
|
56
|
+
if isinstance(node, ast.Constant) and isinstance(node.value, str):
|
|
57
|
+
return node.value
|
|
58
|
+
return None
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def _subscript_args(node: ast.expr) -> list[ast.expr]:
|
|
62
|
+
if not isinstance(node, ast.Subscript):
|
|
63
|
+
return []
|
|
64
|
+
sl = node.slice
|
|
65
|
+
if isinstance(sl, ast.Tuple):
|
|
66
|
+
return list(sl.elts)
|
|
67
|
+
return [sl]
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def _error_name(node: ast.expr) -> str:
|
|
71
|
+
"""Error type annotation as ``A | B``: also for ``Union[A, B]`` and ``mod.A | mod.B``."""
|
|
72
|
+
if isinstance(node, ast.BinOp) and isinstance(node.op, ast.BitOr):
|
|
73
|
+
return f"{_error_name(node.left)} | {_error_name(node.right)}"
|
|
74
|
+
if isinstance(node, ast.Subscript) and _ann_name(node.value) == "Union":
|
|
75
|
+
return " | ".join(_error_name(arg) for arg in _subscript_args(node))
|
|
76
|
+
return _ann_name(node) or ast.unparse(node)
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def parse_returns(annotation: ast.expr | None) -> Returns:
|
|
80
|
+
if annotation is None:
|
|
81
|
+
return ReturnsOther()
|
|
82
|
+
# Result[T, E] or errpath.Result[T, E]
|
|
83
|
+
if isinstance(annotation, ast.Subscript):
|
|
84
|
+
base = _ann_name(annotation.value)
|
|
85
|
+
if base == "Result":
|
|
86
|
+
args = _subscript_args(annotation)
|
|
87
|
+
if len(args) >= 2:
|
|
88
|
+
err = _error_name(args[1])
|
|
89
|
+
return ReturnsResult(error=err)
|
|
90
|
+
return ReturnsResult(error="Exception")
|
|
91
|
+
name = _ann_name(annotation)
|
|
92
|
+
if name == "Result":
|
|
93
|
+
return ReturnsResult(error="Exception")
|
|
94
|
+
return ReturnsOther()
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def _call_name(node: ast.expr) -> str | None:
|
|
98
|
+
if isinstance(node, ast.Name):
|
|
99
|
+
return node.id
|
|
100
|
+
if isinstance(node, ast.Attribute):
|
|
101
|
+
return node.attr
|
|
102
|
+
if isinstance(node, ast.Call):
|
|
103
|
+
return _call_name(node.func)
|
|
104
|
+
return None
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def _unawait(node: ast.expr) -> ast.expr:
|
|
108
|
+
return node.value if isinstance(node, ast.Await) else node
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def _attr_root(node: ast.expr) -> ast.expr:
|
|
112
|
+
while isinstance(node, ast.Attribute):
|
|
113
|
+
node = node.value
|
|
114
|
+
return node
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def _imported_names(tree: ast.AST) -> set[str]:
|
|
118
|
+
"""Names bound by imports; ``mod.f()`` is only a Result call when ``mod`` is one of them."""
|
|
119
|
+
out: set[str] = set()
|
|
120
|
+
for node in ast.walk(tree):
|
|
121
|
+
if isinstance(node, ast.Import):
|
|
122
|
+
out.update(a.asname or a.name.split(".")[0] for a in node.names)
|
|
123
|
+
elif isinstance(node, ast.ImportFrom):
|
|
124
|
+
out.update(a.asname or a.name for a in node.names)
|
|
125
|
+
return out
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
def _is_unwrap_call(node: ast.expr) -> TypeGuard[ast.Call]:
|
|
129
|
+
return (
|
|
130
|
+
isinstance(node, ast.Call)
|
|
131
|
+
and isinstance(node.func, ast.Attribute)
|
|
132
|
+
and node.func.attr == "unwrap"
|
|
133
|
+
and not node.args
|
|
134
|
+
and not node.keywords
|
|
135
|
+
)
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
def _unwrap_receiver(node: ast.Call) -> ast.expr | None:
|
|
139
|
+
if isinstance(node.func, ast.Attribute):
|
|
140
|
+
return node.func.value
|
|
141
|
+
return None
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
def _is_ok_call(node: ast.expr) -> bool:
|
|
145
|
+
return isinstance(node, ast.Call) and _call_name(node) == "Ok"
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
def _is_err_call(node: ast.expr) -> bool:
|
|
149
|
+
return isinstance(node, ast.Call) and _call_name(node) == "Err"
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
def _err_ctor_name(node: ast.expr) -> str:
|
|
153
|
+
"""Best-effort error type name from ``Err(...)`` or ``Err(Foo(...))``."""
|
|
154
|
+
if not isinstance(node, ast.Call) or not node.args:
|
|
155
|
+
return "Error"
|
|
156
|
+
arg = node.args[0]
|
|
157
|
+
name = _call_name(arg) if isinstance(arg, ast.Call) else _ann_name(arg)
|
|
158
|
+
return name or "Error"
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
class _SignatureCollector(ast.NodeVisitor):
|
|
162
|
+
"""First pass: map callee name → error type from Result return.
|
|
163
|
+
|
|
164
|
+
Functions are keyed by bare name, methods by ``.name``, so ``d.update()``
|
|
165
|
+
only matches a Result-returning method, never a function called ``update``.
|
|
166
|
+
"""
|
|
167
|
+
|
|
168
|
+
def __init__(self) -> None:
|
|
169
|
+
self.signatures: dict[str, str] = {}
|
|
170
|
+
self.bases: dict[str, list[str]] = {}
|
|
171
|
+
self._in_class = False
|
|
172
|
+
|
|
173
|
+
def visit_ClassDef(self, node: ast.ClassDef) -> None:
|
|
174
|
+
self.bases[node.name] = [n for n in map(_ann_name, node.bases) if n]
|
|
175
|
+
prev = self._in_class
|
|
176
|
+
self._in_class = True
|
|
177
|
+
self.generic_visit(node)
|
|
178
|
+
self._in_class = prev
|
|
179
|
+
|
|
180
|
+
def visit_FunctionDef(self, node: ast.FunctionDef) -> None:
|
|
181
|
+
self._record(node)
|
|
182
|
+
self._visit_body(node)
|
|
183
|
+
|
|
184
|
+
def visit_AsyncFunctionDef(self, node: ast.AsyncFunctionDef) -> None:
|
|
185
|
+
self._record(node)
|
|
186
|
+
self._visit_body(node)
|
|
187
|
+
|
|
188
|
+
def _visit_body(self, node: ast.FunctionDef | ast.AsyncFunctionDef) -> None:
|
|
189
|
+
prev = self._in_class
|
|
190
|
+
self._in_class = False # functions nested in a method are plain functions
|
|
191
|
+
self.generic_visit(node)
|
|
192
|
+
self._in_class = prev
|
|
193
|
+
|
|
194
|
+
def _record(self, node: ast.FunctionDef | ast.AsyncFunctionDef) -> None:
|
|
195
|
+
returns = parse_returns(node.returns)
|
|
196
|
+
# @safe_with(A, B) makes any function return Result[..., A | B]
|
|
197
|
+
error = returns.error if isinstance(returns, ReturnsResult) else _safe_error(node)
|
|
198
|
+
if error is not None:
|
|
199
|
+
key = f".{node.name}" if self._in_class else node.name
|
|
200
|
+
self.signatures[key] = error
|
|
201
|
+
|
|
202
|
+
|
|
203
|
+
def _collect(tree: ast.AST) -> _SignatureCollector:
|
|
204
|
+
collector = _SignatureCollector()
|
|
205
|
+
collector.visit(tree)
|
|
206
|
+
return collector
|
|
207
|
+
|
|
208
|
+
|
|
209
|
+
_CATCH_ALL = {"BaseException"} # `except Exception` misses KeyboardInterrupt, SystemExit, ...
|
|
210
|
+
|
|
211
|
+
|
|
212
|
+
def _is_subclass(error: str, target: str, bases: dict[str, list[str]]) -> bool:
|
|
213
|
+
"""``error`` is ``target`` or inherits from it, via user classes or builtin exceptions."""
|
|
214
|
+
target_cls = getattr(builtins, target, None)
|
|
215
|
+
todo, seen = [error], set()
|
|
216
|
+
while todo:
|
|
217
|
+
name = todo.pop()
|
|
218
|
+
if name == target:
|
|
219
|
+
return True
|
|
220
|
+
if name in seen:
|
|
221
|
+
continue
|
|
222
|
+
seen.add(name)
|
|
223
|
+
cls = getattr(builtins, name, None)
|
|
224
|
+
if isinstance(cls, type) and isinstance(target_cls, type) and issubclass(cls, target_cls):
|
|
225
|
+
return True
|
|
226
|
+
todo.extend(bases.get(name, []))
|
|
227
|
+
return False
|
|
228
|
+
|
|
229
|
+
|
|
230
|
+
def _outside_exception(error: str, bases: dict[str, list[str]]) -> bool:
|
|
231
|
+
"""Known to derive from BaseException but not Exception (KeyboardInterrupt, SystemExit, ...)."""
|
|
232
|
+
todo, seen = [error], set()
|
|
233
|
+
while todo:
|
|
234
|
+
name = todo.pop()
|
|
235
|
+
if name in seen:
|
|
236
|
+
continue
|
|
237
|
+
seen.add(name)
|
|
238
|
+
cls = getattr(builtins, name, None)
|
|
239
|
+
if isinstance(cls, type) and issubclass(cls, BaseException):
|
|
240
|
+
if not issubclass(cls, Exception):
|
|
241
|
+
return True
|
|
242
|
+
continue
|
|
243
|
+
todo.extend(bases.get(name, []))
|
|
244
|
+
return False
|
|
245
|
+
|
|
246
|
+
|
|
247
|
+
# Base classes that make a class data, not an exception.
|
|
248
|
+
_DATA_BASES = {"BaseModel", "Enum", "StrEnum", "IntEnum", "TypedDict", "NamedTuple", "Protocol"}
|
|
249
|
+
|
|
250
|
+
|
|
251
|
+
def _is_exception(name: str, bases: dict[str, list[str]]) -> bool | None:
|
|
252
|
+
"""Whether ``name`` is an exception class: True, False, or None when it can't tell.
|
|
253
|
+
|
|
254
|
+
False means sure: a builtin like ``str``, a class here with no base, or one built on
|
|
255
|
+
BaseModel/Enum/TypedDict. An imported class with an unknown hierarchy gives None.
|
|
256
|
+
"""
|
|
257
|
+
cls = getattr(builtins, name, None)
|
|
258
|
+
if isinstance(cls, type):
|
|
259
|
+
return issubclass(cls, BaseException)
|
|
260
|
+
if name in _DATA_BASES:
|
|
261
|
+
return False
|
|
262
|
+
if name not in bases:
|
|
263
|
+
return None
|
|
264
|
+
parents = [_is_exception(base, bases) for base in bases[name] if base != name]
|
|
265
|
+
if any(p is True for p in parents):
|
|
266
|
+
return True
|
|
267
|
+
if all(p is False for p in parents): # also true for a class with no bases
|
|
268
|
+
return False
|
|
269
|
+
return None
|
|
270
|
+
|
|
271
|
+
|
|
272
|
+
def _raised_by_unwrap(error: str | None, bases: dict[str, list[str]]) -> str | None:
|
|
273
|
+
"""What ``.unwrap()`` raises for ``error``: itself, or RuntimeError for non-exceptions."""
|
|
274
|
+
members = error_members(error)
|
|
275
|
+
if not members:
|
|
276
|
+
return error
|
|
277
|
+
raised = ["RuntimeError" if _is_exception(m, bases) is False else m for m in members]
|
|
278
|
+
return " | ".join(dict.fromkeys(raised)) # dedupe, keep order
|
|
279
|
+
|
|
280
|
+
|
|
281
|
+
def _handler_catches(names: list[str], error: str, bases: dict[str, list[str]]) -> bool:
|
|
282
|
+
"""``except <names>`` catches ``error``.
|
|
283
|
+
|
|
284
|
+
A type whose hierarchy is unknown (say, imported from a library) is assumed
|
|
285
|
+
to be an ``Exception``, as Result errors almost always are.
|
|
286
|
+
"""
|
|
287
|
+
if "Exception" in names and not _outside_exception(error, bases):
|
|
288
|
+
return True
|
|
289
|
+
return any(_is_subclass(error, n, bases) for n in names)
|
|
290
|
+
|
|
291
|
+
|
|
292
|
+
def _handler_label(handler: ast.ExceptHandler) -> str:
|
|
293
|
+
return f"except {ast.unparse(handler.type)}" if handler.type is not None else "except"
|
|
294
|
+
|
|
295
|
+
|
|
296
|
+
def _handler_types(handler: ast.ExceptHandler) -> tuple[list[str], bool]:
|
|
297
|
+
"""(type names in ``except <types>``, whether it catches everything)."""
|
|
298
|
+
if handler.type is None:
|
|
299
|
+
return [], True
|
|
300
|
+
elts = handler.type.elts if isinstance(handler.type, ast.Tuple) else [handler.type]
|
|
301
|
+
names = [n for n in map(_ann_name, elts) if n]
|
|
302
|
+
return names, bool(set(names) & _CATCH_ALL)
|
|
303
|
+
|
|
304
|
+
|
|
305
|
+
class _BodyWalker:
|
|
306
|
+
def __init__(
|
|
307
|
+
self,
|
|
308
|
+
*,
|
|
309
|
+
file: str,
|
|
310
|
+
function_id: str,
|
|
311
|
+
enclosing_error: str | None,
|
|
312
|
+
signatures: dict[str, str],
|
|
313
|
+
bases: dict[str, list[str]],
|
|
314
|
+
modules: set[str],
|
|
315
|
+
in_safe: bool,
|
|
316
|
+
findings: list[Finding],
|
|
317
|
+
) -> None:
|
|
318
|
+
self.file = file
|
|
319
|
+
self.function_id = function_id
|
|
320
|
+
self.enclosing_error = enclosing_error
|
|
321
|
+
self.signatures = signatures
|
|
322
|
+
self.bases = bases
|
|
323
|
+
self.tries = 0
|
|
324
|
+
self.modules = modules
|
|
325
|
+
self.in_safe = in_safe
|
|
326
|
+
self.findings = findings
|
|
327
|
+
self.seen: set[int] = set() # ids of the Result calls the walker modelled
|
|
328
|
+
|
|
329
|
+
def _callee_error(self, call: ast.Call) -> str | None:
|
|
330
|
+
"""Error type when ``call`` is a known Result-returning callee, else None."""
|
|
331
|
+
func = call.func
|
|
332
|
+
if isinstance(func, ast.Name):
|
|
333
|
+
return self.signatures.get(func.id)
|
|
334
|
+
if isinstance(func, ast.Attribute):
|
|
335
|
+
if f".{func.attr}" in self.signatures:
|
|
336
|
+
return self.signatures[f".{func.attr}"]
|
|
337
|
+
root = _attr_root(func.value)
|
|
338
|
+
if isinstance(root, ast.Name) and root.id in self.modules:
|
|
339
|
+
return self.signatures.get(func.attr)
|
|
340
|
+
return None
|
|
341
|
+
|
|
342
|
+
def walk_stmts(self, stmts: list[ast.stmt]) -> list[Node]:
|
|
343
|
+
nodes: list[Node] = []
|
|
344
|
+
for stmt in stmts:
|
|
345
|
+
nodes.extend(self.walk_stmt(stmt))
|
|
346
|
+
return nodes
|
|
347
|
+
|
|
348
|
+
def walk_stmt(self, stmt: ast.stmt) -> list[Node]:
|
|
349
|
+
if isinstance(stmt, ast.Return):
|
|
350
|
+
return self._return(stmt)
|
|
351
|
+
if isinstance(stmt, ast.Assign):
|
|
352
|
+
return self._assign(stmt)
|
|
353
|
+
if isinstance(stmt, ast.AnnAssign):
|
|
354
|
+
return self._ann_assign(stmt)
|
|
355
|
+
if isinstance(stmt, ast.Expr):
|
|
356
|
+
return self._expr_stmt(stmt)
|
|
357
|
+
if isinstance(stmt, (ast.If,)):
|
|
358
|
+
return [self._if(stmt)]
|
|
359
|
+
if isinstance(stmt, ast.Match):
|
|
360
|
+
return [self._match(stmt)]
|
|
361
|
+
if isinstance(stmt, (ast.For, ast.AsyncFor)):
|
|
362
|
+
return [self._for(stmt)]
|
|
363
|
+
if isinstance(stmt, ast.While):
|
|
364
|
+
return [self._while(stmt)]
|
|
365
|
+
if isinstance(stmt, (ast.Break, ast.Continue)):
|
|
366
|
+
name = "break" if isinstance(stmt, ast.Break) else "continue"
|
|
367
|
+
return [Jump(name=name, line=stmt.lineno)]
|
|
368
|
+
if isinstance(stmt, (ast.With, ast.AsyncWith)):
|
|
369
|
+
return self.walk_stmts(stmt.body)
|
|
370
|
+
if isinstance(stmt, _TryNode):
|
|
371
|
+
return self._try(stmt)
|
|
372
|
+
if isinstance(stmt, ast.Raise):
|
|
373
|
+
return [self._raise(stmt)]
|
|
374
|
+
return []
|
|
375
|
+
|
|
376
|
+
def _raise(self, stmt: ast.Raise) -> Panic:
|
|
377
|
+
"""``raise X(...)`` exits with X; ``@safe`` or a matching ``except`` may catch it."""
|
|
378
|
+
exc = stmt.exc
|
|
379
|
+
error: str | None = None
|
|
380
|
+
if isinstance(exc, ast.Call):
|
|
381
|
+
error = _call_name(exc)
|
|
382
|
+
elif exc is not None:
|
|
383
|
+
name = _ann_name(exc)
|
|
384
|
+
error = name if name and name[0].isupper() else None # `raise e`: type unknown
|
|
385
|
+
return Panic(
|
|
386
|
+
name=f"raise {error}" if error else "raise",
|
|
387
|
+
terminal=True,
|
|
388
|
+
line=stmt.lineno,
|
|
389
|
+
error=error,
|
|
390
|
+
)
|
|
391
|
+
|
|
392
|
+
def _return(self, stmt: ast.Return) -> list[Node]:
|
|
393
|
+
if stmt.value is None:
|
|
394
|
+
return [Succeed()]
|
|
395
|
+
return self._returned(stmt.value, stmt.lineno)
|
|
396
|
+
|
|
397
|
+
def _returned(self, value: ast.expr, line: int) -> list[Node]:
|
|
398
|
+
value = _unawait(value)
|
|
399
|
+
if isinstance(value, ast.IfExp): # return Ok(x) if cond else Err(e)
|
|
400
|
+
return [
|
|
401
|
+
Branch(
|
|
402
|
+
label=f"if {ast.unparse(value.test)}",
|
|
403
|
+
arms=[
|
|
404
|
+
Arm(label="yes", body=self._returned(value.body, line)),
|
|
405
|
+
Arm(label="no", body=self._returned(value.orelse, line)),
|
|
406
|
+
],
|
|
407
|
+
)
|
|
408
|
+
]
|
|
409
|
+
if _is_err_call(value):
|
|
410
|
+
return [Fail(error=_err_ctor_name(value), line=line)]
|
|
411
|
+
# unwrap is itself a Call, so it must be checked before the generic Call case
|
|
412
|
+
if _is_unwrap_call(value):
|
|
413
|
+
return [*self._unwrap_nodes(value, line), Succeed()]
|
|
414
|
+
if isinstance(value, ast.Call) and self._callee_error(value) is not None:
|
|
415
|
+
return [self._step_from_call(value, line), Succeed()]
|
|
416
|
+
return [Succeed()]
|
|
417
|
+
|
|
418
|
+
def _assign(self, stmt: ast.Assign) -> list[Node]:
|
|
419
|
+
return self._binding(stmt.value, stmt.targets, stmt.lineno)
|
|
420
|
+
|
|
421
|
+
def _ann_assign(self, stmt: ast.AnnAssign) -> list[Node]:
|
|
422
|
+
if stmt.value is None:
|
|
423
|
+
return []
|
|
424
|
+
return self._binding(stmt.value, [stmt.target], stmt.lineno)
|
|
425
|
+
|
|
426
|
+
def _binding(self, value: ast.expr, targets: list[ast.expr], line: int) -> list[Node]:
|
|
427
|
+
value = _unawait(value)
|
|
428
|
+
# `_ = fallible()` swallow
|
|
429
|
+
if (
|
|
430
|
+
len(targets) == 1
|
|
431
|
+
and isinstance(targets[0], ast.Name)
|
|
432
|
+
and targets[0].id == "_"
|
|
433
|
+
and isinstance(value, ast.Call)
|
|
434
|
+
and self._callee_error(value) is not None
|
|
435
|
+
):
|
|
436
|
+
self.seen.add(id(value))
|
|
437
|
+
name = _call_name(value) or "call"
|
|
438
|
+
node = Swallow(
|
|
439
|
+
name=name,
|
|
440
|
+
how="discard",
|
|
441
|
+
confidence=Confidence.LIKELY,
|
|
442
|
+
line=line,
|
|
443
|
+
)
|
|
444
|
+
self._finding(
|
|
445
|
+
NO_SWALLOW,
|
|
446
|
+
line,
|
|
447
|
+
f"`_ =` drops the error from `{name}`",
|
|
448
|
+
)
|
|
449
|
+
return [node]
|
|
450
|
+
|
|
451
|
+
if _is_unwrap_call(value):
|
|
452
|
+
return self._unwrap_nodes(value, line)
|
|
453
|
+
|
|
454
|
+
# x = f() where f returns Result — treat as step if known Result callee
|
|
455
|
+
if isinstance(value, ast.Call):
|
|
456
|
+
if self._callee_error(value) is not None:
|
|
457
|
+
return [self._step_from_call(value, line)]
|
|
458
|
+
# chained .and_then(...)
|
|
459
|
+
if isinstance(value.func, ast.Attribute) and value.func.attr in {
|
|
460
|
+
"and_then",
|
|
461
|
+
"map",
|
|
462
|
+
"map_err",
|
|
463
|
+
}:
|
|
464
|
+
return self._chain(value, line)
|
|
465
|
+
|
|
466
|
+
return []
|
|
467
|
+
|
|
468
|
+
def _expr_stmt(self, stmt: ast.Expr) -> list[Node]:
|
|
469
|
+
value = _unawait(stmt.value)
|
|
470
|
+
if _is_unwrap_call(value):
|
|
471
|
+
return self._unwrap_nodes(value, stmt.lineno)
|
|
472
|
+
if isinstance(value, ast.Call):
|
|
473
|
+
name = _call_name(value)
|
|
474
|
+
if name and self._callee_error(value) is not None:
|
|
475
|
+
self.seen.add(id(value))
|
|
476
|
+
node = Swallow(
|
|
477
|
+
name=name,
|
|
478
|
+
how="unused",
|
|
479
|
+
confidence=Confidence.POSSIBLE,
|
|
480
|
+
line=stmt.lineno,
|
|
481
|
+
)
|
|
482
|
+
self._finding(
|
|
483
|
+
NO_SWALLOW,
|
|
484
|
+
stmt.lineno,
|
|
485
|
+
f"unused Result from `{name}`",
|
|
486
|
+
confidence=Confidence.POSSIBLE,
|
|
487
|
+
)
|
|
488
|
+
return [node]
|
|
489
|
+
return []
|
|
490
|
+
|
|
491
|
+
def _unwrap_nodes(self, call: ast.Call, line: int) -> list[Node]:
|
|
492
|
+
self.seen.add(id(call))
|
|
493
|
+
recv = _unwrap_receiver(call)
|
|
494
|
+
if recv is not None:
|
|
495
|
+
recv = _unawait(recv) # (await f()).unwrap()
|
|
496
|
+
name = "unwrap"
|
|
497
|
+
step_call: ast.Call | None = None
|
|
498
|
+
if recv is not None:
|
|
499
|
+
if isinstance(recv, ast.Call):
|
|
500
|
+
step_call = recv
|
|
501
|
+
name = _call_name(recv) or "call"
|
|
502
|
+
elif isinstance(recv, ast.Name):
|
|
503
|
+
name = recv.id
|
|
504
|
+
|
|
505
|
+
if not self.in_safe:
|
|
506
|
+
error = self._callee_error(step_call) if step_call is not None else None
|
|
507
|
+
error = _raised_by_unwrap(error, self.bases)
|
|
508
|
+
panic = Panic(name=f"{name}.unwrap", terminal=False, line=line, error=error)
|
|
509
|
+
self._finding(
|
|
510
|
+
NO_PANIC,
|
|
511
|
+
line,
|
|
512
|
+
f"`{name}.unwrap` can raise",
|
|
513
|
+
)
|
|
514
|
+
nodes: list[Node] = []
|
|
515
|
+
if step_call is not None:
|
|
516
|
+
nodes.append(self._raising_step(step_call, line))
|
|
517
|
+
nodes.append(panic)
|
|
518
|
+
return nodes
|
|
519
|
+
|
|
520
|
+
if step_call is not None:
|
|
521
|
+
return [self._raising_step(step_call, line)]
|
|
522
|
+
return [
|
|
523
|
+
Step(
|
|
524
|
+
name=name,
|
|
525
|
+
error=_raised_by_unwrap(self.enclosing_error, self.bases),
|
|
526
|
+
source=ErrorSource.ENCLOSING if self.enclosing_error else ErrorSource.UNKNOWN,
|
|
527
|
+
line=line,
|
|
528
|
+
raises=True,
|
|
529
|
+
)
|
|
530
|
+
]
|
|
531
|
+
|
|
532
|
+
def _raising_step(self, call: ast.Call, line: int) -> Step:
|
|
533
|
+
"""A step whose error is raised by ``.unwrap()``; a str or model error raises RuntimeError."""
|
|
534
|
+
step = self._step_from_call(call, line)
|
|
535
|
+
return replace(step, raises=True, error=_raised_by_unwrap(step.error, self.bases))
|
|
536
|
+
|
|
537
|
+
def _step_from_call(self, call: ast.Call, line: int) -> Step:
|
|
538
|
+
self.seen.add(id(call))
|
|
539
|
+
name = _call_name(call) or "call"
|
|
540
|
+
error = self._callee_error(call)
|
|
541
|
+
source = ErrorSource.UNKNOWN
|
|
542
|
+
if error is not None:
|
|
543
|
+
source = ErrorSource.CALLEE
|
|
544
|
+
elif self.enclosing_error:
|
|
545
|
+
error = self.enclosing_error
|
|
546
|
+
source = ErrorSource.ENCLOSING
|
|
547
|
+
return Step(name=name, error=error, source=source, line=line)
|
|
548
|
+
|
|
549
|
+
def _chain(self, call: ast.Call, line: int) -> list[Node]:
|
|
550
|
+
nodes: list[Node] = []
|
|
551
|
+
cur: ast.expr = call
|
|
552
|
+
parts: list[ast.Call] = []
|
|
553
|
+
while isinstance(cur, ast.Call) and isinstance(cur.func, ast.Attribute):
|
|
554
|
+
parts.append(cur)
|
|
555
|
+
cur = _unawait(cur.func.value)
|
|
556
|
+
if isinstance(cur, ast.Call):
|
|
557
|
+
nodes.append(self._step_from_call(cur, line))
|
|
558
|
+
for part in reversed(parts):
|
|
559
|
+
assert isinstance(part.func, ast.Attribute)
|
|
560
|
+
attr = part.func.attr
|
|
561
|
+
if attr == "and_then" and part.args:
|
|
562
|
+
arg = part.args[0]
|
|
563
|
+
name = _call_name(arg) or "and_then"
|
|
564
|
+
err = self.signatures.get(name)
|
|
565
|
+
source = (
|
|
566
|
+
ErrorSource.CALLEE
|
|
567
|
+
if err
|
|
568
|
+
else (ErrorSource.ENCLOSING if self.enclosing_error else ErrorSource.UNKNOWN)
|
|
569
|
+
)
|
|
570
|
+
nodes.append(
|
|
571
|
+
Step(
|
|
572
|
+
name=name,
|
|
573
|
+
error=err or self.enclosing_error,
|
|
574
|
+
source=source,
|
|
575
|
+
line=line,
|
|
576
|
+
)
|
|
577
|
+
)
|
|
578
|
+
elif attr in {"map_err", "map"}:
|
|
579
|
+
nodes.append(
|
|
580
|
+
Step(
|
|
581
|
+
name=attr,
|
|
582
|
+
error=self.enclosing_error,
|
|
583
|
+
source=ErrorSource.ENCLOSING
|
|
584
|
+
if self.enclosing_error
|
|
585
|
+
else ErrorSource.UNKNOWN,
|
|
586
|
+
conversions=[ast.unparse(part.args[0])] if part.args else [],
|
|
587
|
+
line=line,
|
|
588
|
+
)
|
|
589
|
+
)
|
|
590
|
+
return nodes
|
|
591
|
+
|
|
592
|
+
def _if(self, stmt: ast.If) -> Branch:
|
|
593
|
+
test = ast.unparse(stmt.test)
|
|
594
|
+
then_arm = Arm(label="yes", body=self.walk_stmts(stmt.body))
|
|
595
|
+
else_body = stmt.orelse
|
|
596
|
+
# elif chain
|
|
597
|
+
if len(else_body) == 1 and isinstance(else_body[0], ast.If):
|
|
598
|
+
else_arm = Arm(label="no", body=[self._if(else_body[0])])
|
|
599
|
+
else:
|
|
600
|
+
else_arm = Arm(label="no", body=self.walk_stmts(else_body))
|
|
601
|
+
return Branch(label=f"if {test}", arms=[then_arm, else_arm])
|
|
602
|
+
|
|
603
|
+
def _match(self, stmt: ast.Match) -> Branch:
|
|
604
|
+
arms: list[Arm] = []
|
|
605
|
+
for case in stmt.cases:
|
|
606
|
+
label = ast.unparse(case.pattern)
|
|
607
|
+
arms.append(Arm(label=label, body=self.walk_stmts(case.body)))
|
|
608
|
+
return Branch(label=f"match {ast.unparse(stmt.subject)}", arms=arms)
|
|
609
|
+
|
|
610
|
+
def _for(self, stmt: ast.For | ast.AsyncFor) -> Loop:
|
|
611
|
+
target = ast.unparse(stmt.target)
|
|
612
|
+
iter_ = ast.unparse(stmt.iter)
|
|
613
|
+
return Loop(
|
|
614
|
+
label=f"for {target} in {iter_}",
|
|
615
|
+
can_skip=True,
|
|
616
|
+
body=self.walk_stmts(stmt.body),
|
|
617
|
+
)
|
|
618
|
+
|
|
619
|
+
def _while(self, stmt: ast.While) -> Loop:
|
|
620
|
+
return Loop(
|
|
621
|
+
label=f"while {ast.unparse(stmt.test)}",
|
|
622
|
+
can_skip=True,
|
|
623
|
+
body=self.walk_stmts(stmt.body),
|
|
624
|
+
)
|
|
625
|
+
|
|
626
|
+
def _try(self, stmt: _TryNode) -> list[Node]:
|
|
627
|
+
self.tries += 1
|
|
628
|
+
tag = f"try{self.tries}"
|
|
629
|
+
# Errors raised in the body (not else) go to the first matching handler.
|
|
630
|
+
body = self._catch(self.walk_stmts(stmt.body), stmt.handlers, tag)
|
|
631
|
+
# Handlers are alternatives to the normal path, not steps after it.
|
|
632
|
+
arms = [Arm(label="ok", body=body + self.walk_stmts(stmt.orelse))]
|
|
633
|
+
for handler in stmt.handlers:
|
|
634
|
+
how = _handler_label(handler)
|
|
635
|
+
recover = Recover(name="try", how=how, arms=[how], line=handler.lineno)
|
|
636
|
+
arms.append(Arm(label=how, body=[recover, *self.walk_stmts(handler.body)]))
|
|
637
|
+
finally_nodes = self.walk_stmts(stmt.finalbody)
|
|
638
|
+
if finally_nodes:
|
|
639
|
+
arms = [Arm(arm.label, _before_exits(arm.body, finally_nodes)) for arm in arms]
|
|
640
|
+
return [Branch(label="try", arms=arms, tag=tag), *finally_nodes]
|
|
641
|
+
|
|
642
|
+
def _catch(self, nodes: list[Node], handlers: list[ast.ExceptHandler], tag: str) -> list[Node]:
|
|
643
|
+
"""Point each raising step not already caught at the first handler that catches it."""
|
|
644
|
+
out: list[Node] = []
|
|
645
|
+
for node in nodes:
|
|
646
|
+
if isinstance(node, (Step, Panic)):
|
|
647
|
+
node = replace(
|
|
648
|
+
node,
|
|
649
|
+
cleanup=self._catch(node.cleanup, handlers, tag),
|
|
650
|
+
caught_by={
|
|
651
|
+
k: replace(c, cleanup=self._catch(c.cleanup, handlers, tag))
|
|
652
|
+
for k, c in node.caught_by.items()
|
|
653
|
+
},
|
|
654
|
+
)
|
|
655
|
+
raises = node.raises if isinstance(node, Step) else True
|
|
656
|
+
if raises and escaping(node.error, node.caught_by) is not None:
|
|
657
|
+
node = replace(node, caught_by=self._route(node, handlers, tag))
|
|
658
|
+
elif isinstance(node, Branch):
|
|
659
|
+
node = replace(
|
|
660
|
+
node, arms=[Arm(a.label, self._catch(a.body, handlers, tag)) for a in node.arms]
|
|
661
|
+
)
|
|
662
|
+
elif isinstance(node, Loop):
|
|
663
|
+
node = replace(node, body=self._catch(node.body, handlers, tag))
|
|
664
|
+
out.append(node)
|
|
665
|
+
return out
|
|
666
|
+
|
|
667
|
+
def _route(
|
|
668
|
+
self, node: Step | Panic, handlers: list[ast.ExceptHandler], tag: str
|
|
669
|
+
) -> dict[str, Catch]:
|
|
670
|
+
"""Add this try's handlers to ``node``'s routing; the first match per exception type wins."""
|
|
671
|
+
caught = dict(node.caught_by)
|
|
672
|
+
members = error_members(node.error)
|
|
673
|
+
for i, handler in enumerate(handlers, start=1):
|
|
674
|
+
names, catch_all = _handler_types(handler)
|
|
675
|
+
catch = Catch(
|
|
676
|
+
handler=f"{tag}#{i}", how=_handler_label(handler), cleanup=list(node.cleanup)
|
|
677
|
+
)
|
|
678
|
+
if members:
|
|
679
|
+
for m in members:
|
|
680
|
+
if m not in caught and (catch_all or _handler_catches(names, m, self.bases)):
|
|
681
|
+
caught[m] = catch
|
|
682
|
+
elif CATCH_ANY not in caught:
|
|
683
|
+
# Unknown type: a catch-all (or Exception: Result errors are Exceptions)
|
|
684
|
+
# surely catches it; a typed handler only might.
|
|
685
|
+
if catch_all or "Exception" in names:
|
|
686
|
+
caught[CATCH_ANY] = catch
|
|
687
|
+
else:
|
|
688
|
+
for n in names:
|
|
689
|
+
caught.setdefault(n, catch)
|
|
690
|
+
return caught
|
|
691
|
+
|
|
692
|
+
def _finding(
|
|
693
|
+
self,
|
|
694
|
+
rule: str,
|
|
695
|
+
line: int,
|
|
696
|
+
message: str,
|
|
697
|
+
*,
|
|
698
|
+
confidence: Confidence = Confidence.CERTAIN,
|
|
699
|
+
) -> None:
|
|
700
|
+
self.findings.append(
|
|
701
|
+
Finding(
|
|
702
|
+
rule=rule,
|
|
703
|
+
file=self.file,
|
|
704
|
+
line=line,
|
|
705
|
+
message=message,
|
|
706
|
+
confidence=confidence,
|
|
707
|
+
function=self.function_id,
|
|
708
|
+
)
|
|
709
|
+
)
|
|
710
|
+
|
|
711
|
+
|
|
712
|
+
def _before_exits(nodes: list[Node], fin: list[Node], *, jumps: bool = True) -> list[Node]:
|
|
713
|
+
"""Put ``fin`` before every exit in ``nodes``: Python runs ``finally`` on return,
|
|
714
|
+
break, continue and on errors raised inside the ``try``.
|
|
715
|
+
|
|
716
|
+
If ``fin`` itself ends in a return, the emitters stop there, so its exit wins, as in Python.
|
|
717
|
+
"""
|
|
718
|
+
out: list[Node] = []
|
|
719
|
+
for node in nodes:
|
|
720
|
+
if isinstance(node, (Succeed, Fail)) or (jumps and isinstance(node, Jump)):
|
|
721
|
+
out.extend(fin)
|
|
722
|
+
elif isinstance(node, (Step, Panic)):
|
|
723
|
+
cleanup = _before_exits(node.cleanup, fin, jumps=False)
|
|
724
|
+
if escaping(node.error, node.caught_by) is not None:
|
|
725
|
+
# Error leaves the try: inner cleanup first, then this finally, then the error.
|
|
726
|
+
cleanup = [*cleanup, *fin]
|
|
727
|
+
caught_by = {
|
|
728
|
+
k: replace(c, cleanup=_before_exits(c.cleanup, fin, jumps=False))
|
|
729
|
+
for k, c in node.caught_by.items()
|
|
730
|
+
}
|
|
731
|
+
node = replace(node, cleanup=cleanup, caught_by=caught_by)
|
|
732
|
+
elif isinstance(node, Branch):
|
|
733
|
+
node = replace(
|
|
734
|
+
node,
|
|
735
|
+
arms=[Arm(a.label, _before_exits(a.body, fin, jumps=jumps)) for a in node.arms],
|
|
736
|
+
)
|
|
737
|
+
elif isinstance(node, Loop):
|
|
738
|
+
# break/continue inside a nested loop stay in that loop, so they skip finally
|
|
739
|
+
node = replace(node, body=_before_exits(node.body, fin, jumps=False))
|
|
740
|
+
out.append(node)
|
|
741
|
+
return out
|
|
742
|
+
|
|
743
|
+
|
|
744
|
+
def _safe_error(node: ast.FunctionDef | ast.AsyncFunctionDef) -> str | None:
|
|
745
|
+
"""The error type a @safe-family decorator gives ``node``, or None if it has none."""
|
|
746
|
+
for dec in node.decorator_list:
|
|
747
|
+
if isinstance(dec, ast.Call) and _call_name(dec) in {"safe_with", "safe_async_with"}:
|
|
748
|
+
names = [_ann_name(arg) or ast.unparse(arg) for arg in dec.args]
|
|
749
|
+
return " | ".join(names) if names else "Exception"
|
|
750
|
+
if not isinstance(dec, ast.Call) and _ann_name(dec) in {"safe", "safe_async"}:
|
|
751
|
+
return "Exception"
|
|
752
|
+
return None
|
|
753
|
+
|
|
754
|
+
|
|
755
|
+
def _has_safe_decorator(node: ast.FunctionDef | ast.AsyncFunctionDef) -> bool:
|
|
756
|
+
return _safe_error(node) is not None
|
|
757
|
+
|
|
758
|
+
|
|
759
|
+
def _calls(node: ast.AST, deferred: bool = False) -> Iterator[tuple[ast.Call, bool]]:
|
|
760
|
+
"""Calls in ``node``, outermost first, each with whether it runs later (in a lambda).
|
|
761
|
+
|
|
762
|
+
A nested function or class body is its own scope (analysed on its own), so its calls
|
|
763
|
+
never count against the enclosing function; what runs while defining it (decorators,
|
|
764
|
+
defaults, class bases) does. A lambda's body may or may not run here, so its calls
|
|
765
|
+
come back marked deferred.
|
|
766
|
+
"""
|
|
767
|
+
children: Iterable[ast.AST]
|
|
768
|
+
if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef, ast.Lambda)):
|
|
769
|
+
args = node.args
|
|
770
|
+
decorators = [] if isinstance(node, ast.Lambda) else node.decorator_list
|
|
771
|
+
children = [*decorators, *args.defaults, *filter(None, args.kw_defaults)]
|
|
772
|
+
elif isinstance(node, ast.ClassDef):
|
|
773
|
+
children = [*node.decorator_list, *node.bases, *(k.value for k in node.keywords)]
|
|
774
|
+
else:
|
|
775
|
+
children = ast.iter_child_nodes(node)
|
|
776
|
+
for child in children:
|
|
777
|
+
if isinstance(child, ast.Call):
|
|
778
|
+
yield child, deferred
|
|
779
|
+
yield from _calls(child, deferred)
|
|
780
|
+
if isinstance(node, ast.Lambda):
|
|
781
|
+
if isinstance(node.body, ast.Call):
|
|
782
|
+
yield node.body, True
|
|
783
|
+
yield from _calls(node.body, True)
|
|
784
|
+
|
|
785
|
+
|
|
786
|
+
def _module_name(path: Path, root: Path) -> str:
|
|
787
|
+
try:
|
|
788
|
+
rel = path.relative_to(root)
|
|
789
|
+
except ValueError:
|
|
790
|
+
return path.stem
|
|
791
|
+
parts = list(rel.with_suffix("").parts)
|
|
792
|
+
if parts and parts[-1] == "__init__":
|
|
793
|
+
parts = parts[:-1]
|
|
794
|
+
return ".".join(parts)
|
|
795
|
+
|
|
796
|
+
|
|
797
|
+
def _is_support_path(path: Path) -> bool:
|
|
798
|
+
parts = set(path.parts)
|
|
799
|
+
return bool(parts & {"tests", "test", "benches", "examples", ".venv", "venv"})
|
|
800
|
+
|
|
801
|
+
|
|
802
|
+
class _FunctionExtractor(ast.NodeVisitor):
|
|
803
|
+
def __init__(
|
|
804
|
+
self,
|
|
805
|
+
*,
|
|
806
|
+
file: str,
|
|
807
|
+
module: str,
|
|
808
|
+
signatures: dict[str, str],
|
|
809
|
+
bases: dict[str, list[str]],
|
|
810
|
+
modules: set[str],
|
|
811
|
+
report: Report,
|
|
812
|
+
) -> None:
|
|
813
|
+
self.file = file
|
|
814
|
+
self.module = module
|
|
815
|
+
self.signatures = signatures
|
|
816
|
+
self.bases = bases
|
|
817
|
+
self.modules = modules
|
|
818
|
+
self.report = report
|
|
819
|
+
self._class: str | None = None
|
|
820
|
+
|
|
821
|
+
def visit_ClassDef(self, node: ast.ClassDef) -> None:
|
|
822
|
+
prev = self._class
|
|
823
|
+
self._class = node.name
|
|
824
|
+
self.generic_visit(node)
|
|
825
|
+
self._class = prev
|
|
826
|
+
|
|
827
|
+
def visit_FunctionDef(self, node: ast.FunctionDef) -> None:
|
|
828
|
+
self._extract(node)
|
|
829
|
+
self.generic_visit(node)
|
|
830
|
+
|
|
831
|
+
def visit_AsyncFunctionDef(self, node: ast.AsyncFunctionDef) -> None:
|
|
832
|
+
self._extract(node)
|
|
833
|
+
self.generic_visit(node)
|
|
834
|
+
|
|
835
|
+
def _qid(self, name: str) -> str:
|
|
836
|
+
if self._class:
|
|
837
|
+
return f"{self.module}.{self._class}.{name}" if self.module else f"{self._class}.{name}"
|
|
838
|
+
return f"{self.module}.{name}" if self.module else name
|
|
839
|
+
|
|
840
|
+
def _extract(self, node: ast.FunctionDef | ast.AsyncFunctionDef) -> None:
|
|
841
|
+
returns = parse_returns(node.returns)
|
|
842
|
+
# Only analyse Result-returning or @safe functions
|
|
843
|
+
in_safe = _has_safe_decorator(node)
|
|
844
|
+
if not isinstance(returns, ReturnsResult) and not in_safe:
|
|
845
|
+
return
|
|
846
|
+
|
|
847
|
+
enclosing = returns.error if isinstance(returns, ReturnsResult) else None
|
|
848
|
+
if not isinstance(returns, ReturnsResult):
|
|
849
|
+
returns = ReturnsResult(error=_safe_error(node) or "Exception")
|
|
850
|
+
fid = self._qid(node.name)
|
|
851
|
+
walker = _BodyWalker(
|
|
852
|
+
file=self.file,
|
|
853
|
+
function_id=fid,
|
|
854
|
+
enclosing_error=enclosing,
|
|
855
|
+
signatures=self.signatures,
|
|
856
|
+
bases=self.bases,
|
|
857
|
+
modules=self.modules,
|
|
858
|
+
in_safe=in_safe,
|
|
859
|
+
findings=self.report.findings,
|
|
860
|
+
)
|
|
861
|
+
nodes: list[Node] = walker.walk_stmts(node.body)
|
|
862
|
+
if not nodes:
|
|
863
|
+
nodes = [Succeed()]
|
|
864
|
+
self._report_unanalysed(node, walker, fid, in_safe)
|
|
865
|
+
|
|
866
|
+
self.report.functions.append(
|
|
867
|
+
Function(
|
|
868
|
+
id=fid,
|
|
869
|
+
name=node.name,
|
|
870
|
+
file=self.file,
|
|
871
|
+
line=node.lineno,
|
|
872
|
+
returns=returns,
|
|
873
|
+
nodes=nodes,
|
|
874
|
+
)
|
|
875
|
+
)
|
|
876
|
+
|
|
877
|
+
def _report_unanalysed(
|
|
878
|
+
self,
|
|
879
|
+
node: ast.FunctionDef | ast.AsyncFunctionDef,
|
|
880
|
+
walker: _BodyWalker,
|
|
881
|
+
fid: str,
|
|
882
|
+
in_safe: bool,
|
|
883
|
+
) -> None:
|
|
884
|
+
"""Say where errpath couldn't follow a Result, instead of silently drawing less."""
|
|
885
|
+
for stmt in node.body:
|
|
886
|
+
for call, deferred in _calls(stmt):
|
|
887
|
+
if id(call) in walker.seen:
|
|
888
|
+
continue
|
|
889
|
+
is_unwrap = _is_unwrap_call(call)
|
|
890
|
+
if not is_unwrap and walker._callee_error(call) is None:
|
|
891
|
+
continue
|
|
892
|
+
receiver = _unwrap_receiver(call) if is_unwrap else None
|
|
893
|
+
if isinstance(receiver, ast.Call):
|
|
894
|
+
walker.seen.add(id(receiver)) # one note for `f().unwrap()`, not two
|
|
895
|
+
code = ast.unparse(call)
|
|
896
|
+
self.report.unanalysed.append(
|
|
897
|
+
Unanalysed(
|
|
898
|
+
file=self.file,
|
|
899
|
+
line=call.lineno,
|
|
900
|
+
function=fid,
|
|
901
|
+
code=code if len(code) <= 60 else code[:57] + "...",
|
|
902
|
+
reason="Result used inside a lambda; errpath can't tell when it runs"
|
|
903
|
+
if deferred
|
|
904
|
+
else "Result used inside an expression; its error path isn't drawn",
|
|
905
|
+
)
|
|
906
|
+
)
|
|
907
|
+
if (
|
|
908
|
+
is_unwrap and not in_safe and not deferred
|
|
909
|
+
): # still a crash point the review must see
|
|
910
|
+
name = _call_name(receiver) if isinstance(receiver, ast.Call) else "unwrap"
|
|
911
|
+
walker._finding(NO_PANIC, call.lineno, f"`{name}.unwrap` can raise")
|
|
912
|
+
|
|
913
|
+
|
|
914
|
+
def _count_coverage(report: Report) -> None:
|
|
915
|
+
cov = Coverage(functions=len(report.functions))
|
|
916
|
+
for fn in report.functions:
|
|
917
|
+
for node in _iter_nodes(fn.nodes):
|
|
918
|
+
if isinstance(node, Step):
|
|
919
|
+
cov.steps += 1
|
|
920
|
+
if node.source == ErrorSource.CALLEE:
|
|
921
|
+
cov.error_from_callee += 1
|
|
922
|
+
elif node.source == ErrorSource.ENCLOSING:
|
|
923
|
+
cov.error_from_enclosing += 1
|
|
924
|
+
else:
|
|
925
|
+
cov.error_unknown += 1
|
|
926
|
+
cov.unanalysed = len(report.unanalysed)
|
|
927
|
+
report.coverage = cov
|
|
928
|
+
|
|
929
|
+
|
|
930
|
+
def _iter_nodes(nodes: list[Node]) -> list[Node]:
|
|
931
|
+
out: list[Node] = []
|
|
932
|
+
for n in nodes:
|
|
933
|
+
out.append(n)
|
|
934
|
+
if isinstance(n, Branch):
|
|
935
|
+
for arm in n.arms:
|
|
936
|
+
out.extend(_iter_nodes(arm.body))
|
|
937
|
+
elif isinstance(n, Loop):
|
|
938
|
+
out.extend(_iter_nodes(n.head))
|
|
939
|
+
out.extend(_iter_nodes(n.body))
|
|
940
|
+
elif isinstance(n, (Scope, Block)):
|
|
941
|
+
out.extend(_iter_nodes(n.body))
|
|
942
|
+
return out
|
|
943
|
+
|
|
944
|
+
|
|
945
|
+
def analyse_source(
|
|
946
|
+
source: str,
|
|
947
|
+
*,
|
|
948
|
+
file: str = "<string>",
|
|
949
|
+
module: str = "",
|
|
950
|
+
signatures: dict[str, str] | None = None,
|
|
951
|
+
bases: dict[str, list[str]] | None = None,
|
|
952
|
+
) -> Report:
|
|
953
|
+
"""Analyse one module. ``signatures`` and class ``bases`` default to those in ``source``."""
|
|
954
|
+
report = Report(schema_version=SCHEMA_VERSION)
|
|
955
|
+
try:
|
|
956
|
+
tree = ast.parse(source, filename=file)
|
|
957
|
+
except SyntaxError as e:
|
|
958
|
+
report.parse_failures.append(ParseFailure(file=file, message=str(e)))
|
|
959
|
+
return report
|
|
960
|
+
|
|
961
|
+
extractor = _FunctionExtractor(
|
|
962
|
+
file=file,
|
|
963
|
+
module=module,
|
|
964
|
+
signatures=_collect(tree).signatures if signatures is None else signatures,
|
|
965
|
+
bases=_collect(tree).bases if bases is None else bases,
|
|
966
|
+
modules=_imported_names(tree),
|
|
967
|
+
report=report,
|
|
968
|
+
)
|
|
969
|
+
extractor.visit(tree)
|
|
970
|
+
_count_coverage(report)
|
|
971
|
+
return report
|
|
972
|
+
|
|
973
|
+
|
|
974
|
+
def _rel(path: Path, root: Path) -> str:
|
|
975
|
+
return str(path.relative_to(root)) if path.is_relative_to(root) else str(path)
|
|
976
|
+
|
|
977
|
+
|
|
978
|
+
def analyse_file(path: Path, *, root: Path | None = None) -> Report:
|
|
979
|
+
root = root or path.parent
|
|
980
|
+
text = path.read_text(encoding="utf-8")
|
|
981
|
+
return analyse_source(text, file=_rel(path, root), module=_module_name(path, root))
|
|
982
|
+
|
|
983
|
+
|
|
984
|
+
def analyse_path(root: Path) -> Report:
|
|
985
|
+
"""Analyse a file or directory of ``.py`` sources."""
|
|
986
|
+
root = root.resolve()
|
|
987
|
+
merged = Report(schema_version=SCHEMA_VERSION)
|
|
988
|
+
if root.is_file():
|
|
989
|
+
files = [root]
|
|
990
|
+
base = root.parent
|
|
991
|
+
else:
|
|
992
|
+
# Skip tests, examples and virtualenvs when walking a tree; a single file is always analysed.
|
|
993
|
+
files = []
|
|
994
|
+
for p in sorted(root.rglob("*.py")):
|
|
995
|
+
if not p.is_file():
|
|
996
|
+
continue
|
|
997
|
+
if _is_support_path(p.relative_to(root)):
|
|
998
|
+
merged.support_files_skipped += 1
|
|
999
|
+
else:
|
|
1000
|
+
files.append(p)
|
|
1001
|
+
base = root
|
|
1002
|
+
|
|
1003
|
+
sources: dict[Path, str] = {}
|
|
1004
|
+
for path in files:
|
|
1005
|
+
try:
|
|
1006
|
+
sources[path] = path.read_text(encoding="utf-8")
|
|
1007
|
+
except OSError as e:
|
|
1008
|
+
merged.parse_failures.append(ParseFailure(file=_rel(path, base), message=str(e)))
|
|
1009
|
+
|
|
1010
|
+
# Collect signatures across the tree first, so calls into other modules resolve
|
|
1011
|
+
all_sigs: dict[str, str] = {}
|
|
1012
|
+
all_bases: dict[str, list[str]] = {}
|
|
1013
|
+
for path, text in sources.items():
|
|
1014
|
+
try:
|
|
1015
|
+
collected = _collect(ast.parse(text, filename=str(path)))
|
|
1016
|
+
all_sigs.update(collected.signatures)
|
|
1017
|
+
all_bases.update(collected.bases)
|
|
1018
|
+
except SyntaxError:
|
|
1019
|
+
pass # reported by analyse_source below
|
|
1020
|
+
|
|
1021
|
+
for path, text in sources.items():
|
|
1022
|
+
sub = analyse_source(
|
|
1023
|
+
text,
|
|
1024
|
+
file=_rel(path, base),
|
|
1025
|
+
module=_module_name(path, base),
|
|
1026
|
+
signatures=all_sigs,
|
|
1027
|
+
bases=all_bases,
|
|
1028
|
+
)
|
|
1029
|
+
merged.functions.extend(sub.functions)
|
|
1030
|
+
merged.findings.extend(sub.findings)
|
|
1031
|
+
merged.parse_failures.extend(sub.parse_failures)
|
|
1032
|
+
merged.unanalysed.extend(sub.unanalysed)
|
|
1033
|
+
|
|
1034
|
+
_count_coverage(merged)
|
|
1035
|
+
return merged
|