milo-cli 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.
@@ -0,0 +1,125 @@
1
+ """Session recording — action log writer."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import hashlib
6
+ import json
7
+ import time
8
+ from dataclasses import dataclass
9
+ from pathlib import Path
10
+ from typing import Any
11
+
12
+ from milo._types import Action
13
+
14
+
15
+ @dataclass(frozen=True, slots=True)
16
+ class ActionRecord:
17
+ """Single recorded action with timestamp."""
18
+
19
+ timestamp: float
20
+ action: Action
21
+ state_hash: str
22
+
23
+
24
+ @dataclass(frozen=True, slots=True)
25
+ class SessionRecording:
26
+ """Complete recorded session."""
27
+
28
+ initial_state: Any
29
+ records: tuple[ActionRecord, ...]
30
+ final_state: Any
31
+ metadata: dict[str, str]
32
+
33
+
34
+ def state_hash(state: Any) -> str:
35
+ """SHA256 hash of state repr, truncated to 16 chars."""
36
+ return hashlib.sha256(repr(state).encode()).hexdigest()[:16]
37
+
38
+
39
+ def recording_middleware(
40
+ records: list[dict],
41
+ ) -> Any:
42
+ """Create a middleware that records actions and state hashes."""
43
+
44
+ def middleware(dispatch: Any, get_state: Any) -> Any:
45
+ def recording_dispatch(action: Action) -> None:
46
+ dispatch(action)
47
+ records.append(
48
+ {
49
+ "timestamp": time.time(),
50
+ "action_type": action.type,
51
+ "action_payload": action.payload,
52
+ "state_hash": state_hash(get_state()),
53
+ }
54
+ )
55
+
56
+ return recording_dispatch
57
+
58
+ return middleware
59
+
60
+
61
+ def save_recording(
62
+ path: str | Path,
63
+ initial_state: Any,
64
+ records: list[dict],
65
+ final_state: Any,
66
+ metadata: dict[str, str] | None = None,
67
+ ) -> None:
68
+ """Save a session recording to JSONL."""
69
+ path = Path(path)
70
+ path.parent.mkdir(parents=True, exist_ok=True)
71
+
72
+ with path.open("w") as f:
73
+ # Header
74
+ header = {
75
+ "type": "header",
76
+ "initial_state": repr(initial_state),
77
+ "metadata": metadata or {},
78
+ }
79
+ f.write(json.dumps(header) + "\n")
80
+
81
+ # Records
82
+ for record in records:
83
+ record_data = {"type": "action", **record}
84
+ # Convert non-serializable payloads
85
+ if "action_payload" in record_data:
86
+ try:
87
+ json.dumps(record_data["action_payload"])
88
+ except TypeError, ValueError:
89
+ record_data["action_payload"] = repr(record_data["action_payload"])
90
+ f.write(json.dumps(record_data) + "\n")
91
+
92
+ # Footer
93
+ footer = {
94
+ "type": "footer",
95
+ "final_state": repr(final_state),
96
+ }
97
+ f.write(json.dumps(footer) + "\n")
98
+
99
+
100
+ def load_recording(path: str | Path) -> SessionRecording:
101
+ """Load a session recording from JSONL."""
102
+ path = Path(path)
103
+ lines = path.read_text().strip().split("\n")
104
+
105
+ header = json.loads(lines[0])
106
+ footer = json.loads(lines[-1])
107
+
108
+ records = []
109
+ for line in lines[1:-1]:
110
+ data = json.loads(line)
111
+ if data["type"] == "action":
112
+ records.append(
113
+ ActionRecord(
114
+ timestamp=data["timestamp"],
115
+ action=Action(data["action_type"], data.get("action_payload")),
116
+ state_hash=data["state_hash"],
117
+ )
118
+ )
119
+
120
+ return SessionRecording(
121
+ initial_state=header.get("initial_state"),
122
+ records=tuple(records),
123
+ final_state=footer.get("final_state"),
124
+ metadata=header.get("metadata", {}),
125
+ )
@@ -0,0 +1,68 @@
1
+ """Action log replay (time-travel)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import time
6
+ from collections.abc import Callable
7
+ from pathlib import Path
8
+ from typing import Any
9
+
10
+ from milo._types import Action, ReducerResult
11
+ from milo.testing._record import SessionRecording, load_recording, state_hash
12
+
13
+
14
+ def replay(
15
+ recording: SessionRecording | str | Path,
16
+ reducer: Callable,
17
+ *,
18
+ speed: float = 1.0,
19
+ step: bool = False,
20
+ on_state: Callable | None = None,
21
+ assert_hashes: bool = False,
22
+ ) -> Any:
23
+ """Replay a recorded session through a reducer.
24
+
25
+ Returns final state. Optionally asserts state_hash matches
26
+ at each step to detect reducer regressions.
27
+ """
28
+ if isinstance(recording, (str, Path)):
29
+ recording = load_recording(recording)
30
+
31
+ state = None
32
+ prev_time = None
33
+
34
+ # Init
35
+ result = reducer(state, Action("@@INIT"))
36
+ state = result.state if isinstance(result, ReducerResult) else result
37
+
38
+ for record in recording.records:
39
+ # Timing
40
+ if prev_time is not None and speed > 0 and not step:
41
+ delay = (record.timestamp - prev_time) / speed
42
+ if delay > 0:
43
+ time.sleep(delay)
44
+
45
+ prev_time = record.timestamp
46
+
47
+ # Apply action
48
+ result = reducer(state, record.action)
49
+ state = result.state if isinstance(result, ReducerResult) else result
50
+
51
+ # Hash check
52
+ if assert_hashes:
53
+ actual_hash = state_hash(state)
54
+ if actual_hash != record.state_hash:
55
+ raise AssertionError(
56
+ f"State hash mismatch at action {record.action.type}: "
57
+ f"expected {record.state_hash}, got {actual_hash}"
58
+ )
59
+
60
+ # Callback
61
+ if on_state is not None:
62
+ on_state(state, record.action)
63
+
64
+ # Step mode
65
+ if step:
66
+ input("Press Enter to continue...")
67
+
68
+ return state
@@ -0,0 +1,96 @@
1
+ """Snapshot capture and comparison."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import os
6
+ import re
7
+ from pathlib import Path
8
+ from typing import Any
9
+
10
+
11
+ def strip_ansi(text: str) -> str:
12
+ """Remove ANSI escape sequences from text."""
13
+ return re.sub(r"\x1b\[[0-9;]*[a-zA-Z]", "", text)
14
+
15
+
16
+ def assert_renders(
17
+ state: Any,
18
+ template: str | Any,
19
+ *,
20
+ snapshot: str | Path | None = None,
21
+ width: int = 80,
22
+ color: bool = False,
23
+ update: bool = False,
24
+ env: Any = None,
25
+ ) -> str:
26
+ """Render state through template, assert output matches snapshot.
27
+
28
+ If snapshot is None, returns rendered string.
29
+ If snapshot is a path and doesn't exist, creates it.
30
+ If update=True, overwrites on mismatch.
31
+ """
32
+ if env is None:
33
+ from milo.templates import get_env
34
+
35
+ env = get_env()
36
+
37
+ tmpl = env.get_template(template) if isinstance(template, str) else template
38
+
39
+ rendered = tmpl.render(state=state)
40
+
41
+ if not color:
42
+ rendered = strip_ansi(rendered)
43
+
44
+ if snapshot is None:
45
+ return rendered
46
+
47
+ snap_path = Path(snapshot)
48
+ should_update = update or os.environ.get("MILO_UPDATE_SNAPSHOTS") == "1"
49
+
50
+ if not snap_path.exists() or should_update:
51
+ snap_path.parent.mkdir(parents=True, exist_ok=True)
52
+ snap_path.write_text(rendered)
53
+ return rendered
54
+
55
+ expected = snap_path.read_text()
56
+ if rendered != expected:
57
+ raise AssertionError(
58
+ f"Snapshot mismatch: {snap_path}\n"
59
+ f"--- expected ---\n{expected}\n"
60
+ f"--- actual ---\n{rendered}"
61
+ )
62
+
63
+ return rendered
64
+
65
+
66
+ def assert_state(
67
+ reducer: Any,
68
+ initial: Any,
69
+ actions: list | tuple,
70
+ expected: Any,
71
+ ) -> None:
72
+ """Feed actions through reducer, assert final state matches."""
73
+ from milo._types import ReducerResult
74
+
75
+ state = initial
76
+ for action in actions:
77
+ result = reducer(state, action)
78
+ state = result.state if isinstance(result, ReducerResult) else result
79
+
80
+ assert state == expected, f"State mismatch:\n expected: {expected}\n actual: {state}"
81
+
82
+
83
+ def assert_saga(
84
+ saga: Any,
85
+ steps: list[tuple[Any, Any]],
86
+ ) -> None:
87
+ """Step through saga, assert each yielded effect matches."""
88
+ effect = next(saga)
89
+ for expected_effect, send_value in steps:
90
+ assert effect == expected_effect, (
91
+ f"Effect mismatch:\n expected: {expected_effect}\n actual: {effect}"
92
+ )
93
+ try:
94
+ effect = saga.send(send_value)
95
+ except StopIteration:
96
+ return