Find blocking calls reachable from a coroutine, and print the whole call path across files and modules.
Project description
blockpath
Find the synchronous call that stalls your event loop, and print the whole path to it: from the
async handler down through services/ and clients/ to the blocking requests.get, across as
many files and modules as it takes.
Existing linters catch a blocking call only when it sits directly in the async def body. In
real code it almost never does -- it is three to five calls deep, in another module, behind a
couple of helpers nobody thinks about. That is where it hides, and that is what blockpath finds.
The problem is real, and it was measured before a line of the analyzer was written
A survey of 57 public aiogram/FastAPI repositories, with the protocol and thresholds registered in advance so the result could not be steered, asked one question: when a coroutine can reach a blocking call, how deep is it?
depth 1 | ######################################## 77 (48.7%) <- a per-file linter sees these
depth 2 | ############### 28 (17.7%)
depth 3 | ################ 31 (19.6%)
depth 4 | ######### 17 (10.8%)
depth 5 | ## 3 ( 1.9%)
depth 6 | # 2 ( 1.3%)
| 158 reachable blocking call sites
51% of them sit at depth 2 or deeper, across module boundaries, where no per-file linter can
follow. As a cross-check, ruff --select ASYNC run on the same repositories flags 0 of those
81 deep sites. The histogram, and the fact that it rebuilds from scratch, is in
benchmark/depth_histogram.md (make bench-depth).
What it looks like
$ blockpath check .
BLK001 blocking call reachable from a coroutine
app/handlers/order.py:11 return resolve_address(address) <- entry: async def
app/services/geo.py:6 return geocode(query)
app/clients/nominatim.py:7 response = requests.get(BASE_URL, params=...) <- blocks the event loop
hint: await asyncio.to_thread(resolve_address, ...)
1 finding(s): 1 BLK001
The path through three files is the point: no existing linter prints it.
Quick start
pip install blockpath
blockpath check .
blockpath check . --json # for CI
Exit code is 0 when clean, 1 when there are findings (fails a build), 2 if the tool itself
errored -- so a crash is never mistaken for a clean run.
Optional configuration, in your own pyproject.toml:
[tool.blockpath]
tiers = ["error"] # error (default) | warning | off
entry-decorators = ["router.message"] # mark framework handlers as entry points
strict = false # also report blocking calls handed to unresolved callees
How it works
Six layers, each a small tested unit, and one idea that carries the whole thing.
collect -> symbols -> resolve -> callgraph -> oracle -> analysis
(walk & (names & (name -> (CALL/REF (what (three BFS,
parse) scopes) function) edges) blocks) the witness)
The idea is that a finding is the intersection of two independent reachability questions, and that intersection is why the tool stays quiet:
- Backward from every blocking leaf: which functions can reach a blocking call at all?
- Forward from every coroutine: which functions can a coroutine reach?
- A third pass over the intersection recovers the witness -- the exact chain of call sites.
Report only their intersection and time.sleep in a synchronous CLI script in the same repo
stays silent. Report either half alone and the tool cries wolf and gets switched off. The whole
analysis is O(V+E) -- three breadth-first searches, no Tarjan, no SCC, no fixpoint
(ADR 0002).
The call graph is built from the AST without importing or running your code, so a project that needs a database to import is analysed the same as any other.
How good is it, honestly
Run over five of the surveyed repositories and adjudicated by hand, finding by finding, with a second pass auditing the riskiest calls (benchmark/adjudication.md):
- precision 110/112 = 98.2% by the tool's literal claim (a blocking call reachable from a coroutine), of which 99 are product-code bugs and 11 are real blocking calls in test code; or 99/112 = 88.4% counting product bugs only. Both numbers are in the table, and each of the two false positives is explained with a permalink.
- recall is a lower bound, never a point estimate. The runtime verifier (
blockpath verify -- pytest) records which functions actually ran on the loop and compares them to the static findings, but it only sees the paths the tests exercised. It can prove a miss and can never prove completeness, and it says nothing about precision -- only the hand adjudication does.
Precision is not a number the analyzer asserts about itself; it is what survived a human reading each finding against the source.
Design decisions, and where it loses
- Only calls by name are resolved. An attribute call on a value (
self.session.get()) needs type inference, which this does not do; the cost is measured (about half of all call sites on one benchmark repo) rather than waved away (ADR 0003). - A reference is not a call. A function passed to
run_in_executor(None, f)runs in a thread, so it is silent by construction -- no offload false positives -- whilerun_in_executor(None, f())with parentheses is a real bug (BLK002) (ADR 0007). - Unknown is quiet by default. A path with an unresolved edge is not reported unless you ask
with
--strict(BLK003) (ADR 0006). - The complete list of limitations is written as they were found, not at the end.
Prior art
- ruff
ASYNCrules are written in Rust and run hundreds of times faster than this. They are the right tool for the depth-1 case, and blockpath does not try to compete on speed. They work per file, by an explicit architectural choice (parallel analysis without global state), so they do not build a cross-module call graph and cannot print the path -- which is the entire reason this exists. The 0-of-81 cross-check above is that difference, measured. - flake8-async is likewise per-file and does not follow calls between modules.
blockpath is not a replacement for either; it is the cross-module analysis they deliberately do not do.
Development
uv sync --all-extras
make check # ruff, mypy --strict, pytest
make bench-depth # rebuild the depth histogram from the pinned corpus
./scripts/reproduce_benchmark.sh # rebuild the precision findings
This project is AI-assisted: written by a human, who made the product and architecture decisions and signed off at every checkpoint, with an AI pair implementing under direction. WORKLOG.md keeps the estimate-versus-actual log from day one, including where the estimates were wrong.
Python 3.12+ (it uses sys.monitoring, ADR 0001). MIT licensed.
Project details
Release history Release notifications | RSS feed
Download files
Download the file for your platform. If you're not sure which to choose, learn more about installing packages.
Source Distribution
Built Distribution
Filter files by name, interpreter, ABI, and platform.
If you're not sure about the file name format, learn more about wheel file names.
Copy a direct link to the current filters
File details
Details for the file blockpath-0.1.0.tar.gz.
File metadata
- Download URL: blockpath-0.1.0.tar.gz
- Upload date:
- Size: 233.8 kB
- Tags: Source
- Uploaded using Trusted Publishing? Yes
- Uploaded via: twine/6.1.0 CPython/3.13.14
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
33eac2917b68924c49807ee661793863eaa6fd6ae957d1395a919fbef0a1f4db
|
|
| MD5 |
19fed21a9598867d542fcb3f32b6b455
|
|
| BLAKE2b-256 |
2dc3b4def117096859b9ba4a90aa6018b3eb310a7d0e6514fd6761f75dc16080
|
Provenance
The following attestation bundles were made for blockpath-0.1.0.tar.gz:
Publisher:
release.yml on iraettae/blockpath
-
Statement:
-
Statement type:
https://in-toto.io/Statement/v1 -
Predicate type:
https://docs.pypi.org/attestations/publish/v1 -
Subject name:
blockpath-0.1.0.tar.gz -
Subject digest:
33eac2917b68924c49807ee661793863eaa6fd6ae957d1395a919fbef0a1f4db - Sigstore transparency entry: 2220604176
- Sigstore integration time:
-
Permalink:
iraettae/blockpath@5ac748c6f611815e9fa3b2c8331c22f7190fd898 -
Branch / Tag:
refs/tags/v0.1.0 - Owner: https://github.com/iraettae
-
Access:
public
-
Token Issuer:
https://token.actions.githubusercontent.com -
Runner Environment:
github-hosted -
Publication workflow:
release.yml@5ac748c6f611815e9fa3b2c8331c22f7190fd898 -
Trigger Event:
push
-
Statement type:
File details
Details for the file blockpath-0.1.0-py3-none-any.whl.
File metadata
- Download URL: blockpath-0.1.0-py3-none-any.whl
- Upload date:
- Size: 45.1 kB
- Tags: Python 3
- Uploaded using Trusted Publishing? Yes
- Uploaded via: twine/6.1.0 CPython/3.13.14
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
6ba26547a7d54c9ba4ab60ab57d1988872dd514cb67cc2640fd38cf356696912
|
|
| MD5 |
e018f355362b23031e13c41fbd29578e
|
|
| BLAKE2b-256 |
655554bfc1ae24cb99a7ea9e86b4c0dee4a60b9cea4d5edc52bdd62a77354277
|
Provenance
The following attestation bundles were made for blockpath-0.1.0-py3-none-any.whl:
Publisher:
release.yml on iraettae/blockpath
-
Statement:
-
Statement type:
https://in-toto.io/Statement/v1 -
Predicate type:
https://docs.pypi.org/attestations/publish/v1 -
Subject name:
blockpath-0.1.0-py3-none-any.whl -
Subject digest:
6ba26547a7d54c9ba4ab60ab57d1988872dd514cb67cc2640fd38cf356696912 - Sigstore transparency entry: 2220605182
- Sigstore integration time:
-
Permalink:
iraettae/blockpath@5ac748c6f611815e9fa3b2c8331c22f7190fd898 -
Branch / Tag:
refs/tags/v0.1.0 - Owner: https://github.com/iraettae
-
Access:
public
-
Token Issuer:
https://token.actions.githubusercontent.com -
Runner Environment:
github-hosted -
Publication workflow:
release.yml@5ac748c6f611815e9fa3b2c8331c22f7190fd898 -
Trigger Event:
push
-
Statement type: