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 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
+ )