Metadata-Version: 2.4
Name: blockpath
Version: 0.1.0
Summary: Find blocking calls reachable from a coroutine, and print the whole call path across files and modules.
Project-URL: Repository, https://github.com/iraettae/blockpath
Project-URL: Issues, https://github.com/iraettae/blockpath/issues
Author: Damir
License-Expression: MIT
License-File: LICENSE
Keywords: asyncio,blocking,call-graph,event-loop,linter,static-analysis
Classifier: Development Status :: 3 - Alpha
Classifier: Environment :: Console
Classifier: Framework :: AsyncIO
Classifier: Intended Audience :: Developers
Classifier: License :: OSI Approved :: MIT License
Classifier: Programming Language :: Python :: 3.12
Classifier: Programming Language :: Python :: 3.13
Classifier: Topic :: Software Development :: Quality Assurance
Classifier: Typing :: Typed
Requires-Python: >=3.12
Requires-Dist: click>=8.1
Requires-Dist: pathspec>=0.12
Requires-Dist: pyyaml>=6.0
Requires-Dist: rich>=13.7
Description-Content-Type: text/markdown

# blockpath

[![ci](https://github.com/iraettae/blockpath/actions/workflows/ci.yml/badge.svg)](https://github.com/iraettae/blockpath/actions/workflows/ci.yml)
[![python](https://img.shields.io/badge/python-3.12%2B-blue)](https://www.python.org/downloads/)

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](benchmark/PREREGISTRATION.md) 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](benchmark/depth_histogram.md) (`make bench-depth`).

## What it looks like

![blockpath check finding a blocking call through three files](docs/demo.svg)

```console
$ 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

```bash
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`:

```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:

1. **Backward** from every blocking leaf: which functions can reach a blocking call at all?
2. **Forward** from every coroutine: which functions can a coroutine reach?
3. 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](docs/adr/0002-three-bfs-not-tarjan.md)).

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](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](docs/adr/0003-attribute-calls-not-resolved.md)).
- **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 -- while
  `run_in_executor(None, f())` with parentheses is a real bug (BLK002)
  ([ADR 0007](docs/adr/0007-call-edges-only-ref-not-traversed.md)).
- **Unknown is quiet by default.** A path with an unresolved edge is not reported unless you ask
  with `--strict` (BLK003) ([ADR 0006](docs/adr/0006-reexport-and-star-import-policy.md)).
- The [complete list of limitations](docs/limitations.md) is written as they were found, not at
  the end.

### Prior art

- **ruff `ASYNC` rules** 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

```bash
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](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](docs/adr/0001-python-312-only.md)). MIT licensed.
