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/cli.py
ADDED
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
"""Command-line interface.
|
|
2
|
+
|
|
3
|
+
blockpath check . static analysis; exit 0 clean, 1 findings, 2 error
|
|
4
|
+
blockpath verify -- pytest runtime cross-check (stage 7)
|
|
5
|
+
blockpath compare static predictions against runtime observation (stage 7)
|
|
6
|
+
|
|
7
|
+
Exit codes are the contract with CI: 1 means "there are findings", which fails a build, and 2 is
|
|
8
|
+
reserved for the tool itself failing so a crash is never mistaken for a clean run.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
import sys
|
|
14
|
+
from dataclasses import replace
|
|
15
|
+
from pathlib import Path
|
|
16
|
+
|
|
17
|
+
import click
|
|
18
|
+
from rich.console import Console
|
|
19
|
+
|
|
20
|
+
from blockpath.check import check as run_check
|
|
21
|
+
from blockpath.config import DEFAULT, Config, load_config
|
|
22
|
+
from blockpath.model import Severity
|
|
23
|
+
from blockpath.report import render_json, render_text
|
|
24
|
+
|
|
25
|
+
EXIT_CLEAN = 0
|
|
26
|
+
EXIT_FINDINGS = 1
|
|
27
|
+
EXIT_ERROR = 2
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
@click.group()
|
|
31
|
+
@click.version_option(package_name="blockpath")
|
|
32
|
+
def main() -> None:
|
|
33
|
+
"""Find blocking calls reachable from a coroutine, across files and modules."""
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
@main.command()
|
|
37
|
+
@click.argument(
|
|
38
|
+
"root",
|
|
39
|
+
type=click.Path(exists=True, file_okay=False, path_type=Path),
|
|
40
|
+
default=".",
|
|
41
|
+
)
|
|
42
|
+
@click.option(
|
|
43
|
+
"--strict",
|
|
44
|
+
is_flag=True,
|
|
45
|
+
help="also report blocking calls handed to an unresolved callee (BLK003)",
|
|
46
|
+
)
|
|
47
|
+
@click.option(
|
|
48
|
+
"--tier",
|
|
49
|
+
"tiers",
|
|
50
|
+
multiple=True,
|
|
51
|
+
type=click.Choice([s.value for s in Severity]),
|
|
52
|
+
help="oracle tiers to enable; repeatable. Default: error",
|
|
53
|
+
)
|
|
54
|
+
@click.option("--json", "as_json", is_flag=True, help="emit findings as JSON")
|
|
55
|
+
@click.option(
|
|
56
|
+
"--no-config", is_flag=True, help="ignore [tool.blockpath] in the project's pyproject.toml"
|
|
57
|
+
)
|
|
58
|
+
def check(root: Path, strict: bool, tiers: tuple[str, ...], as_json: bool, no_config: bool) -> None:
|
|
59
|
+
"""Statically analyse ROOT for blocking calls reachable from a coroutine."""
|
|
60
|
+
console = Console()
|
|
61
|
+
try:
|
|
62
|
+
config = DEFAULT if no_config else load_config(root)
|
|
63
|
+
config = _apply_flags(config, strict=strict, tiers=tiers)
|
|
64
|
+
result = run_check(root, config)
|
|
65
|
+
except Exception as exc: # a crash must never be mistaken for a clean run
|
|
66
|
+
console.print(f"[bold red]blockpath failed:[/] {type(exc).__name__}: {exc}")
|
|
67
|
+
sys.exit(EXIT_ERROR)
|
|
68
|
+
|
|
69
|
+
if as_json:
|
|
70
|
+
click.echo(render_json(result, root.resolve()))
|
|
71
|
+
else:
|
|
72
|
+
render_text(result, root.resolve(), console)
|
|
73
|
+
sys.exit(EXIT_FINDINGS if result.findings else EXIT_CLEAN)
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def _apply_flags(config: Config, *, strict: bool, tiers: tuple[str, ...]) -> Config:
|
|
77
|
+
if strict:
|
|
78
|
+
config = replace(config, strict=True)
|
|
79
|
+
if tiers:
|
|
80
|
+
config = replace(config, enabled_tiers=frozenset(Severity(t) for t in tiers))
|
|
81
|
+
return config
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
@main.command(context_settings={"ignore_unknown_options": True})
|
|
85
|
+
@click.argument("command", nargs=-1, required=True)
|
|
86
|
+
@click.option("--root", type=click.Path(exists=True, file_okay=False, path_type=Path), default=".")
|
|
87
|
+
@click.option(
|
|
88
|
+
"--out",
|
|
89
|
+
type=click.Path(path_type=Path),
|
|
90
|
+
default=Path("observed.json"),
|
|
91
|
+
help="where to write the observations",
|
|
92
|
+
)
|
|
93
|
+
def verify(command: tuple[str, ...], root: Path, out: Path) -> None:
|
|
94
|
+
"""Run COMMAND with the monitor attached and record what ran on the loop.
|
|
95
|
+
|
|
96
|
+
Example: blockpath verify -- pytest -q
|
|
97
|
+
"""
|
|
98
|
+
from blockpath.runtime.verify import run_verify
|
|
99
|
+
|
|
100
|
+
console = Console()
|
|
101
|
+
try:
|
|
102
|
+
result = run_verify(command, root, out.resolve())
|
|
103
|
+
except Exception as exc: # a crash must never be mistaken for a clean run
|
|
104
|
+
console.print(f"[bold red]blockpath verify failed:[/] {type(exc).__name__}: {exc}")
|
|
105
|
+
sys.exit(EXIT_ERROR)
|
|
106
|
+
console.print(
|
|
107
|
+
f"observed [bold]{result.observed}[/] project function(s) running on the loop "
|
|
108
|
+
f"-> {result.observations_path}"
|
|
109
|
+
)
|
|
110
|
+
sys.exit(result.exit_code)
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
@main.command()
|
|
114
|
+
@click.option("--root", type=click.Path(exists=True, file_okay=False, path_type=Path), default=".")
|
|
115
|
+
@click.option(
|
|
116
|
+
"--observed",
|
|
117
|
+
type=click.Path(exists=True, dir_okay=False, path_type=Path),
|
|
118
|
+
default=Path("observed.json"),
|
|
119
|
+
)
|
|
120
|
+
def compare(root: Path, observed: Path) -> None:
|
|
121
|
+
"""Compare static findings against what `verify` actually observed."""
|
|
122
|
+
from blockpath.runtime.verify import compare as run_compare
|
|
123
|
+
from blockpath.runtime.verify import load_observations
|
|
124
|
+
|
|
125
|
+
console = Console()
|
|
126
|
+
try:
|
|
127
|
+
comparison = run_compare(root, load_observations(observed), load_config(root))
|
|
128
|
+
except Exception as exc: # a crash must never be mistaken for a clean run
|
|
129
|
+
console.print(f"[bold red]blockpath compare failed:[/] {type(exc).__name__}: {exc}")
|
|
130
|
+
sys.exit(EXIT_ERROR)
|
|
131
|
+
|
|
132
|
+
console.print(f"confirmed by the runtime : {len(comparison.confirmed)}")
|
|
133
|
+
console.print(f"never exercised by tests : {len(comparison.unexercised)}")
|
|
134
|
+
console.print(f"missed by static analysis: {len(comparison.missed)}")
|
|
135
|
+
for path, name in comparison.missed:
|
|
136
|
+
console.print(f" [yellow]missed[/] {path}::{name}")
|
|
137
|
+
bound = comparison.recall_lower_bound
|
|
138
|
+
if bound is not None:
|
|
139
|
+
console.print(f"\nrecall lower bound: [bold]{bound:.0%}[/]")
|
|
140
|
+
console.print(
|
|
141
|
+
"[dim]A lower bound only: the runtime sees just the paths the tests executed, so it can "
|
|
142
|
+
"prove a miss but never completeness, and it says nothing about precision.[/]"
|
|
143
|
+
)
|
|
144
|
+
sys.exit(EXIT_FINDINGS if comparison.missed else EXIT_CLEAN)
|
blockpath/collect.py
ADDED
|
@@ -0,0 +1,206 @@
|
|
|
1
|
+
"""Layer 1: walk a project tree and parse every module, without importing a line of it.
|
|
2
|
+
|
|
3
|
+
``collect`` yields a :class:`Module` per parseable ``.py`` file, plus the alias map the resolver
|
|
4
|
+
needs -- the dotted names by which one module can import another. Two things make this more than
|
|
5
|
+
a glob:
|
|
6
|
+
|
|
7
|
+
* **It never executes the target.** Everything downstream works from the AST. A project that
|
|
8
|
+
raises on import, or needs a database to import, is analysed all the same.
|
|
9
|
+
* **It resolves a file to the name(s) other files import it by.** A file at ``src/app/geo.py``
|
|
10
|
+
is imported as ``app.geo`` (src-layout), and a package ``app/`` re-exports through its
|
|
11
|
+
``__init__``. Both the src-stripped canonical name and the walk-up-through-``__init__`` name
|
|
12
|
+
are registered, so an import written either way resolves. When two files claim the same name,
|
|
13
|
+
the name is marked ambiguous and the resolver refuses to guess which one an import meant.
|
|
14
|
+
|
|
15
|
+
Ignored paths (``.gitignore`` plus a default set of vendored/generated dirs) are skipped so the
|
|
16
|
+
analyser never wanders into ``.venv`` or ``node_modules``. See ADR 0005.
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
from __future__ import annotations
|
|
20
|
+
|
|
21
|
+
import ast
|
|
22
|
+
from dataclasses import dataclass
|
|
23
|
+
from pathlib import Path, PurePosixPath
|
|
24
|
+
|
|
25
|
+
import pathspec
|
|
26
|
+
|
|
27
|
+
#: Always skipped, whether or not they appear in .gitignore: vendored code and tool caches that
|
|
28
|
+
#: are never the project's own source.
|
|
29
|
+
DEFAULT_IGNORE_DIRS: frozenset[str] = frozenset(
|
|
30
|
+
{
|
|
31
|
+
".git",
|
|
32
|
+
".hg",
|
|
33
|
+
".svn",
|
|
34
|
+
"__pycache__",
|
|
35
|
+
".venv",
|
|
36
|
+
"venv",
|
|
37
|
+
"env",
|
|
38
|
+
".env",
|
|
39
|
+
"node_modules",
|
|
40
|
+
"site-packages",
|
|
41
|
+
".mypy_cache",
|
|
42
|
+
".ruff_cache",
|
|
43
|
+
".pytest_cache",
|
|
44
|
+
".tox",
|
|
45
|
+
".nox",
|
|
46
|
+
"build",
|
|
47
|
+
"dist",
|
|
48
|
+
".eggs",
|
|
49
|
+
}
|
|
50
|
+
)
|
|
51
|
+
|
|
52
|
+
#: Generated Python that is not hand-written source.
|
|
53
|
+
IGNORE_SUFFIXES: tuple[str, ...] = ("_pb2.py", "_pb2_grpc.py", "_pb2.pyi")
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
@dataclass(frozen=True, slots=True)
|
|
57
|
+
class Module:
|
|
58
|
+
"""One parsed source file. ``qualname`` is the canonical (src-stripped) dotted path."""
|
|
59
|
+
|
|
60
|
+
qualname: str
|
|
61
|
+
path: Path
|
|
62
|
+
relpath: str
|
|
63
|
+
source: str
|
|
64
|
+
tree: ast.Module
|
|
65
|
+
is_package: bool # an __init__.py
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
@dataclass(frozen=True, slots=True)
|
|
69
|
+
class Collection:
|
|
70
|
+
"""The result of walking a project: the modules, and how to resolve a name to one."""
|
|
71
|
+
|
|
72
|
+
root: Path
|
|
73
|
+
modules: tuple[Module, ...]
|
|
74
|
+
#: import alias (``app.services.geo``) -> the module it names. Excludes ambiguous names.
|
|
75
|
+
by_alias: dict[str, Module]
|
|
76
|
+
#: aliases claimed by two or more distinct files: the resolver must not resolve into these.
|
|
77
|
+
ambiguous_aliases: frozenset[str]
|
|
78
|
+
#: the set of top-level import roots that belong to this project (``app`` for ``app.geo``),
|
|
79
|
+
#: so the resolver can tell an internal call from a third-party or stdlib one.
|
|
80
|
+
internal_roots: frozenset[str]
|
|
81
|
+
unparsed: tuple[Path, ...]
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def _canonical_qualname(rel: PurePosixPath) -> str:
|
|
85
|
+
"""Repo-root-relative dotted path, with a leading ``src`` stripped so src-layout resolves."""
|
|
86
|
+
parts = list(rel.parts)
|
|
87
|
+
if parts and parts[0] == "src":
|
|
88
|
+
parts = parts[1:]
|
|
89
|
+
if not parts:
|
|
90
|
+
return ""
|
|
91
|
+
if parts[-1] == "__init__.py":
|
|
92
|
+
parts = parts[:-1]
|
|
93
|
+
else:
|
|
94
|
+
parts[-1] = parts[-1].removesuffix(".py")
|
|
95
|
+
return ".".join(parts)
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def _initwalk_qualname(path: Path, root: Path) -> str:
|
|
99
|
+
"""The name a file has when reached by walking up while ``__init__.py`` exists.
|
|
100
|
+
|
|
101
|
+
Guards the bare-stem hijack: a loose module in a non-package directory (``tools/foo.py``
|
|
102
|
+
where ``tools/`` has no ``__init__.py``) is not importable as ``foo``, and registering that
|
|
103
|
+
alias would let a repo file shadow the stdlib module of the same name. Such files get no
|
|
104
|
+
initwalk alias; their canonical name still stands.
|
|
105
|
+
"""
|
|
106
|
+
if (
|
|
107
|
+
path.name != "__init__.py"
|
|
108
|
+
and path.parent != root
|
|
109
|
+
and not (path.parent / "__init__.py").exists()
|
|
110
|
+
):
|
|
111
|
+
return ""
|
|
112
|
+
parts = [path.stem] if path.name != "__init__.py" else []
|
|
113
|
+
cur = path.parent
|
|
114
|
+
while cur != root and (cur / "__init__.py").exists():
|
|
115
|
+
parts.insert(0, cur.name)
|
|
116
|
+
cur = cur.parent
|
|
117
|
+
return ".".join(parts)
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def _load_gitignore(root: Path) -> pathspec.GitIgnoreSpec | None:
|
|
121
|
+
"""The root .gitignore as a matcher. GitIgnoreSpec, not a plain PathSpec, so negation
|
|
122
|
+
(``!keep.py``) and directory anchoring follow real gitignore semantics."""
|
|
123
|
+
gi = root / ".gitignore"
|
|
124
|
+
if not gi.is_file():
|
|
125
|
+
return None
|
|
126
|
+
try:
|
|
127
|
+
return pathspec.GitIgnoreSpec.from_lines(gi.read_text().splitlines())
|
|
128
|
+
except (OSError, UnicodeDecodeError):
|
|
129
|
+
return None
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
def _iter_py_files(root: Path, *, respect_gitignore: bool) -> list[Path]:
|
|
133
|
+
spec = _load_gitignore(root) if respect_gitignore else None
|
|
134
|
+
out: list[Path] = []
|
|
135
|
+
# Manual walk so an ignored directory is pruned, not descended into.
|
|
136
|
+
stack = [root]
|
|
137
|
+
while stack:
|
|
138
|
+
current = stack.pop()
|
|
139
|
+
try:
|
|
140
|
+
entries = sorted(current.iterdir())
|
|
141
|
+
except OSError:
|
|
142
|
+
continue
|
|
143
|
+
for entry in entries:
|
|
144
|
+
rel = PurePosixPath(entry.relative_to(root).as_posix())
|
|
145
|
+
if entry.is_dir():
|
|
146
|
+
if entry.name in DEFAULT_IGNORE_DIRS:
|
|
147
|
+
continue
|
|
148
|
+
if spec is not None and spec.match_file(f"{rel}/"):
|
|
149
|
+
continue
|
|
150
|
+
stack.append(entry)
|
|
151
|
+
elif entry.suffix == ".py":
|
|
152
|
+
if entry.name.endswith(IGNORE_SUFFIXES):
|
|
153
|
+
continue
|
|
154
|
+
if spec is not None and spec.match_file(str(rel)):
|
|
155
|
+
continue
|
|
156
|
+
out.append(entry)
|
|
157
|
+
return sorted(out)
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
def collect(root: Path, *, respect_gitignore: bool = True) -> Collection:
|
|
161
|
+
"""Walk ``root`` and return its parsed modules and the resolver's alias map."""
|
|
162
|
+
root = root.resolve()
|
|
163
|
+
modules: list[Module] = []
|
|
164
|
+
by_alias: dict[str, Module] = {}
|
|
165
|
+
ambiguous: set[str] = set()
|
|
166
|
+
unparsed: list[Path] = []
|
|
167
|
+
|
|
168
|
+
for path in _iter_py_files(root, respect_gitignore=respect_gitignore):
|
|
169
|
+
rel = PurePosixPath(path.relative_to(root).as_posix())
|
|
170
|
+
try:
|
|
171
|
+
source = path.read_text(encoding="utf-8")
|
|
172
|
+
tree = ast.parse(source, filename=str(path))
|
|
173
|
+
except (OSError, UnicodeDecodeError, SyntaxError, ValueError, RecursionError):
|
|
174
|
+
unparsed.append(path)
|
|
175
|
+
continue
|
|
176
|
+
|
|
177
|
+
canonical = _canonical_qualname(rel)
|
|
178
|
+
if not canonical:
|
|
179
|
+
continue
|
|
180
|
+
module = Module(
|
|
181
|
+
qualname=canonical,
|
|
182
|
+
path=path,
|
|
183
|
+
relpath=str(rel),
|
|
184
|
+
source=source,
|
|
185
|
+
tree=tree,
|
|
186
|
+
is_package=rel.name == "__init__.py",
|
|
187
|
+
)
|
|
188
|
+
modules.append(module)
|
|
189
|
+
for alias in {canonical, _initwalk_qualname(path, root)}:
|
|
190
|
+
if not alias:
|
|
191
|
+
continue
|
|
192
|
+
existing = by_alias.get(alias)
|
|
193
|
+
if existing is not None and existing.relpath != module.relpath:
|
|
194
|
+
ambiguous.add(alias)
|
|
195
|
+
else:
|
|
196
|
+
by_alias[alias] = module
|
|
197
|
+
|
|
198
|
+
internal_roots = frozenset(alias.split(".")[0] for alias in by_alias)
|
|
199
|
+
return Collection(
|
|
200
|
+
root=root,
|
|
201
|
+
modules=tuple(modules),
|
|
202
|
+
by_alias=by_alias,
|
|
203
|
+
ambiguous_aliases=frozenset(ambiguous),
|
|
204
|
+
internal_roots=internal_roots,
|
|
205
|
+
unparsed=tuple(unparsed),
|
|
206
|
+
)
|
blockpath/config.py
ADDED
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
"""Configuration for a check run: where it comes from and what it controls.
|
|
2
|
+
|
|
3
|
+
Read from a ``[tool.blockpath]`` table in the project's own ``pyproject.toml`` and nowhere else
|
|
4
|
+
(ADR 0008), because every other Python tool already lives there and a second dotfile is a tax on
|
|
5
|
+
the user. Command-line flags override the file; ``--no-config`` ignores it entirely, which the
|
|
6
|
+
corpus benchmark needs so a third-party project's settings cannot bend our measurements.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import tomllib
|
|
12
|
+
from dataclasses import dataclass, field, replace
|
|
13
|
+
from pathlib import Path
|
|
14
|
+
|
|
15
|
+
from blockpath.model import Severity
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
@dataclass(frozen=True, slots=True)
|
|
19
|
+
class Config:
|
|
20
|
+
"""Everything the analyzer needs to know beyond the source tree itself."""
|
|
21
|
+
|
|
22
|
+
#: Framework decorators that mark an async entry point even on a plain ``def`` handler
|
|
23
|
+
#: (aiogram ``router.message``, aiohttp ``routes.get``, ...). Matched against the decorator
|
|
24
|
+
#: as written, exactly or by dotted suffix. FastAPI's ``async def`` routes need no help.
|
|
25
|
+
entry_decorators: frozenset[str] = field(default_factory=frozenset)
|
|
26
|
+
#: Oracle tiers that are switched on. ``error`` is the default; the rest are opt-in.
|
|
27
|
+
enabled_tiers: frozenset[Severity] = field(default_factory=lambda: frozenset({Severity.ERROR}))
|
|
28
|
+
#: Under strict mode, a blocking callable handed to an unresolved callee is reported (BLK003).
|
|
29
|
+
strict: bool = False
|
|
30
|
+
#: Import-resolution depth ceiling, a guard against pathological re-export chains.
|
|
31
|
+
max_import_depth: int = 8
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
DEFAULT = Config()
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def load_config(root: Path) -> Config:
|
|
38
|
+
"""Read ``[tool.blockpath]`` from ``root/pyproject.toml``; missing or empty means defaults."""
|
|
39
|
+
pyproject = root / "pyproject.toml"
|
|
40
|
+
if not pyproject.is_file():
|
|
41
|
+
return DEFAULT
|
|
42
|
+
try:
|
|
43
|
+
with pyproject.open("rb") as handle:
|
|
44
|
+
data = tomllib.load(handle)
|
|
45
|
+
except (OSError, tomllib.TOMLDecodeError):
|
|
46
|
+
return DEFAULT
|
|
47
|
+
tool = data.get("tool")
|
|
48
|
+
if not isinstance(tool, dict):
|
|
49
|
+
return DEFAULT
|
|
50
|
+
table = tool.get("blockpath")
|
|
51
|
+
if not isinstance(table, dict):
|
|
52
|
+
return DEFAULT
|
|
53
|
+
return _from_table(table)
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def _from_table(table: dict[str, object]) -> Config:
|
|
57
|
+
config = DEFAULT
|
|
58
|
+
strict = table.get("strict")
|
|
59
|
+
if isinstance(strict, bool):
|
|
60
|
+
config = replace(config, strict=strict)
|
|
61
|
+
|
|
62
|
+
tiers = table.get("tiers")
|
|
63
|
+
if isinstance(tiers, list):
|
|
64
|
+
parsed = {Severity(str(t)) for t in tiers if str(t) in {s.value for s in Severity}}
|
|
65
|
+
if parsed:
|
|
66
|
+
config = replace(config, enabled_tiers=frozenset(parsed))
|
|
67
|
+
|
|
68
|
+
decorators = table.get("entry-decorators", table.get("entry_decorators"))
|
|
69
|
+
if isinstance(decorators, list):
|
|
70
|
+
config = replace(config, entry_decorators=frozenset(str(d) for d in decorators))
|
|
71
|
+
|
|
72
|
+
depth = table.get("max-import-depth", table.get("max_import_depth"))
|
|
73
|
+
if isinstance(depth, int) and depth > 0:
|
|
74
|
+
config = replace(config, max_import_depth=depth)
|
|
75
|
+
return config
|
blockpath/model.py
ADDED
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
"""The data model every layer of the pipeline produces or consumes.
|
|
2
|
+
|
|
3
|
+
Deliberately small and frozen. A ``Finding`` is what the tool exists to print: not "there is a
|
|
4
|
+
blocking call in file X" but the whole path from an async entry point down to the blocking leaf,
|
|
5
|
+
frame by frame, with the file, line and column of each hop. Everything else in the package is in
|
|
6
|
+
service of building one of these correctly.
|
|
7
|
+
|
|
8
|
+
Diagnostic codes:
|
|
9
|
+
BLK001 a blocking call is reachable from a coroutine (the main finding)
|
|
10
|
+
BLK002 a function was CALLED where it should have been PASSED to an executor,
|
|
11
|
+
e.g. ``run_in_executor(None, fetch())`` instead of ``run_in_executor(None, fetch)``
|
|
12
|
+
BLK003 a path exists but at least one edge could not be resolved; emitted only under --strict
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
import enum
|
|
18
|
+
from dataclasses import dataclass, field
|
|
19
|
+
from pathlib import Path
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class Code(enum.Enum):
|
|
23
|
+
"""Diagnostic codes. The value is what gets printed."""
|
|
24
|
+
|
|
25
|
+
BLK001 = "BLK001"
|
|
26
|
+
BLK002 = "BLK002"
|
|
27
|
+
BLK003 = "BLK003"
|
|
28
|
+
|
|
29
|
+
def __str__(self) -> str:
|
|
30
|
+
return self.value
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
class Severity(enum.Enum):
|
|
34
|
+
"""Oracle tiers. ``error`` blocks CI; ``warning`` and ``off`` do not, by default."""
|
|
35
|
+
|
|
36
|
+
ERROR = "error"
|
|
37
|
+
WARNING = "warning"
|
|
38
|
+
OFF = "off"
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
@dataclass(frozen=True, slots=True)
|
|
42
|
+
class Position:
|
|
43
|
+
"""A point in a source file. Columns are 0-based, as ``ast`` reports them."""
|
|
44
|
+
|
|
45
|
+
path: Path
|
|
46
|
+
line: int
|
|
47
|
+
col: int
|
|
48
|
+
|
|
49
|
+
def __str__(self) -> str:
|
|
50
|
+
return f"{self.path}:{self.line}:{self.col}"
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
@dataclass(frozen=True, slots=True)
|
|
54
|
+
class Frame:
|
|
55
|
+
"""One hop on a witness path: a call site and the function it lands in.
|
|
56
|
+
|
|
57
|
+
``qualname`` is the function whose body this frame represents; ``position`` is where the
|
|
58
|
+
*previous* frame called into it (or, for the entry frame, where the function is defined).
|
|
59
|
+
``source_line`` is the raw text of that line, kept so the reporter can show it without
|
|
60
|
+
re-reading the file.
|
|
61
|
+
"""
|
|
62
|
+
|
|
63
|
+
qualname: str
|
|
64
|
+
position: Position
|
|
65
|
+
source_line: str
|
|
66
|
+
#: For the entry frame: what makes this an async entry point (an ``async def``, a framework
|
|
67
|
+
#: decorator, a task factory). Empty for interior frames.
|
|
68
|
+
entry_reason: str = ""
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
@dataclass(frozen=True, slots=True)
|
|
72
|
+
class Finding:
|
|
73
|
+
"""A blocking call reachable from a coroutine, with the full path to it."""
|
|
74
|
+
|
|
75
|
+
code: Code
|
|
76
|
+
severity: Severity
|
|
77
|
+
#: The blocking callee, fully qualified, e.g. ``requests.get`` or ``time.sleep``.
|
|
78
|
+
callee: str
|
|
79
|
+
#: The category from the oracle: network, subprocess, sleep, db, ...
|
|
80
|
+
category: str
|
|
81
|
+
#: From the async entry point (first) down to the blocking leaf (last). Never empty; a
|
|
82
|
+
#: depth-1 finding has a single frame that is both entry and leaf.
|
|
83
|
+
witness: tuple[Frame, ...]
|
|
84
|
+
#: A ready-to-print remediation, e.g. "await asyncio.to_thread(resolve_address, addr)".
|
|
85
|
+
hint: str = ""
|
|
86
|
+
|
|
87
|
+
@property
|
|
88
|
+
def entry(self) -> Frame:
|
|
89
|
+
return self.witness[0]
|
|
90
|
+
|
|
91
|
+
@property
|
|
92
|
+
def leaf(self) -> Frame:
|
|
93
|
+
return self.witness[-1]
|
|
94
|
+
|
|
95
|
+
@property
|
|
96
|
+
def depth(self) -> int:
|
|
97
|
+
"""Number of frames from entry to leaf. Depth 1 = the call is in the coroutine body."""
|
|
98
|
+
return len(self.witness)
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
@dataclass(frozen=True, slots=True)
|
|
102
|
+
class CheckResult:
|
|
103
|
+
"""The outcome of analysing one project."""
|
|
104
|
+
|
|
105
|
+
findings: tuple[Finding, ...]
|
|
106
|
+
#: Files that failed to parse, reported rather than silently skipped.
|
|
107
|
+
unparsed: tuple[Path, ...] = field(default_factory=tuple)
|
|
108
|
+
|
|
109
|
+
@property
|
|
110
|
+
def exit_code(self) -> int:
|
|
111
|
+
"""0 clean, 1 findings, matching the CLI contract. 2 (error) is set by the CLI itself."""
|
|
112
|
+
return 1 if self.findings else 0
|
blockpath/oracle.py
ADDED
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
"""Layer 5: which external calls block, and which primitives hand work to another thread.
|
|
2
|
+
|
|
3
|
+
The knowledge lives in ``oracle.yaml`` as *data*, so the list can be read and argued with
|
|
4
|
+
without reading Python. This module only loads it and answers two questions:
|
|
5
|
+
|
|
6
|
+
* ``verdict(dotted)`` -- does calling this name stall the loop, in which category, at which
|
|
7
|
+
severity tier. Unknown names get no verdict at all: the tool does not guess that an unfamiliar
|
|
8
|
+
third-party call blocks.
|
|
9
|
+
* ``blk002(consumer, arg_index)`` -- was a function *called* in the argument slot where an
|
|
10
|
+
offload primitive expected it to be *passed*? ``run_in_executor(None, fetch())`` runs ``fetch``
|
|
11
|
+
on the loop before the executor sees anything, which is the exact inversion of the fix people
|
|
12
|
+
reach for, and it gets its own diagnostic.
|
|
13
|
+
|
|
14
|
+
Severity tiers exist so the default is quiet: ``error`` (unbounded stalls) is on, ``warning``
|
|
15
|
+
(CPU-bound by design) and ``off`` (local disk I/O) are opt-in. Turning disk I/O on by default
|
|
16
|
+
would fire on every ``json.load(open(path))``, and a tool that cries wolf gets switched off.
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
from __future__ import annotations
|
|
20
|
+
|
|
21
|
+
import importlib.resources
|
|
22
|
+
from dataclasses import dataclass
|
|
23
|
+
from pathlib import Path
|
|
24
|
+
from typing import cast
|
|
25
|
+
|
|
26
|
+
import yaml
|
|
27
|
+
|
|
28
|
+
from blockpath.model import Severity
|
|
29
|
+
|
|
30
|
+
_DEFAULT_RESOURCE = "oracle.yaml"
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def _severity(value: object) -> Severity:
|
|
34
|
+
"""Parse a severity, surviving YAML 1.1's booleans.
|
|
35
|
+
|
|
36
|
+
``severity: off`` unquoted is parsed by PyYAML as the boolean ``False`` (YAML 1.1 treats
|
|
37
|
+
off/on/yes/no as booleans). The bundled file quotes it, but an edit that forgets the quotes
|
|
38
|
+
must not silently mangle the oracle, so both spellings are accepted.
|
|
39
|
+
"""
|
|
40
|
+
if value is False:
|
|
41
|
+
return Severity.OFF
|
|
42
|
+
if value is True:
|
|
43
|
+
return Severity.ERROR # `severity: on` -- nobody should write it, but do not crash
|
|
44
|
+
return Severity(str(value))
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
@dataclass(frozen=True, slots=True)
|
|
48
|
+
class BlockingCall:
|
|
49
|
+
"""One claim: calling ``name`` from a coroutine stalls the loop."""
|
|
50
|
+
|
|
51
|
+
name: str
|
|
52
|
+
category: str
|
|
53
|
+
severity: Severity
|
|
54
|
+
note: str = ""
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
@dataclass(frozen=True, slots=True)
|
|
58
|
+
class Offload:
|
|
59
|
+
"""A primitive that runs a callable on another thread."""
|
|
60
|
+
|
|
61
|
+
match: tuple[str, ...]
|
|
62
|
+
func_arg: int
|
|
63
|
+
note: str = ""
|
|
64
|
+
|
|
65
|
+
def matches(self, consumer: str) -> bool:
|
|
66
|
+
return any(consumer == m or consumer.endswith(f".{m}") for m in self.match)
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
class Oracle:
|
|
70
|
+
def __init__(self, entries: list[BlockingCall], offloads: list[Offload]) -> None:
|
|
71
|
+
self._by_name = {entry.name: entry for entry in entries}
|
|
72
|
+
self._offloads = offloads
|
|
73
|
+
|
|
74
|
+
# -- construction -------------------------------------------------------------------------
|
|
75
|
+
|
|
76
|
+
@classmethod
|
|
77
|
+
def load(cls, path: Path | None = None) -> Oracle:
|
|
78
|
+
"""Load the bundled oracle, or an alternative file."""
|
|
79
|
+
if path is None:
|
|
80
|
+
text = importlib.resources.files("blockpath").joinpath(_DEFAULT_RESOURCE).read_text()
|
|
81
|
+
else:
|
|
82
|
+
text = path.read_text()
|
|
83
|
+
return cls.from_yaml(text)
|
|
84
|
+
|
|
85
|
+
@classmethod
|
|
86
|
+
def from_yaml(cls, text: str) -> Oracle:
|
|
87
|
+
raw = cast(dict[str, object], yaml.safe_load(text))
|
|
88
|
+
entries: list[BlockingCall] = []
|
|
89
|
+
for item in cast(list[dict[str, str]], raw.get("entries", [])):
|
|
90
|
+
entries.append(
|
|
91
|
+
BlockingCall(
|
|
92
|
+
name=item["name"],
|
|
93
|
+
category=item["category"],
|
|
94
|
+
severity=_severity(item["severity"]),
|
|
95
|
+
note=item.get("note", ""),
|
|
96
|
+
)
|
|
97
|
+
)
|
|
98
|
+
offloads: list[Offload] = []
|
|
99
|
+
for item2 in cast(list[dict[str, object]], raw.get("offloads", [])):
|
|
100
|
+
offloads.append(
|
|
101
|
+
Offload(
|
|
102
|
+
match=tuple(cast(list[str], item2["match"])),
|
|
103
|
+
func_arg=int(cast(int, item2["func_arg"])),
|
|
104
|
+
note=cast(str, item2.get("note", "")),
|
|
105
|
+
)
|
|
106
|
+
)
|
|
107
|
+
return cls(entries, offloads)
|
|
108
|
+
|
|
109
|
+
# -- queries -----------------------------------------------------------------------------
|
|
110
|
+
|
|
111
|
+
def verdict(self, dotted: str) -> BlockingCall | None:
|
|
112
|
+
"""The blocking claim for this external name, or None if we make no claim about it."""
|
|
113
|
+
return self._by_name.get(dotted)
|
|
114
|
+
|
|
115
|
+
def blocks(self, dotted: str, enabled: frozenset[Severity]) -> BlockingCall | None:
|
|
116
|
+
"""As :meth:`verdict`, but only if the entry's tier is switched on."""
|
|
117
|
+
entry = self._by_name.get(dotted)
|
|
118
|
+
if entry is None or entry.severity not in enabled:
|
|
119
|
+
return None
|
|
120
|
+
return entry
|
|
121
|
+
|
|
122
|
+
def offload_for(self, consumer: str | None) -> Offload | None:
|
|
123
|
+
if consumer is None:
|
|
124
|
+
return None
|
|
125
|
+
for offload in self._offloads:
|
|
126
|
+
if offload.matches(consumer):
|
|
127
|
+
return offload
|
|
128
|
+
return None
|
|
129
|
+
|
|
130
|
+
def blk002(self, consumer: str | None, arg_index: int | None) -> Offload | None:
|
|
131
|
+
"""The offload whose callable slot was *called* instead of passed, if this is that bug."""
|
|
132
|
+
offload = self.offload_for(consumer)
|
|
133
|
+
if offload is None or arg_index is None:
|
|
134
|
+
return None
|
|
135
|
+
return offload if arg_index == offload.func_arg else None
|
|
136
|
+
|
|
137
|
+
@property
|
|
138
|
+
def entries(self) -> list[BlockingCall]:
|
|
139
|
+
return list(self._by_name.values())
|
|
140
|
+
|
|
141
|
+
@property
|
|
142
|
+
def offloads(self) -> list[Offload]:
|
|
143
|
+
return list(self._offloads)
|