rewind-debug 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.
rewind/__init__.py ADDED
@@ -0,0 +1,9 @@
1
+ """
2
+ Rewind - Deterministic Time-Travel Record & Replay Debugger
3
+ """
4
+
5
+ from .tracer import Tracer, step, get_global_tracer
6
+ from .diff import compute_state_diff, serialize_state
7
+
8
+ __version__ = "0.1.0"
9
+ __all__ = ["Tracer", "step", "get_global_tracer", "compute_state_diff", "serialize_state"]
rewind/cli.py ADDED
@@ -0,0 +1,572 @@
1
+ """
2
+ Rewind Universal CLI - Multi-Mode Execution Tracer & Web Scrubber with Universal Hot-Code Sandbox
3
+ """
4
+
5
+ import argparse
6
+ import http.client
7
+ import http.server
8
+ import io
9
+ import json
10
+ import os
11
+ import re
12
+ import socket
13
+ import socketserver
14
+ import subprocess
15
+ import sys
16
+ import threading
17
+ import time
18
+ import traceback
19
+ import urllib.parse
20
+ import webbrowser
21
+ from contextlib import redirect_stderr, redirect_stdout
22
+ from pathlib import Path
23
+
24
+ # Allow direct execution from ANY directory
25
+ parent_dir = str(Path(__file__).resolve().parent.parent)
26
+ if parent_dir not in sys.path:
27
+ sys.path.insert(0, parent_dir)
28
+
29
+ try:
30
+ from .diff import compute_state_diff, serialize_state
31
+ from .tracer import Tracer, TraceStep, get_global_tracer
32
+ except (ImportError, ValueError):
33
+ from rewind.diff import compute_state_diff, serialize_state
34
+ from rewind.tracer import Tracer, TraceStep, get_global_tracer
35
+
36
+
37
+ REWIND_INTERNAL_DIR = os.path.dirname(os.path.abspath(__file__))
38
+
39
+
40
+ # --------------------------------------------------------------------------
41
+ # Stream Interceptor (Prints live to terminal + captures into trace)
42
+ # --------------------------------------------------------------------------
43
+ class LiveTeeStream:
44
+ def __init__(self, original_stream):
45
+ self.orig = original_stream
46
+ self.buf = io.StringIO()
47
+
48
+ def write(self, s):
49
+ self.orig.write(s)
50
+ self.buf.write(s)
51
+
52
+ def flush(self):
53
+ self.orig.flush()
54
+ self.buf.flush()
55
+
56
+ def getvalue(self):
57
+ return self.buf.getvalue()
58
+
59
+
60
+ # --------------------------------------------------------------------------
61
+ # Server Management Utilities
62
+ # --------------------------------------------------------------------------
63
+ def is_port_in_use(port: int) -> bool:
64
+ with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
65
+ return s.connect_ex(("127.0.0.1", port)) == 0
66
+
67
+
68
+ def find_free_port(start_port: int = 8765, max_attempts: int = 20) -> int:
69
+ for p in range(start_port, start_port + max_attempts):
70
+ if not is_port_in_use(p):
71
+ return p
72
+ return start_port
73
+
74
+
75
+ def stop_running_server(port: int = 8765):
76
+ try:
77
+ conn = http.client.HTTPConnection("127.0.0.1", port, timeout=1)
78
+ conn.request("POST", "/api/shutdown")
79
+ res = conn.getresponse()
80
+ if res.status == 200:
81
+ print(f"[Rewind] Server on port {port} shut down cleanly.")
82
+ return True
83
+ except Exception:
84
+ pass
85
+
86
+ try:
87
+ out = subprocess.check_output(["lsof", "-ti", f":{port}"], text=True).strip()
88
+ if out:
89
+ pids = out.split("\n")
90
+ for pid in pids:
91
+ if pid:
92
+ subprocess.run(["kill", "-9", pid], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
93
+ print(f"[Rewind] Stopped server processes on port {port} (PIDs: {', '.join(pids)}).")
94
+ return True
95
+ except Exception:
96
+ pass
97
+
98
+ print(f"[Rewind] No server was running on port {port}.")
99
+ return False
100
+
101
+
102
+ def check_server_status(port: int = 8765):
103
+ if is_port_in_use(port):
104
+ print(f"[Rewind] Web Viewer is RUNNING at: http://localhost:{port}")
105
+ else:
106
+ print(f"[Rewind] Web Viewer is STOPPED.")
107
+
108
+
109
+ # --------------------------------------------------------------------------
110
+ # Universal Hot-Code Sandbox Replay Engine & Disk Patcher
111
+ # --------------------------------------------------------------------------
112
+ def execute_hot_replay(payload: dict) -> dict:
113
+ code_str = payload.get("code", "")
114
+ filepath = payload.get("filepath", "")
115
+
116
+ out_buf = io.StringIO()
117
+ err_buf = io.StringIO()
118
+
119
+ start_t = time.perf_counter()
120
+ try:
121
+ compiled = compile(code_str, filepath or "<sandbox>", "exec")
122
+ sandbox_globals = {"__name__": "__main__", "__builtins__": __builtins__, "state": {}}
123
+
124
+ with redirect_stdout(out_buf), redirect_stderr(err_buf):
125
+ exec(compiled, sandbox_globals)
126
+
127
+ elapsed = round((time.perf_counter() - start_t) * 1000, 2)
128
+ std_out = out_buf.getvalue()
129
+ std_err = err_buf.getvalue()
130
+
131
+ return {
132
+ "success": True,
133
+ "has_crash": False,
134
+ "stdout": std_out,
135
+ "stderr": std_err,
136
+ "timing_ms": elapsed,
137
+ "message": f"Script executed cleanly with zero errors! Output:\n{std_out.strip() or '(none)'}",
138
+ }
139
+ except Exception as e:
140
+ elapsed = round((time.perf_counter() - start_t) * 1000, 2)
141
+ return {
142
+ "success": False,
143
+ "has_crash": True,
144
+ "error": f"{type(e).__name__}: {str(e)}",
145
+ "traceback": traceback.format_exc(),
146
+ "timing_ms": elapsed,
147
+ }
148
+
149
+
150
+ def find_file_in_project(filename: str) -> str:
151
+ if not filename:
152
+ return ""
153
+ if os.path.isabs(filename) and os.path.exists(filename):
154
+ return filename
155
+
156
+ base_name = os.path.basename(filename)
157
+ search_dirs = [os.getcwd(), parent_dir, os.path.join(parent_dir, "tests")]
158
+
159
+ for d in search_dirs:
160
+ candidate = os.path.join(d, filename)
161
+ if os.path.exists(candidate) and os.path.isfile(candidate):
162
+ return os.path.abspath(candidate)
163
+ candidate_base = os.path.join(d, base_name)
164
+ if os.path.exists(candidate_base) and os.path.isfile(candidate_base):
165
+ return os.path.abspath(candidate_base)
166
+
167
+ for root, _, files in os.walk(parent_dir):
168
+ if base_name in files:
169
+ return os.path.abspath(os.path.join(root, base_name))
170
+
171
+ return ""
172
+
173
+
174
+ def apply_patch_to_disk(payload: dict) -> dict:
175
+ raw_filepath = payload.get("filepath", "")
176
+ new_code = payload.get("new_code", "")
177
+
178
+ target_path = find_file_in_project(raw_filepath)
179
+
180
+ if not target_path or not os.path.exists(target_path):
181
+ return {"success": False, "error": f"File not found on disk: '{raw_filepath}'."}
182
+
183
+ backup_path = f"{target_path}.bak"
184
+ try:
185
+ with open(target_path, "r", encoding="utf-8") as src, open(backup_path, "w", encoding="utf-8") as dst:
186
+ dst.write(src.read())
187
+
188
+ with open(target_path, "w", encoding="utf-8") as f:
189
+ f.write(new_code)
190
+
191
+ return {
192
+ "success": True,
193
+ "filepath": target_path,
194
+ "backup": backup_path,
195
+ "message": f"Successfully saved fix to {os.path.basename(target_path)}! (Backup saved at {os.path.basename(backup_path)})",
196
+ }
197
+ except Exception as e:
198
+ return {"success": False, "error": str(e)}
199
+
200
+
201
+ # --------------------------------------------------------------------------
202
+ # Mode 1: Auto Python Script Tracer (Multi-Step Callstack Frame Tracker)
203
+ # --------------------------------------------------------------------------
204
+ def auto_trace_hook(tracer: Tracer):
205
+ last_state = {}
206
+ frame_steps = {}
207
+
208
+ def trace_calls(frame, event, arg):
209
+ nonlocal last_state
210
+ filename = frame.f_code.co_filename
211
+
212
+ # Only ignore Rewind internal files or stdlib
213
+ if filename.startswith(REWIND_INTERNAL_DIR) or filename.startswith("<") or "/lib/python" in filename or "site-packages" in filename:
214
+ return trace_calls
215
+
216
+ func_name = frame.f_code.co_name
217
+ line_no = frame.f_lineno
218
+ frame_id = id(frame)
219
+
220
+ if event == "call":
221
+ if func_name in ("<module>", "main"):
222
+ return trace_calls
223
+ tracer._step_counter += 1
224
+ curr_id = tracer._step_counter
225
+ step_inputs = dict(frame.f_locals)
226
+ if tracer.source_code:
227
+ step_inputs["source_code"] = tracer.source_code
228
+ if tracer.source_filepath:
229
+ step_inputs["filepath"] = tracer.source_filepath
230
+
231
+ step = TraceStep(
232
+ step_id=curr_id,
233
+ name=f"{func_name}()",
234
+ caller_file=os.path.basename(filename),
235
+ caller_line=line_no,
236
+ inputs=serialize_state(step_inputs),
237
+ )
238
+ step.state_before = serialize_state(last_state)
239
+ frame_steps[frame_id] = step
240
+
241
+ elif event == "return":
242
+ step = frame_steps.pop(frame_id, None)
243
+ if step:
244
+ step.output = serialize_state(arg)
245
+ state_now = dict(last_state)
246
+ state_now.update(dict(frame.f_locals))
247
+ if isinstance(arg, dict):
248
+ state_now.update(arg)
249
+ elif arg is not None:
250
+ state_now[f"{func_name}_result"] = arg
251
+ step.state_after = serialize_state(state_now)
252
+ step.diff = compute_state_diff(step.state_before, step.state_after)
253
+ step.status = "SUCCESS"
254
+ last_state = dict(state_now)
255
+ tracer.steps.append(step)
256
+
257
+ elif event == "exception":
258
+ step = frame_steps.get(frame_id)
259
+ if step:
260
+ exc_type, exc_value, _ = arg
261
+ step.status = "FAILED"
262
+ step.error = {
263
+ "type": exc_type.__name__,
264
+ "message": str(exc_value),
265
+ "source_code": tracer.source_code,
266
+ "filepath": tracer.source_filepath,
267
+ }
268
+
269
+ return trace_calls
270
+
271
+ return trace_calls
272
+
273
+
274
+ def run_python_command(args):
275
+ script_path = os.path.abspath(args.script)
276
+ if not os.path.exists(script_path):
277
+ print(f"[Error] File '{args.script}' not found.")
278
+ sys.exit(1)
279
+
280
+ script_dir = os.path.dirname(script_path)
281
+ if script_dir not in sys.path:
282
+ sys.path.insert(0, script_dir)
283
+
284
+ raw_source = ""
285
+ with open(script_path, "r", encoding="utf-8") as f:
286
+ raw_source = f.read()
287
+
288
+ tracer = get_global_tracer()
289
+ tracer.title = f"Auto-Trace: {os.path.basename(script_path)}"
290
+ tracer.source_code = raw_source
291
+ tracer.source_filepath = script_path
292
+ print(f"[Rewind] Auto-tracing Python script: {script_path}")
293
+
294
+ tee_out = LiveTeeStream(sys.stdout)
295
+ tee_err = LiveTeeStream(sys.stderr)
296
+
297
+ sys.settrace(auto_trace_hook(tracer))
298
+ start_time = time.time()
299
+ has_error = False
300
+ try:
301
+ code = compile(raw_source, script_path, "exec")
302
+ with redirect_stdout(tee_out), redirect_stderr(tee_err):
303
+ exec(code, {"__name__": "__main__", "__file__": script_path, "__builtins__": __builtins__})
304
+ except SystemExit as se:
305
+ sys.settrace(None)
306
+ if se.code != 0 and se.code is not None:
307
+ has_error = True
308
+ print(f"\n[Rewind] Process exited with error code: {se.code}")
309
+ step_obj = tracer.record_step_data(
310
+ name=f"SystemExit: {se.code}",
311
+ inputs={"exit_code": se.code, "source_code": raw_source, "filepath": script_path, "stdout": tee_out.getvalue(), "stderr": tee_err.getvalue()},
312
+ state_updates={"crashed": True, "error_type": "SystemExit", "error_msg": f"Exited with code {se.code}", "stdout": tee_out.getvalue()},
313
+ )
314
+ step_obj.caller_file = os.path.basename(script_path)
315
+ step_obj.status = "FAILED"
316
+ step_obj.error = {"type": "SystemExit", "message": f"Exit code {se.code}", "source_code": raw_source, "filepath": script_path}
317
+ except Exception as e:
318
+ sys.settrace(None)
319
+ has_error = True
320
+ tb_str = traceback.format_exc()
321
+ print(f"\n[Rewind] Caught Fatal Execution Exception: {type(e).__name__}: {e}")
322
+
323
+ step_obj = tracer.record_step_data(
324
+ name=f"Fatal Crash: {type(e).__name__}",
325
+ inputs={"error": str(e), "source_code": raw_source, "filepath": script_path, "stdout": tee_out.getvalue(), "stderr": tee_err.getvalue()},
326
+ state_updates={"crashed": True, "error_type": type(e).__name__, "error_msg": str(e), "stdout": tee_out.getvalue()},
327
+ )
328
+ step_obj.caller_file = os.path.basename(script_path)
329
+ step_obj.status = "FAILED"
330
+ step_obj.error = {
331
+ "type": type(e).__name__,
332
+ "message": str(e),
333
+ "traceback": tb_str,
334
+ "source_code": raw_source,
335
+ "filepath": script_path,
336
+ }
337
+ finally:
338
+ sys.settrace(None)
339
+ captured_stdout = tee_out.getvalue()
340
+ captured_stderr = tee_err.getvalue()
341
+
342
+ if not has_error and len(tracer.steps) == 0:
343
+ step_obj = tracer.record_step_data(
344
+ name=f"exec: {os.path.basename(script_path)}",
345
+ inputs={"stdout": captured_stdout, "stderr": captured_stderr, "source_code": raw_source, "filepath": script_path},
346
+ state_updates={"stdout": captured_stdout, "status": "COMPLETED"},
347
+ )
348
+ step_obj.caller_file = os.path.basename(script_path)
349
+ step_obj.status = "SUCCESS"
350
+
351
+ elapsed = round((time.time() - start_time) * 1000, 2)
352
+ out = args.output or "rewind_trace.json"
353
+ tracer.export(out)
354
+ print(f"\n[Rewind] Trace saved: {out} ({len(tracer.steps)} steps, {elapsed} ms)")
355
+ if not args.no_open:
356
+ view_trace(out, port=args.port)
357
+
358
+
359
+ # --------------------------------------------------------------------------
360
+ # Mode 2: Universal Process Wrapper (with Graceful Ctrl+C Handling)
361
+ # --------------------------------------------------------------------------
362
+ def exec_process_command(args):
363
+ cmd = args.command
364
+ if not cmd:
365
+ print("[Error] No command provided to execute.")
366
+ sys.exit(1)
367
+
368
+ tracer = get_global_tracer()
369
+ tracer.title = f"Process Trace: {' '.join(cmd)}"
370
+ print(f"[Rewind] Monitoring process: {' '.join(cmd)}")
371
+
372
+ start_time = time.time()
373
+ try:
374
+ process = subprocess.Popen(
375
+ cmd,
376
+ stdout=subprocess.PIPE,
377
+ stderr=subprocess.PIPE,
378
+ text=True,
379
+ bufsize=1,
380
+ )
381
+ except FileNotFoundError:
382
+ print(f"[Rewind] Error: Command '{cmd[0]}' not found on your system.")
383
+ return
384
+
385
+ step_id = 0
386
+ full_stdout = []
387
+ return_code = 0
388
+ stderr_out = ""
389
+
390
+ try:
391
+ while True:
392
+ line = process.stdout.readline()
393
+ if not line and process.poll() is not None:
394
+ break
395
+ if line:
396
+ step_id += 1
397
+ full_stdout.append(line)
398
+ sys.stdout.write(line)
399
+ tracer.record_step_data(
400
+ name=f"stdout: {line.strip()[:40]}",
401
+ inputs={"raw": line.strip(), "stdout": "".join(full_stdout)},
402
+ state_updates={"last_log": line.strip(), "step_count": step_id, "stdout": "".join(full_stdout)},
403
+ )
404
+
405
+ stderr_out = process.stderr.read()
406
+ return_code = process.poll()
407
+ except KeyboardInterrupt:
408
+ print("\n[Rewind] Process stopped by user (SIGINT).")
409
+ process.terminate()
410
+ return_code = 130
411
+
412
+ if stderr_out or return_code != 0:
413
+ if stderr_out:
414
+ sys.stderr.write(stderr_out)
415
+
416
+ err_type = "ProcessError"
417
+ err_msg = stderr_out.strip() if stderr_out else f"Process exited with code {return_code}"
418
+
419
+ if "ModuleNotFoundError" in stderr_out:
420
+ err_type = "ModuleNotFoundError"
421
+ elif "ImportError" in stderr_out:
422
+ err_type = "ImportError"
423
+ elif "TypeError" in stderr_out:
424
+ err_type = "TypeError"
425
+ elif "SyntaxError" in stderr_out:
426
+ err_type = "SyntaxError"
427
+ elif "npm error" in stderr_out:
428
+ err_type = "NpmScriptError"
429
+ elif return_code == 130:
430
+ err_type = "InterruptedByUser"
431
+
432
+ step_id += 1
433
+ step_obj = tracer.record_step_data(
434
+ name=f"Crash: {err_type}",
435
+ inputs={"stderr": stderr_out.strip(), "exit_code": return_code, "stdout": "".join(full_stdout)},
436
+ state_updates={"has_error": True, "error_type": err_type, "error_output": stderr_out.strip()},
437
+ )
438
+ step_obj.status = "FAILED"
439
+ step_obj.error = {
440
+ "type": err_type,
441
+ "message": err_msg,
442
+ "traceback": stderr_out.strip(),
443
+ }
444
+
445
+ out = args.output or "rewind_trace.json"
446
+ tracer.export(out)
447
+ print(f"\n[Rewind] Process exited with code {return_code}. Trace saved to: {out}")
448
+ if not args.no_open:
449
+ view_trace(out, port=args.port)
450
+
451
+
452
+ # --------------------------------------------------------------------------
453
+ # Mode 3: Robust Web Viewer Server with Hot-Code Sandbox Endpoints
454
+ # --------------------------------------------------------------------------
455
+ def view_trace(trace_path: str = "rewind_trace.json", port: int = 8765):
456
+ viewer_dir = Path(__file__).resolve().parent.parent / "viewer"
457
+ if not viewer_dir.exists():
458
+ viewer_dir = Path("viewer").resolve()
459
+
460
+ if os.path.exists(trace_path):
461
+ dest_path = viewer_dir / "rewind_trace.json"
462
+ with open(trace_path, "r", encoding="utf-8") as src, open(dest_path, "w", encoding="utf-8") as dst:
463
+ dst.write(src.read())
464
+
465
+ active_port = find_free_port(port)
466
+ if active_port != port:
467
+ print(f"[Rewind] Port {port} was busy. Using available port {active_port} instead.")
468
+
469
+ class ManagedHandler(http.server.SimpleHTTPRequestHandler):
470
+ def __init__(self, *args, **kwargs):
471
+ super().__init__(*args, directory=str(viewer_dir), **kwargs)
472
+
473
+ def log_message(self, format, *args): pass
474
+
475
+ def do_POST(self):
476
+ content_len = int(self.headers.get("Content-Length", 0))
477
+ body = self.rfile.read(content_len).decode("utf-8") if content_len > 0 else "{}"
478
+ try:
479
+ payload = json.loads(body)
480
+ except Exception:
481
+ payload = {}
482
+
483
+ if self.path == "/api/shutdown":
484
+ self.send_response(200)
485
+ self.send_header("Content-Type", "application/json")
486
+ self.send_header("Access-Control-Allow-Origin", "*")
487
+ self.end_headers()
488
+ self.wfile.write(b'{"status":"shutting_down"}')
489
+ print("\n[Rewind] Received shutdown command from browser.")
490
+ threading.Thread(target=lambda: (time.sleep(0.3), server.shutdown())).start()
491
+ return
492
+
493
+ elif self.path == "/api/replay":
494
+ result = execute_hot_replay(payload)
495
+ self.send_response(200)
496
+ self.send_header("Content-Type", "application/json")
497
+ self.send_header("Access-Control-Allow-Origin", "*")
498
+ self.end_headers()
499
+ self.wfile.write(json.dumps(result).encode("utf-8"))
500
+ return
501
+
502
+ elif self.path == "/api/patch":
503
+ result = apply_patch_to_disk(payload)
504
+ self.send_response(200)
505
+ self.send_header("Content-Type", "application/json")
506
+ self.send_header("Access-Control-Allow-Origin", "*")
507
+ self.end_headers()
508
+ self.wfile.write(json.dumps(result).encode("utf-8"))
509
+ return
510
+
511
+ self.send_error(404, "Not Found")
512
+
513
+ socketserver.TCPServer.allow_reuse_address = True
514
+ server = socketserver.TCPServer(("", active_port), ManagedHandler)
515
+ url = f"http://localhost:{active_port}"
516
+
517
+ print("=" * 55)
518
+ print(f"[Rewind] Web Scrubber active at: {url}")
519
+ print(f"Press Ctrl+C in terminal or click 'Stop Server' in UI to turn off.")
520
+ print("=" * 55)
521
+
522
+ threading.Thread(target=lambda: (time.sleep(0.4), webbrowser.open(url)), daemon=True).start()
523
+
524
+ try:
525
+ server.serve_forever()
526
+ except KeyboardInterrupt:
527
+ pass
528
+ finally:
529
+ server.server_close()
530
+ print("\n[Rewind] Web server closed successfully.")
531
+
532
+
533
+ # --------------------------------------------------------------------------
534
+ # Main Entry Point
535
+ # --------------------------------------------------------------------------
536
+ def main():
537
+ parser = argparse.ArgumentParser(prog="rewind", description="Rewind - Universal Time-Travel Debugger")
538
+ subparsers = parser.add_subparsers(dest="command", required=True)
539
+
540
+ run_p = subparsers.add_parser("run", help="Auto-trace a Python script with zero code changes")
541
+ run_p.add_argument("-o", "--output", default="rewind_trace.json")
542
+ run_p.add_argument("-p", "--port", type=int, default=8765)
543
+ run_p.add_argument("--no-open", action="store_true")
544
+ run_p.add_argument("script", help="Target Python script")
545
+ run_p.set_defaults(func=run_python_command)
546
+
547
+ exec_p = subparsers.add_parser("exec", help="Trace ANY command/server (Node.js, Go, Docker, etc.)")
548
+ exec_p.add_argument("-o", "--output", default="rewind_trace.json")
549
+ exec_p.add_argument("-p", "--port", type=int, default=8765)
550
+ exec_p.add_argument("--no-open", action="store_true")
551
+ exec_p.add_argument("command", nargs=argparse.REMAINDER, help="Command and args to execute")
552
+ exec_p.set_defaults(func=exec_process_command)
553
+
554
+ view_p = subparsers.add_parser("view", help="Launch the interactive web viewer")
555
+ view_p.add_argument("-p", "--port", type=int, default=8765)
556
+ view_p.add_argument("trace_file", nargs="?", default="rewind_trace.json")
557
+ view_p.set_defaults(func=lambda a: view_trace(a.trace_file, a.port))
558
+
559
+ stop_p = subparsers.add_parser("stop", help="Stop any running Rewind web viewer server")
560
+ stop_p.add_argument("-p", "--port", type=int, default=8765)
561
+ stop_p.set_defaults(func=lambda a: stop_running_server(a.port))
562
+
563
+ status_p = subparsers.add_parser("status", help="Check status of Rewind web viewer")
564
+ status_p.add_argument("-p", "--port", type=int, default=8765)
565
+ status_p.set_defaults(func=lambda a: check_server_status(a.port))
566
+
567
+ args = parser.parse_args()
568
+ args.func(args)
569
+
570
+
571
+ if __name__ == "__main__":
572
+ main()
rewind/diff.py ADDED
@@ -0,0 +1,115 @@
1
+ """
2
+ High-performance structural diffing and circular-safe object serializer.
3
+ """
4
+
5
+ from typing import Any, Dict, List, Optional, Set
6
+
7
+
8
+ def serialize_state(obj: Any, max_depth: int = 6, seen: Optional[Set[int]] = None) -> Any:
9
+ """Recursively serializes any Python data structure or object into a JSON-serializable structure."""
10
+ if seen is None:
11
+ seen = set()
12
+
13
+ if obj is None or isinstance(obj, (bool, int, float, str)):
14
+ return obj
15
+
16
+ # 1. Prevent infinite loops on circular references
17
+ try:
18
+ obj_id = id(obj)
19
+ if obj_id in seen:
20
+ return f"<CircularRef: {type(obj).__name__}>"
21
+
22
+ if max_depth <= 0:
23
+ return f"<MaxDepthReached: {type(obj).__name__}>"
24
+
25
+ seen.add(obj_id)
26
+
27
+ # 2. Dictionaries
28
+ if isinstance(obj, dict):
29
+ return {str(k): serialize_state(v, max_depth - 1, set(seen)) for k, v in obj.items()}
30
+
31
+ # 3. Lists & Tuples
32
+ if isinstance(obj, (list, tuple)):
33
+ return [serialize_state(item, max_depth - 1, set(seen)) for item in obj]
34
+
35
+ # 4. Sets
36
+ if isinstance(obj, set):
37
+ try:
38
+ sorted_items = sorted(list(obj), key=lambda x: str(x))
39
+ except Exception:
40
+ sorted_items = list(obj)
41
+ return [serialize_state(item, max_depth - 1, set(seen)) for item in sorted_items]
42
+
43
+ # 5. Custom Objects / Classes
44
+ if hasattr(obj, "__dict__"):
45
+ try:
46
+ clean_vars = {k: v for k, v in vars(obj).items() if not k.startswith("_")}
47
+ return {
48
+ "__class__": obj.__class__.__name__,
49
+ "__data__": serialize_state(clean_vars, max_depth - 1, set(seen)),
50
+ }
51
+ except Exception:
52
+ pass
53
+
54
+ # 6. Safe String Fallback
55
+ return repr(obj)
56
+ except Exception as e:
57
+ return f"<Unserializable: {type(obj).__name__} ({str(e)})>"
58
+
59
+
60
+ def compute_state_diff(prev_state: Any, curr_state: Any, path: str = "root") -> List[Dict[str, Any]]:
61
+ """Recursively compares two states and computes added (+), removed (-), and mutated (~) diffs."""
62
+ diffs = []
63
+
64
+ # Case 1: Both are Dictionaries
65
+ if isinstance(prev_state, dict) and isinstance(curr_state, dict):
66
+ prev_keys = set(prev_state.keys())
67
+ curr_keys = set(curr_state.keys())
68
+
69
+ # Added Keys
70
+ for k in curr_keys - prev_keys:
71
+ key_path = f"{path}.{k}" if path != "root" else str(k)
72
+ diffs.append({
73
+ "type": "added",
74
+ "path": key_path,
75
+ "value": serialize_state(curr_state[k]),
76
+ })
77
+
78
+ # Removed Keys
79
+ for k in prev_keys - curr_keys:
80
+ key_path = f"{path}.{k}" if path != "root" else str(k)
81
+ diffs.append({
82
+ "type": "removed",
83
+ "path": key_path,
84
+ "prev_value": serialize_state(prev_state[k]),
85
+ })
86
+
87
+ # Common Keys
88
+ for k in prev_keys & curr_keys:
89
+ key_path = f"{path}.{k}" if path != "root" else str(k)
90
+ v_prev = prev_state[k]
91
+ v_curr = curr_state[k]
92
+
93
+ if v_prev != v_curr:
94
+ if isinstance(v_prev, dict) and isinstance(v_curr, dict):
95
+ diffs.extend(compute_state_diff(v_prev, v_curr, key_path))
96
+ else:
97
+ diffs.append({
98
+ "type": "mutated",
99
+ "path": key_path,
100
+ "prev_value": serialize_state(v_prev),
101
+ "new_value": serialize_state(v_curr),
102
+ })
103
+
104
+ return diffs
105
+
106
+ # Case 2: Primitive or Non-Dict Value Comparison
107
+ if prev_state != curr_state:
108
+ diffs.append({
109
+ "type": "mutated",
110
+ "path": path,
111
+ "prev_value": serialize_state(prev_state),
112
+ "new_value": serialize_state(curr_state),
113
+ })
114
+
115
+ return diffs
rewind/tracer.py ADDED
@@ -0,0 +1,192 @@
1
+ """
2
+ Core execution tracer and timeline recording engine for Rewind.
3
+ """
4
+
5
+ import inspect
6
+ import json
7
+ import os
8
+ import threading
9
+ import time
10
+ import traceback
11
+ from contextlib import contextmanager
12
+ from typing import Any, Dict, List, Optional
13
+
14
+ from .diff import compute_state_diff, serialize_state
15
+
16
+
17
+ class TraceStep:
18
+ def __init__(
19
+ self,
20
+ step_id: int,
21
+ name: str,
22
+ caller_file: str,
23
+ caller_line: int,
24
+ inputs: Optional[Dict[str, Any]] = None,
25
+ ):
26
+ self.step_id = step_id
27
+ self.name = name
28
+ self.caller_file = caller_file
29
+ self.caller_line = caller_line
30
+ self.inputs = inputs or {}
31
+ self.output: Any = None
32
+ self.state_before: Dict[str, Any] = {}
33
+ self.state_after: Dict[str, Any] = {}
34
+ self.diff: List[Dict[str, Any]] = []
35
+ self.duration_us: float = 0.0
36
+ self.timestamp: float = time.time()
37
+ self.status: str = "PENDING"
38
+ self.error: Optional[Dict[str, Any]] = None
39
+
40
+ def to_dict(self) -> Dict[str, Any]:
41
+ """Converts this step into a JSON-serializable dictionary."""
42
+ return {
43
+ "step_id": self.step_id,
44
+ "name": self.name,
45
+ "caller_file": self.caller_file,
46
+ "caller_line": self.caller_line,
47
+ "inputs": self.inputs,
48
+ "output": self.output,
49
+ "state_before": self.state_before,
50
+ "state_after": self.state_after,
51
+ "diff": self.diff,
52
+ "duration_us": self.duration_us,
53
+ "timestamp": self.timestamp,
54
+ "status": self.status,
55
+ "error": self.error,
56
+ }
57
+
58
+
59
+ class Tracer:
60
+ def __init__(self, title: str = "Rewind Execution Trace"):
61
+ self.title = title
62
+ self.source_code = ""
63
+ self.source_filepath = ""
64
+ self.lock = threading.RLock()
65
+ self.steps: List[TraceStep] = []
66
+ self.state: Dict[str, Any] = {}
67
+ self._step_counter = 0
68
+ self.start_time = time.time()
69
+
70
+ def record_step_data(
71
+ self,
72
+ name: str,
73
+ inputs: Optional[Dict[str, Any]] = None,
74
+ metadata: Optional[Dict[str, Any]] = None,
75
+ state_updates: Optional[Dict[str, Any]] = None,
76
+ ) -> TraceStep:
77
+ """Manually records a single atomic step data record."""
78
+ with self.lock:
79
+ self._step_counter += 1
80
+ step_inputs = dict(inputs or {})
81
+ if self.source_code and "source_code" not in step_inputs:
82
+ step_inputs["source_code"] = self.source_code
83
+ if self.source_filepath and "filepath" not in step_inputs:
84
+ step_inputs["filepath"] = self.source_filepath
85
+
86
+ step_obj = TraceStep(
87
+ step_id=self._step_counter,
88
+ name=name,
89
+ caller_file=os.path.basename(self.source_filepath) if self.source_filepath else "cli_runner",
90
+ caller_line=0,
91
+ inputs=serialize_state(step_inputs),
92
+ )
93
+ step_obj.state_before = serialize_state(self.state)
94
+ if state_updates:
95
+ self.state.update(state_updates)
96
+ step_obj.state_after = serialize_state(self.state)
97
+ step_obj.diff = compute_state_diff(step_obj.state_before, step_obj.state_after)
98
+ step_obj.status = "SUCCESS"
99
+ self.steps.append(step_obj)
100
+ return step_obj
101
+
102
+ @contextmanager
103
+ def step(self, name: str, **metadata):
104
+ with self.lock:
105
+ self._step_counter += 1
106
+ curr_id = self._step_counter
107
+
108
+ frame = inspect.currentframe()
109
+ caller_frame = frame.f_back.f_back if frame and frame.f_back else None
110
+ caller_file = os.path.basename(caller_frame.f_code.co_filename) if caller_frame else "unknown"
111
+ caller_line = caller_frame.f_lineno if caller_frame else 0
112
+
113
+ step_inputs = dict(metadata)
114
+ if self.source_code and "source_code" not in step_inputs:
115
+ step_inputs["source_code"] = self.source_code
116
+ if self.source_filepath and "filepath" not in step_inputs:
117
+ step_inputs["filepath"] = self.source_filepath
118
+
119
+ step_obj = TraceStep(
120
+ step_id=curr_id,
121
+ name=name,
122
+ caller_file=caller_file,
123
+ caller_line=caller_line,
124
+ inputs=serialize_state(step_inputs),
125
+ )
126
+ step_obj.state_before = serialize_state(self.state)
127
+
128
+ start_t = time.perf_counter()
129
+ try:
130
+ yield self.state
131
+ step_obj.status = "SUCCESS"
132
+ except BaseException as e:
133
+ if isinstance(e, SystemExit) and (e.code == 0 or e.code is None):
134
+ step_obj.status = "SUCCESS"
135
+ else:
136
+ step_obj.status = "FAILED"
137
+ step_obj.error = {
138
+ "type": type(e).__name__,
139
+ "message": str(e),
140
+ "traceback": traceback.format_exc(),
141
+ "source_code": self.source_code,
142
+ "filepath": self.source_filepath,
143
+ }
144
+ raise
145
+ finally:
146
+ end_t = time.perf_counter()
147
+ step_obj.duration_us = round((end_t - start_t) * 1_000_000, 2)
148
+ with self.lock:
149
+ step_obj.state_after = serialize_state(self.state)
150
+ step_obj.diff = compute_state_diff(step_obj.state_before, step_obj.state_after)
151
+ self.steps.append(step_obj)
152
+
153
+ def to_dict(self) -> Dict[str, Any]:
154
+ """Serializes the entire timeline into a portable dictionary."""
155
+ with self.lock:
156
+ has_error = any(s.status == "FAILED" for s in self.steps)
157
+ total_duration_ms = (time.time() - self.start_time) * 1000
158
+
159
+ return {
160
+ "schema_version": "1.0.0",
161
+ "title": self.title,
162
+ "source_code": self.source_code,
163
+ "source_filepath": self.source_filepath,
164
+ "created_at": time.strftime("%Y-%m-%d %H:%M:%S"),
165
+ "has_crash": has_error,
166
+ "total_steps": len(self.steps),
167
+ "total_duration_ms": round(total_duration_ms, 2),
168
+ "final_state": serialize_state(self.state),
169
+ "steps": [s.to_dict() for s in self.steps],
170
+ }
171
+
172
+ def export(self, filepath: str = "rewind_trace.json") -> str:
173
+ """Saves the timeline trace to a JSON file (creating parent directories automatically)."""
174
+ data = self.to_dict()
175
+ dir_name = os.path.dirname(filepath)
176
+ if dir_name:
177
+ os.makedirs(dir_name, exist_ok=True)
178
+ with open(filepath, "w", encoding="utf-8") as f:
179
+ json.dump(data, f, indent=2)
180
+ return filepath
181
+
182
+
183
+ # Global Singleton
184
+ _GLOBAL_TRACER = Tracer()
185
+
186
+
187
+ def get_global_tracer() -> Tracer:
188
+ return _GLOBAL_TRACER
189
+
190
+
191
+ def step(name: str, **metadata):
192
+ return _GLOBAL_TRACER.step(name=name, **metadata)
@@ -0,0 +1,5 @@
1
+ Metadata-Version: 2.4
2
+ Name: rewind-debug
3
+ Version: 0.1.0
4
+ License-File: LICENSE
5
+ Dynamic: license-file
@@ -0,0 +1,10 @@
1
+ rewind/__init__.py,sha256=a3pB_uTEA2M9YWJHA_VzgcyG4beqUaj6hmLfHCLkboI,289
2
+ rewind/cli.py,sha256=hYKfUkBibTFeSGuYqjrcRKRewebp8laccltggimBbMU,21702
3
+ rewind/diff.py,sha256=xuV-4QLwq0zgkjKaLo8JnUmHNWEUGKN1M9bvrYGPZAs,3962
4
+ rewind/tracer.py,sha256=aB-QaVZ9_nx-cgokZtKluDO5EQWd2cmJizGYN6Cqooc,6984
5
+ rewind_debug-0.1.0.dist-info/licenses/LICENSE,sha256=ULrPVcPD4AeHfYVwvl5cbZVHLMvx8bOi5jCXRDIIhZk,1064
6
+ rewind_debug-0.1.0.dist-info/METADATA,sha256=lYSD3tMGoHqNlZudxR3By5lM9YgpEV-jYUr6TJu11F8,100
7
+ rewind_debug-0.1.0.dist-info/WHEEL,sha256=YVMoNqKzERt-wjUZwJ33xBGAwnFl-4cqbYkTtWa4itE,91
8
+ rewind_debug-0.1.0.dist-info/entry_points.txt,sha256=ObbXyL_jYLu8AuXi-7AA7A4u1prBqGnt_jdiRz84tQs,43
9
+ rewind_debug-0.1.0.dist-info/top_level.txt,sha256=aBIzAIcMbzZQLT7Z6DVBnoLkTZbAxm_gSsLGaionfVs,7
10
+ rewind_debug-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,2 @@
1
+ [console_scripts]
2
+ rewind = rewind.cli:main
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Hrinkar
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
+ rewind