pyteman 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
pyteman/__init__.py ADDED
@@ -0,0 +1 @@
1
+ __version__ = "0.1.0"
pyteman/actions.py ADDED
@@ -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)
pyteman/barriers.py ADDED
@@ -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 = {}
pyteman/conditions.py ADDED
@@ -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)
pyteman/firing.py ADDED
@@ -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
pyteman/patcher.py ADDED
@@ -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
pyteman/py.typed ADDED
File without changes
pyteman/rules.py ADDED
@@ -0,0 +1,90 @@
1
+ from dataclasses import dataclass, field
2
+ from typing import Optional
3
+ import yaml
4
+
5
+ from pyteman.targets import parse_target_spec, validate_target_spec
6
+
7
+ class RuleError(Exception):
8
+ pass
9
+
10
+ _EVENTS = ("entry", "exit")
11
+ _ACTION_KINDS = ("sleep", "raise", "return_value", "return_none", "kill", "pragma", "barrier")
12
+ _FIRE_MODES = ("always", "once_per", "countdown")
13
+
14
+ @dataclass
15
+ class Rule:
16
+ id: str
17
+ module: str
18
+ symbol: str
19
+ event: str
20
+ action: dict
21
+ fire: dict = field(default_factory=lambda: {"mode": "always"})
22
+ when: Optional[str] = None
23
+
24
+ def parse_point(point: str) -> tuple[str, str]:
25
+ """Split a point string at the LAST dot: "os.path.join" -> ("os.path", "join").
26
+
27
+ Rule points deliberately resolve differently: load_rules splits at the
28
+ FIRST dot so "pkg.Class.method" yields module "pkg" and symbol
29
+ "Class.method", the attribute chain walked from the imported module.
30
+ """
31
+ if "." not in point:
32
+ raise RuleError(f"point must be 'module.symbol' (got {point!r})")
33
+ mod, _, sym = point.rpartition(".")
34
+ return mod, sym
35
+
36
+ def load_rules(path: str) -> list:
37
+ with open(path) as fh:
38
+ raw = yaml.safe_load(fh)
39
+ if raw is None:
40
+ raw = []
41
+ if not isinstance(raw, list):
42
+ raise RuleError("ruleset must be a YAML list")
43
+ rules = []
44
+ for i, item in enumerate(raw):
45
+ where = f"rule #{i}"
46
+ if not isinstance(item, dict):
47
+ raise RuleError(f"{where}: mapping required")
48
+ for key in ("id", "point", "event", "action"):
49
+ if key not in item:
50
+ raise RuleError(f"{where}: missing {key}")
51
+ if item["event"] not in _EVENTS:
52
+ raise RuleError(f"{where}: event must be one of {_EVENTS}")
53
+ action = item["action"]
54
+ if not isinstance(action, dict) or action.get("kind") not in _ACTION_KINDS:
55
+ raise RuleError(f"{where}: action.kind must be one of {_ACTION_KINDS}")
56
+ # target: is consumed by pragma only (today). Validate loudly here so a
57
+ # typo'd spec dies at load, never as a silent runtime no-op.
58
+ if action.get("kind") == "pragma":
59
+ for field in ("name", "value"):
60
+ if field not in action:
61
+ raise RuleError(f"{where}: pragma action needs '{field}'")
62
+ if "target" in action:
63
+ if action.get("kind") != "pragma":
64
+ raise RuleError(f"{where}: 'target' is only consumed by pragma actions")
65
+ try:
66
+ validate_target_spec(action["target"], where)
67
+ except ValueError as e:
68
+ raise RuleError(str(e)) from None
69
+ parsed, _ = parse_target_spec(action["target"])
70
+ if parsed is not None and parsed[0] == "result" and item["event"] == "entry":
71
+ raise RuleError(
72
+ f"{where}: target 'result' can only resolve on exit events")
73
+ fire = item.get("fire", {"mode": "always"})
74
+ if not isinstance(fire, dict):
75
+ raise RuleError(f"{where}: fire must be a mapping")
76
+ if fire.get("mode") not in _FIRE_MODES:
77
+ raise RuleError(f"{where}: fire.mode must be one of {_FIRE_MODES}")
78
+ # Rule targets are "module.Class.method": the module is the first dot
79
+ # component and the symbol is the attribute path walked from it, so the
80
+ # split here is at the FIRST dot (parse_point keeps the last-dot split
81
+ # for module-path interpretation).
82
+ point = item["point"]
83
+ if not isinstance(point, str):
84
+ raise RuleError(f"{where}: point must be a string")
85
+ if "." not in point:
86
+ raise RuleError(f"point must be 'module.symbol' (got {point!r})")
87
+ mod, _, sym = point.partition(".")
88
+ rules.append(Rule(id=item["id"], module=mod, symbol=sym, event=item["event"],
89
+ action=action, fire=fire, when=item.get("when")))
90
+ return rules
File without changes
@@ -0,0 +1,40 @@
1
+ import json
2
+ import os
3
+ import sqlite3
4
+
5
+ def run_matrix(cells, run_cell, results_db, artifact_root):
6
+ """Run every cell once, persisting each outcome to the results db.
7
+
8
+ Returned status reflects execution outcome: done, failed (run_cell
9
+ raised) or skipped (a previous run already recorded done). The results
10
+ db is the durable record; failed cells re-run on the next invocation.
11
+ """
12
+ # The runner owns its artifact root: create it before opening the results
13
+ # db, so a fresh root (first run of a new matrix) works without the caller
14
+ # having pre-created anything.
15
+ os.makedirs(os.path.dirname(os.path.abspath(results_db)), exist_ok=True)
16
+ con = sqlite3.connect(results_db)
17
+ con.execute("CREATE TABLE IF NOT EXISTS results("
18
+ "cell_id TEXT PRIMARY KEY, status TEXT, result_json TEXT, artifact_dir TEXT)")
19
+ con.commit()
20
+ out = []
21
+ for cell in cells:
22
+ row = con.execute("SELECT status FROM results WHERE cell_id=?",
23
+ (cell["id"],)).fetchone()
24
+ if row and row[0] == "done":
25
+ out.append({"cell_id": cell["id"], "status": "skipped"})
26
+ continue
27
+ adir = os.path.join(artifact_root, cell["id"])
28
+ os.makedirs(adir, exist_ok=True)
29
+ try:
30
+ result = run_cell(cell, adir) or {}
31
+ status = "done"
32
+ except Exception as e:
33
+ result = {"error": repr(e)}
34
+ status = "failed"
35
+ con.execute("INSERT OR REPLACE INTO results VALUES (?,?,?,?)",
36
+ (cell["id"], status, json.dumps(result), adir))
37
+ con.commit()
38
+ out.append({"cell_id": cell["id"], "status": status, "result": result})
39
+ con.close()
40
+ return out
@@ -0,0 +1,19 @@
1
+ import json
2
+ import sqlite3
3
+
4
+ def matrix_markdown(results_db, out_path):
5
+ con = sqlite3.connect(results_db)
6
+ rows = con.execute("SELECT cell_id, status, result_json FROM results "
7
+ "ORDER BY cell_id").fetchall()
8
+ con.close()
9
+ lines = ["| cell | status | signature |", "|---|---|---|"]
10
+ for cid, status, rj in rows:
11
+ sig = ""
12
+ try:
13
+ r = json.loads(rj or "{}")
14
+ sig = r.get("signature", r.get("error", ""))
15
+ except Exception:
16
+ sig = "?"
17
+ lines.append(f"| {cid} | {status} | {sig} |")
18
+ with open(out_path, "w") as fh:
19
+ fh.write("\n".join(lines) + "\n")
@@ -0,0 +1,27 @@
1
+ # src/pyteman/sitecustomize.py
2
+ import os
3
+ import sys
4
+
5
+ def _main():
6
+ rules_path = os.environ.get("PYTEMAN_RULES", "").strip()
7
+ if not rules_path:
8
+ return # INERT: no env, no effects
9
+ marker = os.environ.get("PYTEMAN_REQUIRE_MARKER", "").strip()
10
+ if marker and not os.path.isfile(marker):
11
+ sys.stderr.write(f"pyteman: refusing to start: marker file missing: {marker}\n")
12
+ sys.stderr.flush()
13
+ # sys.exit(2) is swallowed here: an exception escaping sitecustomize
14
+ # during startup hits init_import_site, which exits 1 regardless of
15
+ # the code. Hard-exit so the refusal status survives.
16
+ os._exit(2)
17
+ from pyteman.rules import load_rules
18
+ from pyteman.patcher import install
19
+ from pyteman.firing import open_log
20
+ rules = load_rules(rules_path)
21
+ log = open_log(os.environ.get("PYTEMAN_LOG", "pyteman.log"))
22
+ patcher = install(rules, log=log)
23
+ for modname in {r.module for r in rules}:
24
+ patcher.force_patch_module(modname)
25
+ sys._pyteman = {"patcher": patcher, "log": log}
26
+
27
+ _main()
File without changes
@@ -0,0 +1,20 @@
1
+ # src/pyteman/sqlitekit/integrity.py
2
+ def classify_integrity(text: str) -> dict:
3
+ lines = [l.strip() for l in text.strip().splitlines() if l.strip()]
4
+ if lines == ["ok"]:
5
+ return {"classes": ["CLEAN"], "raw": text}
6
+ classes = set()
7
+ damage = [l for l in lines if l != "*** in database main ***"]
8
+ for l in damage:
9
+ low = l.lower()
10
+ if "file is not a database" in low:
11
+ classes.add("NOTADB")
12
+ elif "malformed database schema" in low:
13
+ classes.add("SCHEMA")
14
+ elif "out of order" in low:
15
+ classes.add("CANONICAL_ROWID_DISORDER")
16
+ elif "wrong # of entries in index" in low:
17
+ classes.add("CANONICAL_INDEX_COUNT")
18
+ if damage and not classes and all("_fts" in l for l in damage):
19
+ classes.add("FTS_ONLY")
20
+ return {"classes": sorted(classes), "raw": text}
pyteman/targets.py ADDED
@@ -0,0 +1,127 @@
1
+ # src/pyteman/targets.py
2
+ """Resolution of `target:` specs against the firing context.
3
+
4
+ Rules sometimes need to reach state the instrumented callable holds rather
5
+ than receives: a SessionDB carries its sqlite3.Connection as `self._conn`,
6
+ so no argument scan can find it. An action may declare:
7
+
8
+ target: self._conn first positional argument, then attribute walk
9
+ target: param:db argument by parameter name (signature-bound,
10
+ positional or keyword)
11
+ target: result the exit-event return value
12
+
13
+ For a method patched through its class ("pkg.Session.append"), `self` is
14
+ the receiver; for a plain function it is simply the first argument.
15
+
16
+ One parser (parse_target_spec) defines the grammar for both consumers:
17
+ load-time validation dies loudly on a bad spec, and resolve_target turns
18
+ runtime misses (attribute absent, parameter not passed) into
19
+ (value=None, reason) so callers leave a firing-log note instead of
20
+ silently no-op'ing. This module deliberately depends on nothing else in
21
+ pyteman.
22
+ """
23
+ from functools import lru_cache
24
+
25
+
26
+ def resolve_target(ctx, spec):
27
+ parsed, err = _parse(spec)
28
+ if parsed is None:
29
+ return None, err
30
+ kind, name, dotted = parsed
31
+ if kind == "result":
32
+ if "result" not in ctx:
33
+ return None, "'result' is only available on exit events"
34
+ if ctx["result"] is None:
35
+ return None, "target 'result' resolved to None"
36
+ return ctx["result"], None
37
+ if kind == "param":
38
+ if ctx.get("_signature_unparseable"):
39
+ return None, "the instrumented callable has no parseable signature"
40
+ sig = ctx.get("_signature")
41
+ if sig is None:
42
+ return None, "param: target reached without an instrumented context"
43
+ try:
44
+ bound = sig.bind_partial(*ctx.get("args", ()), **ctx.get("kwargs", {})).arguments
45
+ except TypeError:
46
+ return None, f"arguments do not bind for parameter {name!r}"
47
+ if name not in bound:
48
+ return None, f"parameter {name!r} not passed in this call"
49
+ obj = bound[name]
50
+ else: # self
51
+ args = ctx.get("args") or ()
52
+ if not args:
53
+ return None, f"{spec!r}: call has no positional arguments"
54
+ obj = args[0]
55
+ if not dotted:
56
+ if obj is None:
57
+ return None, f"target {spec!r} resolved to None"
58
+ return obj, None
59
+ for step in dotted:
60
+ try:
61
+ obj = getattr(obj, step)
62
+ except AttributeError:
63
+ return None, f"{spec!r}: no attribute {step!r} on {type(obj).__name__}"
64
+ if obj is None:
65
+ return None, f"target {spec!r} resolved to None"
66
+ return obj, None
67
+
68
+
69
+ @lru_cache(maxsize=256)
70
+ def parse_target_spec(spec):
71
+ """Structured form of a spec: ((kind, name, dotted), None) or (None, reason).
72
+
73
+ Cached: specs are per-rule constants resolved on every firing.
74
+ Raises nothing; callers decide the failure policy.
75
+ """
76
+ if not isinstance(spec, str) or not spec.strip():
77
+ return None, "target must be a non-empty string"
78
+ spec = spec.strip()
79
+ if spec == "result":
80
+ return ("result", None, ()), None
81
+ if spec.startswith("param:"):
82
+ rest = spec[len("param") + 1:]
83
+ name, _, tail = rest.partition(".")
84
+ if not name:
85
+ return None, "param: needs a parameter name"
86
+ dotted, err = _steps(tail, spec)
87
+ if err:
88
+ return None, err
89
+ return ("param", name, dotted), None
90
+ root, _, tail = spec.partition(".")
91
+ if root == "self":
92
+ dotted, err = _steps(tail, spec)
93
+ if err:
94
+ return None, err
95
+ return ("self", None, dotted), None
96
+ return None, f"target root must be self/param:<name>/result (got {root!r})"
97
+
98
+
99
+ def _steps(tail, spec):
100
+ """Dotted walk steps, or an error: empty components are a typo, not a
101
+ spelling of a shorter walk, so 'self..a' is rejected rather than read
102
+ as 'self.a'."""
103
+ if not tail:
104
+ return (), None
105
+ steps = tuple(tail.split("."))
106
+ if any(not st for st in steps):
107
+ return None, f"bad target spec {spec!r} (empty step)"
108
+ return steps, None
109
+
110
+
111
+ def _parse(spec):
112
+ # lru_cache requires hashables and the spec is operator-authored YAML
113
+ # (a string by construction); guard anyway for direct API callers.
114
+ try:
115
+ return parse_target_spec(spec)
116
+ except TypeError:
117
+ return None, "target must be a string"
118
+
119
+
120
+ def validate_target_spec(spec, where):
121
+ """Load-time check; raises ValueError when the spec can never resolve.
122
+
123
+ rules.py wraps this in its RuleError so this module stays a leaf.
124
+ """
125
+ parsed, reason = _parse(spec)
126
+ if parsed is None:
127
+ raise ValueError(f"{where}: {reason}")
@@ -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,20 @@
1
+ pyteman/__init__.py,sha256=kUR5RAFc7HCeiqdlX36dZOHkUI5wI6V_43RpEcD8b-0,22
2
+ pyteman/actions.py,sha256=eIC9DnTwE2NyByR_z2ugt7f8xlbPtrR4kOmlDIT3afI,3569
3
+ pyteman/barriers.py,sha256=Bk53xHFCvbksOxaiVdeLvjtw5PqrO0hA-1Izl1vQDgo,418
4
+ pyteman/conditions.py,sha256=Un6jRIKgU0dRT-Lum8EB6ggQ_V1nYvsIAaASj98NvJY,383
5
+ pyteman/firing.py,sha256=RmMgwb4A7dvnvlWGjM8wu6YVVhOZ42wNHJJOSG0dJXc,997
6
+ pyteman/patcher.py,sha256=1_KmsgberoH_3qYkORj0hCOANdU-hj_aOQ6-Ja4yI30,5484
7
+ pyteman/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
8
+ pyteman/rules.py,sha256=5RVi938gYlW3-gwaEifJTIQAI8fMgGWzqpTLXN-pb6c,3909
9
+ pyteman/sitecustomize.py,sha256=OW1eQMJO36PoSaHpxkopwEej8ygADBYNTbCQBXGZGvY,1067
10
+ pyteman/targets.py,sha256=39FXxoVSlZ7OKrBjW8FlYhgaYgxW7OPajCG68bOd3Tc,4726
11
+ pyteman/runner/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
12
+ pyteman/runner/matrix.py,sha256=oNqHtb_z82xZS1OwdoRKsh3Fm9_-ys5fKBNzK5O9fzc,1715
13
+ pyteman/runner/report.py,sha256=m_antIzQyPFxusbTJsjVl0vzPP6IP7aY8NcGAXhZ9GI,656
14
+ pyteman/sqlitekit/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
15
+ pyteman/sqlitekit/integrity.py,sha256=U31_vbizbq-i400-qJU9Y7usZ7cMahSEW6r-kVX6Kao,861
16
+ pyteman-0.1.0.dist-info/licenses/LICENSE,sha256=VJb4m-l1fPOvTkuncEQvck6TtUUAgA56Te-XaPhAR74,1071
17
+ pyteman-0.1.0.dist-info/METADATA,sha256=0C3p2qZrgvG16eQTVl5WAuoDdd4F4G-EG_lwOMxboIo,8104
18
+ pyteman-0.1.0.dist-info/WHEEL,sha256=YVMoNqKzERt-wjUZwJ33xBGAwnFl-4cqbYkTtWa4itE,91
19
+ pyteman-0.1.0.dist-info/top_level.txt,sha256=o4gowPIR4Z_UOvUyeaM9DjUTe8pBbSliXOGRm_cvRUo,8
20
+ pyteman-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (84.0.0)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -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.
@@ -0,0 +1 @@
1
+ pyteman