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.
- milo/__init__.py +183 -0
- milo/_child.py +141 -0
- milo/_errors.py +189 -0
- milo/_protocols.py +37 -0
- milo/_types.py +234 -0
- milo/app.py +353 -0
- milo/cli.py +134 -0
- milo/commands.py +951 -0
- milo/config.py +250 -0
- milo/context.py +81 -0
- milo/dev.py +238 -0
- milo/flow.py +146 -0
- milo/form.py +277 -0
- milo/gateway.py +393 -0
- milo/groups.py +194 -0
- milo/help.py +84 -0
- milo/input/__init__.py +6 -0
- milo/input/_platform.py +81 -0
- milo/input/_reader.py +93 -0
- milo/input/_sequences.py +63 -0
- milo/llms.py +172 -0
- milo/mcp.py +299 -0
- milo/middleware.py +67 -0
- milo/observability.py +111 -0
- milo/output.py +106 -0
- milo/pipeline.py +276 -0
- milo/plugins.py +168 -0
- milo/py.typed +0 -0
- milo/registry.py +213 -0
- milo/schema.py +214 -0
- milo/state.py +229 -0
- milo/streaming.py +41 -0
- milo/templates/__init__.py +38 -0
- milo/templates/error.kida +5 -0
- milo/templates/field_confirm.kida +1 -0
- milo/templates/field_select.kida +3 -0
- milo/templates/field_text.kida +1 -0
- milo/templates/form.kida +8 -0
- milo/templates/help.kida +7 -0
- milo/templates/progress.kida +1 -0
- milo/testing/__init__.py +27 -0
- milo/testing/_mcp.py +87 -0
- milo/testing/_record.py +125 -0
- milo/testing/_replay.py +68 -0
- milo/testing/_snapshot.py +96 -0
- milo_cli-0.1.0.dist-info/METADATA +441 -0
- milo_cli-0.1.0.dist-info/RECORD +50 -0
- milo_cli-0.1.0.dist-info/WHEEL +5 -0
- milo_cli-0.1.0.dist-info/entry_points.txt +2 -0
- milo_cli-0.1.0.dist-info/top_level.txt +1 -0
milo/testing/_record.py
ADDED
|
@@ -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
|
+
)
|
milo/testing/_replay.py
ADDED
|
@@ -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
|