astblock 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.
- astblock/__init__.py +29 -0
- astblock/__main__.py +168 -0
- astblock/_blocklist.py +145 -0
- astblock/_errors.py +19 -0
- astblock/_fingerprint.py +135 -0
- astblock/_hook.py +111 -0
- astblock/_runtime.py +43 -0
- astblock/_transform.py +76 -0
- astblock/py.typed +0 -0
- astblock-0.1.0.dist-info/METADATA +148 -0
- astblock-0.1.0.dist-info/RECORD +14 -0
- astblock-0.1.0.dist-info/WHEEL +4 -0
- astblock-0.1.0.dist-info/entry_points.txt +2 -0
- astblock-0.1.0.dist-info/licenses/LICENSE +21 -0
astblock/__init__.py
ADDED
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
"""astblock: block individual statements from running, using an AST-fingerprint blocklist."""
|
|
2
|
+
|
|
3
|
+
from ._blocklist import ACTIONS, Blocklist, Rule
|
|
4
|
+
from ._errors import BlockedStatementError, BlocklistError
|
|
5
|
+
from ._fingerprint import Statement, find_statements, fingerprint_source
|
|
6
|
+
from ._hook import ENV_VAR, install, install_from_env, is_installed, uninstall
|
|
7
|
+
from ._runtime import hits, reset_hits
|
|
8
|
+
from ._transform import compile_with_blocklist
|
|
9
|
+
|
|
10
|
+
__version__ = "0.1.0"
|
|
11
|
+
|
|
12
|
+
__all__ = [
|
|
13
|
+
"ACTIONS",
|
|
14
|
+
"ENV_VAR",
|
|
15
|
+
"BlockedStatementError",
|
|
16
|
+
"Blocklist",
|
|
17
|
+
"BlocklistError",
|
|
18
|
+
"Rule",
|
|
19
|
+
"Statement",
|
|
20
|
+
"compile_with_blocklist",
|
|
21
|
+
"find_statements",
|
|
22
|
+
"fingerprint_source",
|
|
23
|
+
"hits",
|
|
24
|
+
"install",
|
|
25
|
+
"install_from_env",
|
|
26
|
+
"is_installed",
|
|
27
|
+
"reset_hits",
|
|
28
|
+
"uninstall",
|
|
29
|
+
]
|
astblock/__main__.py
ADDED
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
"""Command-line interface.
|
|
2
|
+
|
|
3
|
+
python -m astblock list myapp.billing [--line 42]
|
|
4
|
+
python -m astblock check blocklist.json
|
|
5
|
+
python -m astblock run --blocklist blocklist.json -m myapp [args...]
|
|
6
|
+
python -m astblock run --blocklist blocklist.json script.py [args...]
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import argparse
|
|
12
|
+
import builtins
|
|
13
|
+
import importlib.util
|
|
14
|
+
import json
|
|
15
|
+
import os
|
|
16
|
+
import runpy
|
|
17
|
+
import sys
|
|
18
|
+
import types
|
|
19
|
+
|
|
20
|
+
from ._blocklist import ACTIONS, Blocklist
|
|
21
|
+
from ._errors import BlocklistError
|
|
22
|
+
from ._fingerprint import fingerprint_source
|
|
23
|
+
from ._hook import ENV_VAR, install
|
|
24
|
+
from ._transform import compile_with_blocklist
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def _looks_like_path(target: str) -> bool:
|
|
28
|
+
return target.endswith(".py") or os.sep in target or (os.altsep or os.sep) in target
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def _locate(target: str, module: str | None) -> tuple[str, str]:
|
|
32
|
+
"""Return (module_name, source_path) for a module name or a file path."""
|
|
33
|
+
if _looks_like_path(target):
|
|
34
|
+
return module or "__main__", target
|
|
35
|
+
spec = importlib.util.find_spec(target)
|
|
36
|
+
if spec is None or not spec.origin or not spec.origin.endswith(".py"):
|
|
37
|
+
raise LookupError(f"cannot find Python source for module {target!r}")
|
|
38
|
+
return module or target, spec.origin
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def _read(path: str) -> bytes:
|
|
42
|
+
with open(path, "rb") as handle:
|
|
43
|
+
return handle.read()
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def _first_line(source_lines: list[str], lineno: int, width: int = 60) -> str:
|
|
47
|
+
text = source_lines[lineno - 1].strip() if 0 < lineno <= len(source_lines) else ""
|
|
48
|
+
return text if len(text) <= width else text[: width - 3] + "..."
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def cmd_list(args: argparse.Namespace) -> int:
|
|
52
|
+
module, path = _locate(args.target, args.module)
|
|
53
|
+
source = _read(path)
|
|
54
|
+
statements = fingerprint_source(source, module, path)
|
|
55
|
+
lines = source.decode("utf-8", errors="replace").splitlines()
|
|
56
|
+
if args.line is not None:
|
|
57
|
+
statements = [s for s in statements if s.lineno == args.line]
|
|
58
|
+
if args.json:
|
|
59
|
+
rules = [{"module": s.module, "fingerprint": s.fingerprint, "action": args.action}
|
|
60
|
+
for s in statements]
|
|
61
|
+
print(json.dumps({"version": 1, "rules": rules}, indent=2))
|
|
62
|
+
return 0
|
|
63
|
+
print(f"# module: {module} file: {path}")
|
|
64
|
+
if module == "__main__":
|
|
65
|
+
print("# (pass --module NAME if this file is imported rather than run as a script)")
|
|
66
|
+
for s in statements:
|
|
67
|
+
print(f"{s.lineno:>5} {s.fingerprint} {(s.scope or '<module>'):<24} "
|
|
68
|
+
f"{_first_line(lines, s.lineno)}")
|
|
69
|
+
return 0
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def cmd_check(args: argparse.Namespace) -> int:
|
|
73
|
+
blocklist = Blocklist.load(args.blocklist)
|
|
74
|
+
problems = 0
|
|
75
|
+
for name in sorted(blocklist.modules):
|
|
76
|
+
try:
|
|
77
|
+
module, path = _locate(name, None) if name != "__main__" else (None, None)
|
|
78
|
+
except (LookupError, ImportError) as exc:
|
|
79
|
+
print(f"MISSING {name}: {exc}")
|
|
80
|
+
problems += 1
|
|
81
|
+
continue
|
|
82
|
+
if path is None:
|
|
83
|
+
print(f"SKIP __main__: rules for scripts can't be checked by module name")
|
|
84
|
+
continue
|
|
85
|
+
source = _read(path)
|
|
86
|
+
lines = source.decode("utf-8", errors="replace").splitlines()
|
|
87
|
+
found = {s.fingerprint: s for s in fingerprint_source(source, module, path)}
|
|
88
|
+
for fingerprint, rule in blocklist.rules_for(name).items():
|
|
89
|
+
statement = found.get(fingerprint)
|
|
90
|
+
if statement is None:
|
|
91
|
+
print(f"STALE {name} {fingerprint}: matches no statement")
|
|
92
|
+
problems += 1
|
|
93
|
+
else:
|
|
94
|
+
print(f"OK {name} {fingerprint} [{rule.action}] line "
|
|
95
|
+
f"{statement.lineno}: {_first_line(lines, statement.lineno)}")
|
|
96
|
+
return 1 if problems else 0
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def _run_script(path: str, argv: list[str], blocklist: Blocklist) -> None:
|
|
100
|
+
path = os.path.abspath(path)
|
|
101
|
+
code = compile_with_blocklist(_read(path), path, "__main__", blocklist)
|
|
102
|
+
main = types.ModuleType("__main__")
|
|
103
|
+
main.__file__ = path
|
|
104
|
+
main.__builtins__ = builtins
|
|
105
|
+
sys.modules["__main__"] = main
|
|
106
|
+
sys.argv = [path, *argv]
|
|
107
|
+
sys.path[0] = os.path.dirname(path)
|
|
108
|
+
exec(code, main.__dict__)
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def cmd_run(args: argparse.Namespace) -> int:
|
|
112
|
+
source = args.blocklist or os.environ.get(ENV_VAR)
|
|
113
|
+
if not source:
|
|
114
|
+
print(f"astblock run: pass --blocklist or set {ENV_VAR}", file=sys.stderr)
|
|
115
|
+
return 2
|
|
116
|
+
blocklist = install(source)
|
|
117
|
+
rest = list(args.args)
|
|
118
|
+
if args.module:
|
|
119
|
+
if args.script is not None:
|
|
120
|
+
rest.insert(0, args.script)
|
|
121
|
+
sys.argv = [args.module, *rest]
|
|
122
|
+
runpy.run_module(args.module, run_name="__main__", alter_sys=True)
|
|
123
|
+
elif args.script:
|
|
124
|
+
_run_script(args.script, rest, blocklist)
|
|
125
|
+
else:
|
|
126
|
+
print("astblock run: give a script path or -m MODULE", file=sys.stderr)
|
|
127
|
+
return 2
|
|
128
|
+
return 0
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
def build_parser() -> argparse.ArgumentParser:
|
|
132
|
+
parser = argparse.ArgumentParser(prog="python -m astblock",
|
|
133
|
+
description="Block individual statements from running.")
|
|
134
|
+
sub = parser.add_subparsers(dest="command", required=True)
|
|
135
|
+
|
|
136
|
+
p_list = sub.add_parser("list", help="show statements and their fingerprints")
|
|
137
|
+
p_list.add_argument("target", help="module name (myapp.billing) or path to a .py file")
|
|
138
|
+
p_list.add_argument("--module", help="module name to fingerprint a file path as")
|
|
139
|
+
p_list.add_argument("--line", type=int, help="only statements starting on this line")
|
|
140
|
+
p_list.add_argument("--json", action="store_true", help="print as blocklist JSON")
|
|
141
|
+
p_list.add_argument("--action", choices=ACTIONS, default="raise",
|
|
142
|
+
help="action to use with --json (default: raise)")
|
|
143
|
+
p_list.set_defaults(func=cmd_list)
|
|
144
|
+
|
|
145
|
+
p_check = sub.add_parser("check", help="verify every rule still matches the code")
|
|
146
|
+
p_check.add_argument("blocklist")
|
|
147
|
+
p_check.set_defaults(func=cmd_check)
|
|
148
|
+
|
|
149
|
+
p_run = sub.add_parser("run", help="run a script or module with a blocklist applied")
|
|
150
|
+
p_run.add_argument("--blocklist", help=f"blocklist JSON file (default: ${ENV_VAR})")
|
|
151
|
+
p_run.add_argument("-m", dest="module", help="run a module, like python -m")
|
|
152
|
+
p_run.add_argument("script", nargs="?")
|
|
153
|
+
p_run.add_argument("args", nargs=argparse.REMAINDER)
|
|
154
|
+
p_run.set_defaults(func=cmd_run)
|
|
155
|
+
return parser
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
def main(argv: list[str] | None = None) -> int:
|
|
159
|
+
args = build_parser().parse_args(argv)
|
|
160
|
+
try:
|
|
161
|
+
return args.func(args)
|
|
162
|
+
except (BlocklistError, LookupError, OSError, SyntaxError) as exc:
|
|
163
|
+
print(f"astblock: {exc}", file=sys.stderr)
|
|
164
|
+
return 2
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
if __name__ == "__main__":
|
|
168
|
+
sys.exit(main())
|
astblock/_blocklist.py
ADDED
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
"""Blocklist rules and their JSON file format.
|
|
2
|
+
|
|
3
|
+
Example file::
|
|
4
|
+
|
|
5
|
+
{
|
|
6
|
+
"version": 1,
|
|
7
|
+
"rules": [
|
|
8
|
+
{
|
|
9
|
+
"module": "myapp.billing",
|
|
10
|
+
"fingerprint": "3f9a0c1d2e4b5a67",
|
|
11
|
+
"action": "skip",
|
|
12
|
+
"reason": "INC-1234: duplicate charge email"
|
|
13
|
+
}
|
|
14
|
+
]
|
|
15
|
+
}
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
from __future__ import annotations
|
|
19
|
+
|
|
20
|
+
import json
|
|
21
|
+
import os
|
|
22
|
+
import re
|
|
23
|
+
from dataclasses import asdict, dataclass
|
|
24
|
+
from typing import Iterable, Iterator, Mapping
|
|
25
|
+
|
|
26
|
+
from ._errors import BlocklistError
|
|
27
|
+
from ._fingerprint import FINGERPRINT_LENGTH
|
|
28
|
+
|
|
29
|
+
ACTIONS = ("raise", "skip")
|
|
30
|
+
FILE_VERSION = 1
|
|
31
|
+
|
|
32
|
+
_FINGERPRINT_RE = re.compile(rf"^[0-9a-f]{{{FINGERPRINT_LENGTH}}}$")
|
|
33
|
+
_RULE_KEYS = {"module", "fingerprint", "action", "reason"}
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
@dataclass(frozen=True)
|
|
37
|
+
class Rule:
|
|
38
|
+
"""Block one statement in one module.
|
|
39
|
+
|
|
40
|
+
``action`` is ``"raise"`` (raise BlockedStatementError when reached, the
|
|
41
|
+
default) or ``"skip"`` (do nothing and carry on with the next statement).
|
|
42
|
+
"""
|
|
43
|
+
|
|
44
|
+
module: str
|
|
45
|
+
fingerprint: str
|
|
46
|
+
action: str = "raise"
|
|
47
|
+
reason: str | None = None
|
|
48
|
+
|
|
49
|
+
def __post_init__(self) -> None:
|
|
50
|
+
if not isinstance(self.module, str) or not self.module:
|
|
51
|
+
raise BlocklistError(f"rule module must be a non-empty string, got {self.module!r}")
|
|
52
|
+
if not isinstance(self.fingerprint, str) or not _FINGERPRINT_RE.match(self.fingerprint):
|
|
53
|
+
raise BlocklistError(
|
|
54
|
+
f"rule fingerprint must be {FINGERPRINT_LENGTH} lowercase hex characters, "
|
|
55
|
+
f"got {self.fingerprint!r}"
|
|
56
|
+
)
|
|
57
|
+
if self.action not in ACTIONS:
|
|
58
|
+
raise BlocklistError(f"rule action must be one of {ACTIONS}, got {self.action!r}")
|
|
59
|
+
if self.reason is not None and not isinstance(self.reason, str):
|
|
60
|
+
raise BlocklistError(f"rule reason must be a string, got {self.reason!r}")
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
class Blocklist:
|
|
64
|
+
"""A set of rules, indexed by module and fingerprint."""
|
|
65
|
+
|
|
66
|
+
def __init__(self, rules: Iterable[Rule] = ()) -> None:
|
|
67
|
+
self._by_module: dict[str, dict[str, Rule]] = {}
|
|
68
|
+
for rule in rules:
|
|
69
|
+
self.add(rule)
|
|
70
|
+
|
|
71
|
+
def add(self, rule: Rule) -> None:
|
|
72
|
+
self._by_module.setdefault(rule.module, {})[rule.fingerprint] = rule
|
|
73
|
+
|
|
74
|
+
@property
|
|
75
|
+
def modules(self) -> frozenset[str]:
|
|
76
|
+
return frozenset(self._by_module)
|
|
77
|
+
|
|
78
|
+
def rules_for(self, module: str) -> Mapping[str, Rule]:
|
|
79
|
+
return dict(self._by_module.get(module, {}))
|
|
80
|
+
|
|
81
|
+
def __iter__(self) -> Iterator[Rule]:
|
|
82
|
+
for rules in self._by_module.values():
|
|
83
|
+
yield from rules.values()
|
|
84
|
+
|
|
85
|
+
def __len__(self) -> int:
|
|
86
|
+
return sum(len(rules) for rules in self._by_module.values())
|
|
87
|
+
|
|
88
|
+
def __repr__(self) -> str:
|
|
89
|
+
return f"Blocklist({len(self)} rules in {len(self._by_module)} modules)"
|
|
90
|
+
|
|
91
|
+
# -- serialisation -------------------------------------------------
|
|
92
|
+
|
|
93
|
+
@classmethod
|
|
94
|
+
def from_dict(cls, data: object) -> "Blocklist":
|
|
95
|
+
if not isinstance(data, dict):
|
|
96
|
+
raise BlocklistError("blocklist must be a JSON object")
|
|
97
|
+
unknown = set(data) - {"version", "rules"}
|
|
98
|
+
if unknown:
|
|
99
|
+
raise BlocklistError(f"unknown top-level keys: {sorted(unknown)}")
|
|
100
|
+
if data.get("version", FILE_VERSION) != FILE_VERSION:
|
|
101
|
+
raise BlocklistError(f"unsupported blocklist version {data.get('version')!r}")
|
|
102
|
+
raw_rules = data.get("rules", [])
|
|
103
|
+
if not isinstance(raw_rules, list):
|
|
104
|
+
raise BlocklistError("'rules' must be a list")
|
|
105
|
+
rules = []
|
|
106
|
+
for position, raw in enumerate(raw_rules):
|
|
107
|
+
if not isinstance(raw, dict):
|
|
108
|
+
raise BlocklistError(f"rule #{position} must be an object")
|
|
109
|
+
unknown = set(raw) - _RULE_KEYS
|
|
110
|
+
if unknown:
|
|
111
|
+
raise BlocklistError(f"rule #{position} has unknown keys: {sorted(unknown)}")
|
|
112
|
+
missing = {"module", "fingerprint"} - set(raw)
|
|
113
|
+
if missing:
|
|
114
|
+
raise BlocklistError(f"rule #{position} is missing: {sorted(missing)}")
|
|
115
|
+
try:
|
|
116
|
+
rules.append(Rule(**raw))
|
|
117
|
+
except BlocklistError as exc:
|
|
118
|
+
raise BlocklistError(f"rule #{position}: {exc}") from None
|
|
119
|
+
return cls(rules)
|
|
120
|
+
|
|
121
|
+
@classmethod
|
|
122
|
+
def from_json(cls, text: str) -> "Blocklist":
|
|
123
|
+
try:
|
|
124
|
+
data = json.loads(text)
|
|
125
|
+
except json.JSONDecodeError as exc:
|
|
126
|
+
raise BlocklistError(f"invalid JSON: {exc}") from None
|
|
127
|
+
return cls.from_dict(data)
|
|
128
|
+
|
|
129
|
+
@classmethod
|
|
130
|
+
def load(cls, path: str | os.PathLike[str]) -> "Blocklist":
|
|
131
|
+
try:
|
|
132
|
+
with open(path, encoding="utf-8") as handle:
|
|
133
|
+
text = handle.read()
|
|
134
|
+
except OSError as exc:
|
|
135
|
+
raise BlocklistError(f"cannot read blocklist {os.fspath(path)!r}: {exc}") from None
|
|
136
|
+
return cls.from_json(text)
|
|
137
|
+
|
|
138
|
+
def to_dict(self) -> dict:
|
|
139
|
+
rules = []
|
|
140
|
+
for rule in self:
|
|
141
|
+
entry = asdict(rule)
|
|
142
|
+
if entry["reason"] is None:
|
|
143
|
+
del entry["reason"]
|
|
144
|
+
rules.append(entry)
|
|
145
|
+
return {"version": FILE_VERSION, "rules": rules}
|
astblock/_errors.py
ADDED
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
"""Exception types raised by astblock."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class BlocklistError(ValueError):
|
|
7
|
+
"""Raised when a blocklist is malformed or cannot be loaded."""
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class BlockedStatementError(RuntimeError):
|
|
11
|
+
"""Raised at runtime when execution reaches a statement blocked with action="raise"."""
|
|
12
|
+
|
|
13
|
+
def __init__(self, fingerprint: str, reason: str | None = None) -> None:
|
|
14
|
+
self.fingerprint = fingerprint
|
|
15
|
+
self.reason = reason
|
|
16
|
+
message = f"statement {fingerprint} is blocked by astblock"
|
|
17
|
+
if reason:
|
|
18
|
+
message += f" ({reason})"
|
|
19
|
+
super().__init__(message)
|
astblock/_fingerprint.py
ADDED
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
"""Stable fingerprints for individual statements.
|
|
2
|
+
|
|
3
|
+
A fingerprint identifies one statement by *what it is and where it lives*,
|
|
4
|
+
not by its line number, so it survives reformatting, comment edits and code
|
|
5
|
+
being added above it. It is a hash of:
|
|
6
|
+
|
|
7
|
+
* the module name (``myapp.billing``),
|
|
8
|
+
* the enclosing scope (``Invoice.total`` for a statement inside that method),
|
|
9
|
+
* a canonical serialisation of the statement's AST (no positions),
|
|
10
|
+
* an occurrence index, so identical statements in the same scope differ.
|
|
11
|
+
|
|
12
|
+
Any change to the statement itself, or moving it to another function,
|
|
13
|
+
produces a different fingerprint. That is deliberate: a blocklist rule
|
|
14
|
+
should stop matching when the code it was written against has changed.
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
from __future__ import annotations
|
|
18
|
+
|
|
19
|
+
import ast
|
|
20
|
+
import hashlib
|
|
21
|
+
from dataclasses import dataclass, field
|
|
22
|
+
from typing import Iterator, List
|
|
23
|
+
|
|
24
|
+
FINGERPRINT_VERSION = "1"
|
|
25
|
+
FINGERPRINT_LENGTH = 16
|
|
26
|
+
|
|
27
|
+
_SCOPE_NODES = (ast.FunctionDef, ast.AsyncFunctionDef, ast.ClassDef)
|
|
28
|
+
|
|
29
|
+
# Compiler directives rather than runtime actions; replacing them would change
|
|
30
|
+
# how the surrounding code is compiled, so they are never blockable.
|
|
31
|
+
_UNBLOCKABLE = (ast.Global, ast.Nonlocal)
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
@dataclass(frozen=True)
|
|
35
|
+
class Statement:
|
|
36
|
+
"""One blockable statement found in a module."""
|
|
37
|
+
|
|
38
|
+
fingerprint: str
|
|
39
|
+
module: str
|
|
40
|
+
scope: str
|
|
41
|
+
lineno: int
|
|
42
|
+
end_lineno: int | None
|
|
43
|
+
node: ast.stmt = field(compare=False, repr=False)
|
|
44
|
+
block: List[ast.stmt] = field(compare=False, repr=False)
|
|
45
|
+
index: int = field(compare=False, repr=False)
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def _canon(value: object) -> str:
|
|
49
|
+
"""Serialise an AST without positions, skipping empty/None fields.
|
|
50
|
+
|
|
51
|
+
Skipping empty fields keeps fingerprints stable across Python versions
|
|
52
|
+
that add new optional fields (for example ``type_params`` in 3.12).
|
|
53
|
+
"""
|
|
54
|
+
if isinstance(value, ast.AST):
|
|
55
|
+
parts = []
|
|
56
|
+
for name in value._fields:
|
|
57
|
+
child = getattr(value, name, None)
|
|
58
|
+
if child is None or (isinstance(child, list) and not child):
|
|
59
|
+
continue
|
|
60
|
+
parts.append(f"{name}={_canon(child)}")
|
|
61
|
+
return f"{type(value).__name__}({','.join(parts)})"
|
|
62
|
+
if isinstance(value, list):
|
|
63
|
+
return "[" + ",".join(_canon(item) for item in value) + "]"
|
|
64
|
+
return f"{type(value).__name__}:{value!r}"
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def _hash(module: str, scope: str, canon: str, occurrence: int) -> str:
|
|
68
|
+
payload = "\0".join(
|
|
69
|
+
(f"astblock-v{FINGERPRINT_VERSION}", module, scope, canon, str(occurrence))
|
|
70
|
+
)
|
|
71
|
+
return hashlib.sha256(payload.encode("utf-8")).hexdigest()[:FINGERPRINT_LENGTH]
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def _is_future_import(node: ast.stmt) -> bool:
|
|
75
|
+
return isinstance(node, ast.ImportFrom) and node.module == "__future__"
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def _child_blocks(node: ast.AST) -> Iterator[List[ast.stmt]]:
|
|
79
|
+
"""Yield every statement list directly inside ``node``.
|
|
80
|
+
|
|
81
|
+
Covers ``body``/``orelse``/``finalbody`` and also the bodies of
|
|
82
|
+
``except`` handlers and ``match`` cases, which are not statements
|
|
83
|
+
themselves but contain statement lists.
|
|
84
|
+
"""
|
|
85
|
+
for name in node._fields:
|
|
86
|
+
value = getattr(node, name, None)
|
|
87
|
+
if not isinstance(value, list) or not value:
|
|
88
|
+
continue
|
|
89
|
+
if isinstance(value[0], ast.stmt):
|
|
90
|
+
yield value
|
|
91
|
+
continue
|
|
92
|
+
for item in value:
|
|
93
|
+
body = getattr(item, "body", None)
|
|
94
|
+
if isinstance(body, list) and body and isinstance(body[0], ast.stmt):
|
|
95
|
+
yield body
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def find_statements(tree: ast.Module, module: str) -> list[Statement]:
|
|
99
|
+
"""Return every blockable statement in ``tree``, in source order."""
|
|
100
|
+
found: list[Statement] = []
|
|
101
|
+
seen: dict[tuple[str, str], int] = {}
|
|
102
|
+
|
|
103
|
+
def visit(block: List[ast.stmt], scope: list[str]) -> None:
|
|
104
|
+
scope_name = ".".join(scope)
|
|
105
|
+
for index, node in enumerate(block):
|
|
106
|
+
if not isinstance(node, _UNBLOCKABLE) and not _is_future_import(node):
|
|
107
|
+
canon = _canon(node)
|
|
108
|
+
key = (scope_name, canon)
|
|
109
|
+
occurrence = seen.get(key, 0)
|
|
110
|
+
seen[key] = occurrence + 1
|
|
111
|
+
found.append(
|
|
112
|
+
Statement(
|
|
113
|
+
fingerprint=_hash(module, scope_name, canon, occurrence),
|
|
114
|
+
module=module,
|
|
115
|
+
scope=scope_name,
|
|
116
|
+
lineno=node.lineno,
|
|
117
|
+
end_lineno=getattr(node, "end_lineno", None),
|
|
118
|
+
node=node,
|
|
119
|
+
block=block,
|
|
120
|
+
index=index,
|
|
121
|
+
)
|
|
122
|
+
)
|
|
123
|
+
inner = scope + [node.name] if isinstance(node, _SCOPE_NODES) else scope
|
|
124
|
+
for child in _child_blocks(node):
|
|
125
|
+
visit(child, inner)
|
|
126
|
+
|
|
127
|
+
visit(tree.body, [])
|
|
128
|
+
return found
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
def fingerprint_source(
|
|
132
|
+
source: str | bytes, module: str, filename: str = "<unknown>"
|
|
133
|
+
) -> list[Statement]:
|
|
134
|
+
"""Parse ``source`` and return its blockable statements."""
|
|
135
|
+
return find_statements(ast.parse(source, filename=filename), module)
|
astblock/_hook.py
ADDED
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
"""Import hook that compiles targeted modules with the blocklist applied.
|
|
2
|
+
|
|
3
|
+
Only modules named in the blocklist are intercepted; everything else imports
|
|
4
|
+
exactly as normal. Targeted modules are always compiled from source and their
|
|
5
|
+
bytecode is never cached, so a stale ``.pyc`` can't bypass a rule and a
|
|
6
|
+
patched ``.pyc`` never outlives the blocklist.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import importlib.abc
|
|
12
|
+
import importlib.machinery
|
|
13
|
+
import logging
|
|
14
|
+
import os
|
|
15
|
+
import sys
|
|
16
|
+
from types import CodeType
|
|
17
|
+
|
|
18
|
+
from ._blocklist import Blocklist
|
|
19
|
+
from ._transform import compile_with_blocklist
|
|
20
|
+
|
|
21
|
+
logger = logging.getLogger("astblock")
|
|
22
|
+
|
|
23
|
+
ENV_VAR = "ASTBLOCK_FILE"
|
|
24
|
+
|
|
25
|
+
_finder: "_BlockingFinder | None" = None
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class _BlockingLoader(importlib.machinery.SourceFileLoader):
|
|
29
|
+
def __init__(self, fullname: str, path: str, blocklist: Blocklist) -> None:
|
|
30
|
+
super().__init__(fullname, path)
|
|
31
|
+
self._blocklist = blocklist
|
|
32
|
+
|
|
33
|
+
def get_code(self, fullname: str) -> CodeType:
|
|
34
|
+
source = self.get_data(self.path)
|
|
35
|
+
return compile_with_blocklist(source, self.path, fullname, self._blocklist)
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
class _BlockingFinder(importlib.abc.MetaPathFinder):
|
|
39
|
+
def __init__(self, blocklist: Blocklist) -> None:
|
|
40
|
+
self.blocklist = blocklist
|
|
41
|
+
|
|
42
|
+
def find_spec(self, fullname, path, target=None):
|
|
43
|
+
if fullname not in self.blocklist.modules:
|
|
44
|
+
return None
|
|
45
|
+
spec = None
|
|
46
|
+
for finder in sys.meta_path:
|
|
47
|
+
if finder is self or not hasattr(finder, "find_spec"):
|
|
48
|
+
continue
|
|
49
|
+
spec = finder.find_spec(fullname, path, target)
|
|
50
|
+
if spec is not None:
|
|
51
|
+
break
|
|
52
|
+
if spec is None:
|
|
53
|
+
return None
|
|
54
|
+
loader = spec.loader
|
|
55
|
+
if isinstance(loader, importlib.machinery.SourceFileLoader) and not isinstance(
|
|
56
|
+
loader, _BlockingLoader
|
|
57
|
+
):
|
|
58
|
+
spec.loader = _BlockingLoader(fullname, loader.path, self.blocklist)
|
|
59
|
+
else:
|
|
60
|
+
logger.warning(
|
|
61
|
+
"astblock: cannot apply rules to %s: it is not loaded from a .py "
|
|
62
|
+
"source file (loader: %r)", fullname, loader)
|
|
63
|
+
return spec
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def install(blocklist: Blocklist | str | os.PathLike[str]) -> Blocklist:
|
|
67
|
+
"""Start applying ``blocklist`` to modules imported from now on.
|
|
68
|
+
|
|
69
|
+
Accepts a Blocklist or a path to a blocklist JSON file. Replaces any
|
|
70
|
+
previously installed blocklist. Returns the installed Blocklist.
|
|
71
|
+
"""
|
|
72
|
+
global _finder
|
|
73
|
+
if not isinstance(blocklist, Blocklist):
|
|
74
|
+
blocklist = Blocklist.load(blocklist)
|
|
75
|
+
uninstall()
|
|
76
|
+
already = sorted(name for name in blocklist.modules if name in sys.modules)
|
|
77
|
+
if already:
|
|
78
|
+
logger.warning(
|
|
79
|
+
"astblock: already imported, rules won't apply to these until the "
|
|
80
|
+
"process restarts: %s", ", ".join(already))
|
|
81
|
+
_finder = _BlockingFinder(blocklist)
|
|
82
|
+
sys.meta_path.insert(0, _finder)
|
|
83
|
+
return blocklist
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def uninstall() -> None:
|
|
87
|
+
"""Stop intercepting imports. Modules already imported stay patched."""
|
|
88
|
+
global _finder
|
|
89
|
+
if _finder is not None:
|
|
90
|
+
try:
|
|
91
|
+
sys.meta_path.remove(_finder)
|
|
92
|
+
except ValueError:
|
|
93
|
+
pass
|
|
94
|
+
_finder = None
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def is_installed() -> bool:
|
|
98
|
+
return _finder is not None and _finder in sys.meta_path
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def install_from_env(var: str = ENV_VAR) -> Blocklist | None:
|
|
102
|
+
"""Install the blocklist named by ``$ASTBLOCK_FILE``, if set.
|
|
103
|
+
|
|
104
|
+
A blocklist that is set but unreadable or invalid raises BlocklistError
|
|
105
|
+
rather than being ignored: running unpatched when an emergency patch was
|
|
106
|
+
requested is worse than failing to start.
|
|
107
|
+
"""
|
|
108
|
+
path = os.environ.get(var)
|
|
109
|
+
if not path:
|
|
110
|
+
return None
|
|
111
|
+
return install(path)
|
astblock/_runtime.py
ADDED
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
"""Runtime side of a blocked statement.
|
|
2
|
+
|
|
3
|
+
Every blocked statement is rewritten into a call to :func:`blocked`, so that
|
|
4
|
+
hits are counted and logged and ``raise`` rules can raise. This module is
|
|
5
|
+
imported by rewritten code, so it must stay small and dependency-free.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import logging
|
|
11
|
+
import threading
|
|
12
|
+
from collections import Counter
|
|
13
|
+
|
|
14
|
+
from ._errors import BlockedStatementError
|
|
15
|
+
|
|
16
|
+
logger = logging.getLogger("astblock")
|
|
17
|
+
|
|
18
|
+
_hits: Counter[str] = Counter()
|
|
19
|
+
_lock = threading.Lock()
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def blocked(fingerprint: str, action: str, reason: str | None) -> None:
|
|
23
|
+
with _lock:
|
|
24
|
+
_hits[fingerprint] += 1
|
|
25
|
+
first = _hits[fingerprint] == 1
|
|
26
|
+
# Log the first hit loudly and later hits quietly, so a blocked statement
|
|
27
|
+
# inside a hot loop doesn't flood the logs.
|
|
28
|
+
level = logging.WARNING if first else logging.DEBUG
|
|
29
|
+
logger.log(level, "astblock: blocked statement %s reached (action=%s, reason=%s)",
|
|
30
|
+
fingerprint, action, reason)
|
|
31
|
+
if action == "raise":
|
|
32
|
+
raise BlockedStatementError(fingerprint, reason)
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def hits() -> dict[str, int]:
|
|
36
|
+
"""Return how many times each blocked statement has been reached."""
|
|
37
|
+
with _lock:
|
|
38
|
+
return dict(_hits)
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def reset_hits() -> None:
|
|
42
|
+
with _lock:
|
|
43
|
+
_hits.clear()
|
astblock/_transform.py
ADDED
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
"""Rewrite blocked statements and compile the result."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import ast
|
|
6
|
+
import logging
|
|
7
|
+
from types import CodeType
|
|
8
|
+
from typing import Mapping
|
|
9
|
+
|
|
10
|
+
from ._blocklist import Blocklist, Rule
|
|
11
|
+
from ._fingerprint import find_statements
|
|
12
|
+
|
|
13
|
+
logger = logging.getLogger("astblock")
|
|
14
|
+
|
|
15
|
+
_NEW_SCOPES = (ast.FunctionDef, ast.AsyncFunctionDef, ast.ClassDef, ast.Lambda)
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def _contains_yield(node: ast.AST) -> bool:
|
|
19
|
+
"""True if ``node`` yields on behalf of its enclosing function."""
|
|
20
|
+
for child in ast.iter_child_nodes(node):
|
|
21
|
+
if isinstance(child, (ast.Yield, ast.YieldFrom)):
|
|
22
|
+
return True
|
|
23
|
+
if not isinstance(child, _NEW_SCOPES) and _contains_yield(child):
|
|
24
|
+
return True
|
|
25
|
+
return False
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def _replacement(node: ast.stmt, rule: Rule) -> ast.stmt:
|
|
29
|
+
call = (
|
|
30
|
+
"__import__('astblock._runtime', fromlist=['blocked']).blocked("
|
|
31
|
+
f"{rule.fingerprint!r}, {rule.action!r}, {rule.reason!r})"
|
|
32
|
+
)
|
|
33
|
+
if _contains_yield(node) and not isinstance(node, _NEW_SCOPES):
|
|
34
|
+
# Removing a function's only ``yield`` would silently turn a generator
|
|
35
|
+
# into a plain function. Keep an unreachable yield so it stays one.
|
|
36
|
+
source = f"if False:\n yield\nelse:\n {call}"
|
|
37
|
+
else:
|
|
38
|
+
source = call
|
|
39
|
+
new = ast.parse(source).body[0]
|
|
40
|
+
for child in ast.walk(new):
|
|
41
|
+
ast.copy_location(child, node)
|
|
42
|
+
return new
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def apply_rules(tree: ast.Module, module: str, rules: Mapping[str, Rule]) -> set[str]:
|
|
46
|
+
"""Replace blocked statements in ``tree`` in place.
|
|
47
|
+
|
|
48
|
+
Returns the fingerprints that matched a statement.
|
|
49
|
+
"""
|
|
50
|
+
matched: set[str] = set()
|
|
51
|
+
for statement in find_statements(tree, module):
|
|
52
|
+
rule = rules.get(statement.fingerprint)
|
|
53
|
+
if rule is None:
|
|
54
|
+
continue
|
|
55
|
+
matched.add(statement.fingerprint)
|
|
56
|
+
statement.block[statement.index] = _replacement(statement.node, rule)
|
|
57
|
+
return matched
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def compile_with_blocklist(
|
|
61
|
+
source: str | bytes, filename: str, module: str, blocklist: Blocklist
|
|
62
|
+
) -> CodeType:
|
|
63
|
+
"""Compile ``source`` as ``module`` with the blocklist's rules applied."""
|
|
64
|
+
tree = ast.parse(source, filename=filename)
|
|
65
|
+
rules = blocklist.rules_for(module)
|
|
66
|
+
if rules:
|
|
67
|
+
matched = apply_rules(tree, module, rules)
|
|
68
|
+
if matched:
|
|
69
|
+
logger.warning("astblock: blocked %d statement(s) in %s: %s",
|
|
70
|
+
len(matched), module, ", ".join(sorted(matched)))
|
|
71
|
+
stale = sorted(set(rules) - matched)
|
|
72
|
+
if stale:
|
|
73
|
+
logger.warning(
|
|
74
|
+
"astblock: %d rule(s) for %s matched nothing (code changed since the "
|
|
75
|
+
"rule was written?): %s", len(stale), module, ", ".join(stale))
|
|
76
|
+
return compile(tree, filename, "exec", dont_inherit=True)
|
astblock/py.typed
ADDED
|
File without changes
|
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: astblock
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Block individual Python statements from running, using an AST-fingerprint blocklist.
|
|
5
|
+
Project-URL: Homepage, https://github.com/troyteodoro/astblock
|
|
6
|
+
Project-URL: Source, https://github.com/troyteodoro/astblock
|
|
7
|
+
Project-URL: Issues, https://github.com/troyteodoro/astblock/issues
|
|
8
|
+
Author-email: Troy Teodoro <troyteodoro00@gmail.com>
|
|
9
|
+
License-Expression: MIT
|
|
10
|
+
License-File: LICENSE
|
|
11
|
+
Keywords: ast,feature-flag,import-hook,incident-response,mitigation
|
|
12
|
+
Classifier: Development Status :: 3 - Alpha
|
|
13
|
+
Classifier: Intended Audience :: Developers
|
|
14
|
+
Classifier: Intended Audience :: System Administrators
|
|
15
|
+
Classifier: Programming Language :: Python :: 3
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
18
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
19
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
20
|
+
Classifier: Programming Language :: Python :: 3.14
|
|
21
|
+
Classifier: Topic :: Software Development :: Debuggers
|
|
22
|
+
Classifier: Topic :: System :: Systems Administration
|
|
23
|
+
Classifier: Typing :: Typed
|
|
24
|
+
Requires-Python: >=3.10
|
|
25
|
+
Provides-Extra: test
|
|
26
|
+
Requires-Dist: pytest>=7; extra == 'test'
|
|
27
|
+
Description-Content-Type: text/markdown
|
|
28
|
+
|
|
29
|
+
# astblock
|
|
30
|
+
|
|
31
|
+
Switch off individual Python statements without editing or redeploying the code.
|
|
32
|
+
|
|
33
|
+
You write a small JSON blocklist naming statements by an AST fingerprint. When
|
|
34
|
+
the program starts with that blocklist, each blocked statement is rewritten at
|
|
35
|
+
import time so that it either **skips** (does nothing) or **raises**
|
|
36
|
+
`BlockedStatementError`. Everything not on the list is compiled exactly as normal.
|
|
37
|
+
|
|
38
|
+
The intended use is emergency mitigation: a third-party call that hangs, a
|
|
39
|
+
side effect that fires twice, a code path that corrupts data. It lets you turn
|
|
40
|
+
that one statement off with a config change and a restart, while the proper fix
|
|
41
|
+
goes through your normal release process.
|
|
42
|
+
|
|
43
|
+
## Workflow
|
|
44
|
+
|
|
45
|
+
```console
|
|
46
|
+
# 1. Find the statement's fingerprint
|
|
47
|
+
$ python -m astblock list shop.checkout --line 15
|
|
48
|
+
15 ebfa39018db86a24 checkout notify_partner_api(order)
|
|
49
|
+
|
|
50
|
+
# 2. Generate a rule (then add a reason)
|
|
51
|
+
$ python -m astblock list shop.checkout --line 15 --json --action skip > blocklist.json
|
|
52
|
+
|
|
53
|
+
# 3. Verify every rule matches the code you're about to run
|
|
54
|
+
$ python -m astblock check blocklist.json
|
|
55
|
+
OK shop.checkout ebfa39018db86a24 [skip] line 15: notify_partner_api(order)
|
|
56
|
+
|
|
57
|
+
# 4. Run with it
|
|
58
|
+
$ python -m astblock run --blocklist blocklist.json -m shop.checkout
|
|
59
|
+
```
|
|
60
|
+
|
|
61
|
+
`examples/` contains this exact scenario.
|
|
62
|
+
|
|
63
|
+
## Activating it in an application
|
|
64
|
+
|
|
65
|
+
Pick one:
|
|
66
|
+
|
|
67
|
+
- **CLI wrapper:** `python -m astblock run --blocklist FILE -m yourapp` or
|
|
68
|
+
`... run --blocklist FILE script.py`.
|
|
69
|
+
- **One line at the top of your entry point**, before your own modules are
|
|
70
|
+
imported: `import astblock; astblock.install_from_env()`. It does nothing
|
|
71
|
+
unless `ASTBLOCK_FILE` is set.
|
|
72
|
+
- **No code change:** a `.pth` file in site-packages containing the single line
|
|
73
|
+
`import astblock; astblock.install_from_env()` runs at interpreter startup.
|
|
74
|
+
This is powerful, so only do it in environments you control.
|
|
75
|
+
|
|
76
|
+
If `ASTBLOCK_FILE` is set but the file is missing or invalid, startup fails
|
|
77
|
+
rather than running unpatched.
|
|
78
|
+
|
|
79
|
+
## Fingerprints
|
|
80
|
+
|
|
81
|
+
A fingerprint is a hash of the module name, the enclosing function/class path,
|
|
82
|
+
the statement's AST (without positions), and an occurrence index for identical
|
|
83
|
+
statements in the same scope. So it:
|
|
84
|
+
|
|
85
|
+
- survives reformatting, comment changes and code added above it;
|
|
86
|
+
- changes if the statement itself changes or moves to another function, so an
|
|
87
|
+
old rule stops matching instead of hitting the wrong code. Stale rules are
|
|
88
|
+
logged at import time and reported by `astblock check`.
|
|
89
|
+
|
|
90
|
+
Generate fingerprints with the same Python minor version you run in
|
|
91
|
+
production: AST shapes occasionally change between versions.
|
|
92
|
+
|
|
93
|
+
## Blocklist format
|
|
94
|
+
|
|
95
|
+
```json
|
|
96
|
+
{
|
|
97
|
+
"version": 1,
|
|
98
|
+
"rules": [
|
|
99
|
+
{
|
|
100
|
+
"module": "shop.checkout",
|
|
101
|
+
"fingerprint": "ebfa39018db86a24",
|
|
102
|
+
"action": "skip",
|
|
103
|
+
"reason": "Partner API outage, INC-2231"
|
|
104
|
+
}
|
|
105
|
+
]
|
|
106
|
+
}
|
|
107
|
+
```
|
|
108
|
+
|
|
109
|
+
`action` is `"raise"` (the default) or `"skip"`. Scripts run directly use the
|
|
110
|
+
module name `__main__`; code run with `-m pkg.mod` uses `pkg.mod`.
|
|
111
|
+
|
|
112
|
+
## Semantics and limits: read before using in an incident
|
|
113
|
+
|
|
114
|
+
- **Skipping is not free.** A skipped assignment leaves the name undefined, a
|
|
115
|
+
skipped `return` falls through to the following code, and a skipped `def` or
|
|
116
|
+
`import` removes the name entirely. Block the narrowest statement that does
|
|
117
|
+
the job, and prefer `raise` where the caller already handles errors.
|
|
118
|
+
- Blocking a compound statement (`if`, `for`, `with`, `def`) blocks all of it.
|
|
119
|
+
- If you block a function's only `yield`, it stays a generator (it just yields nothing).
|
|
120
|
+
- **Import time only.** Rules apply when a module is imported, so the process
|
|
121
|
+
must restart. Modules imported before `install()` are not patched, and a
|
|
122
|
+
warning names them.
|
|
123
|
+
- Only modules loaded from `.py` source are patchable, not extension modules or
|
|
124
|
+
pyc-only distributions. Targeted modules are always compiled from source and
|
|
125
|
+
never cached, so a stale `.pyc` can't bypass a rule.
|
|
126
|
+
- Hits are logged to the `astblock` logger (first hit at WARNING, later hits at
|
|
127
|
+
DEBUG) and counted in `astblock.hits()`.
|
|
128
|
+
- **Security:** whoever can write the blocklist can disable any statement,
|
|
129
|
+
including an authorization check. Treat the file and the `ASTBLOCK_FILE`
|
|
130
|
+
variable with the same care as your deploy credentials.
|
|
131
|
+
|
|
132
|
+
## Python API
|
|
133
|
+
|
|
134
|
+
```python
|
|
135
|
+
import astblock
|
|
136
|
+
|
|
137
|
+
astblock.install("blocklist.json") # or a Blocklist object
|
|
138
|
+
astblock.fingerprint_source(src, "mod") # -> list[Statement]
|
|
139
|
+
astblock.hits() # {fingerprint: count}
|
|
140
|
+
astblock.uninstall()
|
|
141
|
+
```
|
|
142
|
+
|
|
143
|
+
## Development
|
|
144
|
+
|
|
145
|
+
```console
|
|
146
|
+
pip install -e ".[test]"
|
|
147
|
+
pytest
|
|
148
|
+
```
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
astblock/__init__.py,sha256=Ijq768eXgpUZ4FDjijDhhiTGsyrFg8BLemBpreGKgJM,791
|
|
2
|
+
astblock/__main__.py,sha256=l6eQl7Ify5w6JlKD8BPjR0VMHo24mf4ifM6z-sE77qY,6576
|
|
3
|
+
astblock/_blocklist.py,sha256=OVLNRWdDQh2PqUbN7W3motZEkX5UQpBAM98b1IGTlQo,5071
|
|
4
|
+
astblock/_errors.py,sha256=ZeF7tqPgUJy9zruAkH8U5qOJMVsNGXdY5CX8RAryvmE,624
|
|
5
|
+
astblock/_fingerprint.py,sha256=RMGjEW62jCRdO2sysGn4asjmmv1sgWc7UbdkDJkBc-U,4975
|
|
6
|
+
astblock/_hook.py,sha256=7Tub4zi459LCdcFBtohormCyGTp3iBKB8fNOEgRPuIM,3698
|
|
7
|
+
astblock/_runtime.py,sha256=oeCgnGxbbj0YA_ssNgl4pbyHVkSxYQwOshkK5gTU2D4,1282
|
|
8
|
+
astblock/_transform.py,sha256=evQqtaNjPhhMyKZ4Tp2Aur_LfStm3sJvRaOidtqR34s,2760
|
|
9
|
+
astblock/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
10
|
+
astblock-0.1.0.dist-info/METADATA,sha256=R7d29ieG4Zop9APUvThGZPjMKdbkSNhUFG7bJMF2IZ8,5774
|
|
11
|
+
astblock-0.1.0.dist-info/WHEEL,sha256=W3fkpkm7-wf9vBI5Z-7s0eWkeM-spu78I8Neb98DeEg,87
|
|
12
|
+
astblock-0.1.0.dist-info/entry_points.txt,sha256=mA-8LoZ2QlATQjx3LUaX4Ku8Pj7yIEexEcA5puaQaOw,52
|
|
13
|
+
astblock-0.1.0.dist-info/licenses/LICENSE,sha256=ESYyLizI0WWtxMeS7rGVcX3ivMezm-HOd5WdeOh-9oU,1056
|
|
14
|
+
astblock-0.1.0.dist-info/RECORD,,
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026
|
|
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.
|