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/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"]
|