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/oracle.yaml ADDED
@@ -0,0 +1,222 @@
1
+ # Which external calls stall an event loop.
2
+ #
3
+ # Data, not code: every entry is a claim that calling this name from a coroutine blocks the loop
4
+ # thread, and each claim is small enough to argue with. Kept deliberately short -- roughly forty
5
+ # entries -- because a list of 120 is a list nobody has checked. Wildcards are not used: a
6
+ # wildcard is a claim about names that do not exist yet.
7
+ #
8
+ # Severity tiers:
9
+ # error stalls the loop for an unbounded time (network, subprocess, a synchronous DB round
10
+ # trip, an explicit sleep). On by default; these fail CI.
11
+ # warning stalls the loop for a bounded but real time, usually CPU-bound by design.
12
+ # Off by default, enable with --tier warning.
13
+ # off local disk I/O. Off by default on purpose: turn it on and every
14
+ # `json.load(open(config))` fires, the signal drowns, and the tool gets switched off
15
+ # the first day. Enable deliberately with --tier off.
16
+ #
17
+ # Only module-level functions appear here. `requests.Session().get` and `self.session.get` are
18
+ # attribute calls on a value, which v1 does not resolve at all (ADR 0003) -- listing them would
19
+ # be theatre.
20
+
21
+ version: 1
22
+
23
+ entries:
24
+ # --- sleep -------------------------------------------------------------------------------
25
+ - name: time.sleep
26
+ category: sleep
27
+ severity: error
28
+ note: the canonical loop-stall; ruff ASYNC251 catches it only in the coroutine body
29
+
30
+ # --- synchronous HTTP --------------------------------------------------------------------
31
+ - name: requests.get
32
+ category: http
33
+ severity: error
34
+ - name: requests.post
35
+ category: http
36
+ severity: error
37
+ - name: requests.put
38
+ category: http
39
+ severity: error
40
+ - name: requests.patch
41
+ category: http
42
+ severity: error
43
+ - name: requests.delete
44
+ category: http
45
+ severity: error
46
+ - name: requests.head
47
+ category: http
48
+ severity: error
49
+ - name: requests.options
50
+ category: http
51
+ severity: error
52
+ - name: requests.request
53
+ category: http
54
+ severity: error
55
+ - name: httpx.get
56
+ category: http
57
+ severity: error
58
+ note: the sync API; httpx.AsyncClient is fine
59
+ - name: httpx.post
60
+ category: http
61
+ severity: error
62
+ - name: httpx.put
63
+ category: http
64
+ severity: error
65
+ - name: httpx.patch
66
+ category: http
67
+ severity: error
68
+ - name: httpx.delete
69
+ category: http
70
+ severity: error
71
+ - name: httpx.request
72
+ category: http
73
+ severity: error
74
+ - name: httpx.stream
75
+ category: http
76
+ severity: error
77
+ - name: urllib.request.urlopen
78
+ category: http
79
+ severity: error
80
+ - name: urllib.request.urlretrieve
81
+ category: http
82
+ severity: error
83
+
84
+ # --- sockets, DNS, legacy protocol clients -----------------------------------------------
85
+ - name: socket.create_connection
86
+ category: socket
87
+ severity: error
88
+ - name: socket.getaddrinfo
89
+ category: socket
90
+ severity: error
91
+ note: blocking DNS; a slow resolver stalls the loop for seconds
92
+ - name: socket.gethostbyname
93
+ category: socket
94
+ severity: error
95
+ - name: socket.gethostbyname_ex
96
+ category: socket
97
+ severity: error
98
+ - name: smtplib.SMTP
99
+ category: socket
100
+ severity: error
101
+ note: the constructor opens the connection
102
+ - name: smtplib.SMTP_SSL
103
+ category: socket
104
+ severity: error
105
+ - name: ftplib.FTP
106
+ category: socket
107
+ severity: error
108
+ - name: ftplib.FTP_TLS
109
+ category: socket
110
+ severity: error
111
+
112
+ # --- subprocess --------------------------------------------------------------------------
113
+ # subprocess.Popen is deliberately absent: it forks and returns. It is .wait() / .communicate()
114
+ # that block, and those are attribute calls on a value, which v1 does not resolve.
115
+ - name: subprocess.run
116
+ category: subprocess
117
+ severity: error
118
+ - name: subprocess.call
119
+ category: subprocess
120
+ severity: error
121
+ - name: subprocess.check_call
122
+ category: subprocess
123
+ severity: error
124
+ - name: subprocess.check_output
125
+ category: subprocess
126
+ severity: error
127
+ - name: subprocess.getoutput
128
+ category: subprocess
129
+ severity: error
130
+ - name: subprocess.getstatusoutput
131
+ category: subprocess
132
+ severity: error
133
+ - name: os.system
134
+ category: subprocess
135
+ severity: error
136
+ - name: os.popen
137
+ category: subprocess
138
+ severity: error
139
+ - name: os.waitpid
140
+ category: subprocess
141
+ severity: error
142
+
143
+ # --- synchronous database drivers ---------------------------------------------------------
144
+ # The connect call itself performs a round trip (or, for sqlite, touches the filesystem and
145
+ # takes locks). Queries run through cursor objects, which are attribute calls v1 cannot see.
146
+ - name: sqlite3.connect
147
+ category: db
148
+ severity: error
149
+ note: arguable -- local file, not network -- but it is a synchronous driver entry point and
150
+ it takes locks; the single most common finding on the survey corpus
151
+ - name: psycopg2.connect
152
+ category: db
153
+ severity: error
154
+ - name: psycopg.connect
155
+ category: db
156
+ severity: error
157
+ - name: pymysql.connect
158
+ category: db
159
+ severity: error
160
+ - name: MySQLdb.connect
161
+ category: db
162
+ severity: error
163
+ - name: pyodbc.connect
164
+ category: db
165
+ severity: error
166
+ - name: oracledb.connect
167
+ category: db
168
+ severity: error
169
+ - name: cx_Oracle.connect
170
+ category: db
171
+ severity: error
172
+ - name: clickhouse_driver.Client
173
+ category: db
174
+ severity: error
175
+
176
+ # --- CPU-bound by design (warning tier) ----------------------------------------------------
177
+ - name: hashlib.pbkdf2_hmac
178
+ category: cpu
179
+ severity: warning
180
+ note: deliberately slow key derivation; hundreds of milliseconds is the point
181
+ - name: hashlib.scrypt
182
+ category: cpu
183
+ severity: warning
184
+ - name: bcrypt.hashpw
185
+ category: cpu
186
+ severity: warning
187
+ - name: bcrypt.checkpw
188
+ category: cpu
189
+ severity: warning
190
+
191
+ # --- local disk I/O (off tier) --------------------------------------------------------------
192
+ - name: open
193
+ category: disk
194
+ severity: "off"
195
+ note: on by default this fires on every json.load(open(config)) and the tool gets disabled
196
+ - name: shutil.copy
197
+ category: disk
198
+ severity: "off"
199
+ - name: shutil.copytree
200
+ category: disk
201
+ severity: "off"
202
+ - name: shutil.rmtree
203
+ category: disk
204
+ severity: "off"
205
+
206
+ # Primitives that hand a callable to another thread. A function *referenced* here runs off the
207
+ # loop, which the analysis already handles by not traversing REF edges (ADR 0007). They are
208
+ # listed because the reverse mistake -- CALLING the function instead of passing it -- is a real
209
+ # bug with its own diagnostic (BLK002): `run_in_executor(None, fetch())` evaluates fetch() on the
210
+ # loop before the executor ever sees it.
211
+ #
212
+ # `func_arg` is the positional index of the callable argument.
213
+ offloads:
214
+ - match: [asyncio.to_thread, to_thread]
215
+ func_arg: 0
216
+ - match: [run_in_executor]
217
+ func_arg: 1
218
+ note: always a method on the loop, so matched on the attribute name alone
219
+ - match: [anyio.to_thread.run_sync, to_thread.run_sync, run_sync]
220
+ func_arg: 0
221
+ - match: [concurrent.futures.Executor.submit, submit]
222
+ func_arg: 0
blockpath/py.typed ADDED
File without changes
blockpath/report.py ADDED
@@ -0,0 +1,140 @@
1
+ """Turning findings into something a human acts on, or a machine parses.
2
+
3
+ The text reporter prints the *path*, not the fact. Anyone can say "there is a requests.get in
4
+ clients/nominatim.py"; the point of this tool is the four lines above it that explain how a
5
+ coroutine gets there, so a reader can see which hop to move off the loop. The witness is printed
6
+ as a descending trace with the entry point and the blocking leaf both called out, and a hint
7
+ naming the first hop -- the place ``asyncio.to_thread`` actually belongs.
8
+
9
+ The JSON reporter emits the same content as a stable object per finding, for CI and for the
10
+ runtime comparison in stage 7.
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ import json
16
+ from pathlib import Path
17
+
18
+ from rich.console import Console
19
+ from rich.text import Text
20
+
21
+ from blockpath.model import CheckResult, Code, Finding, Severity
22
+
23
+ _HEADLINE = {
24
+ Code.BLK001: "blocking call reachable from a coroutine",
25
+ Code.BLK002: "function called where it should have been passed to the executor",
26
+ Code.BLK003: "blocking call handed to an unresolved callee (strict)",
27
+ }
28
+
29
+ _SEVERITY_STYLE = {
30
+ Severity.ERROR: "bold red",
31
+ Severity.WARNING: "bold yellow",
32
+ Severity.OFF: "dim",
33
+ }
34
+
35
+
36
+ def _rel(path: Path, root: Path) -> str:
37
+ try:
38
+ return path.relative_to(root).as_posix()
39
+ except ValueError:
40
+ return str(path)
41
+
42
+
43
+ #: Source lines longer than this are elided; a trace that wraps is a trace nobody reads.
44
+ _MAX_SOURCE = 88
45
+
46
+
47
+ def render_text(result: CheckResult, root: Path, console: Console | None = None) -> None:
48
+ """Print findings as descending traces. Colour is dropped automatically when piped."""
49
+ out = console or Console()
50
+ for finding in result.findings:
51
+ # soft_wrap keeps a frame on one line: wrapping a call site mid-token makes the path
52
+ # unreadable, which defeats the entire point of printing the path.
53
+ out.print(_finding_text(finding, root), soft_wrap=True)
54
+ out.print()
55
+ out.print(_summary(result), soft_wrap=True)
56
+
57
+
58
+ def _elide(source: str) -> str:
59
+ if len(source) <= _MAX_SOURCE:
60
+ return source
61
+ return source[: _MAX_SOURCE - 1] + "..."
62
+
63
+
64
+ def _finding_text(finding: Finding, root: Path) -> Text:
65
+ text = Text()
66
+ text.append(str(finding.code), style=_SEVERITY_STYLE.get(finding.severity, "bold"))
67
+ text.append(f" {_HEADLINE[finding.code]}\n")
68
+
69
+ width = max(
70
+ (
71
+ len(f"{_rel(frame.position.path, root)}:{frame.position.line}")
72
+ for frame in finding.witness
73
+ ),
74
+ default=0,
75
+ )
76
+ last = len(finding.witness) - 1
77
+ for index, frame in enumerate(finding.witness):
78
+ where = f"{_rel(frame.position.path, root)}:{frame.position.line}"
79
+ indent = " " + " " * index
80
+ text.append(f"{indent}{where:<{width}} ", style="dim")
81
+ text.append(_elide(frame.source_line) or frame.qualname.rsplit(".", 1)[-1])
82
+ if frame.entry_reason:
83
+ text.append(f" <- entry: {frame.entry_reason}", style="cyan")
84
+ elif index == last:
85
+ text.append(" <- blocks the event loop", style="red")
86
+ text.append("\n")
87
+
88
+ if finding.hint:
89
+ text.append(" hint: ", style="dim")
90
+ text.append(finding.hint, style="green")
91
+ text.append("\n")
92
+ return text
93
+
94
+
95
+ def _summary(result: CheckResult) -> Text:
96
+ text = Text()
97
+ if not result.findings:
98
+ text.append("no blocking calls reachable from a coroutine", style="bold green")
99
+ else:
100
+ counts: dict[str, int] = {}
101
+ for finding in result.findings:
102
+ counts[str(finding.code)] = counts.get(str(finding.code), 0) + 1
103
+ parts = ", ".join(f"{count} {code}" for code, count in sorted(counts.items()))
104
+ text.append(f"{len(result.findings)} finding(s): {parts}", style="bold red")
105
+ if result.unparsed:
106
+ text.append(
107
+ f"\n{len(result.unparsed)} file(s) could not be parsed and were skipped", style="yellow"
108
+ )
109
+ return text
110
+
111
+
112
+ def render_json(result: CheckResult, root: Path) -> str:
113
+ """A stable machine-readable rendering: one object per finding, witness frames in order."""
114
+ payload = {
115
+ "version": 1,
116
+ "findings": [
117
+ {
118
+ "code": str(finding.code),
119
+ "severity": finding.severity.value,
120
+ "callee": finding.callee,
121
+ "category": finding.category,
122
+ "depth": finding.depth,
123
+ "hint": finding.hint,
124
+ "witness": [
125
+ {
126
+ "qualname": frame.qualname,
127
+ "path": _rel(frame.position.path, root),
128
+ "line": frame.position.line,
129
+ "col": frame.position.col,
130
+ "source": frame.source_line,
131
+ "entry_reason": frame.entry_reason,
132
+ }
133
+ for frame in finding.witness
134
+ ],
135
+ }
136
+ for finding in result.findings
137
+ ],
138
+ "unparsed": [_rel(path, root) for path in result.unparsed],
139
+ }
140
+ return json.dumps(payload, indent=2)
blockpath/resolve.py ADDED
@@ -0,0 +1,192 @@
1
+ """Layer 3: resolve a name to the project function it denotes, or say why it cannot.
2
+
3
+ This is the part the project lives or dies on, and the part that is routinely underestimated:
4
+ resolving imports statically, without importing anything. ``resolve_dotted`` answers "what does
5
+ ``app.services.geo.locate`` point at" the way Python's own import machinery would -- walking
6
+ ``import`` bindings, unwinding re-exports through ``__init__.py``, honouring relative imports and
7
+ src-layout -- and ``resolve_callee`` answers the caller's real question, "what does ``foo()`` or
8
+ ``self.session.get()`` in *this* function call", using the scope table to tell a shadow from a
9
+ real reference.
10
+
11
+ Three outcomes, and the third is a first-class citizen, not a failure:
12
+
13
+ * :class:`Internal` -- a function defined in this project. This becomes a call-graph edge.
14
+ * :class:`External` -- a name outside the project root (stdlib, a third-party package). The
15
+ oracle decides whether it blocks.
16
+ * :class:`Unresolved` -- an attribute call on a value, a name bound two ways, a star-imported
17
+ name, a computed callee. The tool does not guess; by default these paths stay silent, and only
18
+ ``--strict`` surfaces them (BLK003). Guessing is how a linter earns its way into ``# noqa``.
19
+ """
20
+
21
+ from __future__ import annotations
22
+
23
+ import ast
24
+ import builtins
25
+ from dataclasses import dataclass
26
+ from typing import Literal
27
+
28
+ from blockpath.collect import Collection
29
+ from blockpath.symbols import ImportBinding, ModuleSymbols, ScopeInfo
30
+
31
+ _BUILTINS: frozenset[str] = frozenset(dir(builtins))
32
+
33
+ UnresolvedReason = Literal[
34
+ "local_shadow",
35
+ "ambiguous_name",
36
+ "unknown_name",
37
+ "star_import",
38
+ "attribute_on_value",
39
+ "computed_callee",
40
+ "class_call",
41
+ "attribute_chain",
42
+ "ambiguous_module",
43
+ "chain_limit",
44
+ ]
45
+
46
+
47
+ @dataclass(frozen=True, slots=True)
48
+ class Internal:
49
+ qualname: str
50
+
51
+
52
+ @dataclass(frozen=True, slots=True)
53
+ class External:
54
+ dotted: str
55
+
56
+
57
+ @dataclass(frozen=True, slots=True)
58
+ class Unresolved:
59
+ reason: UnresolvedReason
60
+
61
+
62
+ Target = Internal | External | Unresolved
63
+
64
+
65
+ class Resolver:
66
+ def __init__(
67
+ self,
68
+ collection: Collection,
69
+ symbols: dict[str, ModuleSymbols],
70
+ *,
71
+ max_depth: int = 8,
72
+ ) -> None:
73
+ self._modules = collection.by_alias
74
+ self._ambiguous_modules = collection.ambiguous_aliases
75
+ self._internal_roots = collection.internal_roots
76
+ self._symbols = symbols
77
+ self._max_depth = max_depth
78
+
79
+ # -- dotted-name resolution (the unit the property test pins against importlib) ----------
80
+
81
+ def resolve_dotted(
82
+ self, dotted: str, _depth: int = 0, _seen: frozenset[str] = frozenset()
83
+ ) -> Target:
84
+ """Resolve a fully-qualified dotted name to the project function it ultimately denotes."""
85
+ if _depth > self._max_depth or dotted in _seen:
86
+ return Unresolved("chain_limit")
87
+
88
+ parts = dotted.split(".")
89
+ for i in range(len(parts) - 1, 0, -1):
90
+ module_name = ".".join(parts[:i])
91
+ if module_name in self._ambiguous_modules:
92
+ return Unresolved("ambiguous_module")
93
+ module = self._modules.get(module_name)
94
+ if module is None:
95
+ continue
96
+ attr = parts[i:]
97
+ if len(attr) > 1:
98
+ return Unresolved("attribute_chain") # mod.Class.method: not resolved in v1
99
+ return self._resolve_in_module(module.qualname, attr[0], _depth, _seen | {dotted})
100
+
101
+ root = parts[0]
102
+ if root not in self._internal_roots:
103
+ return External(dotted)
104
+ return Unresolved("ambiguous_module")
105
+
106
+ def _resolve_in_module(
107
+ self, module_qualname: str, name: str, depth: int, seen: frozenset[str]
108
+ ) -> Target:
109
+ symbols = self._symbols.get(module_qualname)
110
+ if symbols is None:
111
+ return Unresolved("unknown_name")
112
+ if name in symbols.ambiguous:
113
+ return Unresolved("ambiguous_name")
114
+ sym = symbols.top_level.get(name)
115
+ if sym is None:
116
+ return Unresolved("star_import" if symbols.has_star_import else "unknown_name")
117
+ if isinstance(sym, ImportBinding):
118
+ return self.resolve_dotted(sym.target, depth + 1, seen)
119
+ if sym.kind == "class":
120
+ return Unresolved("class_call")
121
+ return Internal(sym.qualname)
122
+
123
+ # -- callee resolution (what callgraph actually asks) ------------------------------------
124
+
125
+ def resolve_callee(
126
+ self, module_symbols: ModuleSymbols, callee: ast.expr, scope: ScopeInfo | None
127
+ ) -> Target:
128
+ """Resolve the callee of a call expression in a given module and function scope."""
129
+ if scope is not None and scope.shadow_all:
130
+ # a scope we could not map: every name might be local, so resolve nothing
131
+ return (
132
+ Unresolved("local_shadow")
133
+ if isinstance(callee, ast.Name | ast.Attribute)
134
+ else Unresolved("computed_callee")
135
+ )
136
+ shadows = scope.shadows if scope is not None else frozenset()
137
+ local_imports = scope.local_imports if scope is not None else {}
138
+
139
+ if isinstance(callee, ast.Name):
140
+ name = callee.id
141
+ if name in local_imports:
142
+ return self.resolve_dotted(local_imports[name])
143
+ if name in shadows:
144
+ return Unresolved("local_shadow")
145
+ return self._resolve_bare_name(module_symbols, name)
146
+
147
+ if isinstance(callee, ast.Attribute):
148
+ dotted = _dotted_of(callee)
149
+ if dotted is None:
150
+ return Unresolved("computed_callee")
151
+ root, _, rest = dotted.partition(".")
152
+ if root in local_imports:
153
+ return self.resolve_dotted(f"{local_imports[root]}.{rest}")
154
+ if root in shadows:
155
+ return Unresolved("attribute_on_value")
156
+ if root in module_symbols.ambiguous:
157
+ return Unresolved("ambiguous_name")
158
+ sym = module_symbols.top_level.get(root)
159
+ if isinstance(sym, ImportBinding) and sym.target:
160
+ return self.resolve_dotted(f"{sym.target}.{rest}")
161
+ return Unresolved("attribute_on_value")
162
+
163
+ return Unresolved("computed_callee")
164
+
165
+ def _resolve_bare_name(self, module_symbols: ModuleSymbols, name: str) -> Target:
166
+ if name in module_symbols.ambiguous:
167
+ return Unresolved("ambiguous_name")
168
+ sym = module_symbols.top_level.get(name)
169
+ if sym is None:
170
+ if name in _BUILTINS:
171
+ return External(name)
172
+ return Unresolved("star_import" if module_symbols.has_star_import else "unknown_name")
173
+ if isinstance(sym, ImportBinding):
174
+ if not sym.target:
175
+ return Unresolved("ambiguous_name")
176
+ return self.resolve_dotted(sym.target)
177
+ if sym.kind == "class":
178
+ return Unresolved("class_call")
179
+ return Internal(sym.qualname)
180
+
181
+
182
+ def _dotted_of(expr: ast.expr) -> str | None:
183
+ """``a.b.c`` -> "a.b.c"; a computed base (``foo().bar``) -> None."""
184
+ parts: list[str] = []
185
+ cur: ast.expr = expr
186
+ while isinstance(cur, ast.Attribute):
187
+ parts.append(cur.attr)
188
+ cur = cur.value
189
+ if not isinstance(cur, ast.Name):
190
+ return None
191
+ parts.append(cur.id)
192
+ return ".".join(reversed(parts))
@@ -0,0 +1,22 @@
1
+ """Runtime verification: what the static analysis claims, checked against what actually ran.
2
+
3
+ Two different measurements, deliberately kept apart because they answer different questions:
4
+
5
+ * :mod:`blockpath.runtime.watchdog` measures **impact** -- how long the event loop was actually
6
+ stalled, and what the loop thread was doing at the time.
7
+ * :mod:`blockpath.runtime.monitor` measures **execution** -- which of the project's own functions
8
+ really ran while a loop was running in that thread. This is what gives a *lower bound* on the
9
+ static analysis's recall: the runtime only ever sees code the tests happened to execute, so it
10
+ can prove a miss but can never prove completeness, and it says nothing at all about precision.
11
+
12
+ Both rest on one predicate -- "is a loop running in this thread right now" -- answered by
13
+ ``asyncio.get_running_loop()`` and never by inspecting frame ``co_flags`` (ADR 0004).
14
+ """
15
+
16
+ from __future__ import annotations
17
+
18
+ from blockpath.runtime.monitor import Monitor, Observation
19
+ from blockpath.runtime.predicate import on_event_loop
20
+ from blockpath.runtime.watchdog import LoopWatchdog, Stall
21
+
22
+ __all__ = ["LoopWatchdog", "Monitor", "Observation", "Stall", "on_event_loop"]