blockpath 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.
- blockpath/__init__.py +22 -0
- blockpath/analysis.py +329 -0
- blockpath/callgraph.py +298 -0
- blockpath/check.py +33 -0
- blockpath/cli.py +144 -0
- blockpath/collect.py +206 -0
- blockpath/config.py +75 -0
- blockpath/model.py +112 -0
- blockpath/oracle.py +143 -0
- blockpath/oracle.yaml +222 -0
- blockpath/py.typed +0 -0
- blockpath/report.py +140 -0
- blockpath/resolve.py +192 -0
- blockpath/runtime/__init__.py +22 -0
- blockpath/runtime/monitor.py +157 -0
- blockpath/runtime/predicate.py +39 -0
- blockpath/runtime/verify.py +177 -0
- blockpath/runtime/watchdog.py +117 -0
- blockpath/symbols.py +284 -0
- blockpath-0.1.0.dist-info/METADATA +180 -0
- blockpath-0.1.0.dist-info/RECORD +24 -0
- blockpath-0.1.0.dist-info/WHEEL +4 -0
- blockpath-0.1.0.dist-info/entry_points.txt +2 -0
- blockpath-0.1.0.dist-info/licenses/LICENSE +21 -0
blockpath/symbols.py
ADDED
|
@@ -0,0 +1,284 @@
|
|
|
1
|
+
"""Layer 2: what each name means, at module scope and inside every function.
|
|
2
|
+
|
|
3
|
+
Two questions the resolver cannot answer without this layer:
|
|
4
|
+
|
|
5
|
+
* **At module scope**, is ``sleep`` a function defined here, a name imported from ``time``, or
|
|
6
|
+
a plain assignment? Built from the AST: ``top_level`` maps each top-level name to a
|
|
7
|
+
:class:`Definition` or an :class:`ImportBinding`, and a name bound two incompatible ways is
|
|
8
|
+
marked ambiguous so the resolver refuses to guess.
|
|
9
|
+
|
|
10
|
+
* **Inside a function**, is ``sleep`` a *local* thing (a parameter, an assignment) that shadows
|
|
11
|
+
the module ``sleep``, or a *local import* (``def h(): import time``) that should resolve, or a
|
|
12
|
+
reference to the outer name? This is where reimplementing Python's scoping rules would be a
|
|
13
|
+
mistake, so we ask the standard library: ``symtable`` classifies every name in a scope as
|
|
14
|
+
local / imported / global / free / parameter, and gets the corners right -- ``global x`` inside
|
|
15
|
+
a function refers to the module ``x``, an inlined comprehension target is local, a nested
|
|
16
|
+
``def`` name is local. We keep only the classification and read the actual import *targets*
|
|
17
|
+
back from the AST, since ``symtable`` does not carry them.
|
|
18
|
+
|
|
19
|
+
Scopes are keyed by ``(name, lineno)`` -- unique for real functions and classes; the only
|
|
20
|
+
collisions are several lambdas or genexprs on one physical line, which are marked ambiguous and
|
|
21
|
+
handled conservatively (treat every name as a shadow, i.e. resolve nothing there -- the safe
|
|
22
|
+
direction). A ``def f[T]()`` generic adds a ``type parameter`` wrapper scope in ``symtable``; we
|
|
23
|
+
skip the wrapper and key the real function scope.
|
|
24
|
+
"""
|
|
25
|
+
|
|
26
|
+
from __future__ import annotations
|
|
27
|
+
|
|
28
|
+
import ast
|
|
29
|
+
import symtable
|
|
30
|
+
from dataclasses import dataclass
|
|
31
|
+
from typing import Literal
|
|
32
|
+
|
|
33
|
+
from blockpath.collect import Module
|
|
34
|
+
|
|
35
|
+
#: How Python names a lambda / generator-expression scope in symtable.
|
|
36
|
+
_LAMBDA = "lambda"
|
|
37
|
+
_GENEXPR = "genexpr"
|
|
38
|
+
|
|
39
|
+
#: A node that introduces a function scope.
|
|
40
|
+
ScopeNode = ast.FunctionDef | ast.AsyncFunctionDef | ast.Lambda
|
|
41
|
+
#: A ``def``/``async def`` (a lambda has no statement body).
|
|
42
|
+
FuncNode = ast.FunctionDef | ast.AsyncFunctionDef
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
@dataclass(frozen=True, slots=True)
|
|
46
|
+
class Definition:
|
|
47
|
+
"""A function or class defined at module top level."""
|
|
48
|
+
|
|
49
|
+
qualname: str
|
|
50
|
+
kind: Literal["function", "class"]
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
@dataclass(frozen=True, slots=True)
|
|
54
|
+
class ImportBinding:
|
|
55
|
+
"""A top-level name bound by an import, pointing at a dotted target (possibly external)."""
|
|
56
|
+
|
|
57
|
+
target: str
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
Symbol = Definition | ImportBinding
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
@dataclass(frozen=True, slots=True)
|
|
64
|
+
class ModuleSymbols:
|
|
65
|
+
"""The top-level name table of one module."""
|
|
66
|
+
|
|
67
|
+
module: Module
|
|
68
|
+
top_level: dict[str, Symbol]
|
|
69
|
+
ambiguous: frozenset[str]
|
|
70
|
+
has_star_import: bool
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
@dataclass(frozen=True, slots=True)
|
|
74
|
+
class ScopeInfo:
|
|
75
|
+
"""What is bound locally inside one function scope."""
|
|
76
|
+
|
|
77
|
+
#: name -> dotted import target, for imports written in this function's own body
|
|
78
|
+
local_imports: dict[str, str]
|
|
79
|
+
#: names bound locally that are not imports: parameters, assignments, nested defs, ...
|
|
80
|
+
shadows: frozenset[str]
|
|
81
|
+
#: when True, treat *every* callee name as shadowed and resolve nothing. Used when the scope
|
|
82
|
+
#: could not be mapped unambiguously, so guessing would risk a fabricated edge.
|
|
83
|
+
shadow_all: bool = False
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
#: A scope whose symtable mapping was ambiguous or missing: resolve nothing through it (shadow
|
|
87
|
+
#: everything). Conservative -- an undercount, never a false finding.
|
|
88
|
+
_OPAQUE_SCOPE = ScopeInfo(local_imports={}, shadows=frozenset(), shadow_all=True)
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def _relative_base(module: Module, level: int) -> str | None:
|
|
92
|
+
"""The package a ``from . import x`` at ``level`` dots resolves against, or None if it walks
|
|
93
|
+
above the top package (which Python rejects as 'beyond top-level package')."""
|
|
94
|
+
parts = module.qualname.split(".") if module.qualname else []
|
|
95
|
+
if not module.is_package:
|
|
96
|
+
parts = parts[:-1] # a module resolves relatives against its containing package
|
|
97
|
+
up = level - 1
|
|
98
|
+
if up >= len(parts):
|
|
99
|
+
return None # one more dot than there are package components: beyond the top
|
|
100
|
+
if up:
|
|
101
|
+
parts = parts[:-up]
|
|
102
|
+
return ".".join(parts)
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def import_bindings(stmt: ast.Import | ast.ImportFrom, module: Module) -> list[tuple[str, str]]:
|
|
106
|
+
"""Names an import binds, each with its dotted target. ``("*", "*")`` signals a star import."""
|
|
107
|
+
out: list[tuple[str, str]] = []
|
|
108
|
+
if isinstance(stmt, ast.Import):
|
|
109
|
+
for alias in stmt.names:
|
|
110
|
+
if alias.asname:
|
|
111
|
+
out.append((alias.asname, alias.name))
|
|
112
|
+
else:
|
|
113
|
+
top = alias.name.split(".")[0]
|
|
114
|
+
out.append((top, top))
|
|
115
|
+
return out
|
|
116
|
+
if stmt.level:
|
|
117
|
+
base = _relative_base(module, stmt.level)
|
|
118
|
+
if base is None:
|
|
119
|
+
return out
|
|
120
|
+
prefix = f"{base}.{stmt.module}" if stmt.module else base
|
|
121
|
+
else:
|
|
122
|
+
prefix = stmt.module or ""
|
|
123
|
+
if not prefix:
|
|
124
|
+
return out
|
|
125
|
+
for alias in stmt.names:
|
|
126
|
+
if alias.name == "*":
|
|
127
|
+
out.append(("*", "*"))
|
|
128
|
+
else:
|
|
129
|
+
out.append((alias.asname or alias.name, f"{prefix}.{alias.name}"))
|
|
130
|
+
return out
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
def _build_top_level(module: Module) -> ModuleSymbols:
|
|
134
|
+
seen: dict[str, list[Symbol]] = {}
|
|
135
|
+
last_bind_line: dict[str, int] = {}
|
|
136
|
+
deleted: set[str] = set()
|
|
137
|
+
star_lines: list[int] = []
|
|
138
|
+
|
|
139
|
+
def bind(name: str, sym: Symbol, line: int) -> None:
|
|
140
|
+
seen.setdefault(name, []).append(sym)
|
|
141
|
+
last_bind_line[name] = max(last_bind_line.get(name, 0), line)
|
|
142
|
+
|
|
143
|
+
for stmt in module.tree.body:
|
|
144
|
+
match stmt:
|
|
145
|
+
case ast.FunctionDef() | ast.AsyncFunctionDef():
|
|
146
|
+
bind(
|
|
147
|
+
stmt.name, Definition(f"{module.qualname}.{stmt.name}", "function"), stmt.lineno
|
|
148
|
+
)
|
|
149
|
+
case ast.ClassDef():
|
|
150
|
+
bind(stmt.name, Definition(f"{module.qualname}.{stmt.name}", "class"), stmt.lineno)
|
|
151
|
+
case ast.Import() | ast.ImportFrom():
|
|
152
|
+
for name, target in import_bindings(stmt, module):
|
|
153
|
+
if name == "*":
|
|
154
|
+
star_lines.append(stmt.lineno)
|
|
155
|
+
else:
|
|
156
|
+
bind(name, ImportBinding(target), stmt.lineno)
|
|
157
|
+
case ast.Assign() | ast.AnnAssign() | ast.AugAssign():
|
|
158
|
+
targets = stmt.targets if isinstance(stmt, ast.Assign) else [stmt.target]
|
|
159
|
+
for tgt in targets:
|
|
160
|
+
if isinstance(tgt, ast.Name):
|
|
161
|
+
bind(tgt.id, ImportBinding(""), stmt.lineno) # opaque module-level rebind
|
|
162
|
+
case ast.Delete():
|
|
163
|
+
for tgt in stmt.targets:
|
|
164
|
+
if isinstance(tgt, ast.Name):
|
|
165
|
+
deleted.add(tgt.id) # `del name` removes the top-level binding
|
|
166
|
+
case _:
|
|
167
|
+
pass
|
|
168
|
+
|
|
169
|
+
has_star = bool(star_lines)
|
|
170
|
+
last_star = max(star_lines) if star_lines else -1
|
|
171
|
+
|
|
172
|
+
top_level: dict[str, Symbol] = {}
|
|
173
|
+
ambiguous: set[str] = set()
|
|
174
|
+
for name, syms in seen.items():
|
|
175
|
+
variants = {_symbol_key(s) for s in syms}
|
|
176
|
+
first = syms[0]
|
|
177
|
+
idempotent_import = (
|
|
178
|
+
len(variants) == 1 and isinstance(first, ImportBinding) and bool(first.target)
|
|
179
|
+
)
|
|
180
|
+
# A `from x import *` executed after a name is bound can silently overwrite it, and we do
|
|
181
|
+
# not expand stars (ADR 0006), so such a name is ambiguous, not definite.
|
|
182
|
+
overridden_by_star = has_star and last_bind_line[name] < last_star
|
|
183
|
+
if name in deleted:
|
|
184
|
+
ambiguous.add(name) # bound then `del`eted: at runtime the name is gone
|
|
185
|
+
elif (len(syms) > 1 and not idempotent_import) or overridden_by_star:
|
|
186
|
+
ambiguous.add(name)
|
|
187
|
+
elif isinstance(first, ImportBinding) and not first.target:
|
|
188
|
+
ambiguous.add(name) # a bare module-level assignment: not resolvable, do not follow
|
|
189
|
+
else:
|
|
190
|
+
top_level[name] = first
|
|
191
|
+
return ModuleSymbols(module, top_level, frozenset(ambiguous), has_star)
|
|
192
|
+
|
|
193
|
+
|
|
194
|
+
def _symbol_key(sym: Symbol) -> tuple[str, str]:
|
|
195
|
+
if isinstance(sym, Definition):
|
|
196
|
+
return ("def", sym.qualname + sym.kind)
|
|
197
|
+
return ("import", sym.target)
|
|
198
|
+
|
|
199
|
+
|
|
200
|
+
class ModuleScopes:
|
|
201
|
+
"""Per-function scope classification for one module, backed by symtable."""
|
|
202
|
+
|
|
203
|
+
def __init__(self, module: Module) -> None:
|
|
204
|
+
self._module = module
|
|
205
|
+
self._by_key: dict[tuple[str, int], symtable.SymbolTable] = {}
|
|
206
|
+
self._ambiguous_keys: set[tuple[str, int]] = set()
|
|
207
|
+
self.desynced = False
|
|
208
|
+
try:
|
|
209
|
+
top = symtable.symtable(module.source, module.relpath, "exec")
|
|
210
|
+
except (SyntaxError, ValueError):
|
|
211
|
+
self.desynced = True
|
|
212
|
+
return
|
|
213
|
+
self._index(top)
|
|
214
|
+
|
|
215
|
+
def _index(self, scope: symtable.SymbolTable) -> None:
|
|
216
|
+
for child in scope.get_children():
|
|
217
|
+
kind = child.get_type()
|
|
218
|
+
if kind == "type parameter":
|
|
219
|
+
self._index(child) # skip the PEP 695 wrapper, register its inner scope
|
|
220
|
+
continue
|
|
221
|
+
if kind in ("function", "class"):
|
|
222
|
+
key = (child.get_name(), child.get_lineno())
|
|
223
|
+
if key in self._by_key:
|
|
224
|
+
self._ambiguous_keys.add(key)
|
|
225
|
+
self._by_key[key] = child
|
|
226
|
+
self._index(child)
|
|
227
|
+
|
|
228
|
+
def info(self, node: ScopeNode) -> ScopeInfo:
|
|
229
|
+
if self.desynced:
|
|
230
|
+
return _OPAQUE_SCOPE
|
|
231
|
+
name = _LAMBDA if isinstance(node, ast.Lambda) else node.name
|
|
232
|
+
key = (name, node.lineno)
|
|
233
|
+
if key in self._ambiguous_keys or key not in self._by_key:
|
|
234
|
+
return _OPAQUE_SCOPE
|
|
235
|
+
scope = self._by_key[key]
|
|
236
|
+
# A name shadows the module if it is bound locally (but not as an import, which we
|
|
237
|
+
# resolve) OR is free -- closing over an enclosing function scope. In Python's LEGB, an
|
|
238
|
+
# enclosing binding beats the module global, so a free variable is not the module name.
|
|
239
|
+
shadows = frozenset(
|
|
240
|
+
sym.get_name()
|
|
241
|
+
for sym in scope.get_symbols()
|
|
242
|
+
if (sym.is_local() and not sym.is_imported()) or sym.is_free()
|
|
243
|
+
)
|
|
244
|
+
return ScopeInfo(local_imports=self._local_imports(node), shadows=shadows)
|
|
245
|
+
|
|
246
|
+
def _local_imports(self, node: ScopeNode) -> dict[str, str]:
|
|
247
|
+
if isinstance(node, ast.Lambda):
|
|
248
|
+
return {} # a lambda body is a single expression: no import statements
|
|
249
|
+
out: dict[str, str] = {}
|
|
250
|
+
for stmt in _scope_import_stmts(node):
|
|
251
|
+
for name, target in import_bindings(stmt, self._module):
|
|
252
|
+
if name != "*":
|
|
253
|
+
out[name] = target
|
|
254
|
+
return out
|
|
255
|
+
|
|
256
|
+
|
|
257
|
+
def _scope_import_stmts(fn: FuncNode) -> list[ast.Import | ast.ImportFrom]:
|
|
258
|
+
"""Import statements executed in ``fn``'s scope, descending into ``if``/``try``/``with``/loops
|
|
259
|
+
but never into a nested function, class, or lambda (whose imports are local to *them*)."""
|
|
260
|
+
out: list[ast.Import | ast.ImportFrom] = []
|
|
261
|
+
|
|
262
|
+
def visit(node: ast.AST) -> None:
|
|
263
|
+
if isinstance(node, ast.Import | ast.ImportFrom):
|
|
264
|
+
out.append(node)
|
|
265
|
+
return
|
|
266
|
+
if isinstance(node, ast.FunctionDef | ast.AsyncFunctionDef | ast.ClassDef | ast.Lambda):
|
|
267
|
+
return # nested scope: pruned
|
|
268
|
+
for child in ast.iter_child_nodes(node):
|
|
269
|
+
visit(child)
|
|
270
|
+
|
|
271
|
+
for stmt in fn.body:
|
|
272
|
+
visit(stmt)
|
|
273
|
+
return out
|
|
274
|
+
|
|
275
|
+
|
|
276
|
+
@dataclass(slots=True)
|
|
277
|
+
class Analysis:
|
|
278
|
+
symbols: ModuleSymbols
|
|
279
|
+
scopes: ModuleScopes
|
|
280
|
+
|
|
281
|
+
|
|
282
|
+
def analyze(module: Module) -> Analysis:
|
|
283
|
+
"""Top-level symbols plus per-function scope classification for one module."""
|
|
284
|
+
return Analysis(symbols=_build_top_level(module), scopes=ModuleScopes(module))
|
|
@@ -0,0 +1,180 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: blockpath
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Find blocking calls reachable from a coroutine, and print the whole call path across files and modules.
|
|
5
|
+
Project-URL: Repository, https://github.com/iraettae/blockpath
|
|
6
|
+
Project-URL: Issues, https://github.com/iraettae/blockpath/issues
|
|
7
|
+
Author: Damir
|
|
8
|
+
License-Expression: MIT
|
|
9
|
+
License-File: LICENSE
|
|
10
|
+
Keywords: asyncio,blocking,call-graph,event-loop,linter,static-analysis
|
|
11
|
+
Classifier: Development Status :: 3 - Alpha
|
|
12
|
+
Classifier: Environment :: Console
|
|
13
|
+
Classifier: Framework :: AsyncIO
|
|
14
|
+
Classifier: Intended Audience :: Developers
|
|
15
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
18
|
+
Classifier: Topic :: Software Development :: Quality Assurance
|
|
19
|
+
Classifier: Typing :: Typed
|
|
20
|
+
Requires-Python: >=3.12
|
|
21
|
+
Requires-Dist: click>=8.1
|
|
22
|
+
Requires-Dist: pathspec>=0.12
|
|
23
|
+
Requires-Dist: pyyaml>=6.0
|
|
24
|
+
Requires-Dist: rich>=13.7
|
|
25
|
+
Description-Content-Type: text/markdown
|
|
26
|
+
|
|
27
|
+
# blockpath
|
|
28
|
+
|
|
29
|
+
[](https://github.com/iraettae/blockpath/actions/workflows/ci.yml)
|
|
30
|
+
[](https://www.python.org/downloads/)
|
|
31
|
+
|
|
32
|
+
Find the synchronous call that stalls your event loop, and print the whole path to it: from the
|
|
33
|
+
`async` handler down through `services/` and `clients/` to the blocking `requests.get`, across as
|
|
34
|
+
many files and modules as it takes.
|
|
35
|
+
|
|
36
|
+
Existing linters catch a blocking call only when it sits directly in the `async def` body. In
|
|
37
|
+
real code it almost never does -- it is three to five calls deep, in another module, behind a
|
|
38
|
+
couple of helpers nobody thinks about. That is where it hides, and that is what blockpath finds.
|
|
39
|
+
|
|
40
|
+
## The problem is real, and it was measured before a line of the analyzer was written
|
|
41
|
+
|
|
42
|
+
A survey of **57 public aiogram/FastAPI repositories**, with the protocol and thresholds
|
|
43
|
+
[registered in advance](benchmark/PREREGISTRATION.md) so the result could not be steered, asked
|
|
44
|
+
one question: when a coroutine can reach a blocking call, how deep is it?
|
|
45
|
+
|
|
46
|
+
```
|
|
47
|
+
depth 1 | ######################################## 77 (48.7%) <- a per-file linter sees these
|
|
48
|
+
depth 2 | ############### 28 (17.7%)
|
|
49
|
+
depth 3 | ################ 31 (19.6%)
|
|
50
|
+
depth 4 | ######### 17 (10.8%)
|
|
51
|
+
depth 5 | ## 3 ( 1.9%)
|
|
52
|
+
depth 6 | # 2 ( 1.3%)
|
|
53
|
+
|
|
54
|
+
| 158 reachable blocking call sites
|
|
55
|
+
```
|
|
56
|
+
|
|
57
|
+
**51% of them sit at depth 2 or deeper**, across module boundaries, where no per-file linter can
|
|
58
|
+
follow. As a cross-check, `ruff --select ASYNC` run on the same repositories flags **0 of those
|
|
59
|
+
81 deep sites**. The histogram, and the fact that it rebuilds from scratch, is in
|
|
60
|
+
[benchmark/depth_histogram.md](benchmark/depth_histogram.md) (`make bench-depth`).
|
|
61
|
+
|
|
62
|
+
## What it looks like
|
|
63
|
+
|
|
64
|
+

|
|
65
|
+
|
|
66
|
+
```console
|
|
67
|
+
$ blockpath check .
|
|
68
|
+
BLK001 blocking call reachable from a coroutine
|
|
69
|
+
app/handlers/order.py:11 return resolve_address(address) <- entry: async def
|
|
70
|
+
app/services/geo.py:6 return geocode(query)
|
|
71
|
+
app/clients/nominatim.py:7 response = requests.get(BASE_URL, params=...) <- blocks the event loop
|
|
72
|
+
hint: await asyncio.to_thread(resolve_address, ...)
|
|
73
|
+
|
|
74
|
+
1 finding(s): 1 BLK001
|
|
75
|
+
```
|
|
76
|
+
|
|
77
|
+
The path through three files is the point: no existing linter prints it.
|
|
78
|
+
|
|
79
|
+
## Quick start
|
|
80
|
+
|
|
81
|
+
```bash
|
|
82
|
+
pip install blockpath
|
|
83
|
+
blockpath check .
|
|
84
|
+
blockpath check . --json # for CI
|
|
85
|
+
```
|
|
86
|
+
|
|
87
|
+
Exit code is `0` when clean, `1` when there are findings (fails a build), `2` if the tool itself
|
|
88
|
+
errored -- so a crash is never mistaken for a clean run.
|
|
89
|
+
|
|
90
|
+
Optional configuration, in your own `pyproject.toml`:
|
|
91
|
+
|
|
92
|
+
```toml
|
|
93
|
+
[tool.blockpath]
|
|
94
|
+
tiers = ["error"] # error (default) | warning | off
|
|
95
|
+
entry-decorators = ["router.message"] # mark framework handlers as entry points
|
|
96
|
+
strict = false # also report blocking calls handed to unresolved callees
|
|
97
|
+
```
|
|
98
|
+
|
|
99
|
+
## How it works
|
|
100
|
+
|
|
101
|
+
Six layers, each a small tested unit, and one idea that carries the whole thing.
|
|
102
|
+
|
|
103
|
+
```
|
|
104
|
+
collect -> symbols -> resolve -> callgraph -> oracle -> analysis
|
|
105
|
+
(walk & (names & (name -> (CALL/REF (what (three BFS,
|
|
106
|
+
parse) scopes) function) edges) blocks) the witness)
|
|
107
|
+
```
|
|
108
|
+
|
|
109
|
+
The idea is that a finding is the **intersection of two independent reachability questions**, and
|
|
110
|
+
that intersection is why the tool stays quiet:
|
|
111
|
+
|
|
112
|
+
1. **Backward** from every blocking leaf: which functions can reach a blocking call at all?
|
|
113
|
+
2. **Forward** from every coroutine: which functions can a coroutine reach?
|
|
114
|
+
3. A third pass over the intersection recovers the *witness* -- the exact chain of call sites.
|
|
115
|
+
|
|
116
|
+
Report only their intersection and `time.sleep` in a synchronous CLI script in the same repo
|
|
117
|
+
stays silent. Report either half alone and the tool cries wolf and gets switched off. The whole
|
|
118
|
+
analysis is `O(V+E)` -- three breadth-first searches, no Tarjan, no SCC, no fixpoint
|
|
119
|
+
([ADR 0002](docs/adr/0002-three-bfs-not-tarjan.md)).
|
|
120
|
+
|
|
121
|
+
The call graph is built from the AST **without importing or running your code**, so a project
|
|
122
|
+
that needs a database to import is analysed the same as any other.
|
|
123
|
+
|
|
124
|
+
## How good is it, honestly
|
|
125
|
+
|
|
126
|
+
Run over five of the surveyed repositories and adjudicated by hand, finding by finding, with a
|
|
127
|
+
second pass auditing the riskiest calls ([benchmark/adjudication.md](benchmark/adjudication.md)):
|
|
128
|
+
|
|
129
|
+
- **precision 110/112 = 98.2%** by the tool's literal claim (a blocking call reachable from a
|
|
130
|
+
coroutine), of which 99 are product-code bugs and 11 are real blocking calls in test code; or
|
|
131
|
+
**99/112 = 88.4%** counting product bugs only. Both numbers are in the table, and each of the
|
|
132
|
+
two false positives is explained with a permalink.
|
|
133
|
+
- **recall is a lower bound, never a point estimate.** The runtime verifier (`blockpath verify --
|
|
134
|
+
pytest`) records which functions actually ran on the loop and compares them to the static
|
|
135
|
+
findings, but it only sees the paths the tests exercised. It can prove a miss and can never
|
|
136
|
+
prove completeness, and it says nothing about precision -- only the hand adjudication does.
|
|
137
|
+
|
|
138
|
+
Precision is not a number the analyzer asserts about itself; it is what survived a human reading
|
|
139
|
+
each finding against the source.
|
|
140
|
+
|
|
141
|
+
## Design decisions, and where it loses
|
|
142
|
+
|
|
143
|
+
- **Only calls by name are resolved.** An attribute call on a value (`self.session.get()`) needs
|
|
144
|
+
type inference, which this does not do; the cost is measured (about half of all call sites on
|
|
145
|
+
one benchmark repo) rather than waved away ([ADR 0003](docs/adr/0003-attribute-calls-not-resolved.md)).
|
|
146
|
+
- **A reference is not a call.** A function passed to `run_in_executor(None, f)` runs in a
|
|
147
|
+
thread, so it is silent by construction -- no offload false positives -- while
|
|
148
|
+
`run_in_executor(None, f())` with parentheses is a real bug (BLK002)
|
|
149
|
+
([ADR 0007](docs/adr/0007-call-edges-only-ref-not-traversed.md)).
|
|
150
|
+
- **Unknown is quiet by default.** A path with an unresolved edge is not reported unless you ask
|
|
151
|
+
with `--strict` (BLK003) ([ADR 0006](docs/adr/0006-reexport-and-star-import-policy.md)).
|
|
152
|
+
- The [complete list of limitations](docs/limitations.md) is written as they were found, not at
|
|
153
|
+
the end.
|
|
154
|
+
|
|
155
|
+
### Prior art
|
|
156
|
+
|
|
157
|
+
- **ruff `ASYNC` rules** are written in Rust and run hundreds of times faster than this. They are
|
|
158
|
+
the right tool for the depth-1 case, and blockpath does not try to compete on speed. They work
|
|
159
|
+
per file, by an explicit architectural choice (parallel analysis without global state), so they
|
|
160
|
+
do not build a cross-module call graph and cannot print the path -- which is the entire reason
|
|
161
|
+
this exists. The 0-of-81 cross-check above is that difference, measured.
|
|
162
|
+
- **flake8-async** is likewise per-file and does not follow calls between modules.
|
|
163
|
+
|
|
164
|
+
blockpath is not a replacement for either; it is the cross-module analysis they deliberately do
|
|
165
|
+
not do.
|
|
166
|
+
|
|
167
|
+
## Development
|
|
168
|
+
|
|
169
|
+
```bash
|
|
170
|
+
uv sync --all-extras
|
|
171
|
+
make check # ruff, mypy --strict, pytest
|
|
172
|
+
make bench-depth # rebuild the depth histogram from the pinned corpus
|
|
173
|
+
./scripts/reproduce_benchmark.sh # rebuild the precision findings
|
|
174
|
+
```
|
|
175
|
+
|
|
176
|
+
This project is AI-assisted: written by a human, who made the product and architecture decisions
|
|
177
|
+
and signed off at every checkpoint, with an AI pair implementing under direction. [WORKLOG.md](WORKLOG.md)
|
|
178
|
+
keeps the estimate-versus-actual log from day one, including where the estimates were wrong.
|
|
179
|
+
|
|
180
|
+
Python 3.12+ (it uses `sys.monitoring`, [ADR 0001](docs/adr/0001-python-312-only.md)). MIT licensed.
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
blockpath/__init__.py,sha256=Z-ehpFXhOEezlzBADOTc5oEc66PlJ56C44aX2hor5rc,601
|
|
2
|
+
blockpath/analysis.py,sha256=Rfpac1ZdSmgD1ELQ-MBbmYrhBC0DFji54m93wO0QjaQ,12665
|
|
3
|
+
blockpath/callgraph.py,sha256=WDCbkUZH7jVYpgDqq2k6eML0njRlu3zWy1RqkLCmnxQ,11933
|
|
4
|
+
blockpath/check.py,sha256=IHMx2d1oor75V8pS5VO8U3UHq-_k1IbIhxoRWRwamIE,1502
|
|
5
|
+
blockpath/cli.py,sha256=Rphiv0eDNeM-4rwFRX-QkIn7FHX8nTA46OY6j-BUKhQ,5352
|
|
6
|
+
blockpath/collect.py,sha256=H2kvjCfpFWUtwrAv5ybeOoAGi4KxaO6TI7bbSLrquuI,7367
|
|
7
|
+
blockpath/config.py,sha256=PlgolhAt4nGWGr8QEzFHAuF02yRBs3OxbUguKU5p2vw,3000
|
|
8
|
+
blockpath/model.py,sha256=HV3RAu4kMe5wwsB8BMpz2XE4ZKSxpQ8uoCtAc_Lxzgg,3680
|
|
9
|
+
blockpath/oracle.py,sha256=6qFJgAPQSFYkB1pf9Ax6lE_H936mXsjmUTGZsaJNe0w,5418
|
|
10
|
+
blockpath/oracle.yaml,sha256=8Vx9GONVCZIB36mc8W79yD-Is4dJw6kgDcKSV2EW2mE,7096
|
|
11
|
+
blockpath/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
12
|
+
blockpath/report.py,sha256=N3bIq1WdQpJdBSoDCoxhnIKYyxl1s6o0l2IU0TWNV24,5114
|
|
13
|
+
blockpath/resolve.py,sha256=777qsYtcmNhXbEm2X60nhCp0nb3zslH78PKVm5rqpEs,7377
|
|
14
|
+
blockpath/symbols.py,sha256=tIY247bPNRiTRcW1TFaI8nBQQoZCoR9Mf14f3kt4Ajc,11619
|
|
15
|
+
blockpath/runtime/__init__.py,sha256=NdD3fEJObRg7rSQjNJaNET6oM_Ny4AJ4kp1hFGZU_hg,1201
|
|
16
|
+
blockpath/runtime/monitor.py,sha256=7-q8BdH6WhVRAc4jQjZpH6HZZ1tsRnr601xFEXaC5NM,5868
|
|
17
|
+
blockpath/runtime/predicate.py,sha256=TgkzxaVqnB5LABs2hpVTTZHA0Di84dTWpZUkzdvPGcE,1782
|
|
18
|
+
blockpath/runtime/verify.py,sha256=4na-QZ4ybMA2oFOIDP0vpGieOLWcPim_Ru_d-hGKJfE,6784
|
|
19
|
+
blockpath/runtime/watchdog.py,sha256=I7iyGEH-KO395OxWTcRGpNxE_BcXrsFytJzvlHNeq-U,4408
|
|
20
|
+
blockpath-0.1.0.dist-info/METADATA,sha256=ktb_SMES4zTAdyGliIu1kH3FpQheJgQMjaNVKAehg3o,8767
|
|
21
|
+
blockpath-0.1.0.dist-info/WHEEL,sha256=lCkmxWfQsSc9CfIClYeavTdQeEX2toPqufh9gI35EQA,87
|
|
22
|
+
blockpath-0.1.0.dist-info/entry_points.txt,sha256=YS-kJTUwO3fttQt9HpRR5G6iJZwKgmKoJHEyDwofSkE,49
|
|
23
|
+
blockpath-0.1.0.dist-info/licenses/LICENSE,sha256=Q7ngUffbbZwscUKxWkTT4rIlx8HUsW5Z27spnkTNsJQ,1062
|
|
24
|
+
blockpath-0.1.0.dist-info/RECORD,,
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Damir
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|