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.
- astblock-0.1.0/.github/workflows/ci.yml +37 -0
- astblock-0.1.0/.github/workflows/publish.yml +34 -0
- astblock-0.1.0/.gitignore +6 -0
- astblock-0.1.0/LICENSE +21 -0
- astblock-0.1.0/PKG-INFO +148 -0
- astblock-0.1.0/README.md +120 -0
- astblock-0.1.0/examples/blocklist.json +11 -0
- astblock-0.1.0/examples/shop/__init__.py +0 -0
- astblock-0.1.0/examples/shop/checkout.py +21 -0
- astblock-0.1.0/pyproject.toml +48 -0
- astblock-0.1.0/src/astblock/__init__.py +29 -0
- astblock-0.1.0/src/astblock/__main__.py +168 -0
- astblock-0.1.0/src/astblock/_blocklist.py +145 -0
- astblock-0.1.0/src/astblock/_errors.py +19 -0
- astblock-0.1.0/src/astblock/_fingerprint.py +135 -0
- astblock-0.1.0/src/astblock/_hook.py +111 -0
- astblock-0.1.0/src/astblock/_runtime.py +43 -0
- astblock-0.1.0/src/astblock/_transform.py +76 -0
- astblock-0.1.0/src/astblock/py.typed +0 -0
- astblock-0.1.0/tests/test_core.py +148 -0
- astblock-0.1.0/tests/test_hook_and_cli.py +124 -0
|
@@ -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
|
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.
|
astblock-0.1.0/PKG-INFO
ADDED
|
@@ -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
|
+
```
|
astblock-0.1.0/README.md
ADDED
|
@@ -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
|
+
```
|
|
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())
|