pyteman 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.
- pyteman-0.1.0/LICENSE +21 -0
- pyteman-0.1.0/PKG-INFO +180 -0
- pyteman-0.1.0/README.md +135 -0
- pyteman-0.1.0/pyproject.toml +38 -0
- pyteman-0.1.0/setup.cfg +4 -0
- pyteman-0.1.0/src/pyteman/__init__.py +1 -0
- pyteman-0.1.0/src/pyteman/actions.py +84 -0
- pyteman-0.1.0/src/pyteman/barriers.py +21 -0
- pyteman-0.1.0/src/pyteman/conditions.py +7 -0
- pyteman-0.1.0/src/pyteman/firing.py +32 -0
- pyteman-0.1.0/src/pyteman/patcher.py +146 -0
- pyteman-0.1.0/src/pyteman/py.typed +0 -0
- pyteman-0.1.0/src/pyteman/rules.py +90 -0
- pyteman-0.1.0/src/pyteman/runner/__init__.py +0 -0
- pyteman-0.1.0/src/pyteman/runner/matrix.py +40 -0
- pyteman-0.1.0/src/pyteman/runner/report.py +19 -0
- pyteman-0.1.0/src/pyteman/sitecustomize.py +27 -0
- pyteman-0.1.0/src/pyteman/sqlitekit/__init__.py +0 -0
- pyteman-0.1.0/src/pyteman/sqlitekit/integrity.py +20 -0
- pyteman-0.1.0/src/pyteman/targets.py +127 -0
- pyteman-0.1.0/src/pyteman.egg-info/PKG-INFO +180 -0
- pyteman-0.1.0/src/pyteman.egg-info/SOURCES.txt +33 -0
- pyteman-0.1.0/src/pyteman.egg-info/dependency_links.txt +1 -0
- pyteman-0.1.0/src/pyteman.egg-info/requires.txt +1 -0
- pyteman-0.1.0/src/pyteman.egg-info/top_level.txt +1 -0
- pyteman-0.1.0/tests/test_actions.py +38 -0
- pyteman-0.1.0/tests/test_barriers.py +19 -0
- pyteman-0.1.0/tests/test_firing.py +15 -0
- pyteman-0.1.0/tests/test_integrity.py +26 -0
- pyteman-0.1.0/tests/test_kill.py +19 -0
- pyteman-0.1.0/tests/test_matrix.py +52 -0
- pyteman-0.1.0/tests/test_patcher.py +82 -0
- pyteman-0.1.0/tests/test_rules.py +66 -0
- pyteman-0.1.0/tests/test_sitecustomize.py +46 -0
- pyteman-0.1.0/tests/test_targets.py +286 -0
pyteman-0.1.0/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Paolo Antinori
|
|
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.
|
pyteman-0.1.0/PKG-INFO
ADDED
|
@@ -0,0 +1,180 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: pyteman
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Rule-based runtime fault injection for Python, inspired by Byteman
|
|
5
|
+
Author: Paolo Antinori
|
|
6
|
+
License: MIT License
|
|
7
|
+
|
|
8
|
+
Copyright (c) 2026 Paolo Antinori
|
|
9
|
+
|
|
10
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
11
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
12
|
+
in the Software without restriction, including without limitation the rights
|
|
13
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
14
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
15
|
+
furnished to do so, subject to the following conditions:
|
|
16
|
+
|
|
17
|
+
The above copyright notice and this permission notice shall be included in all
|
|
18
|
+
copies or substantial portions of the Software.
|
|
19
|
+
|
|
20
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
21
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
22
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
23
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
24
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
25
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
26
|
+
SOFTWARE.
|
|
27
|
+
|
|
28
|
+
Project-URL: Homepage, https://github.com/RisorseArtificiali/pyteman
|
|
29
|
+
Project-URL: Repository, https://github.com/RisorseArtificiali/pyteman
|
|
30
|
+
Project-URL: Issues, https://github.com/RisorseArtificiali/pyteman/issues
|
|
31
|
+
Keywords: fault-injection,testing,chaos,byteman,sqlite,reproducer
|
|
32
|
+
Classifier: Development Status :: 3 - Alpha
|
|
33
|
+
Classifier: Intended Audience :: Developers
|
|
34
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
35
|
+
Classifier: Programming Language :: Python :: 3
|
|
36
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
37
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
38
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
39
|
+
Classifier: Topic :: Software Development :: Testing
|
|
40
|
+
Requires-Python: >=3.11
|
|
41
|
+
Description-Content-Type: text/markdown
|
|
42
|
+
License-File: LICENSE
|
|
43
|
+
Requires-Dist: PyYAML>=6.0
|
|
44
|
+
Dynamic: license-file
|
|
45
|
+
|
|
46
|
+
# pyteman
|
|
47
|
+
|
|
48
|
+
Rule-based runtime fault injection for Python, inspired by Byteman.
|
|
49
|
+
|
|
50
|
+
Wrap a function with a YAML rule that fires on entry or exit, under a
|
|
51
|
+
condition. The action can inject a sleep, raise an exception, override the
|
|
52
|
+
return value, switch a SQLite PRAGMA on a connection passed to the call,
|
|
53
|
+
kill the process at the exact injection point (`os._exit`), or hold a named
|
|
54
|
+
barrier so two threads meet in the interleaving you want. Each firing is
|
|
55
|
+
logged with a sequence number, so you can reconstruct the interleaving after
|
|
56
|
+
the run.
|
|
57
|
+
|
|
58
|
+
## Activation contract (safety)
|
|
59
|
+
|
|
60
|
+
- Put the directory containing `sitecustomize.py` on the PYTHONPATH of TEST
|
|
61
|
+
runs only; that is `src/pyteman`, not `src`. Python imports `sitecustomize`
|
|
62
|
+
as a top-level module from whichever directory holds it. `pyteman.*` itself
|
|
63
|
+
resolves for normal imports via the editable install.
|
|
64
|
+
- Without `PYTEMAN_RULES` set, the sitecustomize does nothing.
|
|
65
|
+
- With `PYTEMAN_REQUIRE_MARKER=<file>` set, pyteman refuses to start unless
|
|
66
|
+
that marker file exists. It writes a refusal message to stderr and exits
|
|
67
|
+
with code 2 via `os._exit`. The hard exit is deliberate: a `SystemExit`
|
|
68
|
+
raised inside sitecustomize escapes into interpreter startup, and the
|
|
69
|
+
interpreter dies with a Fatal Python error and status 1 instead of your
|
|
70
|
+
exit code. Callers use the marker to pin execution to scratch directories.
|
|
71
|
+
Never install sitecustomize into production venvs or images.
|
|
72
|
+
|
|
73
|
+
## Ruleset example
|
|
74
|
+
|
|
75
|
+
```yaml
|
|
76
|
+
- id: hold-commit
|
|
77
|
+
point: hermes_state.SessionDB._execute_write
|
|
78
|
+
event: entry
|
|
79
|
+
when: "fires > 3 and kwargs.get('sid', '').startswith('stress-')"
|
|
80
|
+
action: {kind: sleep, ms: 250}
|
|
81
|
+
fire: {mode: once_per, key: "kwargs.get('sid')"}
|
|
82
|
+
- id: crash-at-commit
|
|
83
|
+
point: hermes_state.SessionDB.commit
|
|
84
|
+
event: exit
|
|
85
|
+
action: {kind: kill, exit_code: 70}
|
|
86
|
+
fire: {mode: countdown, n: 50}
|
|
87
|
+
```
|
|
88
|
+
|
|
89
|
+
The module is everything before the FIRST dot of `point`; the remainder is an
|
|
90
|
+
attribute path walked from the module, and the final component is the patched
|
|
91
|
+
attribute. `hermes_state.SessionDB._execute_write` resolves to module
|
|
92
|
+
`hermes_state` with attribute path `SessionDB._execute_write`.
|
|
93
|
+
|
|
94
|
+
Conditions see `args`, `kwargs`, `fires`, and on exit events also
|
|
95
|
+
`result`/`exc`. They are trusted operator input for test tooling.
|
|
96
|
+
|
|
97
|
+
Actions: `sleep`, `raise`, `return_value`, `return_none`, `pragma` (reaches
|
|
98
|
+
attribute-held connections through `target:` specs, see docs/targeting.md),
|
|
99
|
+
`kill` (`os._exit`), `barrier` (role `wait` or `open`).
|
|
100
|
+
|
|
101
|
+
`return_value`/`return_none` follow Byteman RETURN semantics and depend on the
|
|
102
|
+
event. On an ENTRY event the wrapped body is skipped entirely and the override
|
|
103
|
+
value is returned in its place. On an EXIT event the original body has already
|
|
104
|
+
run and the override swaps the result it produced.
|
|
105
|
+
|
|
106
|
+
Fire gating uses `fire: {mode: ...}` with three modes. `always` is the
|
|
107
|
+
default. `once_per <key-expr>` consumes its key only when the condition
|
|
108
|
+
passes. `countdown n` fires on call n+1.
|
|
109
|
+
|
|
110
|
+
## Runner and sqlitekit
|
|
111
|
+
|
|
112
|
+
`pyteman.runner.matrix.run_matrix(cells, run_cell, results_db, artifact_root)`
|
|
113
|
+
runs cells sequentially and resumes across re-runs via the results SQLite;
|
|
114
|
+
`pyteman.runner.report.matrix_markdown` renders the outcome table.
|
|
115
|
+
`pyteman.sqlitekit.integrity.classify_integrity` parses `PRAGMA
|
|
116
|
+
integrity_check` output into typed signatures (CLEAN / FTS_ONLY /
|
|
117
|
+
CANONICAL_INDEX_COUNT / CANONICAL_ROWID_DISORDER / SCHEMA / NOTADB).
|
|
118
|
+
|
|
119
|
+
## Patchable-target contract
|
|
120
|
+
|
|
121
|
+
Rules can patch two shapes of callable:
|
|
122
|
+
|
|
123
|
+
- Plain module-level functions: `point: mymodule.my_function`.
|
|
124
|
+
- Instance methods, addressed through the class:
|
|
125
|
+
`point: mymodule.MyClass.my_method` (resolution as in the Ruleset example
|
|
126
|
+
above).
|
|
127
|
+
|
|
128
|
+
Not supported: `classmethod`, `staticmethod`, and other descriptor-based
|
|
129
|
+
attributes. Patching replaces the class attribute, so descriptor binding is
|
|
130
|
+
lost. Calls through the instance pass `self` into the wrapper, so you usually
|
|
131
|
+
get a TypeError, not a silent no-op. If you need them, wrap an inner plain
|
|
132
|
+
function instead.
|
|
133
|
+
|
|
134
|
+
The `pragma` action reaches its `sqlite3.Connection` in two ways. Without a
|
|
135
|
+
`target:` it scans the call's direct arguments and keyword values. With a
|
|
136
|
+
`target:` spec it resolves state the callable holds instead of receives:
|
|
137
|
+
|
|
138
|
+
```yaml
|
|
139
|
+
- id: flip-sync
|
|
140
|
+
point: myapp.session.SessionDB.append
|
|
141
|
+
event: entry
|
|
142
|
+
action:
|
|
143
|
+
kind: pragma
|
|
144
|
+
name: synchronous
|
|
145
|
+
value: "OFF"
|
|
146
|
+
target: self._conn
|
|
147
|
+
```
|
|
148
|
+
|
|
149
|
+
`self` is the first positional argument (the receiver for a patched method)
|
|
150
|
+
with an optional dotted attribute walk; `param:<name>` binds an argument by
|
|
151
|
+
name through the real signature; `result` is the exit-event return value.
|
|
152
|
+
Spec syntax is validated when the ruleset loads, and a spec that resolves
|
|
153
|
+
for no call leaves an `outcome` record in the firing log instead of
|
|
154
|
+
silently doing nothing. The full grammar and failure policy live in
|
|
155
|
+
`docs/targeting.md`.
|
|
156
|
+
|
|
157
|
+
## Import-hook name matching
|
|
158
|
+
|
|
159
|
+
Patching happens when the target module is imported. The import hook matches
|
|
160
|
+
the module name Python passes to `import`, so rules must name the target's
|
|
161
|
+
absolute TOP-LEVEL module as it is imported directly: `import mymodule` or
|
|
162
|
+
`from mymodule import thing`. Two shapes do not match:
|
|
163
|
+
|
|
164
|
+
- Relative imports (`from . import x` inside a package) never reach the hook.
|
|
165
|
+
importlib resolves them internally; the hook only sees the outer top-level
|
|
166
|
+
import. No rule-module renaming can match them.
|
|
167
|
+
- Submodule imports (`import package.mymodule`) do not match a rule on the
|
|
168
|
+
submodule. The hook sees the full dotted name, but a ruleset cannot express
|
|
169
|
+
a dotted module, because the point splits at the first dot. The form
|
|
170
|
+
`from package import mymodule` does match a rule anchored on the parent
|
|
171
|
+
(`point: package.mymodule.func`). The hook sees `package`, and the symbol
|
|
172
|
+
walk descends into the submodule attribute.
|
|
173
|
+
|
|
174
|
+
## Status
|
|
175
|
+
|
|
176
|
+
Pre-release; born out of a real SQLite corruption investigation.
|
|
177
|
+
|
|
178
|
+
## License
|
|
179
|
+
|
|
180
|
+
MIT; see [LICENSE](LICENSE).
|
pyteman-0.1.0/README.md
ADDED
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
# pyteman
|
|
2
|
+
|
|
3
|
+
Rule-based runtime fault injection for Python, inspired by Byteman.
|
|
4
|
+
|
|
5
|
+
Wrap a function with a YAML rule that fires on entry or exit, under a
|
|
6
|
+
condition. The action can inject a sleep, raise an exception, override the
|
|
7
|
+
return value, switch a SQLite PRAGMA on a connection passed to the call,
|
|
8
|
+
kill the process at the exact injection point (`os._exit`), or hold a named
|
|
9
|
+
barrier so two threads meet in the interleaving you want. Each firing is
|
|
10
|
+
logged with a sequence number, so you can reconstruct the interleaving after
|
|
11
|
+
the run.
|
|
12
|
+
|
|
13
|
+
## Activation contract (safety)
|
|
14
|
+
|
|
15
|
+
- Put the directory containing `sitecustomize.py` on the PYTHONPATH of TEST
|
|
16
|
+
runs only; that is `src/pyteman`, not `src`. Python imports `sitecustomize`
|
|
17
|
+
as a top-level module from whichever directory holds it. `pyteman.*` itself
|
|
18
|
+
resolves for normal imports via the editable install.
|
|
19
|
+
- Without `PYTEMAN_RULES` set, the sitecustomize does nothing.
|
|
20
|
+
- With `PYTEMAN_REQUIRE_MARKER=<file>` set, pyteman refuses to start unless
|
|
21
|
+
that marker file exists. It writes a refusal message to stderr and exits
|
|
22
|
+
with code 2 via `os._exit`. The hard exit is deliberate: a `SystemExit`
|
|
23
|
+
raised inside sitecustomize escapes into interpreter startup, and the
|
|
24
|
+
interpreter dies with a Fatal Python error and status 1 instead of your
|
|
25
|
+
exit code. Callers use the marker to pin execution to scratch directories.
|
|
26
|
+
Never install sitecustomize into production venvs or images.
|
|
27
|
+
|
|
28
|
+
## Ruleset example
|
|
29
|
+
|
|
30
|
+
```yaml
|
|
31
|
+
- id: hold-commit
|
|
32
|
+
point: hermes_state.SessionDB._execute_write
|
|
33
|
+
event: entry
|
|
34
|
+
when: "fires > 3 and kwargs.get('sid', '').startswith('stress-')"
|
|
35
|
+
action: {kind: sleep, ms: 250}
|
|
36
|
+
fire: {mode: once_per, key: "kwargs.get('sid')"}
|
|
37
|
+
- id: crash-at-commit
|
|
38
|
+
point: hermes_state.SessionDB.commit
|
|
39
|
+
event: exit
|
|
40
|
+
action: {kind: kill, exit_code: 70}
|
|
41
|
+
fire: {mode: countdown, n: 50}
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
The module is everything before the FIRST dot of `point`; the remainder is an
|
|
45
|
+
attribute path walked from the module, and the final component is the patched
|
|
46
|
+
attribute. `hermes_state.SessionDB._execute_write` resolves to module
|
|
47
|
+
`hermes_state` with attribute path `SessionDB._execute_write`.
|
|
48
|
+
|
|
49
|
+
Conditions see `args`, `kwargs`, `fires`, and on exit events also
|
|
50
|
+
`result`/`exc`. They are trusted operator input for test tooling.
|
|
51
|
+
|
|
52
|
+
Actions: `sleep`, `raise`, `return_value`, `return_none`, `pragma` (reaches
|
|
53
|
+
attribute-held connections through `target:` specs, see docs/targeting.md),
|
|
54
|
+
`kill` (`os._exit`), `barrier` (role `wait` or `open`).
|
|
55
|
+
|
|
56
|
+
`return_value`/`return_none` follow Byteman RETURN semantics and depend on the
|
|
57
|
+
event. On an ENTRY event the wrapped body is skipped entirely and the override
|
|
58
|
+
value is returned in its place. On an EXIT event the original body has already
|
|
59
|
+
run and the override swaps the result it produced.
|
|
60
|
+
|
|
61
|
+
Fire gating uses `fire: {mode: ...}` with three modes. `always` is the
|
|
62
|
+
default. `once_per <key-expr>` consumes its key only when the condition
|
|
63
|
+
passes. `countdown n` fires on call n+1.
|
|
64
|
+
|
|
65
|
+
## Runner and sqlitekit
|
|
66
|
+
|
|
67
|
+
`pyteman.runner.matrix.run_matrix(cells, run_cell, results_db, artifact_root)`
|
|
68
|
+
runs cells sequentially and resumes across re-runs via the results SQLite;
|
|
69
|
+
`pyteman.runner.report.matrix_markdown` renders the outcome table.
|
|
70
|
+
`pyteman.sqlitekit.integrity.classify_integrity` parses `PRAGMA
|
|
71
|
+
integrity_check` output into typed signatures (CLEAN / FTS_ONLY /
|
|
72
|
+
CANONICAL_INDEX_COUNT / CANONICAL_ROWID_DISORDER / SCHEMA / NOTADB).
|
|
73
|
+
|
|
74
|
+
## Patchable-target contract
|
|
75
|
+
|
|
76
|
+
Rules can patch two shapes of callable:
|
|
77
|
+
|
|
78
|
+
- Plain module-level functions: `point: mymodule.my_function`.
|
|
79
|
+
- Instance methods, addressed through the class:
|
|
80
|
+
`point: mymodule.MyClass.my_method` (resolution as in the Ruleset example
|
|
81
|
+
above).
|
|
82
|
+
|
|
83
|
+
Not supported: `classmethod`, `staticmethod`, and other descriptor-based
|
|
84
|
+
attributes. Patching replaces the class attribute, so descriptor binding is
|
|
85
|
+
lost. Calls through the instance pass `self` into the wrapper, so you usually
|
|
86
|
+
get a TypeError, not a silent no-op. If you need them, wrap an inner plain
|
|
87
|
+
function instead.
|
|
88
|
+
|
|
89
|
+
The `pragma` action reaches its `sqlite3.Connection` in two ways. Without a
|
|
90
|
+
`target:` it scans the call's direct arguments and keyword values. With a
|
|
91
|
+
`target:` spec it resolves state the callable holds instead of receives:
|
|
92
|
+
|
|
93
|
+
```yaml
|
|
94
|
+
- id: flip-sync
|
|
95
|
+
point: myapp.session.SessionDB.append
|
|
96
|
+
event: entry
|
|
97
|
+
action:
|
|
98
|
+
kind: pragma
|
|
99
|
+
name: synchronous
|
|
100
|
+
value: "OFF"
|
|
101
|
+
target: self._conn
|
|
102
|
+
```
|
|
103
|
+
|
|
104
|
+
`self` is the first positional argument (the receiver for a patched method)
|
|
105
|
+
with an optional dotted attribute walk; `param:<name>` binds an argument by
|
|
106
|
+
name through the real signature; `result` is the exit-event return value.
|
|
107
|
+
Spec syntax is validated when the ruleset loads, and a spec that resolves
|
|
108
|
+
for no call leaves an `outcome` record in the firing log instead of
|
|
109
|
+
silently doing nothing. The full grammar and failure policy live in
|
|
110
|
+
`docs/targeting.md`.
|
|
111
|
+
|
|
112
|
+
## Import-hook name matching
|
|
113
|
+
|
|
114
|
+
Patching happens when the target module is imported. The import hook matches
|
|
115
|
+
the module name Python passes to `import`, so rules must name the target's
|
|
116
|
+
absolute TOP-LEVEL module as it is imported directly: `import mymodule` or
|
|
117
|
+
`from mymodule import thing`. Two shapes do not match:
|
|
118
|
+
|
|
119
|
+
- Relative imports (`from . import x` inside a package) never reach the hook.
|
|
120
|
+
importlib resolves them internally; the hook only sees the outer top-level
|
|
121
|
+
import. No rule-module renaming can match them.
|
|
122
|
+
- Submodule imports (`import package.mymodule`) do not match a rule on the
|
|
123
|
+
submodule. The hook sees the full dotted name, but a ruleset cannot express
|
|
124
|
+
a dotted module, because the point splits at the first dot. The form
|
|
125
|
+
`from package import mymodule` does match a rule anchored on the parent
|
|
126
|
+
(`point: package.mymodule.func`). The hook sees `package`, and the symbol
|
|
127
|
+
walk descends into the submodule attribute.
|
|
128
|
+
|
|
129
|
+
## Status
|
|
130
|
+
|
|
131
|
+
Pre-release; born out of a real SQLite corruption investigation.
|
|
132
|
+
|
|
133
|
+
## License
|
|
134
|
+
|
|
135
|
+
MIT; see [LICENSE](LICENSE).
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "pyteman"
|
|
3
|
+
version = "0.1.0"
|
|
4
|
+
description = "Rule-based runtime fault injection for Python, inspired by Byteman"
|
|
5
|
+
readme = "README.md"
|
|
6
|
+
license = { file = "LICENSE" }
|
|
7
|
+
authors = [{ name = "Paolo Antinori" }]
|
|
8
|
+
requires-python = ">=3.11"
|
|
9
|
+
dependencies = ["PyYAML>=6.0"]
|
|
10
|
+
keywords = ["fault-injection", "testing", "chaos", "byteman", "sqlite", "reproducer"]
|
|
11
|
+
classifiers = [
|
|
12
|
+
"Development Status :: 3 - Alpha",
|
|
13
|
+
"Intended Audience :: Developers",
|
|
14
|
+
"License :: OSI Approved :: MIT License",
|
|
15
|
+
"Programming Language :: Python :: 3",
|
|
16
|
+
"Programming Language :: Python :: 3.11",
|
|
17
|
+
"Programming Language :: Python :: 3.12",
|
|
18
|
+
"Programming Language :: Python :: 3.13",
|
|
19
|
+
"Topic :: Software Development :: Testing",
|
|
20
|
+
]
|
|
21
|
+
|
|
22
|
+
[project.urls]
|
|
23
|
+
Homepage = "https://github.com/RisorseArtificiali/pyteman"
|
|
24
|
+
Repository = "https://github.com/RisorseArtificiali/pyteman"
|
|
25
|
+
Issues = "https://github.com/RisorseArtificiali/pyteman/issues"
|
|
26
|
+
|
|
27
|
+
[build-system]
|
|
28
|
+
requires = ["setuptools>=68"]
|
|
29
|
+
build-backend = "setuptools.build_meta"
|
|
30
|
+
|
|
31
|
+
[tool.setuptools.packages.find]
|
|
32
|
+
where = ["src"]
|
|
33
|
+
|
|
34
|
+
[tool.setuptools.package-data]
|
|
35
|
+
pyteman = ["py.typed"]
|
|
36
|
+
|
|
37
|
+
[tool.pytest.ini_options]
|
|
38
|
+
testpaths = ["tests"]
|
pyteman-0.1.0/setup.cfg
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
__version__ = "0.1.0"
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
# src/pyteman/actions.py
|
|
2
|
+
"""Action execution for injected rules.
|
|
3
|
+
|
|
4
|
+
Trusted-operator posture: rules come from operator-authored YAML used for
|
|
5
|
+
local test tooling, never from untrusted input. By design, `when` condition
|
|
6
|
+
expressions and `pragma` name/value strings are passed through unsanitized;
|
|
7
|
+
`raise` resolves only builtin exception classes. Extending this module to
|
|
8
|
+
face untrusted rule sources would require sanitizing all three.
|
|
9
|
+
"""
|
|
10
|
+
import builtins as _builtins
|
|
11
|
+
import os
|
|
12
|
+
import sqlite3
|
|
13
|
+
import time
|
|
14
|
+
|
|
15
|
+
from pyteman.targets import resolve_target
|
|
16
|
+
|
|
17
|
+
def run_action(rule, ctx, log=None):
|
|
18
|
+
if log is not None:
|
|
19
|
+
log.record(rule, ctx, note=str(rule.action))
|
|
20
|
+
kind = rule.action["kind"]
|
|
21
|
+
if kind == "return_value":
|
|
22
|
+
ctx["_override"] = rule.action.get("value")
|
|
23
|
+
return
|
|
24
|
+
if kind == "return_none":
|
|
25
|
+
ctx["_override"] = None
|
|
26
|
+
return
|
|
27
|
+
if kind == "sleep":
|
|
28
|
+
time.sleep(int(rule.action.get("ms", 0)) / 1000.0)
|
|
29
|
+
return
|
|
30
|
+
if kind == "raise":
|
|
31
|
+
name = rule.action.get("exc", "RuntimeError")
|
|
32
|
+
exc = getattr(_builtins, name, None)
|
|
33
|
+
if not isinstance(exc, type) or not issubclass(exc, BaseException):
|
|
34
|
+
raise RuntimeError(f"unknown exception class {name}")
|
|
35
|
+
raise exc(rule.action.get("message", "pyteman injected"))
|
|
36
|
+
if kind == "pragma":
|
|
37
|
+
target_spec = rule.action.get("target")
|
|
38
|
+
con, why = (resolve_target(ctx, target_spec) if target_spec is not None
|
|
39
|
+
else _find_connection(ctx))
|
|
40
|
+
if con is None:
|
|
41
|
+
_note(log, rule, ctx, f"pragma skipped: {why}")
|
|
42
|
+
return
|
|
43
|
+
try:
|
|
44
|
+
con.execute(f"PRAGMA {rule.action['name']}={rule.action['value']}")
|
|
45
|
+
except Exception as exc:
|
|
46
|
+
_note(log, rule, ctx, f"pragma execute failed on {type(con).__name__}: {exc}")
|
|
47
|
+
return
|
|
48
|
+
if kind == "kill":
|
|
49
|
+
os._exit(int(rule.action.get("exit_code", 70)))
|
|
50
|
+
if kind == "barrier":
|
|
51
|
+
from pyteman import barriers
|
|
52
|
+
name = rule.action["barrier"]
|
|
53
|
+
if rule.action.get("role", "wait") == "open":
|
|
54
|
+
barriers.open(name)
|
|
55
|
+
return True
|
|
56
|
+
return barriers.wait(name, timeout_s=float(rule.action.get("timeout_s", 30)))
|
|
57
|
+
raise NotImplementedError(f"unknown action kind {kind}")
|
|
58
|
+
|
|
59
|
+
def _find_connection(ctx):
|
|
60
|
+
"""Legacy no-target path: (con, None) or (None, reason), same protocol
|
|
61
|
+
as resolve_target so the pragma action has one miss branch."""
|
|
62
|
+
for v in list(ctx.get("args", ())) + list(ctx.get("kwargs", {}).values()):
|
|
63
|
+
if isinstance(v, sqlite3.Connection):
|
|
64
|
+
return v, None
|
|
65
|
+
return None, "no target spec and no sqlite3.Connection in the call arguments"
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def _note(log, rule, ctx, message):
|
|
69
|
+
# Unresolvable pragma targets must be visible, not silent no-ops: the
|
|
70
|
+
# firing log is the operator's only channel when the workload runs in a
|
|
71
|
+
# container. A separate outcome record (new seq) keeps the attempt and
|
|
72
|
+
# its result distinguishable; but an identical miss repeats on every
|
|
73
|
+
# call under fire: always, so record each distinct (rule, message) once
|
|
74
|
+
# per LOG INSTANCE, never per process: a second FiringLog in the same
|
|
75
|
+
# process (a reopened leg, a new test) must still see its own note.
|
|
76
|
+
if log is None:
|
|
77
|
+
return
|
|
78
|
+
noted = getattr(log, "_pyteman_noted", None)
|
|
79
|
+
if noted is None:
|
|
80
|
+
noted = log._pyteman_noted = set()
|
|
81
|
+
key = (rule.id, message)
|
|
82
|
+
if key not in noted:
|
|
83
|
+
noted.add(key) # idempotent; a rare check-then-add race costs one duplicate line
|
|
84
|
+
log.record(rule, ctx, outcome=message)
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
# src/pyteman/barriers.py
|
|
2
|
+
import threading
|
|
3
|
+
|
|
4
|
+
_lock = threading.Lock()
|
|
5
|
+
_state = {}
|
|
6
|
+
|
|
7
|
+
def wait(name, timeout_s=30.0):
|
|
8
|
+
with _lock:
|
|
9
|
+
ev = _state.setdefault(name, threading.Event())
|
|
10
|
+
if ev.is_set():
|
|
11
|
+
return True
|
|
12
|
+
return ev.wait(timeout_s)
|
|
13
|
+
|
|
14
|
+
def open(name):
|
|
15
|
+
with _lock:
|
|
16
|
+
_state.setdefault(name, threading.Event()).set()
|
|
17
|
+
|
|
18
|
+
def reset_all():
|
|
19
|
+
global _state
|
|
20
|
+
with _lock:
|
|
21
|
+
_state = {}
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
# The eval namespace is convenience scoping, not a security boundary: rules are trusted operator input.
|
|
2
|
+
_SAFE = {"len": len, "str": str, "int": int, "float": float, "bool": bool,
|
|
3
|
+
"abs": abs, "min": min, "max": max, "sorted": sorted, "isinstance": isinstance}
|
|
4
|
+
_EVAL_GLOBALS = {"__builtins__": {}, **_SAFE}
|
|
5
|
+
|
|
6
|
+
def eval_expr(code, ctx):
|
|
7
|
+
return eval(code, _EVAL_GLOBALS, ctx)
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
# src/pyteman/firing.py
|
|
2
|
+
import json
|
|
3
|
+
import threading
|
|
4
|
+
import time
|
|
5
|
+
|
|
6
|
+
class FiringLog:
|
|
7
|
+
def __init__(self, path):
|
|
8
|
+
self.path = path
|
|
9
|
+
self._seq = 0
|
|
10
|
+
self._lock = threading.Lock()
|
|
11
|
+
self._fh = open(path, "a")
|
|
12
|
+
|
|
13
|
+
def record(self, rule, ctx, note=None, outcome=None):
|
|
14
|
+
# "outcome" marks action-outcome annotations (skips, execute
|
|
15
|
+
# failures) so log consumers can tell them from firing records,
|
|
16
|
+
# which carry the action dump in "note".
|
|
17
|
+
rec = {
|
|
18
|
+
"ts": time.strftime("%Y-%m-%dT%H:%M:%S"),
|
|
19
|
+
"rule": rule.id,
|
|
20
|
+
"event": rule.event,
|
|
21
|
+
"thread": threading.current_thread().name,
|
|
22
|
+
"note": note}
|
|
23
|
+
if outcome is not None:
|
|
24
|
+
rec["outcome"] = outcome
|
|
25
|
+
with self._lock:
|
|
26
|
+
self._seq += 1
|
|
27
|
+
rec["seq"] = self._seq
|
|
28
|
+
self._fh.write(json.dumps(rec) + "\n")
|
|
29
|
+
self._fh.flush()
|
|
30
|
+
|
|
31
|
+
def open_log(path):
|
|
32
|
+
return FiringLog(path) if path else None
|
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
import builtins
|
|
2
|
+
import functools
|
|
3
|
+
import sys
|
|
4
|
+
|
|
5
|
+
from pyteman.actions import run_action
|
|
6
|
+
from pyteman.conditions import eval_expr
|
|
7
|
+
from pyteman.targets import parse_target_spec
|
|
8
|
+
|
|
9
|
+
_NO_OVERRIDE = object()
|
|
10
|
+
|
|
11
|
+
class Patcher:
|
|
12
|
+
def __init__(self, rules, log):
|
|
13
|
+
self.rules = rules
|
|
14
|
+
self.log = log
|
|
15
|
+
self.applied = []
|
|
16
|
+
self._orig_import = None
|
|
17
|
+
self._wrapped = []
|
|
18
|
+
|
|
19
|
+
def force_patch_module(self, modname):
|
|
20
|
+
mod = sys.modules.get(modname)
|
|
21
|
+
if mod is not None:
|
|
22
|
+
self._patch(mod, modname)
|
|
23
|
+
|
|
24
|
+
def _patch(self, mod, modname):
|
|
25
|
+
for rule in self.rules:
|
|
26
|
+
if rule.module != modname:
|
|
27
|
+
continue
|
|
28
|
+
parts = rule.symbol.split(".")
|
|
29
|
+
container = mod
|
|
30
|
+
for part in parts[:-1]:
|
|
31
|
+
container = getattr(container, part, None)
|
|
32
|
+
if container is None:
|
|
33
|
+
break
|
|
34
|
+
if container is None or not hasattr(container, parts[-1]):
|
|
35
|
+
continue
|
|
36
|
+
name = parts[-1]
|
|
37
|
+
original = getattr(container, name)
|
|
38
|
+
if getattr(original, "_pyteman_state", None) is not None:
|
|
39
|
+
continue # already wrapped by us: re-patching would double-count fires
|
|
40
|
+
wrapper = self._make_wrapper(rule, original)
|
|
41
|
+
setattr(container, name, wrapper)
|
|
42
|
+
self._wrapped.append((container, name, original))
|
|
43
|
+
self.applied.append(f"{modname}:{rule.symbol}")
|
|
44
|
+
|
|
45
|
+
def _make_wrapper(self, rule, original):
|
|
46
|
+
state = {"fires": 0, "seen_keys": set()}
|
|
47
|
+
when_code = compile(rule.when, f"<pyteman:{rule.id}:when>", "eval") if rule.when else None
|
|
48
|
+
key_expr = rule.fire.get("key")
|
|
49
|
+
key_code = compile(key_expr, f"<pyteman:{rule.id}:key>", "eval") if key_expr else None
|
|
50
|
+
# Install-time analysis (like when_code/key_code above): a param:
|
|
51
|
+
# target needs the real signature to bind positional-or-keyword
|
|
52
|
+
# arguments by name, so compute it once here instead of per firing
|
|
53
|
+
# and never mutate the user's callable. The kind comes from the
|
|
54
|
+
# same parser the resolver uses, so whitespace or a typo cannot
|
|
55
|
+
# make the two disagree.
|
|
56
|
+
sig = None
|
|
57
|
+
sig_unparseable = False
|
|
58
|
+
parsed, _ = parse_target_spec(str(rule.action.get("target", "")))
|
|
59
|
+
if (rule.action.get("kind") == "pragma" and parsed is not None
|
|
60
|
+
and parsed[0] == "param"):
|
|
61
|
+
try:
|
|
62
|
+
import inspect
|
|
63
|
+
sig = inspect.signature(original)
|
|
64
|
+
except (TypeError, ValueError):
|
|
65
|
+
sig = None
|
|
66
|
+
sig_unparseable = True # param targets note-and-skip with the true cause
|
|
67
|
+
|
|
68
|
+
@functools.wraps(original)
|
|
69
|
+
def wrapped(*args, **kwargs):
|
|
70
|
+
ctx = {"args": args, "kwargs": kwargs, "fires": state["fires"]}
|
|
71
|
+
if sig is not None or sig_unparseable:
|
|
72
|
+
# Only param:-targeted rules pay for the ctx entry.
|
|
73
|
+
if sig is not None:
|
|
74
|
+
ctx["_signature"] = sig
|
|
75
|
+
if sig_unparseable:
|
|
76
|
+
ctx["_signature_unparseable"] = True
|
|
77
|
+
if rule.event == "entry" and _gate(rule, state, ctx, when_code, key_code):
|
|
78
|
+
run_action(rule, ctx, log=self.log)
|
|
79
|
+
override = ctx.get("_override", _NO_OVERRIDE)
|
|
80
|
+
if override is not _NO_OVERRIDE:
|
|
81
|
+
return override
|
|
82
|
+
result = None
|
|
83
|
+
exc = None
|
|
84
|
+
try:
|
|
85
|
+
result = original(*args, **kwargs)
|
|
86
|
+
except BaseException as e:
|
|
87
|
+
exc = e
|
|
88
|
+
raise
|
|
89
|
+
finally:
|
|
90
|
+
if rule.event == "exit":
|
|
91
|
+
ctx["result"] = result
|
|
92
|
+
ctx["exc"] = exc
|
|
93
|
+
if _gate(rule, state, ctx, when_code, key_code):
|
|
94
|
+
run_action(rule, ctx, log=self.log)
|
|
95
|
+
override = ctx.get("_override", _NO_OVERRIDE)
|
|
96
|
+
return override if override is not _NO_OVERRIDE else result
|
|
97
|
+
|
|
98
|
+
wrapped._pyteman_state = state
|
|
99
|
+
return wrapped
|
|
100
|
+
|
|
101
|
+
def install_hook(self):
|
|
102
|
+
orig = builtins.__import__
|
|
103
|
+
|
|
104
|
+
def hooked(name, *a, **k):
|
|
105
|
+
mod = orig(name, *a, **k)
|
|
106
|
+
target = sys.modules.get(name)
|
|
107
|
+
if target is not None:
|
|
108
|
+
self._patch(target, name)
|
|
109
|
+
return mod
|
|
110
|
+
|
|
111
|
+
self._orig_import = orig
|
|
112
|
+
builtins.__import__ = hooked
|
|
113
|
+
|
|
114
|
+
def uninstall(self):
|
|
115
|
+
if self._orig_import is not None:
|
|
116
|
+
builtins.__import__ = self._orig_import
|
|
117
|
+
self._orig_import = None
|
|
118
|
+
for container, name, original in reversed(self._wrapped):
|
|
119
|
+
setattr(container, name, original)
|
|
120
|
+
self._wrapped.clear()
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def _gate(rule, state, ctx, when_code=None, key_code=None):
|
|
124
|
+
state["fires"] += 1
|
|
125
|
+
ctx["fires"] = state["fires"]
|
|
126
|
+
mode = rule.fire.get("mode", "always")
|
|
127
|
+
pending_key = None
|
|
128
|
+
if mode == "countdown":
|
|
129
|
+
n = int(rule.fire.get("n", 1))
|
|
130
|
+
if state["fires"] != n + 1:
|
|
131
|
+
return False
|
|
132
|
+
elif mode == "once_per":
|
|
133
|
+
pending_key = eval_expr(key_code, ctx) if key_code is not None else None
|
|
134
|
+
if pending_key in state["seen_keys"]:
|
|
135
|
+
return False
|
|
136
|
+
if when_code is not None and not eval_expr(when_code, ctx):
|
|
137
|
+
return False
|
|
138
|
+
if mode == "once_per":
|
|
139
|
+
state["seen_keys"].add(pending_key)
|
|
140
|
+
return True
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
def install(rules, log=None):
|
|
144
|
+
p = Patcher(rules, log)
|
|
145
|
+
p.install_hook()
|
|
146
|
+
return p
|
|
File without changes
|