blockpath 0.1.0__tar.gz

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.
Files changed (152) hide show
  1. blockpath-0.1.0/.github/workflows/ci.yml +43 -0
  2. blockpath-0.1.0/.github/workflows/release.yml +47 -0
  3. blockpath-0.1.0/.gitignore +38 -0
  4. blockpath-0.1.0/.pre-commit-config.yaml +25 -0
  5. blockpath-0.1.0/CHANGELOG.md +35 -0
  6. blockpath-0.1.0/LICENSE +21 -0
  7. blockpath-0.1.0/Makefile +36 -0
  8. blockpath-0.1.0/PKG-INFO +180 -0
  9. blockpath-0.1.0/README.md +154 -0
  10. blockpath-0.1.0/WORKLOG.md +65 -0
  11. blockpath-0.1.0/benchmark/PREREGISTRATION.md +277 -0
  12. blockpath-0.1.0/benchmark/adjudication.md +195 -0
  13. blockpath-0.1.0/benchmark/adjudication_raw.json +2181 -0
  14. blockpath-0.1.0/benchmark/corpus.tsv +58 -0
  15. blockpath-0.1.0/benchmark/corpus_rejected.tsv +57 -0
  16. blockpath-0.1.0/benchmark/depth_histogram.md +158 -0
  17. blockpath-0.1.0/benchmark/findings.json +2888 -0
  18. blockpath-0.1.0/benchmark/m0_adjudication.md +252 -0
  19. blockpath-0.1.0/benchmark/m0_adjudication_raw.json +903 -0
  20. blockpath-0.1.0/benchmark/raw_repos.json +1860 -0
  21. blockpath-0.1.0/benchmark/raw_sites.jsonl +158 -0
  22. blockpath-0.1.0/benchmark/ruff_crosscheck.json +27 -0
  23. blockpath-0.1.0/benchmark/runtime_demo/app/__init__.py +0 -0
  24. blockpath-0.1.0/benchmark/runtime_demo/app/clients.py +11 -0
  25. blockpath-0.1.0/benchmark/runtime_demo/app/handlers.py +20 -0
  26. blockpath-0.1.0/benchmark/runtime_demo/app/services.py +10 -0
  27. blockpath-0.1.0/benchmark/runtime_demo/test_app.py +16 -0
  28. blockpath-0.1.0/docs/adr/0001-python-312-only.md +40 -0
  29. blockpath-0.1.0/docs/adr/0002-three-bfs-not-tarjan.md +47 -0
  30. blockpath-0.1.0/docs/adr/0003-attribute-calls-not-resolved.md +40 -0
  31. blockpath-0.1.0/docs/adr/0004-get-running-loop-predicate.md +42 -0
  32. blockpath-0.1.0/docs/adr/0005-pathspec-for-gitignore.md +46 -0
  33. blockpath-0.1.0/docs/adr/0006-reexport-and-star-import-policy.md +52 -0
  34. blockpath-0.1.0/docs/adr/0007-call-edges-only-ref-not-traversed.md +58 -0
  35. blockpath-0.1.0/docs/adr/0008-config-in-pyproject.md +45 -0
  36. blockpath-0.1.0/docs/adr/0009-watchdog-thresholds.md +40 -0
  37. blockpath-0.1.0/docs/demo.cast +4 -0
  38. blockpath-0.1.0/docs/demo.svg +1 -0
  39. blockpath-0.1.0/docs/golden_cases.md +86 -0
  40. blockpath-0.1.0/docs/interview-questions.md +93 -0
  41. blockpath-0.1.0/docs/limitations.md +133 -0
  42. blockpath-0.1.0/docs/runtime-sysmon-check.md +61 -0
  43. blockpath-0.1.0/observed.json +204 -0
  44. blockpath-0.1.0/pyproject.toml +126 -0
  45. blockpath-0.1.0/scripts/build_corpus.py +362 -0
  46. blockpath-0.1.0/scripts/clone_pinned.py +136 -0
  47. blockpath-0.1.0/scripts/corpus_queries.txt +19 -0
  48. blockpath-0.1.0/scripts/demo_session.sh +52 -0
  49. blockpath-0.1.0/scripts/depth_survey.py +872 -0
  50. blockpath-0.1.0/scripts/m0_oracle.py +133 -0
  51. blockpath-0.1.0/scripts/m0_regressions.py +364 -0
  52. blockpath-0.1.0/scripts/make_adjudication.py +186 -0
  53. blockpath-0.1.0/scripts/make_adjudication_table.py +149 -0
  54. blockpath-0.1.0/scripts/make_histogram.py +360 -0
  55. blockpath-0.1.0/scripts/reproduce_benchmark.sh +26 -0
  56. blockpath-0.1.0/scripts/reproduce_depth_histogram.sh +36 -0
  57. blockpath-0.1.0/scripts/ruff_crosscheck.py +172 -0
  58. blockpath-0.1.0/scripts/run_benchmark.py +100 -0
  59. blockpath-0.1.0/src/blockpath/__init__.py +22 -0
  60. blockpath-0.1.0/src/blockpath/analysis.py +329 -0
  61. blockpath-0.1.0/src/blockpath/callgraph.py +298 -0
  62. blockpath-0.1.0/src/blockpath/check.py +33 -0
  63. blockpath-0.1.0/src/blockpath/cli.py +144 -0
  64. blockpath-0.1.0/src/blockpath/collect.py +206 -0
  65. blockpath-0.1.0/src/blockpath/config.py +75 -0
  66. blockpath-0.1.0/src/blockpath/model.py +112 -0
  67. blockpath-0.1.0/src/blockpath/oracle.py +143 -0
  68. blockpath-0.1.0/src/blockpath/oracle.yaml +222 -0
  69. blockpath-0.1.0/src/blockpath/py.typed +0 -0
  70. blockpath-0.1.0/src/blockpath/report.py +140 -0
  71. blockpath-0.1.0/src/blockpath/resolve.py +192 -0
  72. blockpath-0.1.0/src/blockpath/runtime/__init__.py +22 -0
  73. blockpath-0.1.0/src/blockpath/runtime/monitor.py +157 -0
  74. blockpath-0.1.0/src/blockpath/runtime/predicate.py +39 -0
  75. blockpath-0.1.0/src/blockpath/runtime/verify.py +177 -0
  76. blockpath-0.1.0/src/blockpath/runtime/watchdog.py +117 -0
  77. blockpath-0.1.0/src/blockpath/symbols.py +284 -0
  78. blockpath-0.1.0/tests/__init__.py +0 -0
  79. blockpath-0.1.0/tests/golden/__init__.py +0 -0
  80. blockpath-0.1.0/tests/golden/cases/01_three_file_chain/expected.json +14 -0
  81. blockpath-0.1.0/tests/golden/cases/01_three_file_chain/project/app/__init__.py +0 -0
  82. blockpath-0.1.0/tests/golden/cases/01_three_file_chain/project/app/clients/__init__.py +0 -0
  83. blockpath-0.1.0/tests/golden/cases/01_three_file_chain/project/app/clients/nominatim.py +8 -0
  84. blockpath-0.1.0/tests/golden/cases/01_three_file_chain/project/app/handlers/__init__.py +0 -0
  85. blockpath-0.1.0/tests/golden/cases/01_three_file_chain/project/app/handlers/order.py +11 -0
  86. blockpath-0.1.0/tests/golden/cases/01_three_file_chain/project/app/services/__init__.py +0 -0
  87. blockpath-0.1.0/tests/golden/cases/01_three_file_chain/project/app/services/geo.py +6 -0
  88. blockpath-0.1.0/tests/golden/cases/02_direct_in_handler/expected.json +12 -0
  89. blockpath-0.1.0/tests/golden/cases/02_direct_in_handler/project/bot.py +5 -0
  90. blockpath-0.1.0/tests/golden/cases/03_sync_script_silent/expected.json +4 -0
  91. blockpath-0.1.0/tests/golden/cases/03_sync_script_silent/project/app.py +9 -0
  92. blockpath-0.1.0/tests/golden/cases/04_offload_to_thread_ref/expected.json +4 -0
  93. blockpath-0.1.0/tests/golden/cases/04_offload_to_thread_ref/project/app/__init__.py +0 -0
  94. blockpath-0.1.0/tests/golden/cases/04_offload_to_thread_ref/project/app/main.py +7 -0
  95. blockpath-0.1.0/tests/golden/cases/04_offload_to_thread_ref/project/app/work.py +5 -0
  96. blockpath-0.1.0/tests/golden/cases/05_offload_run_in_executor_ref/expected.json +4 -0
  97. blockpath-0.1.0/tests/golden/cases/05_offload_run_in_executor_ref/project/app/__init__.py +0 -0
  98. blockpath-0.1.0/tests/golden/cases/05_offload_run_in_executor_ref/project/app/main.py +8 -0
  99. blockpath-0.1.0/tests/golden/cases/05_offload_run_in_executor_ref/project/app/work.py +5 -0
  100. blockpath-0.1.0/tests/golden/cases/06_called_not_passed_blk002/expected.json +20 -0
  101. blockpath-0.1.0/tests/golden/cases/06_called_not_passed_blk002/project/app/__init__.py +0 -0
  102. blockpath-0.1.0/tests/golden/cases/06_called_not_passed_blk002/project/app/main.py +8 -0
  103. blockpath-0.1.0/tests/golden/cases/06_called_not_passed_blk002/project/app/work.py +5 -0
  104. blockpath-0.1.0/tests/golden/cases/07_relative_imports/expected.json +13 -0
  105. blockpath-0.1.0/tests/golden/cases/07_relative_imports/project/app/__init__.py +0 -0
  106. blockpath-0.1.0/tests/golden/cases/07_relative_imports/project/app/handlers/__init__.py +0 -0
  107. blockpath-0.1.0/tests/golden/cases/07_relative_imports/project/app/handlers/start.py +5 -0
  108. blockpath-0.1.0/tests/golden/cases/07_relative_imports/project/app/services/__init__.py +0 -0
  109. blockpath-0.1.0/tests/golden/cases/07_relative_imports/project/app/services/geo.py +5 -0
  110. blockpath-0.1.0/tests/golden/cases/08_reexport_through_init/expected.json +13 -0
  111. blockpath-0.1.0/tests/golden/cases/08_reexport_through_init/project/app/__init__.py +0 -0
  112. blockpath-0.1.0/tests/golden/cases/08_reexport_through_init/project/app/handlers/__init__.py +0 -0
  113. blockpath-0.1.0/tests/golden/cases/08_reexport_through_init/project/app/handlers/order.py +5 -0
  114. blockpath-0.1.0/tests/golden/cases/08_reexport_through_init/project/app/services/__init__.py +1 -0
  115. blockpath-0.1.0/tests/golden/cases/08_reexport_through_init/project/app/services/geo.py +5 -0
  116. blockpath-0.1.0/tests/golden/cases/09_src_layout/expected.json +13 -0
  117. blockpath-0.1.0/tests/golden/cases/09_src_layout/project/src/app/__init__.py +0 -0
  118. blockpath-0.1.0/tests/golden/cases/09_src_layout/project/src/app/bot.py +5 -0
  119. blockpath-0.1.0/tests/golden/cases/09_src_layout/project/src/app/net.py +5 -0
  120. blockpath-0.1.0/tests/golden/cases/10_mutual_recursion_terminates/expected.json +14 -0
  121. blockpath-0.1.0/tests/golden/cases/10_mutual_recursion_terminates/project/app/__init__.py +0 -0
  122. blockpath-0.1.0/tests/golden/cases/10_mutual_recursion_terminates/project/app/loop.py +14 -0
  123. blockpath-0.1.0/tests/golden/cases/11_shadowed_name_silent/expected.json +4 -0
  124. blockpath-0.1.0/tests/golden/cases/11_shadowed_name_silent/project/app/__init__.py +0 -0
  125. blockpath-0.1.0/tests/golden/cases/11_shadowed_name_silent/project/app/h.py +11 -0
  126. blockpath-0.1.0/tests/golden/cases/12_unresolved_silent_default/expected.json +4 -0
  127. blockpath-0.1.0/tests/golden/cases/12_unresolved_silent_default/project/app/__init__.py +0 -0
  128. blockpath-0.1.0/tests/golden/cases/12_unresolved_silent_default/project/app/h.py +5 -0
  129. blockpath-0.1.0/tests/golden/cases/13_unresolved_strict_blk003/expected.json +15 -0
  130. blockpath-0.1.0/tests/golden/cases/13_unresolved_strict_blk003/project/app/__init__.py +0 -0
  131. blockpath-0.1.0/tests/golden/cases/13_unresolved_strict_blk003/project/app/h.py +5 -0
  132. blockpath-0.1.0/tests/golden/cases/14_framework_decorator_entry/expected.json +18 -0
  133. blockpath-0.1.0/tests/golden/cases/14_framework_decorator_entry/project/app/__init__.py +0 -0
  134. blockpath-0.1.0/tests/golden/cases/14_framework_decorator_entry/project/app/handlers/__init__.py +0 -0
  135. blockpath-0.1.0/tests/golden/cases/14_framework_decorator_entry/project/app/handlers/cmd.py +10 -0
  136. blockpath-0.1.0/tests/golden/cases/14_framework_decorator_entry/project/app/svc.py +5 -0
  137. blockpath-0.1.0/tests/golden/cases/15_attribute_call_missed/expected.json +4 -0
  138. blockpath-0.1.0/tests/golden/cases/15_attribute_call_missed/project/app/__init__.py +0 -0
  139. blockpath-0.1.0/tests/golden/cases/15_attribute_call_missed/project/app/client.py +9 -0
  140. blockpath-0.1.0/tests/golden/harness.py +74 -0
  141. blockpath-0.1.0/tests/golden/test_golden.py +66 -0
  142. blockpath-0.1.0/tests/runtime/__init__.py +0 -0
  143. blockpath-0.1.0/tests/runtime/test_predicate.py +138 -0
  144. blockpath-0.1.0/tests/support.py +64 -0
  145. blockpath-0.1.0/tests/test_callgraph.py +209 -0
  146. blockpath-0.1.0/tests/test_cli.py +215 -0
  147. blockpath-0.1.0/tests/test_collect.py +125 -0
  148. blockpath-0.1.0/tests/test_resolve.py +184 -0
  149. blockpath-0.1.0/tests/test_resolver_property.py +95 -0
  150. blockpath-0.1.0/tests/test_resolver_regressions.py +130 -0
  151. blockpath-0.1.0/tests/test_scopes.py +120 -0
  152. blockpath-0.1.0/uv.lock +633 -0
