oddrun 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.
- oddrun/__init__.py +34 -0
- oddrun/cli.py +325 -0
- oddrun/compare.py +93 -0
- oddrun/diagnose.py +374 -0
- oddrun/execution.py +414 -0
- oddrun/experiments.py +257 -0
- oddrun/io.py +138 -0
- oddrun/models.py +144 -0
- oddrun/security.py +66 -0
- oddrun/snapshot.py +93 -0
- oddrun-0.1.0.dist-info/METADATA +136 -0
- oddrun-0.1.0.dist-info/RECORD +16 -0
- oddrun-0.1.0.dist-info/WHEEL +5 -0
- oddrun-0.1.0.dist-info/entry_points.txt +2 -0
- oddrun-0.1.0.dist-info/licenses/LICENSE +20 -0
- oddrun-0.1.0.dist-info/top_level.txt +1 -0
oddrun/__init__.py
ADDED
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
"""OddRun - When the same code doesn't behave the same."""
|
|
2
|
+
|
|
3
|
+
from oddrun.compare import compare_snapshots
|
|
4
|
+
from oddrun.diagnose import diagnose_behavior
|
|
5
|
+
from oddrun.execution import ExecutionRecord, record_execution
|
|
6
|
+
from oddrun.io import load_record, load_snapshot, save_record, save_snapshot
|
|
7
|
+
from oddrun.models import (
|
|
8
|
+
DiffItem,
|
|
9
|
+
EnvironmentSnapshot,
|
|
10
|
+
PythonInfo,
|
|
11
|
+
SnapshotDiff,
|
|
12
|
+
SystemInfo,
|
|
13
|
+
)
|
|
14
|
+
from oddrun.snapshot import capture_environment
|
|
15
|
+
|
|
16
|
+
__version__ = "0.1.0"
|
|
17
|
+
|
|
18
|
+
__all__ = [
|
|
19
|
+
"__version__",
|
|
20
|
+
"EnvironmentSnapshot",
|
|
21
|
+
"ExecutionRecord",
|
|
22
|
+
"PythonInfo",
|
|
23
|
+
"SystemInfo",
|
|
24
|
+
"SnapshotDiff",
|
|
25
|
+
"DiffItem",
|
|
26
|
+
"capture_environment",
|
|
27
|
+
"compare_snapshots",
|
|
28
|
+
"diagnose_behavior",
|
|
29
|
+
"load_record",
|
|
30
|
+
"load_snapshot",
|
|
31
|
+
"record_execution",
|
|
32
|
+
"save_record",
|
|
33
|
+
"save_snapshot",
|
|
34
|
+
]
|
oddrun/cli.py
ADDED
|
@@ -0,0 +1,325 @@
|
|
|
1
|
+
"""Command-line interface for OddRun."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import argparse
|
|
6
|
+
import json
|
|
7
|
+
import sys
|
|
8
|
+
from collections.abc import Sequence
|
|
9
|
+
|
|
10
|
+
from oddrun import __version__
|
|
11
|
+
from oddrun.compare import compare_snapshots
|
|
12
|
+
from oddrun.diagnose import diagnose_behavior, format_diagnosis_text
|
|
13
|
+
from oddrun.execution import ExecutionStatus, record_execution
|
|
14
|
+
from oddrun.io import SnapshotIOError, load_snapshot, save_record, save_snapshot
|
|
15
|
+
from oddrun.models import SnapshotDiff
|
|
16
|
+
from oddrun.snapshot import capture_environment
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def format_diff_text(diff: SnapshotDiff) -> str:
|
|
20
|
+
"""Format a SnapshotDiff into readable text lines."""
|
|
21
|
+
if not diff.has_differences:
|
|
22
|
+
return "No differences found between snapshots."
|
|
23
|
+
|
|
24
|
+
lines: list[str] = [
|
|
25
|
+
f"OddRun Environment Comparison: {diff.left_label} vs {diff.right_label}",
|
|
26
|
+
f"Total Differences: {len(diff.differences)}",
|
|
27
|
+
"-" * 60,
|
|
28
|
+
]
|
|
29
|
+
|
|
30
|
+
current_cat = ""
|
|
31
|
+
for item in diff.differences:
|
|
32
|
+
if item.category != current_cat:
|
|
33
|
+
current_cat = item.category
|
|
34
|
+
lines.append(f"\n[{current_cat.upper()}]")
|
|
35
|
+
|
|
36
|
+
if item.change_type == "added":
|
|
37
|
+
lines.append(f" + {item.key}: {item.right_value}")
|
|
38
|
+
elif item.change_type == "removed":
|
|
39
|
+
lines.append(f" - {item.key}: (was: {item.left_value})")
|
|
40
|
+
else:
|
|
41
|
+
lines.append(f" ~ {item.key}: {item.left_value} -> {item.right_value}")
|
|
42
|
+
|
|
43
|
+
return "\n".join(lines)
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def run_capture(args: argparse.Namespace) -> int:
|
|
47
|
+
"""Execute 'oddrun capture' subcommand."""
|
|
48
|
+
snapshot = capture_environment()
|
|
49
|
+
output_path = args.output or "oddrun-snapshot.json"
|
|
50
|
+
|
|
51
|
+
try:
|
|
52
|
+
save_snapshot(snapshot, output_path)
|
|
53
|
+
print(f"Successfully captured environment snapshot: {output_path}")
|
|
54
|
+
return 0
|
|
55
|
+
except SnapshotIOError as exc:
|
|
56
|
+
print(f"ERROR: Could not write snapshot: {output_path}", file=sys.stderr)
|
|
57
|
+
print(f"Reason: {exc}", file=sys.stderr)
|
|
58
|
+
return 1
|
|
59
|
+
except Exception as exc:
|
|
60
|
+
if args.debug:
|
|
61
|
+
raise
|
|
62
|
+
print(f"ERROR: Unexpected failure during capture: {exc}", file=sys.stderr)
|
|
63
|
+
return 1
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def run_record(args: argparse.Namespace) -> int:
|
|
67
|
+
"""Execute 'oddrun record' subcommand."""
|
|
68
|
+
command_argv = args.command
|
|
69
|
+
if not command_argv:
|
|
70
|
+
print("ERROR: No command specified for recording.", file=sys.stderr)
|
|
71
|
+
msg = "Usage: oddrun record --output RECORD.json -- COMMAND [ARGUMENTS...]"
|
|
72
|
+
print(msg, file=sys.stderr)
|
|
73
|
+
return 1
|
|
74
|
+
|
|
75
|
+
output_path = args.output or "oddrun-record.json"
|
|
76
|
+
|
|
77
|
+
try:
|
|
78
|
+
record = record_execution(command_argv, timeout_seconds=args.timeout)
|
|
79
|
+
if record.execution_result.status == ExecutionStatus.EXEC_ERROR:
|
|
80
|
+
err_msg = record.execution_result.error_message or "Execution failed"
|
|
81
|
+
print(f"ERROR: Could not execute command: {err_msg}", file=sys.stderr)
|
|
82
|
+
return 1
|
|
83
|
+
|
|
84
|
+
save_record(record, output_path)
|
|
85
|
+
outcome = "PASS" if record.execution_result.exit_code == 0 else "FAIL"
|
|
86
|
+
code = record.execution_result.exit_code
|
|
87
|
+
print(
|
|
88
|
+
f"Successfully recorded execution "
|
|
89
|
+
f"(Outcome: {outcome}, Exit Code: {code}): {output_path}"
|
|
90
|
+
)
|
|
91
|
+
return 0
|
|
92
|
+
except SnapshotIOError as exc:
|
|
93
|
+
print(f"ERROR: Could not write record file: {output_path}", file=sys.stderr)
|
|
94
|
+
print(f"Reason: {exc}", file=sys.stderr)
|
|
95
|
+
return 1
|
|
96
|
+
except Exception as exc:
|
|
97
|
+
if args.debug:
|
|
98
|
+
raise
|
|
99
|
+
print(f"ERROR: Unexpected failure during recording: {exc}", file=sys.stderr)
|
|
100
|
+
return 1
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def run_compare(args: argparse.Namespace) -> int:
|
|
104
|
+
"""Execute 'oddrun compare' subcommand."""
|
|
105
|
+
left_file = args.snapshot1
|
|
106
|
+
right_file = args.snapshot2
|
|
107
|
+
|
|
108
|
+
try:
|
|
109
|
+
left_snap = load_snapshot(left_file)
|
|
110
|
+
except SnapshotIOError as exc:
|
|
111
|
+
print(f"ERROR: Could not read snapshot: {left_file}", file=sys.stderr)
|
|
112
|
+
print(f"Reason: {exc}", file=sys.stderr)
|
|
113
|
+
return 1
|
|
114
|
+
except Exception as exc:
|
|
115
|
+
if args.debug:
|
|
116
|
+
raise
|
|
117
|
+
print(f"ERROR: Failed loading snapshot '{left_file}': {exc}", file=sys.stderr)
|
|
118
|
+
return 1
|
|
119
|
+
|
|
120
|
+
try:
|
|
121
|
+
right_snap = load_snapshot(right_file)
|
|
122
|
+
except SnapshotIOError as exc:
|
|
123
|
+
print(f"ERROR: Could not read snapshot: {right_file}", file=sys.stderr)
|
|
124
|
+
print(f"Reason: {exc}", file=sys.stderr)
|
|
125
|
+
return 1
|
|
126
|
+
except Exception as exc:
|
|
127
|
+
if args.debug:
|
|
128
|
+
raise
|
|
129
|
+
print(f"ERROR: Failed loading snapshot '{right_file}': {exc}", file=sys.stderr)
|
|
130
|
+
return 1
|
|
131
|
+
|
|
132
|
+
diff = compare_snapshots(
|
|
133
|
+
left_snap, right_snap, left_label=str(left_file), right_label=str(right_file)
|
|
134
|
+
)
|
|
135
|
+
|
|
136
|
+
if args.json:
|
|
137
|
+
print(json.dumps(diff.to_dict(), indent=2))
|
|
138
|
+
else:
|
|
139
|
+
print(format_diff_text(diff))
|
|
140
|
+
|
|
141
|
+
return 0
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
def run_why(args: argparse.Namespace) -> int:
|
|
145
|
+
"""Execute 'oddrun why' subcommand."""
|
|
146
|
+
target_record = args.target_record
|
|
147
|
+
command_argv = args.command
|
|
148
|
+
|
|
149
|
+
if not command_argv:
|
|
150
|
+
print("ERROR: No command specified for diagnosis.", file=sys.stderr)
|
|
151
|
+
msg = "Usage: oddrun why TARGET_RECORD.json -- COMMAND [ARGUMENTS...]"
|
|
152
|
+
print(msg, file=sys.stderr)
|
|
153
|
+
return 1
|
|
154
|
+
|
|
155
|
+
try:
|
|
156
|
+
report = diagnose_behavior(
|
|
157
|
+
target_record=target_record,
|
|
158
|
+
command=command_argv,
|
|
159
|
+
allow_path_perturbation=args.allow_path_perturbation,
|
|
160
|
+
max_experiments=args.max_experiments,
|
|
161
|
+
timeout_seconds=args.timeout,
|
|
162
|
+
)
|
|
163
|
+
print(format_diagnosis_text(report))
|
|
164
|
+
return 0 if not report.error_message else 1
|
|
165
|
+
except SnapshotIOError as exc:
|
|
166
|
+
print(f"ERROR: Could not read target record: {target_record}", file=sys.stderr)
|
|
167
|
+
print(f"Reason: {exc}", file=sys.stderr)
|
|
168
|
+
return 1
|
|
169
|
+
except Exception as exc:
|
|
170
|
+
if args.debug:
|
|
171
|
+
raise
|
|
172
|
+
print(f"ERROR: Causal diagnosis failed: {exc}", file=sys.stderr)
|
|
173
|
+
return 1
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
def build_parser() -> argparse.ArgumentParser:
|
|
177
|
+
"""Construct the main argument parser for OddRun CLI."""
|
|
178
|
+
parser = argparse.ArgumentParser(
|
|
179
|
+
prog="oddrun",
|
|
180
|
+
description="OddRun - When the same code doesn't behave the same.",
|
|
181
|
+
)
|
|
182
|
+
parser.add_argument(
|
|
183
|
+
"--version",
|
|
184
|
+
action="version",
|
|
185
|
+
version=f"%(prog)s {__version__}",
|
|
186
|
+
)
|
|
187
|
+
parser.add_argument(
|
|
188
|
+
"--debug",
|
|
189
|
+
action="store_true",
|
|
190
|
+
help="Enable full exception tracebacks on error.",
|
|
191
|
+
)
|
|
192
|
+
|
|
193
|
+
subparsers = parser.add_subparsers(dest="subcommand", help="Subcommands")
|
|
194
|
+
|
|
195
|
+
# Command: capture
|
|
196
|
+
capture_parser = subparsers.add_parser(
|
|
197
|
+
"capture",
|
|
198
|
+
help="Capture a privacy-aware snapshot of the current environment.",
|
|
199
|
+
)
|
|
200
|
+
capture_parser.add_argument(
|
|
201
|
+
"-o",
|
|
202
|
+
"--output",
|
|
203
|
+
default="oddrun-snapshot.json",
|
|
204
|
+
help="Path to output JSON file (default: oddrun-snapshot.json).",
|
|
205
|
+
)
|
|
206
|
+
|
|
207
|
+
# Command: record
|
|
208
|
+
record_parser = subparsers.add_parser(
|
|
209
|
+
"record",
|
|
210
|
+
help="Execute a command and record environment snapshot + behavior signature.",
|
|
211
|
+
)
|
|
212
|
+
record_parser.add_argument(
|
|
213
|
+
"-o",
|
|
214
|
+
"--output",
|
|
215
|
+
default="oddrun-record.json",
|
|
216
|
+
help="Path to output JSON record file (default: oddrun-record.json).",
|
|
217
|
+
)
|
|
218
|
+
record_parser.add_argument(
|
|
219
|
+
"--timeout",
|
|
220
|
+
type=float,
|
|
221
|
+
default=30.0,
|
|
222
|
+
help="Timeout in seconds for recording command execution (default: 30.0).",
|
|
223
|
+
)
|
|
224
|
+
record_parser.add_argument(
|
|
225
|
+
"command",
|
|
226
|
+
nargs=argparse.REMAINDER,
|
|
227
|
+
help="Command and arguments to execute after '--' separator.",
|
|
228
|
+
)
|
|
229
|
+
|
|
230
|
+
# Command: compare
|
|
231
|
+
compare_parser = subparsers.add_parser(
|
|
232
|
+
"compare",
|
|
233
|
+
help="Compare two environment snapshot files.",
|
|
234
|
+
)
|
|
235
|
+
compare_parser.add_argument(
|
|
236
|
+
"snapshot1",
|
|
237
|
+
help="Path to first snapshot JSON file.",
|
|
238
|
+
)
|
|
239
|
+
compare_parser.add_argument(
|
|
240
|
+
"snapshot2",
|
|
241
|
+
help="Path to second snapshot JSON file.",
|
|
242
|
+
)
|
|
243
|
+
compare_parser.add_argument(
|
|
244
|
+
"--json",
|
|
245
|
+
action="store_true",
|
|
246
|
+
help="Output comparison results in structured JSON format.",
|
|
247
|
+
)
|
|
248
|
+
|
|
249
|
+
# Command: why
|
|
250
|
+
why_parser = subparsers.add_parser(
|
|
251
|
+
"why",
|
|
252
|
+
help="Diagnose which environmental difference causes a program to fail.",
|
|
253
|
+
)
|
|
254
|
+
why_parser.add_argument(
|
|
255
|
+
"target_record",
|
|
256
|
+
help="Path to target ExecutionRecord JSON file.",
|
|
257
|
+
)
|
|
258
|
+
why_parser.add_argument(
|
|
259
|
+
"--allow-path-perturbation",
|
|
260
|
+
action="store_true",
|
|
261
|
+
help=(
|
|
262
|
+
"Allow testing Tier-2 executable/import factors "
|
|
263
|
+
"(PATH, PYTHONPATH, PYTHONHOME)."
|
|
264
|
+
),
|
|
265
|
+
)
|
|
266
|
+
why_parser.add_argument(
|
|
267
|
+
"--max-experiments",
|
|
268
|
+
type=int,
|
|
269
|
+
default=10,
|
|
270
|
+
help="Maximum candidate perturbation experiments to test (default: 10).",
|
|
271
|
+
)
|
|
272
|
+
why_parser.add_argument(
|
|
273
|
+
"--timeout",
|
|
274
|
+
type=float,
|
|
275
|
+
default=30.0,
|
|
276
|
+
help="Timeout in seconds per perturbation experiment (default: 30.0).",
|
|
277
|
+
)
|
|
278
|
+
why_parser.add_argument(
|
|
279
|
+
"command",
|
|
280
|
+
nargs=argparse.REMAINDER,
|
|
281
|
+
help="Command and arguments to execute after '--' separator.",
|
|
282
|
+
)
|
|
283
|
+
|
|
284
|
+
return parser
|
|
285
|
+
|
|
286
|
+
|
|
287
|
+
def main(argv: Sequence[str] | None = None) -> int:
|
|
288
|
+
"""CLI entry point."""
|
|
289
|
+
parser = build_parser()
|
|
290
|
+
|
|
291
|
+
raw_argv = list(argv) if argv is not None else sys.argv[1:]
|
|
292
|
+
command_argv: list[str] = []
|
|
293
|
+
|
|
294
|
+
if "--" in raw_argv:
|
|
295
|
+
split_idx = raw_argv.index("--")
|
|
296
|
+
oddrun_args = raw_argv[:split_idx]
|
|
297
|
+
command_argv = raw_argv[split_idx + 1 :]
|
|
298
|
+
else:
|
|
299
|
+
oddrun_args = raw_argv
|
|
300
|
+
|
|
301
|
+
args = parser.parse_args(oddrun_args)
|
|
302
|
+
if command_argv:
|
|
303
|
+
args.command = command_argv
|
|
304
|
+
elif hasattr(args, "command") and args.command and args.command[0] == "--":
|
|
305
|
+
args.command = args.command[1:]
|
|
306
|
+
|
|
307
|
+
if not args.subcommand:
|
|
308
|
+
parser.print_help()
|
|
309
|
+
return 0
|
|
310
|
+
|
|
311
|
+
if args.subcommand == "capture":
|
|
312
|
+
return run_capture(args)
|
|
313
|
+
elif args.subcommand == "record":
|
|
314
|
+
return run_record(args)
|
|
315
|
+
elif args.subcommand == "compare":
|
|
316
|
+
return run_compare(args)
|
|
317
|
+
elif args.subcommand == "why":
|
|
318
|
+
return run_why(args)
|
|
319
|
+
|
|
320
|
+
parser.print_help()
|
|
321
|
+
return 0
|
|
322
|
+
|
|
323
|
+
|
|
324
|
+
if __name__ == "__main__":
|
|
325
|
+
sys.exit(main())
|
oddrun/compare.py
ADDED
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
"""Deterministic comparison engine for OddRun environment snapshots."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import Any
|
|
6
|
+
|
|
7
|
+
from oddrun.models import DiffItem, EnvironmentSnapshot, SnapshotDiff
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def _compare_dicts(
|
|
11
|
+
category: str, left: dict[str, Any], right: dict[str, Any]
|
|
12
|
+
) -> list[DiffItem]:
|
|
13
|
+
"""Compare two dictionaries deterministically and produce a list of DiffItems."""
|
|
14
|
+
diffs: list[DiffItem] = []
|
|
15
|
+
all_keys = sorted(set(left.keys()) | set(right.keys()))
|
|
16
|
+
|
|
17
|
+
for key in all_keys:
|
|
18
|
+
in_left = key in left
|
|
19
|
+
in_right = key in right
|
|
20
|
+
|
|
21
|
+
left_val = str(left[key]) if (in_left and left[key] is not None) else None
|
|
22
|
+
right_val = str(right[key]) if (in_right and right[key] is not None) else None
|
|
23
|
+
|
|
24
|
+
if in_left and not in_right:
|
|
25
|
+
diffs.append(
|
|
26
|
+
DiffItem(
|
|
27
|
+
category=category,
|
|
28
|
+
key=key,
|
|
29
|
+
left_value=left_val,
|
|
30
|
+
right_value=None,
|
|
31
|
+
change_type="removed",
|
|
32
|
+
)
|
|
33
|
+
)
|
|
34
|
+
elif not in_left and in_right:
|
|
35
|
+
diffs.append(
|
|
36
|
+
DiffItem(
|
|
37
|
+
category=category,
|
|
38
|
+
key=key,
|
|
39
|
+
left_value=None,
|
|
40
|
+
right_value=right_val,
|
|
41
|
+
change_type="added",
|
|
42
|
+
)
|
|
43
|
+
)
|
|
44
|
+
elif left_val != right_val:
|
|
45
|
+
diffs.append(
|
|
46
|
+
DiffItem(
|
|
47
|
+
category=category,
|
|
48
|
+
key=key,
|
|
49
|
+
left_value=left_val,
|
|
50
|
+
right_value=right_val,
|
|
51
|
+
change_type="changed",
|
|
52
|
+
)
|
|
53
|
+
)
|
|
54
|
+
|
|
55
|
+
return diffs
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def compare_snapshots(
|
|
59
|
+
left: EnvironmentSnapshot,
|
|
60
|
+
right: EnvironmentSnapshot,
|
|
61
|
+
left_label: str = "left",
|
|
62
|
+
right_label: str = "right",
|
|
63
|
+
) -> SnapshotDiff:
|
|
64
|
+
"""Compare two EnvironmentSnapshots and return a SnapshotDiff."""
|
|
65
|
+
differences: list[DiffItem] = []
|
|
66
|
+
|
|
67
|
+
# Category 1: Python
|
|
68
|
+
differences.extend(
|
|
69
|
+
_compare_dicts("python", left.python.to_dict(), right.python.to_dict())
|
|
70
|
+
)
|
|
71
|
+
|
|
72
|
+
# Category 2: System
|
|
73
|
+
differences.extend(
|
|
74
|
+
_compare_dicts("system", left.system.to_dict(), right.system.to_dict())
|
|
75
|
+
)
|
|
76
|
+
|
|
77
|
+
# Category 3: Environment
|
|
78
|
+
differences.extend(
|
|
79
|
+
_compare_dicts("environment", left.environment, right.environment)
|
|
80
|
+
)
|
|
81
|
+
|
|
82
|
+
# Category 4: Packages
|
|
83
|
+
differences.extend(_compare_dicts("packages", left.packages, right.packages))
|
|
84
|
+
|
|
85
|
+
# Guarantee deterministic final ordering by category and key
|
|
86
|
+
category_order = {"python": 0, "system": 1, "environment": 2, "packages": 3}
|
|
87
|
+
differences.sort(key=lambda d: (category_order.get(d.category, 99), d.key))
|
|
88
|
+
|
|
89
|
+
return SnapshotDiff(
|
|
90
|
+
left_label=left_label,
|
|
91
|
+
right_label=right_label,
|
|
92
|
+
differences=differences,
|
|
93
|
+
)
|