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.
@@ -0,0 +1,157 @@
1
+ """Which of the project's own functions actually executed while a loop was running.
2
+
3
+ Built on ``sys.monitoring`` (PEP 669, hence Python 3.12+, ADR 0001). The shape of the
4
+ instrumentation matters more than it looks:
5
+
6
+ * A ``PY_START`` callback is registered globally, but the *first* time it sees a code object
7
+ from outside the project it returns ``sys.monitoring.DISABLE``, which permanently stops events
8
+ for that code object. Third-party and stdlib code therefore costs one event each, once, and
9
+ nothing thereafter. Measured on a call-heavy workload: 1.09x baseline, against 1.29x for the
10
+ same callback without ``DISABLE``. That is the difference between a verifier you can leave on
11
+ during a test suite and one nobody runs twice.
12
+ * The tool id is 3 by default, not the reserved 0/1/2/5 (debugger, coverage, profiler,
13
+ optimizer). Checked empirically on 3.13 with ``COVERAGE_CORE=sysmon``: coverage.py holds id 1
14
+ and blockpath holds id 3 in the same process, both receiving their events. ``sys.monitoring``
15
+ multiplexes by tool id, so running under coverage is not a conflict.
16
+
17
+ Recording is gated on :func:`blockpath.runtime.predicate.on_event_loop`, so a project function
18
+ that runs in a worker thread (``to_thread``) is correctly *not* recorded as having run on the
19
+ loop.
20
+ """
21
+
22
+ from __future__ import annotations
23
+
24
+ import json
25
+ import sys
26
+ from collections import Counter
27
+ from dataclasses import dataclass
28
+ from pathlib import Path
29
+ from types import CodeType
30
+
31
+ from blockpath.runtime.predicate import on_event_loop
32
+
33
+ #: Default tool id. 0/1/2/5 are reserved for debugger / coverage / profiler / optimizer.
34
+ DEFAULT_TOOL_ID = 3
35
+ _FALLBACK_TOOL_IDS = (4, 3)
36
+
37
+
38
+ @dataclass(frozen=True, slots=True)
39
+ class Observation:
40
+ """A project function seen entering while a loop ran in that thread."""
41
+
42
+ path: str
43
+ qualname: str
44
+ line: int
45
+ count: int
46
+
47
+
48
+ class MonitorError(RuntimeError):
49
+ pass
50
+
51
+
52
+ class Monitor:
53
+ """Records project functions that execute while an event loop runs in the same thread."""
54
+
55
+ def __init__(self, project_root: Path, tool_id: int | None = None) -> None:
56
+ self._root = project_root.resolve()
57
+ self._requested_id = tool_id
58
+ self._tool_id: int | None = None
59
+ self._seen: Counter[tuple[str, str, int]] = Counter()
60
+ self._started = False
61
+
62
+ # -- lifecycle ----------------------------------------------------------------------------
63
+
64
+ def start(self) -> None:
65
+ if self._started:
66
+ return
67
+ self._tool_id = self._claim_tool_id()
68
+ mon = sys.monitoring
69
+ mon.register_callback(self._tool_id, mon.events.PY_START, self._on_py_start)
70
+ mon.set_events(self._tool_id, mon.events.PY_START)
71
+ self._started = True
72
+
73
+ def stop(self) -> None:
74
+ if not self._started or self._tool_id is None:
75
+ return
76
+ mon = sys.monitoring
77
+ mon.set_events(self._tool_id, 0)
78
+ mon.register_callback(self._tool_id, mon.events.PY_START, None)
79
+ mon.free_tool_id(self._tool_id)
80
+ self._tool_id = None
81
+ self._started = False
82
+
83
+ def __enter__(self) -> Monitor:
84
+ self.start()
85
+ return self
86
+
87
+ def __exit__(self, *exc: object) -> None:
88
+ self.stop()
89
+
90
+ def _claim_tool_id(self) -> int:
91
+ candidates = (
92
+ (self._requested_id,)
93
+ if self._requested_id is not None
94
+ else (DEFAULT_TOOL_ID, *_FALLBACK_TOOL_IDS)
95
+ )
96
+ for candidate in candidates:
97
+ if sys.monitoring.get_tool(candidate) is None:
98
+ sys.monitoring.use_tool_id(candidate, "blockpath")
99
+ return candidate
100
+ holders = {i: sys.monitoring.get_tool(i) for i in range(6)}
101
+ raise MonitorError(
102
+ f"no free sys.monitoring tool id; currently held: {holders}. "
103
+ "Another tool (a debugger, coverage in sysmon mode, a profiler) holds them all."
104
+ )
105
+
106
+ # -- the hot path -------------------------------------------------------------------------
107
+
108
+ def _on_py_start(self, code: CodeType, instruction_offset: int) -> object:
109
+ if not self._is_project(code.co_filename):
110
+ # permanently stop events for this code object: third-party and stdlib cost one
111
+ # event each, once, and nothing afterwards
112
+ return sys.monitoring.DISABLE
113
+ if not on_event_loop():
114
+ # project code, but not on a loop right now; it may be later, so keep watching it
115
+ return None
116
+ self._seen[(code.co_filename, code.co_qualname, code.co_firstlineno)] += 1
117
+ return None
118
+
119
+ def _is_project(self, filename: str) -> bool:
120
+ if not filename or filename.startswith("<"):
121
+ return False
122
+ try:
123
+ return Path(filename).resolve().is_relative_to(self._root)
124
+ except (OSError, ValueError):
125
+ return False
126
+
127
+ # -- results ------------------------------------------------------------------------------
128
+
129
+ @property
130
+ def observations(self) -> list[Observation]:
131
+ out = [
132
+ Observation(
133
+ path=self._relative(path),
134
+ qualname=qualname,
135
+ line=line,
136
+ count=count,
137
+ )
138
+ for (path, qualname, line), count in sorted(self._seen.items())
139
+ ]
140
+ return out
141
+
142
+ def _relative(self, filename: str) -> str:
143
+ try:
144
+ return Path(filename).resolve().relative_to(self._root).as_posix()
145
+ except (OSError, ValueError):
146
+ return filename
147
+
148
+ def dump(self, path: Path) -> None:
149
+ payload = {
150
+ "version": 1,
151
+ "root": str(self._root),
152
+ "observations": [
153
+ {"path": o.path, "qualname": o.qualname, "line": o.line, "count": o.count}
154
+ for o in self.observations
155
+ ],
156
+ }
157
+ path.write_text(json.dumps(payload, indent=2))
@@ -0,0 +1,39 @@
1
+ """The one predicate the whole runtime layer rests on: is a loop running in *this* thread?
2
+
3
+ Three lines, and the reason they are these three lines rather than a walk over frame
4
+ ``co_flags`` is worth stating, because the frame version looks equivalent and is not (ADR 0004):
5
+
6
+ * ``loop.call_soon(blocking_fn)`` runs a *synchronous* callback. There is no coroutine anywhere
7
+ on the stack, yet the loop is stalled for the duration. ``co_flags`` says "not async" and
8
+ misses it entirely -- and this is the single most common way a blocking call sneaks onto a
9
+ loop that someone has already audited for ``async def`` bodies.
10
+ * A coroutine running in *another* thread's loop puts ``CO_COROUTINE`` on that thread's stack.
11
+ It stalls that loop, not this one. ``co_flags`` cannot tell the threads apart.
12
+ * Synchronous code inside ``run_in_executor`` has a coroutine above it in the calling thread,
13
+ but executes in a worker thread where no loop runs. ``co_flags`` gives a false positive --
14
+ precisely the offloaded case the tool must stay quiet about.
15
+
16
+ ``asyncio.get_running_loop()`` asks exactly the right question, and asks it per-thread, which is
17
+ what the question was in the first place. Frames are used only to report *where* something
18
+ happened, never to decide *whether* it was on the loop.
19
+ """
20
+
21
+ from __future__ import annotations
22
+
23
+ import asyncio
24
+
25
+
26
+ def on_event_loop() -> bool:
27
+ """True when an asyncio event loop is running in the calling thread."""
28
+ try:
29
+ asyncio.get_running_loop()
30
+ except RuntimeError:
31
+ return False
32
+ return True
33
+
34
+
35
+ def running_loop_thread_id() -> int | None:
36
+ """The id of the thread whose loop is running here, or None when there is no loop."""
37
+ import threading
38
+
39
+ return threading.get_ident() if on_event_loop() else None
@@ -0,0 +1,177 @@
1
+ """``blockpath verify -- <command>`` and ``blockpath compare``.
2
+
3
+ ``verify`` runs the project's own test suite (or any command) with the monitor attached, and
4
+ records which of the project's functions actually executed while a loop was running. The monitor
5
+ has to live *inside* that process, so a ``sitecustomize`` shim is put on ``PYTHONPATH`` -- the
6
+ same mechanism coverage uses to measure subprocesses -- and it dumps its observations at exit.
7
+
8
+ ``compare`` puts the static findings next to those observations and sorts them into three piles:
9
+
10
+ * **confirmed** -- static said this function reaches a blocking call, and the runtime saw the
11
+ function run on the loop.
12
+ * **unexercised** -- static said so, but the tests never ran that function. This says nothing
13
+ about whether the finding is right; it says the suite does not cover it.
14
+ * **missed** -- the runtime saw a function run on the loop, that function contains a blocking
15
+ call, and static did *not* report it. Each of these is a proven hole in the analysis.
16
+
17
+ The ratio confirmed / (confirmed + missed) is a **lower bound on recall**, and nothing else. The
18
+ runtime only ever sees paths the tests happened to execute, so it can prove a miss and can never
19
+ prove completeness -- and it says nothing whatsoever about precision, which only hand
20
+ adjudication can establish.
21
+ """
22
+
23
+ from __future__ import annotations
24
+
25
+ import json
26
+ import os
27
+ import subprocess
28
+ import tempfile
29
+ from dataclasses import dataclass
30
+ from pathlib import Path
31
+
32
+ from blockpath.check import check
33
+ from blockpath.config import Config
34
+ from blockpath.model import Finding
35
+
36
+ _SHIM = '''\
37
+ """Injected by `blockpath verify`: start the monitor before the target runs."""
38
+ import atexit
39
+ import os
40
+ from pathlib import Path
41
+
42
+ try:
43
+ from blockpath.runtime.monitor import Monitor
44
+ except Exception: # blockpath not importable in this interpreter: stay out of the way
45
+ pass
46
+ else:
47
+ _root = os.environ.get("BLOCKPATH_ROOT")
48
+ _out = os.environ.get("BLOCKPATH_OUT")
49
+ if _root and _out:
50
+ _monitor = Monitor(Path(_root))
51
+ try:
52
+ _monitor.start()
53
+ except Exception:
54
+ pass
55
+ else:
56
+ @atexit.register
57
+ def _dump() -> None:
58
+ try:
59
+ _monitor.stop()
60
+ _monitor.dump(Path(_out))
61
+ except Exception:
62
+ pass
63
+ '''
64
+
65
+
66
+ @dataclass(frozen=True, slots=True)
67
+ class VerifyResult:
68
+ exit_code: int
69
+ observations_path: Path
70
+ observed: int
71
+
72
+
73
+ def run_verify(command: tuple[str, ...], root: Path, out_path: Path) -> VerifyResult:
74
+ """Run ``command`` with the monitor attached; write observations to ``out_path``."""
75
+ root = root.resolve()
76
+ with tempfile.TemporaryDirectory(prefix="blockpath-shim-") as shim_dir:
77
+ (Path(shim_dir) / "sitecustomize.py").write_text(_SHIM)
78
+ env = os.environ.copy()
79
+ existing = env.get("PYTHONPATH", "")
80
+ env["PYTHONPATH"] = shim_dir + (os.pathsep + existing if existing else "")
81
+ env["BLOCKPATH_ROOT"] = str(root)
82
+ env["BLOCKPATH_OUT"] = str(out_path)
83
+ proc = subprocess.run(command, env=env, cwd=root, check=False)
84
+ observed = 0
85
+ if out_path.is_file():
86
+ try:
87
+ observed = len(json.loads(out_path.read_text()).get("observations", []))
88
+ except (OSError, json.JSONDecodeError):
89
+ observed = 0
90
+ return VerifyResult(exit_code=proc.returncode, observations_path=out_path, observed=observed)
91
+
92
+
93
+ @dataclass(frozen=True, slots=True)
94
+ class Comparison:
95
+ confirmed: tuple[Finding, ...]
96
+ unexercised: tuple[Finding, ...]
97
+ missed: tuple[tuple[str, str], ...] # (path, qualname) observed on the loop but not reported
98
+
99
+ @property
100
+ def recall_lower_bound(self) -> float | None:
101
+ denominator = len(self.confirmed) + len(self.missed)
102
+ if denominator == 0:
103
+ return None
104
+ return len(self.confirmed) / denominator
105
+
106
+
107
+ def _simple(qualname: str) -> str:
108
+ return qualname.rsplit(".", 1)[-1]
109
+
110
+
111
+ def load_observations(path: Path) -> set[tuple[str, str]]:
112
+ """(relpath, simple function name) pairs seen executing while a loop ran."""
113
+ data = json.loads(path.read_text())
114
+ return {(item["path"], _simple(str(item["qualname"]))) for item in data.get("observations", [])}
115
+
116
+
117
+ def compare(root: Path, observations: set[tuple[str, str]], config: Config) -> Comparison:
118
+ """Sort static findings against what the runtime actually saw execute.
119
+
120
+ Functions are matched on (relative path, simple function name). That is a heuristic: two
121
+ functions of the same name in one file collide. It is documented rather than hidden, and it
122
+ only ever affects which pile a finding lands in, never the finding itself.
123
+ """
124
+ root = root.resolve()
125
+ result = check(root, config)
126
+
127
+ confirmed: list[Finding] = []
128
+ unexercised: list[Finding] = []
129
+ for finding in result.findings:
130
+ leaf = finding.leaf
131
+ rel = _rel(leaf.position.path, root)
132
+ name = _simple(leaf.qualname)
133
+ if (rel, name) in observations:
134
+ confirmed.append(finding)
135
+ else:
136
+ unexercised.append(finding)
137
+
138
+ reported = {
139
+ (_rel(f.leaf.position.path, root), _simple(f.leaf.qualname)) for f in result.findings
140
+ }
141
+ missed = tuple(sorted(observations - reported - _clean_functions(root, config, observations)))
142
+ return Comparison(tuple(confirmed), tuple(unexercised), missed)
143
+
144
+
145
+ def _clean_functions(
146
+ root: Path, config: Config, observations: set[tuple[str, str]]
147
+ ) -> set[tuple[str, str]]:
148
+ """Observed functions that contain no blocking call at all, so their absence is correct."""
149
+ from blockpath.callgraph import EdgeKind, build_call_graph
150
+ from blockpath.collect import collect
151
+ from blockpath.oracle import Oracle
152
+ from blockpath.resolve import External, Resolver
153
+ from blockpath.symbols import analyze
154
+
155
+ collection = collect(root)
156
+ analyses = {m.qualname: analyze(m) for m in collection.modules}
157
+ resolver = Resolver(collection, {q: a.symbols for q, a in analyses.items()})
158
+ graph = build_call_graph(collection, analyses, resolver)
159
+ oracle = Oracle.load()
160
+
161
+ blocking_owners: set[tuple[str, str]] = set()
162
+ for edge in graph.edges:
163
+ if edge.kind is not EdgeKind.CALL or not isinstance(edge.target, External):
164
+ continue
165
+ if oracle.blocks(edge.target.dotted, config.enabled_tiers) is None:
166
+ continue
167
+ node = graph.nodes.get(edge.source)
168
+ if node is not None:
169
+ blocking_owners.add((_rel(node.position.path, root), _simple(edge.source)))
170
+ return observations - blocking_owners
171
+
172
+
173
+ def _rel(path: Path, root: Path) -> str:
174
+ try:
175
+ return path.resolve().relative_to(root).as_posix()
176
+ except ValueError:
177
+ return str(path)
@@ -0,0 +1,117 @@
1
+ """How long the event loop was actually stalled, and what it was doing at the time.
2
+
3
+ A thread outside the loop pings it with ``call_soon_threadsafe`` and times the reply. If the
4
+ reply does not come back within the lag threshold the loop is, by definition, busy doing
5
+ something synchronous -- so the watchdog snapshots the *loop thread's* stack right then, while
6
+ the blocking call is still on it. That stack is the evidence: it names the file and line that is
7
+ holding the loop, which is exactly the claim the static analysis makes in advance.
8
+
9
+ This is the "impact" half of the runtime layer. It answers "the loop stalled 812 ms, here", where
10
+ the monitor answers "this function ran while a loop was running". Neither substitutes for the
11
+ other, and neither measures precision -- see the recall caveat in the package docstring.
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ import sys
17
+ import threading
18
+ import time
19
+ import traceback
20
+ from asyncio import AbstractEventLoop
21
+ from dataclasses import dataclass, field
22
+
23
+ #: How often the watchdog pings the loop.
24
+ DEFAULT_PING_INTERVAL = 0.010
25
+ #: A reply slower than this means the loop was busy; snapshot it.
26
+ DEFAULT_LAG_THRESHOLD = 0.050
27
+
28
+
29
+ @dataclass(frozen=True, slots=True)
30
+ class Stall:
31
+ """One observed stall: how long the loop failed to answer, and where it was."""
32
+
33
+ duration: float
34
+ #: innermost-last frames of the loop thread, captured while it was still blocked
35
+ stack: tuple[str, ...] = field(default_factory=tuple)
36
+
37
+ @property
38
+ def where(self) -> str:
39
+ return self.stack[-1] if self.stack else "<unknown>"
40
+
41
+
42
+ class LoopWatchdog:
43
+ """Pings a running loop from another thread and records how long replies take.
44
+
45
+ Thresholds are constructor arguments rather than constants so a slow CI box or a chatty
46
+ service can be tuned without editing the source; the defaults (ping every 10 ms, complain
47
+ past 50 ms) are chosen so ordinary scheduling jitter does not register as a stall.
48
+ """
49
+
50
+ def __init__(
51
+ self,
52
+ loop: AbstractEventLoop,
53
+ *,
54
+ ping_interval: float = DEFAULT_PING_INTERVAL,
55
+ lag_threshold: float = DEFAULT_LAG_THRESHOLD,
56
+ ) -> None:
57
+ self._loop = loop
58
+ self._ping_interval = ping_interval
59
+ self._lag_threshold = lag_threshold
60
+ self._stop = threading.Event()
61
+ self._thread: threading.Thread | None = None
62
+ self._loop_thread_id: int | None = None
63
+ self.stalls: list[Stall] = []
64
+
65
+ def start(self) -> None:
66
+ # Learn which thread the loop runs on, so the snapshot targets the right stack.
67
+ done = threading.Event()
68
+
69
+ def note_thread() -> None:
70
+ self._loop_thread_id = threading.get_ident()
71
+ done.set()
72
+
73
+ self._loop.call_soon_threadsafe(note_thread)
74
+ done.wait(timeout=1.0)
75
+
76
+ self._thread = threading.Thread(target=self._run, name="blockpath-watchdog", daemon=True)
77
+ self._thread.start()
78
+
79
+ def stop(self) -> None:
80
+ self._stop.set()
81
+ if self._thread is not None:
82
+ self._thread.join(timeout=1.0)
83
+ self._thread = None
84
+
85
+ def __enter__(self) -> LoopWatchdog:
86
+ self.start()
87
+ return self
88
+
89
+ def __exit__(self, *exc: object) -> None:
90
+ self.stop()
91
+
92
+ def _run(self) -> None:
93
+ while not self._stop.is_set():
94
+ answered = threading.Event()
95
+ sent = time.perf_counter()
96
+ try:
97
+ self._loop.call_soon_threadsafe(answered.set)
98
+ except RuntimeError:
99
+ return # loop closed underneath us; nothing left to watch
100
+ if not answered.wait(self._lag_threshold):
101
+ # The loop owes us a reply and has not sent it: it is blocked right now, so this
102
+ # is the one moment the offending frame is still on its stack.
103
+ stack = self._snapshot()
104
+ answered.wait(timeout=30.0)
105
+ self.stalls.append(Stall(duration=time.perf_counter() - sent, stack=stack))
106
+ self._stop.wait(self._ping_interval)
107
+
108
+ def _snapshot(self) -> tuple[str, ...]:
109
+ if self._loop_thread_id is None:
110
+ return ()
111
+ frame = sys._current_frames().get(self._loop_thread_id)
112
+ if frame is None:
113
+ return ()
114
+ return tuple(
115
+ f"{entry.filename}:{entry.lineno} {entry.name}"
116
+ for entry in traceback.extract_stack(frame)
117
+ )