@@ -0,0 +1,43 @@
1
+ name: ci
2
+
3
+ on:
4
+ push:
5
+ branches: [main]
6
+ pull_request:
7
+
8
+ concurrency:
9
+ group: ci-${{ github.ref }}
10
+ cancel-in-progress: true
11
+
12
+ jobs:
13
+ check:
14
+ runs-on: ubuntu-latest
15
+ strategy:
16
+ fail-fast: false
17
+ matrix:
18
+ python-version: ["3.12", "3.13"]
19
+ steps:
20
+ - uses: actions/checkout@v4
21
+
22
+ - name: Install uv
23
+ uses: astral-sh/setup-uv@v5
24
+ with:
25
+ enable-cache: true
26
+
27
+ - name: Set up Python ${{ matrix.python-version }}
28
+ run: uv python install ${{ matrix.python-version }}
29
+
30
+ - name: Sync (locked)
31
+ run: uv sync --locked --all-extras
32
+
33
+ - name: Lint (ruff)
34
+ run: uv run ruff check .
35
+
36
+ - name: Format check (ruff)
37
+ run: uv run ruff format --check .
38
+
39
+ - name: Type check (mypy --strict)
40
+ run: uv run mypy
41
+
42
+ - name: Test
43
+ run: uv run pytest
@@ -0,0 +1,47 @@
1
+ name: release
2
+
3
+ # Publishes to PyPI via Trusted Publishing (OIDC) when a v* tag is pushed. No API token is
4
+ # stored anywhere: PyPI is configured to trust this repository and workflow, and mints a
5
+ # short-lived credential for the run. The maintainer creates the tag; this does the rest.
6
+ #
7
+ # One-time setup on PyPI (the maintainer, not this workflow):
8
+ # pypi.org -> the blockpath project -> Publishing -> add a trusted publisher:
9
+ # owner: iraettae repository: blockpath workflow: release.yml environment: pypi
10
+
11
+ on:
12
+ push:
13
+ tags:
14
+ - "v*"
15
+
16
+ permissions:
17
+ contents: read
18
+
19
+ jobs:
20
+ build:
21
+ runs-on: ubuntu-latest
22
+ steps:
23
+ - uses: actions/checkout@v4
24
+ - name: Install uv
25
+ uses: astral-sh/setup-uv@v5
26
+ - name: Build sdist and wheel
27
+ run: uv build
28
+ - name: Check metadata
29
+ run: uvx twine check dist/*
30
+ - uses: actions/upload-artifact@v4
31
+ with:
32
+ name: dist
33
+ path: dist/
34
+
35
+ publish:
36
+ needs: build
37
+ runs-on: ubuntu-latest
38
+ environment: pypi
39
+ permissions:
40
+ id-token: write # the OIDC token Trusted Publishing exchanges for a PyPI credential
41
+ steps:
42
+ - uses: actions/download-artifact@v4
43
+ with:
44
+ name: dist
45
+ path: dist/
46
+ - name: Publish to PyPI
47
+ uses: pypa/gh-action-pypi-publish@release/v1
@@ -0,0 +1,38 @@
1
+ # Corpus clones: third-party code, never re-published from this repository.
2
+ # Only the URL list, pinned SHAs and derived numbers are committed.
3
+ .corpus/
4
+
5
+ # Raw per-run scratch that reproduce scripts regenerate
6
+ .cache/
7
+ *.log
8
+
9
+ # Python
10
+ __pycache__/
11
+ *.py[cod]
12
+ .venv/
13
+ venv/
14
+ *.egg-info/
15
+ dist/
16
+ build/
17
+
18
+ # Tooling
19
+ .mypy_cache/
20
+ .ruff_cache/
21
+ .pytest_cache/
22
+ .coverage
23
+ htmlcov/
24
+
25
+ # OS / editors
26
+ .DS_Store
27
+ .idea/
28
+ .vscode/
29
+
30
+ # Local assistant/tooling config, never part of the published project
31
+ .claude/
32
+ CLAUDE.md
33
+ .cursor/
34
+
35
+ # Dev interpreter pin: the supported range is in pyproject (requires-python),
36
+ # and CI is explicit, so we do not force one local version on contributors.
37
+ .python-version
38
+ benchmark/runtime_demo/observed.json
@@ -0,0 +1,25 @@
1
+ # Commits do not physically pass with dirty code: ruff lints and formats, mypy type-checks.
2
+ # Install once with `uv run pre-commit install`.
3
+ repos:
4
+ - repo: local
5
+ hooks:
6
+ - id: ruff-check
7
+ name: ruff check
8
+ entry: uv run ruff check --fix
9
+ language: system
10
+ types_or: [python, pyi]
11
+ require_serial: true
12
+
13
+ - id: ruff-format
14
+ name: ruff format
15
+ entry: uv run ruff format
16
+ language: system
17
+ types_or: [python, pyi]
18
+ require_serial: true
19
+
20
+ - id: mypy
21
+ name: mypy --strict
22
+ entry: uv run mypy
23
+ language: system
24
+ pass_filenames: false
25
+ types_or: [python, pyi]
@@ -0,0 +1,35 @@
1
+ # Changelog
2
+
3
+ All notable changes to this project are documented here. The format follows
4
+ [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and versions follow
5
+ [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
6
+
7
+ ## [0.1.0] - 2026-07-09
8
+
9
+ First release.
10
+
11
+ ### Added
12
+
13
+ - `blockpath check` -- static analysis that finds blocking calls reachable from a coroutine and
14
+ prints the full call path across files and modules. Text and `--json` reporters; exit codes
15
+ `0` clean / `1` findings / `2` error.
16
+ - Three diagnostics: **BLK001** (a blocking call reachable from a coroutine), **BLK002** (a
17
+ function called where it should have been passed to an executor), **BLK003** (`--strict` only:
18
+ a blocking call handed to a callee that could not be resolved).
19
+ - Import resolver handling relative imports, re-exports through `__init__.py`, `as`-aliases,
20
+ src-layout, and cycles, resolving names without importing or running the target code.
21
+ - Blocking-call oracle as data (`oracle.yaml`): ~50 entries over three severity tiers (`error`
22
+ on by default, `warning` and `off` opt-in), plus the thread-offload primitives.
23
+ - `blockpath verify -- <command>` and `blockpath compare` -- a runtime verifier built on
24
+ `sys.monitoring` that records which functions actually ran on the loop and reports a lower
25
+ bound on recall.
26
+ - Configuration via `[tool.blockpath]` in `pyproject.toml`.
27
+
28
+ ### Measured
29
+
30
+ - A pre-registered survey of 57 public aiogram/FastAPI repositories: 51% of blocking calls
31
+ reachable from a coroutine sit at depth 2 or deeper, where per-file linters cannot follow.
32
+ - Hand-adjudicated precision on five of those repositories: 110/112 (98.2%) by the tool's literal
33
+ claim, 99/112 (88.4%) counting product-code bugs only, every false positive explained.
34
+
35
+ [0.1.0]: https://github.com/iraettae/blockpath/releases/tag/v0.1.0
@@ -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.
@@ -0,0 +1,36 @@
1
+ .DEFAULT_GOAL := help
2
+ .PHONY: help install lint fmt typecheck test check demo bench bench-depth
3
+
4
+ help: ## show this help
5
+ @grep -E '^[a-zA-Z_-]+:.*?## .*$$' $(MAKEFILE_LIST) | \
6
+ awk 'BEGIN {FS = ":.*?## "}; {printf " %-14s %s\n", $$1, $$2}'
7
+
8
+ install: ## sync the locked dev environment and install the pre-commit hook
9
+ uv sync --locked --all-extras
10
+ uv run pre-commit install
11
+
12
+ lint: ## ruff check + format check
13
+ uv run ruff check .
14
+ uv run ruff format --check .
15
+
16
+ fmt: ## ruff autofix + format
17
+ uv run ruff check --fix .
18
+ uv run ruff format .
19
+
20
+ typecheck: ## mypy --strict
21
+ uv run mypy
22
+
23
+ test: ## pytest
24
+ uv run pytest
25
+
26
+ check: lint typecheck test ## everything CI runs
27
+
28
+ demo: ## re-record the README demo (asciinema cast + animated svg via svg-term)
29
+ asciinema rec --overwrite -c scripts/demo_session.sh --cols 100 --rows 12 docs/demo.cast
30
+ npx --yes svg-term-cli --in docs/demo.cast --out docs/demo.svg --window --width 100 --height 12
31
+
32
+ bench-depth: ## rebuild the Milestone 0 depth histogram from the pinned corpus
33
+ ./scripts/reproduce_depth_histogram.sh
34
+
35
+ bench: ## rebuild the precision findings from the pinned benchmark repositories
36
+ ./scripts/reproduce_benchmark.sh
@@ -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
+ [![ci](https://github.com/iraettae/blockpath/actions/workflows/ci.yml/badge.svg)](https://github.com/iraettae/blockpath/actions/workflows/ci.yml)
30
+ [![python](https://img.shields.io/badge/python-3.12%2B-blue)](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
+ ![blockpath check finding a blocking call through three files](docs/demo.svg)
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,154 @@
1
+ # blockpath
2
+
3
+ [![ci](https://github.com/iraettae/blockpath/actions/workflows/ci.yml/badge.svg)](https://github.com/iraettae/blockpath/actions/workflows/ci.yml)
4
+ [![python](https://img.shields.io/badge/python-3.12%2B-blue)](https://www.python.org/downloads/)
5
+
6
+ Find the synchronous call that stalls your event loop, and print the whole path to it: from the
7
+ `async` handler down through `services/` and `clients/` to the blocking `requests.get`, across as
8
+ many files and modules as it takes.
9
+
10
+ Existing linters catch a blocking call only when it sits directly in the `async def` body. In
11
+ real code it almost never does -- it is three to five calls deep, in another module, behind a
12
+ couple of helpers nobody thinks about. That is where it hides, and that is what blockpath finds.
13
+
14
+ ## The problem is real, and it was measured before a line of the analyzer was written
15
+
16
+ A survey of **57 public aiogram/FastAPI repositories**, with the protocol and thresholds
17
+ [registered in advance](benchmark/PREREGISTRATION.md) so the result could not be steered, asked
18
+ one question: when a coroutine can reach a blocking call, how deep is it?
19
+
20
+ ```
21
+ depth 1 | ######################################## 77 (48.7%) <- a per-file linter sees these
22
+ depth 2 | ############### 28 (17.7%)
23
+ depth 3 | ################ 31 (19.6%)
24
+ depth 4 | ######### 17 (10.8%)
25
+ depth 5 | ## 3 ( 1.9%)
26
+ depth 6 | # 2 ( 1.3%)
27
+
28
+ | 158 reachable blocking call sites
29
+ ```
30
+
31
+ **51% of them sit at depth 2 or deeper**, across module boundaries, where no per-file linter can
32
+ follow. As a cross-check, `ruff --select ASYNC` run on the same repositories flags **0 of those
33
+ 81 deep sites**. The histogram, and the fact that it rebuilds from scratch, is in
34
+ [benchmark/depth_histogram.md](benchmark/depth_histogram.md) (`make bench-depth`).
35
+
36
+ ## What it looks like
37
+
38
+ ![blockpath check finding a blocking call through three files](docs/demo.svg)
39
+
40
+ ```console
41
+ $ blockpath check .
42
+ BLK001 blocking call reachable from a coroutine
43
+ app/handlers/order.py:11 return resolve_address(address) <- entry: async def
44
+ app/services/geo.py:6 return geocode(query)
45
+ app/clients/nominatim.py:7 response = requests.get(BASE_URL, params=...) <- blocks the event loop
46
+ hint: await asyncio.to_thread(resolve_address, ...)
47
+
48
+ 1 finding(s): 1 BLK001
49
+ ```
50
+
51
+ The path through three files is the point: no existing linter prints it.
52
+
53
+ ## Quick start
54
+
55
+ ```bash
56
+ pip install blockpath
57
+ blockpath check .
58
+ blockpath check . --json # for CI
59
+ ```
60
+
61
+ Exit code is `0` when clean, `1` when there are findings (fails a build), `2` if the tool itself
62
+ errored -- so a crash is never mistaken for a clean run.
63
+
64
+ Optional configuration, in your own `pyproject.toml`:
65
+
66
+ ```toml
67
+ [tool.blockpath]
68
+ tiers = ["error"] # error (default) | warning | off
69
+ entry-decorators = ["router.message"] # mark framework handlers as entry points
70
+ strict = false # also report blocking calls handed to unresolved callees
71
+ ```
72
+
73
+ ## How it works
74
+
75
+ Six layers, each a small tested unit, and one idea that carries the whole thing.
76
+
77
+ ```
78
+ collect -> symbols -> resolve -> callgraph -> oracle -> analysis
79
+ (walk & (names & (name -> (CALL/REF (what (three BFS,
80
+ parse) scopes) function) edges) blocks) the witness)
81
+ ```
82
+
83
+ The idea is that a finding is the **intersection of two independent reachability questions**, and
84
+ that intersection is why the tool stays quiet:
85
+
86
+ 1. **Backward** from every blocking leaf: which functions can reach a blocking call at all?
87
+ 2. **Forward** from every coroutine: which functions can a coroutine reach?
88
+ 3. A third pass over the intersection recovers the *witness* -- the exact chain of call sites.
89
+
90
+ Report only their intersection and `time.sleep` in a synchronous CLI script in the same repo
91
+ stays silent. Report either half alone and the tool cries wolf and gets switched off. The whole
92
+ analysis is `O(V+E)` -- three breadth-first searches, no Tarjan, no SCC, no fixpoint
93
+ ([ADR 0002](docs/adr/0002-three-bfs-not-tarjan.md)).
94
+
95
+ The call graph is built from the AST **without importing or running your code**, so a project
96
+ that needs a database to import is analysed the same as any other.
97
+
98
+ ## How good is it, honestly
99
+
100
+ Run over five of the surveyed repositories and adjudicated by hand, finding by finding, with a
101
+ second pass auditing the riskiest calls ([benchmark/adjudication.md](benchmark/adjudication.md)):
102
+
103
+ - **precision 110/112 = 98.2%** by the tool's literal claim (a blocking call reachable from a
104
+ coroutine), of which 99 are product-code bugs and 11 are real blocking calls in test code; or
105
+ **99/112 = 88.4%** counting product bugs only. Both numbers are in the table, and each of the
106
+ two false positives is explained with a permalink.
107
+ - **recall is a lower bound, never a point estimate.** The runtime verifier (`blockpath verify --
108
+ pytest`) records which functions actually ran on the loop and compares them to the static
109
+ findings, but it only sees the paths the tests exercised. It can prove a miss and can never
110
+ prove completeness, and it says nothing about precision -- only the hand adjudication does.
111
+
112
+ Precision is not a number the analyzer asserts about itself; it is what survived a human reading
113
+ each finding against the source.
114
+
115
+ ## Design decisions, and where it loses
116
+
117
+ - **Only calls by name are resolved.** An attribute call on a value (`self.session.get()`) needs
118
+ type inference, which this does not do; the cost is measured (about half of all call sites on
119
+ one benchmark repo) rather than waved away ([ADR 0003](docs/adr/0003-attribute-calls-not-resolved.md)).
120
+ - **A reference is not a call.** A function passed to `run_in_executor(None, f)` runs in a
121
+ thread, so it is silent by construction -- no offload false positives -- while
122
+ `run_in_executor(None, f())` with parentheses is a real bug (BLK002)
123
+ ([ADR 0007](docs/adr/0007-call-edges-only-ref-not-traversed.md)).
124
+ - **Unknown is quiet by default.** A path with an unresolved edge is not reported unless you ask
125
+ with `--strict` (BLK003) ([ADR 0006](docs/adr/0006-reexport-and-star-import-policy.md)).
126
+ - The [complete list of limitations](docs/limitations.md) is written as they were found, not at
127
+ the end.
128
+
129
+ ### Prior art
130
+
131
+ - **ruff `ASYNC` rules** are written in Rust and run hundreds of times faster than this. They are
132
+ the right tool for the depth-1 case, and blockpath does not try to compete on speed. They work
133
+ per file, by an explicit architectural choice (parallel analysis without global state), so they
134
+ do not build a cross-module call graph and cannot print the path -- which is the entire reason
135
+ this exists. The 0-of-81 cross-check above is that difference, measured.
136
+ - **flake8-async** is likewise per-file and does not follow calls between modules.
137
+
138
+ blockpath is not a replacement for either; it is the cross-module analysis they deliberately do
139
+ not do.
140
+
141
+ ## Development
142
+
143
+ ```bash
144
+ uv sync --all-extras
145
+ make check # ruff, mypy --strict, pytest
146
+ make bench-depth # rebuild the depth histogram from the pinned corpus
147
+ ./scripts/reproduce_benchmark.sh # rebuild the precision findings
148
+ ```
149
+
150
+ This project is AI-assisted: written by a human, who made the product and architecture decisions
151
+ and signed off at every checkpoint, with an AI pair implementing under direction. [WORKLOG.md](WORKLOG.md)
152
+ keeps the estimate-versus-actual log from day one, including where the estimates were wrong.
153
+
154
+ Python 3.12+ (it uses `sys.monitoring`, [ADR 0001](docs/adr/0001-python-312-only.md)). MIT licensed.
@@ -0,0 +1,65 @@
1
+ # WORKLOG
2
+
3
+ Estimate vs actual, per stage. Kept from day one, because a miss in estimation is itself
4
+ diagnostic and hiding it would waste the only evidence this file produces.
5
+
6
+ ## How to read the "actual" column
7
+
8
+ This project is AI-assisted (see README). The `actual` column records **wall-clock time of
9
+ the working session**, not human keyboard-hours, and it is not comparable to the `estimate`
10
+ column, which is a human-hours budget. Both are recorded because the interesting quantity is
11
+ the *ratio between stages*: which stage blew its budget relative to the others, and whether
12
+ the stage flagged up front as the risky one (stage 2, the import resolver) is in fact the one
13
+ that did.
14
+
15
+ Every stage row also records the decisions taken and the ones reversed.
16
+
17
+ | Stage | Estimate (h) | Actual (wall-clock) | Delta | Notes |
18
+ |---|---|---|---|---|
19
+ | 0. Milestone 0: premise check | 3 | ~1 session, over budget | over | premise HOLDS, gate passed; see note below |
20
+ | 1. Skeleton + golden tests | 5 | ~part of a session | roughly on budget | uv/hatchling, ruff+mypy strict green, CI, 4 ADRs, 15 golden cases xfail-until-implemented |
21
+ | 2. collect + symbols + resolve | 20 | ~a session, incl. audit | roughly on budget | resolver green; symtable scopes; property test vs importlib; adversarial audit found 1 critical + majors, all fixed |
22
+ | 3. callgraph | 10 | ~part of a session | under budget | nodes + CALL/REF edges + consumer context; resolution tally; dogfooded on a 725-module repo (attribute calls ~51%) |
23
+ | 4. oracle.yaml | 5 | ~part of a session | under budget | 52 entries, 3 severity tiers, 4 offload primitives; YAML-boolean `off` trap handled |
24
+ | 5. analysis.py (three BFS) | 4 | ~part of a session | under budget | backward/forward/witness BFS + BLK002 + BLK003; **all 15 golden cases green**; 33 findings on a real repo, depth-5 witness |
25
+ | 6. reporters + CLI | 5 | ~part of a session | under budget | rich trace + JSON, exit codes 0/1/2, config in pyproject (ADR 0008), --strict/--tier/--no-config |
26
+ | 7. runtime/ (watchdog, sys.monitoring) | 12 | ~part of a session | under budget | sysmon/coverage coexistence proved empirically first; DISABLE pattern 1.09x vs 1.29x; predicate test reddens on co_flags |
27
+ | 8. corpus + adjudication | 12 | ~part of a session | under budget | 112 findings on 5 repos, hand-adjudicated; precision 110/112 (98.2%); audit HONEST, 0 overturned; runtime pipeline demoed (recall bound 100% on a built app) |
28
+ | 9. packaging + release | 8 | ~part of a session | under budget | README (histogram first, animated svg demo, prior art), CHANGELOG, PyPI Trusted-Publishing workflow, wheel verified in isolation, gitleaks clean, tag prepared |
29
+ | **Total** | **84** | one long build across sessions | see notes | 8/10 stages under or near budget; stage 0 over (the honesty task, not the mechanical one); every gate green, precision 110/112 measured |
30
+
31
+ ## Stage 0 note (why it ran over 3 hours)
32
+
33
+ The 3-hour budget covered "clone repos, count depths, draw a histogram." It did not cover what
34
+ actually happened, which is the more valuable thing: the first histogram was produced by a
35
+ buggy instrument, an adversarial audit caught the bugs, and the number was rebuilt on a
36
+ corrected one.
37
+
38
+ Sequence:
39
+ - Pre-registered the protocol and oracle before any data (commits `a06a211`, `b6660a1`).
40
+ - Built the corpus (57 repos, pinned SHAs), ran the survey: pooled `D1` 0.679, but the cluster
41
+ bootstrap CI [0.31, 0.87] straddled the STOP line, so the number was not yet trustworthy.
42
+ - Put the survey through an adversarial audit: hostile hand-built cases aimed at the resolver,
43
+ a by-hand trace of every depth>=2 finding back to source, and an investigation of why 41
44
+ repos came back empty. It falsified PREREGISTRATION section 5's "measured D1 is an upper
45
+ bound" claim with two corpus-firing bugs (offload inlining, function-local-import dropping).
46
+ - Rewrote the survey (`0974978`): nested defs/lambdas as graph nodes, function-local import
47
+ resolution. 17-case regression suite, red before and green after. Corrected pooled `D1`
48
+ 0.487, CI [0.17, 0.76], now entirely inside "premise holds."
49
+ - Re-adjudicated all 81 depth>=2 findings on corrected data: 75 real, 5 test-only, 1 dead
50
+ branch, 0 fabricated.
51
+
52
+ Diagnostic value of the overrun: the estimate was wrong because it priced the mechanical task,
53
+ not the honesty task. That is the same class of miss anticipated for stage 2 (the import
54
+ resolver, budgeted at 20h precisely because it is usually underestimated 2-3x), and it is the
55
+ first concrete evidence that the "measure your own tool" axis of this project has teeth.
56
+
57
+ ## Reverts and abandoned approaches
58
+
59
+ Recorded here as they happen, with the commit that reverted them.
60
+
61
+ - The original inlining model for closures (`Site.inlined_nested`, PREREGISTRATION section 3.4)
62
+ did not survive the audit: it manufactured offload false positives. Replaced wholesale by the
63
+ nested-defs-as-nodes model in `0974978`. `inlined_nested` is now always False and kept only
64
+ for the raw-data schema -- a revert on contact with reality, left visible in the history
65
+ rather than squashed away.