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/symbols.py ADDED
@@ -0,0 +1,284 @@
1
+ """Layer 2: what each name means, at module scope and inside every function.
2
+
3
+ Two questions the resolver cannot answer without this layer:
4
+
5
+ * **At module scope**, is ``sleep`` a function defined here, a name imported from ``time``, or
6
+ a plain assignment? Built from the AST: ``top_level`` maps each top-level name to a
7
+ :class:`Definition` or an :class:`ImportBinding`, and a name bound two incompatible ways is
8
+ marked ambiguous so the resolver refuses to guess.
9
+
10
+ * **Inside a function**, is ``sleep`` a *local* thing (a parameter, an assignment) that shadows
11
+ the module ``sleep``, or a *local import* (``def h(): import time``) that should resolve, or a
12
+ reference to the outer name? This is where reimplementing Python's scoping rules would be a
13
+ mistake, so we ask the standard library: ``symtable`` classifies every name in a scope as
14
+ local / imported / global / free / parameter, and gets the corners right -- ``global x`` inside
15
+ a function refers to the module ``x``, an inlined comprehension target is local, a nested
16
+ ``def`` name is local. We keep only the classification and read the actual import *targets*
17
+ back from the AST, since ``symtable`` does not carry them.
18
+
19
+ Scopes are keyed by ``(name, lineno)`` -- unique for real functions and classes; the only
20
+ collisions are several lambdas or genexprs on one physical line, which are marked ambiguous and
21
+ handled conservatively (treat every name as a shadow, i.e. resolve nothing there -- the safe
22
+ direction). A ``def f[T]()`` generic adds a ``type parameter`` wrapper scope in ``symtable``; we
23
+ skip the wrapper and key the real function scope.
24
+ """
25
+
26
+ from __future__ import annotations
27
+
28
+ import ast
29
+ import symtable
30
+ from dataclasses import dataclass
31
+ from typing import Literal
32
+
33
+ from blockpath.collect import Module
34
+
35
+ #: How Python names a lambda / generator-expression scope in symtable.
36
+ _LAMBDA = "lambda"
37
+ _GENEXPR = "genexpr"
38
+
39
+ #: A node that introduces a function scope.
40
+ ScopeNode = ast.FunctionDef | ast.AsyncFunctionDef | ast.Lambda
41
+ #: A ``def``/``async def`` (a lambda has no statement body).
42
+ FuncNode = ast.FunctionDef | ast.AsyncFunctionDef
43
+
44
+
45
+ @dataclass(frozen=True, slots=True)
46
+ class Definition:
47
+ """A function or class defined at module top level."""
48
+
49
+ qualname: str
50
+ kind: Literal["function", "class"]
51
+
52
+
53
+ @dataclass(frozen=True, slots=True)
54
+ class ImportBinding:
55
+ """A top-level name bound by an import, pointing at a dotted target (possibly external)."""
56
+
57
+ target: str
58
+
59
+
60
+ Symbol = Definition | ImportBinding
61
+
62
+
63
+ @dataclass(frozen=True, slots=True)
64
+ class ModuleSymbols:
65
+ """The top-level name table of one module."""
66
+
67
+ module: Module
68
+ top_level: dict[str, Symbol]
69
+ ambiguous: frozenset[str]
70
+ has_star_import: bool
71
+
72
+
73
+ @dataclass(frozen=True, slots=True)
74
+ class ScopeInfo:
75
+ """What is bound locally inside one function scope."""
76
+
77
+ #: name -> dotted import target, for imports written in this function's own body
78
+ local_imports: dict[str, str]
79
+ #: names bound locally that are not imports: parameters, assignments, nested defs, ...
80
+ shadows: frozenset[str]
81
+ #: when True, treat *every* callee name as shadowed and resolve nothing. Used when the scope
82
+ #: could not be mapped unambiguously, so guessing would risk a fabricated edge.
83
+ shadow_all: bool = False
84
+
85
+
86
+ #: A scope whose symtable mapping was ambiguous or missing: resolve nothing through it (shadow
87
+ #: everything). Conservative -- an undercount, never a false finding.
88
+ _OPAQUE_SCOPE = ScopeInfo(local_imports={}, shadows=frozenset(), shadow_all=True)
89
+
90
+
91
+ def _relative_base(module: Module, level: int) -> str | None:
92
+ """The package a ``from . import x`` at ``level`` dots resolves against, or None if it walks
93
+ above the top package (which Python rejects as 'beyond top-level package')."""
94
+ parts = module.qualname.split(".") if module.qualname else []
95
+ if not module.is_package:
96
+ parts = parts[:-1] # a module resolves relatives against its containing package
97
+ up = level - 1
98
+ if up >= len(parts):
99
+ return None # one more dot than there are package components: beyond the top
100
+ if up:
101
+ parts = parts[:-up]
102
+ return ".".join(parts)
103
+
104
+
105
+ def import_bindings(stmt: ast.Import | ast.ImportFrom, module: Module) -> list[tuple[str, str]]:
106
+ """Names an import binds, each with its dotted target. ``("*", "*")`` signals a star import."""
107
+ out: list[tuple[str, str]] = []
108
+ if isinstance(stmt, ast.Import):
109
+ for alias in stmt.names:
110
+ if alias.asname:
111
+ out.append((alias.asname, alias.name))
112
+ else:
113
+ top = alias.name.split(".")[0]
114
+ out.append((top, top))
115
+ return out
116
+ if stmt.level:
117
+ base = _relative_base(module, stmt.level)
118
+ if base is None:
119
+ return out
120
+ prefix = f"{base}.{stmt.module}" if stmt.module else base
121
+ else:
122
+ prefix = stmt.module or ""
123
+ if not prefix:
124
+ return out
125
+ for alias in stmt.names:
126
+ if alias.name == "*":
127
+ out.append(("*", "*"))
128
+ else:
129
+ out.append((alias.asname or alias.name, f"{prefix}.{alias.name}"))
130
+ return out
131
+
132
+
133
+ def _build_top_level(module: Module) -> ModuleSymbols:
134
+ seen: dict[str, list[Symbol]] = {}
135
+ last_bind_line: dict[str, int] = {}
136
+ deleted: set[str] = set()
137
+ star_lines: list[int] = []
138
+
139
+ def bind(name: str, sym: Symbol, line: int) -> None:
140
+ seen.setdefault(name, []).append(sym)
141
+ last_bind_line[name] = max(last_bind_line.get(name, 0), line)
142
+
143
+ for stmt in module.tree.body:
144
+ match stmt:
145
+ case ast.FunctionDef() | ast.AsyncFunctionDef():
146
+ bind(
147
+ stmt.name, Definition(f"{module.qualname}.{stmt.name}", "function"), stmt.lineno
148
+ )
149
+ case ast.ClassDef():
150
+ bind(stmt.name, Definition(f"{module.qualname}.{stmt.name}", "class"), stmt.lineno)
151
+ case ast.Import() | ast.ImportFrom():
152
+ for name, target in import_bindings(stmt, module):
153
+ if name == "*":
154
+ star_lines.append(stmt.lineno)
155
+ else:
156
+ bind(name, ImportBinding(target), stmt.lineno)
157
+ case ast.Assign() | ast.AnnAssign() | ast.AugAssign():
158
+ targets = stmt.targets if isinstance(stmt, ast.Assign) else [stmt.target]
159
+ for tgt in targets:
160
+ if isinstance(tgt, ast.Name):
161
+ bind(tgt.id, ImportBinding(""), stmt.lineno) # opaque module-level rebind
162
+ case ast.Delete():
163
+ for tgt in stmt.targets:
164
+ if isinstance(tgt, ast.Name):
165
+ deleted.add(tgt.id) # `del name` removes the top-level binding
166
+ case _:
167
+ pass
168
+
169
+ has_star = bool(star_lines)
170
+ last_star = max(star_lines) if star_lines else -1
171
+
172
+ top_level: dict[str, Symbol] = {}
173
+ ambiguous: set[str] = set()
174
+ for name, syms in seen.items():
175
+ variants = {_symbol_key(s) for s in syms}
176
+ first = syms[0]
177
+ idempotent_import = (
178
+ len(variants) == 1 and isinstance(first, ImportBinding) and bool(first.target)
179
+ )
180
+ # A `from x import *` executed after a name is bound can silently overwrite it, and we do
181
+ # not expand stars (ADR 0006), so such a name is ambiguous, not definite.
182
+ overridden_by_star = has_star and last_bind_line[name] < last_star
183
+ if name in deleted:
184
+ ambiguous.add(name) # bound then `del`eted: at runtime the name is gone
185
+ elif (len(syms) > 1 and not idempotent_import) or overridden_by_star:
186
+ ambiguous.add(name)
187
+ elif isinstance(first, ImportBinding) and not first.target:
188
+ ambiguous.add(name) # a bare module-level assignment: not resolvable, do not follow
189
+ else:
190
+ top_level[name] = first
191
+ return ModuleSymbols(module, top_level, frozenset(ambiguous), has_star)
192
+
193
+
194
+ def _symbol_key(sym: Symbol) -> tuple[str, str]:
195
+ if isinstance(sym, Definition):
196
+ return ("def", sym.qualname + sym.kind)
197
+ return ("import", sym.target)
198
+
199
+
200
+ class ModuleScopes:
201
+ """Per-function scope classification for one module, backed by symtable."""
202
+
203
+ def __init__(self, module: Module) -> None:
204
+ self._module = module
205
+ self._by_key: dict[tuple[str, int], symtable.SymbolTable] = {}
206
+ self._ambiguous_keys: set[tuple[str, int]] = set()
207
+ self.desynced = False
208
+ try:
209
+ top = symtable.symtable(module.source, module.relpath, "exec")
210
+ except (SyntaxError, ValueError):
211
+ self.desynced = True
212
+ return
213
+ self._index(top)
214
+
215
+ def _index(self, scope: symtable.SymbolTable) -> None:
216
+ for child in scope.get_children():
217
+ kind = child.get_type()
218
+ if kind == "type parameter":
219
+ self._index(child) # skip the PEP 695 wrapper, register its inner scope
220
+ continue
221
+ if kind in ("function", "class"):
222
+ key = (child.get_name(), child.get_lineno())
223
+ if key in self._by_key:
224
+ self._ambiguous_keys.add(key)
225
+ self._by_key[key] = child
226
+ self._index(child)
227
+
228
+ def info(self, node: ScopeNode) -> ScopeInfo:
229
+ if self.desynced:
230
+ return _OPAQUE_SCOPE
231
+ name = _LAMBDA if isinstance(node, ast.Lambda) else node.name
232
+ key = (name, node.lineno)
233
+ if key in self._ambiguous_keys or key not in self._by_key:
234
+ return _OPAQUE_SCOPE
235
+ scope = self._by_key[key]
236
+ # A name shadows the module if it is bound locally (but not as an import, which we
237
+ # resolve) OR is free -- closing over an enclosing function scope. In Python's LEGB, an
238
+ # enclosing binding beats the module global, so a free variable is not the module name.
239
+ shadows = frozenset(
240
+ sym.get_name()
241
+ for sym in scope.get_symbols()
242
+ if (sym.is_local() and not sym.is_imported()) or sym.is_free()
243
+ )
244
+ return ScopeInfo(local_imports=self._local_imports(node), shadows=shadows)
245
+
246
+ def _local_imports(self, node: ScopeNode) -> dict[str, str]:
247
+ if isinstance(node, ast.Lambda):
248
+ return {} # a lambda body is a single expression: no import statements
249
+ out: dict[str, str] = {}
250
+ for stmt in _scope_import_stmts(node):
251
+ for name, target in import_bindings(stmt, self._module):
252
+ if name != "*":
253
+ out[name] = target
254
+ return out
255
+
256
+
257
+ def _scope_import_stmts(fn: FuncNode) -> list[ast.Import | ast.ImportFrom]:
258
+ """Import statements executed in ``fn``'s scope, descending into ``if``/``try``/``with``/loops
259
+ but never into a nested function, class, or lambda (whose imports are local to *them*)."""
260
+ out: list[ast.Import | ast.ImportFrom] = []
261
+
262
+ def visit(node: ast.AST) -> None:
263
+ if isinstance(node, ast.Import | ast.ImportFrom):
264
+ out.append(node)
265
+ return
266
+ if isinstance(node, ast.FunctionDef | ast.AsyncFunctionDef | ast.ClassDef | ast.Lambda):
267
+ return # nested scope: pruned
268
+ for child in ast.iter_child_nodes(node):
269
+ visit(child)
270
+
271
+ for stmt in fn.body:
272
+ visit(stmt)
273
+ return out
274
+
275
+
276
+ @dataclass(slots=True)
277
+ class Analysis:
278
+ symbols: ModuleSymbols
279
+ scopes: ModuleScopes
280
+
281
+
282
+ def analyze(module: Module) -> Analysis:
283
+ """Top-level symbols plus per-function scope classification for one module."""
284
+ return Analysis(symbols=_build_top_level(module), scopes=ModuleScopes(module))
@@ -0,0 +1,180 @@
1
+ Metadata-Version: 2.4
2
+ Name: blockpath
3
+ Version: 0.1.0
4
+ Summary: Find blocking calls reachable from a coroutine, and print the whole call path across files and modules.
5
+ Project-URL: Repository, https://github.com/iraettae/blockpath
6
+ Project-URL: Issues, https://github.com/iraettae/blockpath/issues
7
+ Author: Damir
8
+ License-Expression: MIT
9
+ License-File: LICENSE
10
+ Keywords: asyncio,blocking,call-graph,event-loop,linter,static-analysis
11
+ Classifier: Development Status :: 3 - Alpha
12
+ Classifier: Environment :: Console
13
+ Classifier: Framework :: AsyncIO
14
+ Classifier: Intended Audience :: Developers
15
+ Classifier: License :: OSI Approved :: MIT License
16
+ Classifier: Programming Language :: Python :: 3.12
17
+ Classifier: Programming Language :: Python :: 3.13
18
+ Classifier: Topic :: Software Development :: Quality Assurance
19
+ Classifier: Typing :: Typed
20
+ Requires-Python: >=3.12
21
+ Requires-Dist: click>=8.1
22
+ Requires-Dist: pathspec>=0.12
23
+ Requires-Dist: pyyaml>=6.0
24
+ Requires-Dist: rich>=13.7
25
+ Description-Content-Type: text/markdown
26
+
27
+ # blockpath
28
+
29
+ [![ci](https://github.com/iraettae/blockpath/actions/workflows/ci.yml/badge.svg)](https://github.com/iraettae/blockpath/actions/workflows/ci.yml)
30
+ [![python](https://img.shields.io/badge/python-3.12%2B-blue)](https://www.python.org/downloads/)
31
+
32
+ Find the synchronous call that stalls your event loop, and print the whole path to it: from the
33
+ `async` handler down through `services/` and `clients/` to the blocking `requests.get`, across as
34
+ many files and modules as it takes.
35
+
36
+ Existing linters catch a blocking call only when it sits directly in the `async def` body. In
37
+ real code it almost never does -- it is three to five calls deep, in another module, behind a
38
+ couple of helpers nobody thinks about. That is where it hides, and that is what blockpath finds.
39
+
40
+ ## The problem is real, and it was measured before a line of the analyzer was written
41
+
42
+ A survey of **57 public aiogram/FastAPI repositories**, with the protocol and thresholds
43
+ [registered in advance](benchmark/PREREGISTRATION.md) so the result could not be steered, asked
44
+ one question: when a coroutine can reach a blocking call, how deep is it?
45
+
46
+ ```
47
+ depth 1 | ######################################## 77 (48.7%) <- a per-file linter sees these
48
+ depth 2 | ############### 28 (17.7%)
49
+ depth 3 | ################ 31 (19.6%)
50
+ depth 4 | ######### 17 (10.8%)
51
+ depth 5 | ## 3 ( 1.9%)
52
+ depth 6 | # 2 ( 1.3%)
53
+
54
+ | 158 reachable blocking call sites
55
+ ```
56
+
57
+ **51% of them sit at depth 2 or deeper**, across module boundaries, where no per-file linter can
58
+ follow. As a cross-check, `ruff --select ASYNC` run on the same repositories flags **0 of those
59
+ 81 deep sites**. The histogram, and the fact that it rebuilds from scratch, is in
60
+ [benchmark/depth_histogram.md](benchmark/depth_histogram.md) (`make bench-depth`).
61
+
62
+ ## What it looks like
63
+
64
+ ![blockpath check finding a blocking call through three files](docs/demo.svg)
65
+
66
+ ```console
67
+ $ blockpath check .
68
+ BLK001 blocking call reachable from a coroutine
69
+ app/handlers/order.py:11 return resolve_address(address) <- entry: async def
70
+ app/services/geo.py:6 return geocode(query)
71
+ app/clients/nominatim.py:7 response = requests.get(BASE_URL, params=...) <- blocks the event loop
72
+ hint: await asyncio.to_thread(resolve_address, ...)
73
+
74
+ 1 finding(s): 1 BLK001
75
+ ```
76
+
77
+ The path through three files is the point: no existing linter prints it.
78
+
79
+ ## Quick start
80
+
81
+ ```bash
82
+ pip install blockpath
83
+ blockpath check .
84
+ blockpath check . --json # for CI
85
+ ```
86
+
87
+ Exit code is `0` when clean, `1` when there are findings (fails a build), `2` if the tool itself
88
+ errored -- so a crash is never mistaken for a clean run.
89
+
90
+ Optional configuration, in your own `pyproject.toml`:
91
+
92
+ ```toml
93
+ [tool.blockpath]
94
+ tiers = ["error"] # error (default) | warning | off
95
+ entry-decorators = ["router.message"] # mark framework handlers as entry points
96
+ strict = false # also report blocking calls handed to unresolved callees
97
+ ```
98
+
99
+ ## How it works
100
+
101
+ Six layers, each a small tested unit, and one idea that carries the whole thing.
102
+
103
+ ```
104
+ collect -> symbols -> resolve -> callgraph -> oracle -> analysis
105
+ (walk & (names & (name -> (CALL/REF (what (three BFS,
106
+ parse) scopes) function) edges) blocks) the witness)
107
+ ```
108
+
109
+ The idea is that a finding is the **intersection of two independent reachability questions**, and
110
+ that intersection is why the tool stays quiet:
111
+
112
+ 1. **Backward** from every blocking leaf: which functions can reach a blocking call at all?
113
+ 2. **Forward** from every coroutine: which functions can a coroutine reach?
114
+ 3. A third pass over the intersection recovers the *witness* -- the exact chain of call sites.
115
+
116
+ Report only their intersection and `time.sleep` in a synchronous CLI script in the same repo
117
+ stays silent. Report either half alone and the tool cries wolf and gets switched off. The whole
118
+ analysis is `O(V+E)` -- three breadth-first searches, no Tarjan, no SCC, no fixpoint
119
+ ([ADR 0002](docs/adr/0002-three-bfs-not-tarjan.md)).
120
+
121
+ The call graph is built from the AST **without importing or running your code**, so a project
122
+ that needs a database to import is analysed the same as any other.
123
+
124
+ ## How good is it, honestly
125
+
126
+ Run over five of the surveyed repositories and adjudicated by hand, finding by finding, with a
127
+ second pass auditing the riskiest calls ([benchmark/adjudication.md](benchmark/adjudication.md)):
128
+
129
+ - **precision 110/112 = 98.2%** by the tool's literal claim (a blocking call reachable from a
130
+ coroutine), of which 99 are product-code bugs and 11 are real blocking calls in test code; or
131
+ **99/112 = 88.4%** counting product bugs only. Both numbers are in the table, and each of the
132
+ two false positives is explained with a permalink.
133
+ - **recall is a lower bound, never a point estimate.** The runtime verifier (`blockpath verify --
134
+ pytest`) records which functions actually ran on the loop and compares them to the static
135
+ findings, but it only sees the paths the tests exercised. It can prove a miss and can never
136
+ prove completeness, and it says nothing about precision -- only the hand adjudication does.
137
+
138
+ Precision is not a number the analyzer asserts about itself; it is what survived a human reading
139
+ each finding against the source.
140
+
141
+ ## Design decisions, and where it loses
142
+
143
+ - **Only calls by name are resolved.** An attribute call on a value (`self.session.get()`) needs
144
+ type inference, which this does not do; the cost is measured (about half of all call sites on
145
+ one benchmark repo) rather than waved away ([ADR 0003](docs/adr/0003-attribute-calls-not-resolved.md)).
146
+ - **A reference is not a call.** A function passed to `run_in_executor(None, f)` runs in a
147
+ thread, so it is silent by construction -- no offload false positives -- while
148
+ `run_in_executor(None, f())` with parentheses is a real bug (BLK002)
149
+ ([ADR 0007](docs/adr/0007-call-edges-only-ref-not-traversed.md)).
150
+ - **Unknown is quiet by default.** A path with an unresolved edge is not reported unless you ask
151
+ with `--strict` (BLK003) ([ADR 0006](docs/adr/0006-reexport-and-star-import-policy.md)).
152
+ - The [complete list of limitations](docs/limitations.md) is written as they were found, not at
153
+ the end.
154
+
155
+ ### Prior art
156
+
157
+ - **ruff `ASYNC` rules** are written in Rust and run hundreds of times faster than this. They are
158
+ the right tool for the depth-1 case, and blockpath does not try to compete on speed. They work
159
+ per file, by an explicit architectural choice (parallel analysis without global state), so they
160
+ do not build a cross-module call graph and cannot print the path -- which is the entire reason
161
+ this exists. The 0-of-81 cross-check above is that difference, measured.
162
+ - **flake8-async** is likewise per-file and does not follow calls between modules.
163
+
164
+ blockpath is not a replacement for either; it is the cross-module analysis they deliberately do
165
+ not do.
166
+
167
+ ## Development
168
+
169
+ ```bash
170
+ uv sync --all-extras
171
+ make check # ruff, mypy --strict, pytest
172
+ make bench-depth # rebuild the depth histogram from the pinned corpus
173
+ ./scripts/reproduce_benchmark.sh # rebuild the precision findings
174
+ ```
175
+
176
+ This project is AI-assisted: written by a human, who made the product and architecture decisions
177
+ and signed off at every checkpoint, with an AI pair implementing under direction. [WORKLOG.md](WORKLOG.md)
178
+ keeps the estimate-versus-actual log from day one, including where the estimates were wrong.
179
+
180
+ Python 3.12+ (it uses `sys.monitoring`, [ADR 0001](docs/adr/0001-python-312-only.md)). MIT licensed.
@@ -0,0 +1,24 @@
1
+ blockpath/__init__.py,sha256=Z-ehpFXhOEezlzBADOTc5oEc66PlJ56C44aX2hor5rc,601
2
+ blockpath/analysis.py,sha256=Rfpac1ZdSmgD1ELQ-MBbmYrhBC0DFji54m93wO0QjaQ,12665
3
+ blockpath/callgraph.py,sha256=WDCbkUZH7jVYpgDqq2k6eML0njRlu3zWy1RqkLCmnxQ,11933
4
+ blockpath/check.py,sha256=IHMx2d1oor75V8pS5VO8U3UHq-_k1IbIhxoRWRwamIE,1502
5
+ blockpath/cli.py,sha256=Rphiv0eDNeM-4rwFRX-QkIn7FHX8nTA46OY6j-BUKhQ,5352
6
+ blockpath/collect.py,sha256=H2kvjCfpFWUtwrAv5ybeOoAGi4KxaO6TI7bbSLrquuI,7367
7
+ blockpath/config.py,sha256=PlgolhAt4nGWGr8QEzFHAuF02yRBs3OxbUguKU5p2vw,3000
8
+ blockpath/model.py,sha256=HV3RAu4kMe5wwsB8BMpz2XE4ZKSxpQ8uoCtAc_Lxzgg,3680
9
+ blockpath/oracle.py,sha256=6qFJgAPQSFYkB1pf9Ax6lE_H936mXsjmUTGZsaJNe0w,5418
10
+ blockpath/oracle.yaml,sha256=8Vx9GONVCZIB36mc8W79yD-Is4dJw6kgDcKSV2EW2mE,7096
11
+ blockpath/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
12
+ blockpath/report.py,sha256=N3bIq1WdQpJdBSoDCoxhnIKYyxl1s6o0l2IU0TWNV24,5114
13
+ blockpath/resolve.py,sha256=777qsYtcmNhXbEm2X60nhCp0nb3zslH78PKVm5rqpEs,7377
14
+ blockpath/symbols.py,sha256=tIY247bPNRiTRcW1TFaI8nBQQoZCoR9Mf14f3kt4Ajc,11619
15
+ blockpath/runtime/__init__.py,sha256=NdD3fEJObRg7rSQjNJaNET6oM_Ny4AJ4kp1hFGZU_hg,1201
16
+ blockpath/runtime/monitor.py,sha256=7-q8BdH6WhVRAc4jQjZpH6HZZ1tsRnr601xFEXaC5NM,5868
17
+ blockpath/runtime/predicate.py,sha256=TgkzxaVqnB5LABs2hpVTTZHA0Di84dTWpZUkzdvPGcE,1782
18
+ blockpath/runtime/verify.py,sha256=4na-QZ4ybMA2oFOIDP0vpGieOLWcPim_Ru_d-hGKJfE,6784
19
+ blockpath/runtime/watchdog.py,sha256=I7iyGEH-KO395OxWTcRGpNxE_BcXrsFytJzvlHNeq-U,4408
20
+ blockpath-0.1.0.dist-info/METADATA,sha256=ktb_SMES4zTAdyGliIu1kH3FpQheJgQMjaNVKAehg3o,8767
21
+ blockpath-0.1.0.dist-info/WHEEL,sha256=lCkmxWfQsSc9CfIClYeavTdQeEX2toPqufh9gI35EQA,87
22
+ blockpath-0.1.0.dist-info/entry_points.txt,sha256=YS-kJTUwO3fttQt9HpRR5G6iJZwKgmKoJHEyDwofSkE,49
23
+ blockpath-0.1.0.dist-info/licenses/LICENSE,sha256=Q7ngUffbbZwscUKxWkTT4rIlx8HUsW5Z27spnkTNsJQ,1062
24
+ blockpath-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.31.0
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ blockpath = blockpath.cli:main
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Damir
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.