astblock 0.1.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,37 @@
1
+ name: CI
2
+
3
+ on:
4
+ push:
5
+ branches: [main]
6
+ pull_request:
7
+ workflow_dispatch:
8
+
9
+ jobs:
10
+ test:
11
+ runs-on: ubuntu-latest
12
+ strategy:
13
+ fail-fast: false
14
+ matrix:
15
+ python-version: ["3.10", "3.11", "3.12", "3.13", "3.14"]
16
+ steps:
17
+ - uses: actions/checkout@v4
18
+ - uses: actions/setup-python@v5
19
+ with:
20
+ python-version: ${{ matrix.python-version }}
21
+ - run: python -m pip install --upgrade pip
22
+ - run: pip install -e ".[test]"
23
+ - run: pytest -q
24
+
25
+ build:
26
+ runs-on: ubuntu-latest
27
+ steps:
28
+ - uses: actions/checkout@v4
29
+ - uses: actions/setup-python@v5
30
+ with:
31
+ python-version: "3.12"
32
+ - run: pipx run build
33
+ - run: pipx run twine check --strict dist/*
34
+ - uses: actions/upload-artifact@v4
35
+ with:
36
+ name: dist
37
+ path: dist/
@@ -0,0 +1,34 @@
1
+ name: Publish
2
+
3
+ on:
4
+ release:
5
+ types: [published]
6
+ workflow_dispatch:
7
+
8
+ jobs:
9
+ build:
10
+ runs-on: ubuntu-latest
11
+ steps:
12
+ - uses: actions/checkout@v4
13
+ - uses: actions/setup-python@v5
14
+ with:
15
+ python-version: "3.12"
16
+ - run: pipx run build
17
+ - run: pipx run twine check --strict dist/*
18
+ - uses: actions/upload-artifact@v4
19
+ with:
20
+ name: dist
21
+ path: dist/
22
+
23
+ publish:
24
+ needs: build
25
+ runs-on: ubuntu-latest
26
+ environment: pypi
27
+ permissions:
28
+ id-token: write # mints the OIDC token PyPI trusted publishing verifies
29
+ steps:
30
+ - uses: actions/download-artifact@v4
31
+ with:
32
+ name: dist
33
+ path: dist/
34
+ - uses: pypa/gh-action-pypi-publish@release/v1
@@ -0,0 +1,6 @@
1
+ __pycache__/
2
+ *.egg-info/
3
+ dist/
4
+ build/
5
+ .pytest_cache/
6
+ .venv/
astblock-0.1.0/LICENSE ADDED
@@ -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.
@@ -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,120 @@
1
+ # astblock
2
+
3
+ Switch off individual Python statements without editing or redeploying the code.
4
+
5
+ You write a small JSON blocklist naming statements by an AST fingerprint. When
6
+ the program starts with that blocklist, each blocked statement is rewritten at
7
+ import time so that it either **skips** (does nothing) or **raises**
8
+ `BlockedStatementError`. Everything not on the list is compiled exactly as normal.
9
+
10
+ The intended use is emergency mitigation: a third-party call that hangs, a
11
+ side effect that fires twice, a code path that corrupts data. It lets you turn
12
+ that one statement off with a config change and a restart, while the proper fix
13
+ goes through your normal release process.
14
+
15
+ ## Workflow
16
+
17
+ ```console
18
+ # 1. Find the statement's fingerprint
19
+ $ python -m astblock list shop.checkout --line 15
20
+ 15 ebfa39018db86a24 checkout notify_partner_api(order)
21
+
22
+ # 2. Generate a rule (then add a reason)
23
+ $ python -m astblock list shop.checkout --line 15 --json --action skip > blocklist.json
24
+
25
+ # 3. Verify every rule matches the code you're about to run
26
+ $ python -m astblock check blocklist.json
27
+ OK shop.checkout ebfa39018db86a24 [skip] line 15: notify_partner_api(order)
28
+
29
+ # 4. Run with it
30
+ $ python -m astblock run --blocklist blocklist.json -m shop.checkout
31
+ ```
32
+
33
+ `examples/` contains this exact scenario.
34
+
35
+ ## Activating it in an application
36
+
37
+ Pick one:
38
+
39
+ - **CLI wrapper:** `python -m astblock run --blocklist FILE -m yourapp` or
40
+ `... run --blocklist FILE script.py`.
41
+ - **One line at the top of your entry point**, before your own modules are
42
+ imported: `import astblock; astblock.install_from_env()`. It does nothing
43
+ unless `ASTBLOCK_FILE` is set.
44
+ - **No code change:** a `.pth` file in site-packages containing the single line
45
+ `import astblock; astblock.install_from_env()` runs at interpreter startup.
46
+ This is powerful, so only do it in environments you control.
47
+
48
+ If `ASTBLOCK_FILE` is set but the file is missing or invalid, startup fails
49
+ rather than running unpatched.
50
+
51
+ ## Fingerprints
52
+
53
+ A fingerprint is a hash of the module name, the enclosing function/class path,
54
+ the statement's AST (without positions), and an occurrence index for identical
55
+ statements in the same scope. So it:
56
+
57
+ - survives reformatting, comment changes and code added above it;
58
+ - changes if the statement itself changes or moves to another function, so an
59
+ old rule stops matching instead of hitting the wrong code. Stale rules are
60
+ logged at import time and reported by `astblock check`.
61
+
62
+ Generate fingerprints with the same Python minor version you run in
63
+ production: AST shapes occasionally change between versions.
64
+
65
+ ## Blocklist format
66
+
67
+ ```json
68
+ {
69
+ "version": 1,
70
+ "rules": [
71
+ {
72
+ "module": "shop.checkout",
73
+ "fingerprint": "ebfa39018db86a24",
74
+ "action": "skip",
75
+ "reason": "Partner API outage, INC-2231"
76
+ }
77
+ ]
78
+ }
79
+ ```
80
+
81
+ `action` is `"raise"` (the default) or `"skip"`. Scripts run directly use the
82
+ module name `__main__`; code run with `-m pkg.mod` uses `pkg.mod`.
83
+
84
+ ## Semantics and limits: read before using in an incident
85
+
86
+ - **Skipping is not free.** A skipped assignment leaves the name undefined, a
87
+ skipped `return` falls through to the following code, and a skipped `def` or
88
+ `import` removes the name entirely. Block the narrowest statement that does
89
+ the job, and prefer `raise` where the caller already handles errors.
90
+ - Blocking a compound statement (`if`, `for`, `with`, `def`) blocks all of it.
91
+ - If you block a function's only `yield`, it stays a generator (it just yields nothing).
92
+ - **Import time only.** Rules apply when a module is imported, so the process
93
+ must restart. Modules imported before `install()` are not patched, and a
94
+ warning names them.
95
+ - Only modules loaded from `.py` source are patchable, not extension modules or
96
+ pyc-only distributions. Targeted modules are always compiled from source and
97
+ never cached, so a stale `.pyc` can't bypass a rule.
98
+ - Hits are logged to the `astblock` logger (first hit at WARNING, later hits at
99
+ DEBUG) and counted in `astblock.hits()`.
100
+ - **Security:** whoever can write the blocklist can disable any statement,
101
+ including an authorization check. Treat the file and the `ASTBLOCK_FILE`
102
+ variable with the same care as your deploy credentials.
103
+
104
+ ## Python API
105
+
106
+ ```python
107
+ import astblock
108
+
109
+ astblock.install("blocklist.json") # or a Blocklist object
110
+ astblock.fingerprint_source(src, "mod") # -> list[Statement]
111
+ astblock.hits() # {fingerprint: count}
112
+ astblock.uninstall()
113
+ ```
114
+
115
+ ## Development
116
+
117
+ ```console
118
+ pip install -e ".[test]"
119
+ pytest
120
+ ```
@@ -0,0 +1,11 @@
1
+ {
2
+ "version": 1,
3
+ "rules": [
4
+ {
5
+ "module": "shop.checkout",
6
+ "fingerprint": "ebfa39018db86a24",
7
+ "action": "skip",
8
+ "reason": "Partner API outage, INC-2231"
9
+ }
10
+ ]
11
+ }
File without changes
@@ -0,0 +1,21 @@
1
+ """A toy checkout flow with a statement we want to switch off in an emergency."""
2
+
3
+
4
+ def send_receipt(order):
5
+ print(f"receipt emailed for order {order}")
6
+
7
+
8
+ def notify_partner_api(order):
9
+ # Imagine this third-party API is down and every call hangs for 30s.
10
+ print(f"notified partner about order {order}")
11
+
12
+
13
+ def checkout(order):
14
+ print(f"charging order {order}")
15
+ notify_partner_api(order)
16
+ send_receipt(order)
17
+ return "ok"
18
+
19
+
20
+ if __name__ == "__main__":
21
+ print(checkout(1001))
@@ -0,0 +1,48 @@
1
+ [build-system]
2
+ requires = ["hatchling"]
3
+ build-backend = "hatchling.build"
4
+
5
+ [project]
6
+ name = "astblock"
7
+ dynamic = ["version"]
8
+ description = "Block individual Python statements from running, using an AST-fingerprint blocklist."
9
+ readme = "README.md"
10
+ requires-python = ">=3.10"
11
+ license = "MIT"
12
+ authors = [{ name = "Troy Teodoro", email = "troyteodoro00@gmail.com" }]
13
+ keywords = ["ast", "import-hook", "incident-response", "mitigation", "feature-flag"]
14
+ classifiers = [
15
+ "Development Status :: 3 - Alpha",
16
+ "Intended Audience :: Developers",
17
+ "Intended Audience :: System Administrators",
18
+ "Programming Language :: Python :: 3",
19
+ "Programming Language :: Python :: 3.10",
20
+ "Programming Language :: Python :: 3.11",
21
+ "Programming Language :: Python :: 3.12",
22
+ "Programming Language :: Python :: 3.13",
23
+ "Programming Language :: Python :: 3.14",
24
+ "Topic :: Software Development :: Debuggers",
25
+ "Topic :: System :: Systems Administration",
26
+ "Typing :: Typed",
27
+ ]
28
+
29
+ [project.urls]
30
+ Homepage = "https://github.com/troyteodoro/astblock"
31
+ Source = "https://github.com/troyteodoro/astblock"
32
+ Issues = "https://github.com/troyteodoro/astblock/issues"
33
+
34
+ [project.optional-dependencies]
35
+ test = ["pytest>=7"]
36
+
37
+ [project.scripts]
38
+ astblock = "astblock.__main__:main"
39
+
40
+ [tool.hatch.version]
41
+ path = "src/astblock/__init__.py"
42
+
43
+ [tool.hatch.build.targets.wheel]
44
+ packages = ["src/astblock"]
45
+
46
+ [tool.pytest.ini_options]
47
+ pythonpath = ["src"]
48
+ testpaths = ["tests"]
@@ -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
+ ]
@@ -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())