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.
Files changed (35) hide show
  1. pyteman-0.1.0/LICENSE +21 -0
  2. pyteman-0.1.0/PKG-INFO +180 -0
  3. pyteman-0.1.0/README.md +135 -0
  4. pyteman-0.1.0/pyproject.toml +38 -0
  5. pyteman-0.1.0/setup.cfg +4 -0
  6. pyteman-0.1.0/src/pyteman/__init__.py +1 -0
  7. pyteman-0.1.0/src/pyteman/actions.py +84 -0
  8. pyteman-0.1.0/src/pyteman/barriers.py +21 -0
  9. pyteman-0.1.0/src/pyteman/conditions.py +7 -0
  10. pyteman-0.1.0/src/pyteman/firing.py +32 -0
  11. pyteman-0.1.0/src/pyteman/patcher.py +146 -0
  12. pyteman-0.1.0/src/pyteman/py.typed +0 -0
  13. pyteman-0.1.0/src/pyteman/rules.py +90 -0
  14. pyteman-0.1.0/src/pyteman/runner/__init__.py +0 -0
  15. pyteman-0.1.0/src/pyteman/runner/matrix.py +40 -0
  16. pyteman-0.1.0/src/pyteman/runner/report.py +19 -0
  17. pyteman-0.1.0/src/pyteman/sitecustomize.py +27 -0
  18. pyteman-0.1.0/src/pyteman/sqlitekit/__init__.py +0 -0
  19. pyteman-0.1.0/src/pyteman/sqlitekit/integrity.py +20 -0
  20. pyteman-0.1.0/src/pyteman/targets.py +127 -0
  21. pyteman-0.1.0/src/pyteman.egg-info/PKG-INFO +180 -0
  22. pyteman-0.1.0/src/pyteman.egg-info/SOURCES.txt +33 -0
  23. pyteman-0.1.0/src/pyteman.egg-info/dependency_links.txt +1 -0
  24. pyteman-0.1.0/src/pyteman.egg-info/requires.txt +1 -0
  25. pyteman-0.1.0/src/pyteman.egg-info/top_level.txt +1 -0
  26. pyteman-0.1.0/tests/test_actions.py +38 -0
  27. pyteman-0.1.0/tests/test_barriers.py +19 -0
  28. pyteman-0.1.0/tests/test_firing.py +15 -0
  29. pyteman-0.1.0/tests/test_integrity.py +26 -0
  30. pyteman-0.1.0/tests/test_kill.py +19 -0
  31. pyteman-0.1.0/tests/test_matrix.py +52 -0
  32. pyteman-0.1.0/tests/test_patcher.py +82 -0
  33. pyteman-0.1.0/tests/test_rules.py +66 -0
  34. pyteman-0.1.0/tests/test_sitecustomize.py +46 -0
  35. 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).
@@ -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"]
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -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