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/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