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 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)
@@ -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,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.32.4
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ astblock = astblock.__main__:main
@@ -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.