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/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)