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/schema.py
ADDED
|
@@ -0,0 +1,214 @@
|
|
|
1
|
+
"""Function signature → JSON Schema for MCP tool compatibility."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import dataclasses
|
|
6
|
+
import enum
|
|
7
|
+
import inspect
|
|
8
|
+
import types
|
|
9
|
+
import typing
|
|
10
|
+
from collections.abc import Callable
|
|
11
|
+
from typing import Any, Literal, Union, get_args, get_origin
|
|
12
|
+
|
|
13
|
+
_TYPE_MAP: dict[type, str] = {
|
|
14
|
+
str: "string",
|
|
15
|
+
int: "integer",
|
|
16
|
+
float: "number",
|
|
17
|
+
bool: "boolean",
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def function_to_schema(func: Callable[..., Any]) -> dict[str, Any]:
|
|
22
|
+
"""Generate MCP-compatible JSON Schema from function type annotations.
|
|
23
|
+
|
|
24
|
+
Parameters with defaults are optional (not in required).
|
|
25
|
+
X | None unions unwrapped to base type.
|
|
26
|
+
Supports: str, int, float, bool, list[X], dict[str, X], X | None,
|
|
27
|
+
Enum, Literal, dataclass, TypedDict, Union.
|
|
28
|
+
"""
|
|
29
|
+
sig = inspect.signature(func)
|
|
30
|
+
# Resolve string annotations (from __future__ import annotations)
|
|
31
|
+
try:
|
|
32
|
+
hints = typing.get_type_hints(func)
|
|
33
|
+
except Exception:
|
|
34
|
+
hints = {}
|
|
35
|
+
|
|
36
|
+
properties: dict[str, Any] = {}
|
|
37
|
+
required: list[str] = []
|
|
38
|
+
|
|
39
|
+
for name, param in sig.parameters.items():
|
|
40
|
+
annotation = hints.get(name, param.annotation)
|
|
41
|
+
if annotation is inspect.Parameter.empty:
|
|
42
|
+
annotation = str
|
|
43
|
+
|
|
44
|
+
# Strip return type
|
|
45
|
+
if name == "return":
|
|
46
|
+
continue
|
|
47
|
+
|
|
48
|
+
# Skip Context parameters (injected by CLI dispatcher)
|
|
49
|
+
if _is_context_type(annotation, name):
|
|
50
|
+
continue
|
|
51
|
+
|
|
52
|
+
is_optional = _is_optional(annotation)
|
|
53
|
+
if is_optional:
|
|
54
|
+
annotation = _unwrap_optional(annotation)
|
|
55
|
+
|
|
56
|
+
properties[name] = _type_to_schema(annotation)
|
|
57
|
+
|
|
58
|
+
has_default = param.default is not inspect.Parameter.empty
|
|
59
|
+
if not has_default and not is_optional:
|
|
60
|
+
required.append(name)
|
|
61
|
+
|
|
62
|
+
result: dict[str, Any] = {"type": "object", "properties": properties}
|
|
63
|
+
if required:
|
|
64
|
+
result["required"] = required
|
|
65
|
+
return result
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def _type_to_schema(annotation: Any, _seen: set[int] | None = None) -> dict[str, Any]:
|
|
69
|
+
"""Convert Python type annotation to JSON Schema fragment."""
|
|
70
|
+
# Primitive types
|
|
71
|
+
if annotation in _TYPE_MAP:
|
|
72
|
+
return {"type": _TYPE_MAP[annotation]}
|
|
73
|
+
|
|
74
|
+
# Handle bare dict/list (unparameterized)
|
|
75
|
+
if annotation is dict:
|
|
76
|
+
return {"type": "object"}
|
|
77
|
+
if annotation is list:
|
|
78
|
+
return {"type": "array"}
|
|
79
|
+
|
|
80
|
+
# Enum subclass
|
|
81
|
+
if isinstance(annotation, type) and issubclass(annotation, enum.Enum):
|
|
82
|
+
values = [m.value for m in annotation]
|
|
83
|
+
if all(isinstance(v, int) for v in values):
|
|
84
|
+
return {"type": "integer", "enum": values}
|
|
85
|
+
return {"type": "string", "enum": values}
|
|
86
|
+
|
|
87
|
+
# Literal
|
|
88
|
+
origin = get_origin(annotation)
|
|
89
|
+
if origin is Literal:
|
|
90
|
+
return {"enum": list(get_args(annotation))}
|
|
91
|
+
|
|
92
|
+
# dataclass
|
|
93
|
+
if dataclasses.is_dataclass(annotation) and isinstance(annotation, type):
|
|
94
|
+
if _seen is None:
|
|
95
|
+
_seen = set()
|
|
96
|
+
type_id = id(annotation)
|
|
97
|
+
if type_id in _seen:
|
|
98
|
+
return {"type": "object"} # cycle detected
|
|
99
|
+
_seen = _seen | {type_id}
|
|
100
|
+
props = {}
|
|
101
|
+
req = []
|
|
102
|
+
hints = typing.get_type_hints(annotation)
|
|
103
|
+
for f in dataclasses.fields(annotation):
|
|
104
|
+
field_type = hints.get(f.name, str)
|
|
105
|
+
props[f.name] = _type_to_schema(field_type, _seen)
|
|
106
|
+
if f.default is dataclasses.MISSING and f.default_factory is dataclasses.MISSING:
|
|
107
|
+
req.append(f.name)
|
|
108
|
+
result: dict[str, Any] = {"type": "object", "properties": props}
|
|
109
|
+
if req:
|
|
110
|
+
result["required"] = req
|
|
111
|
+
return result
|
|
112
|
+
|
|
113
|
+
# TypedDict
|
|
114
|
+
if _is_typed_dict(annotation):
|
|
115
|
+
if _seen is None:
|
|
116
|
+
_seen = set()
|
|
117
|
+
type_id = id(annotation)
|
|
118
|
+
if type_id in _seen:
|
|
119
|
+
return {"type": "object"}
|
|
120
|
+
_seen = _seen | {type_id}
|
|
121
|
+
hints = typing.get_type_hints(annotation)
|
|
122
|
+
props = {}
|
|
123
|
+
for fname, ftype in hints.items():
|
|
124
|
+
props[fname] = _type_to_schema(ftype, _seen)
|
|
125
|
+
result = {"type": "object", "properties": props}
|
|
126
|
+
# TypedDict required keys
|
|
127
|
+
req_keys = getattr(annotation, "__required_keys__", set())
|
|
128
|
+
if req_keys:
|
|
129
|
+
result["required"] = sorted(req_keys)
|
|
130
|
+
return result
|
|
131
|
+
|
|
132
|
+
# Union (non-Optional, non-None)
|
|
133
|
+
if origin is Union or origin is types.UnionType:
|
|
134
|
+
args = get_args(annotation)
|
|
135
|
+
non_none = [a for a in args if a is not type(None)]
|
|
136
|
+
if len(non_none) > 1:
|
|
137
|
+
if _seen is None:
|
|
138
|
+
_seen = set()
|
|
139
|
+
return {"anyOf": [_type_to_schema(a, _seen) for a in non_none]}
|
|
140
|
+
if len(non_none) == 1:
|
|
141
|
+
return _type_to_schema(non_none[0], _seen)
|
|
142
|
+
|
|
143
|
+
# list[T] with recursive item schema
|
|
144
|
+
if origin is list:
|
|
145
|
+
args = get_args(annotation)
|
|
146
|
+
if args:
|
|
147
|
+
return {"type": "array", "items": _type_to_schema(args[0], _seen)}
|
|
148
|
+
return {"type": "array"}
|
|
149
|
+
|
|
150
|
+
# dict[str, V] with additionalProperties
|
|
151
|
+
if origin is dict:
|
|
152
|
+
args = get_args(annotation)
|
|
153
|
+
if args and len(args) == 2:
|
|
154
|
+
return {"type": "object", "additionalProperties": _type_to_schema(args[1], _seen)}
|
|
155
|
+
return {"type": "object"}
|
|
156
|
+
|
|
157
|
+
return {"type": "string"}
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
def _is_typed_dict(annotation: Any) -> bool:
|
|
161
|
+
"""Check if annotation is a TypedDict subclass."""
|
|
162
|
+
return (
|
|
163
|
+
isinstance(annotation, type)
|
|
164
|
+
and issubclass(annotation, dict)
|
|
165
|
+
and hasattr(annotation, "__annotations__")
|
|
166
|
+
and hasattr(annotation, "__required_keys__")
|
|
167
|
+
)
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
def _is_optional(annotation: Any) -> bool:
|
|
171
|
+
"""Check if annotation is X | None."""
|
|
172
|
+
origin = get_origin(annotation)
|
|
173
|
+
if origin is Union or origin is types.UnionType:
|
|
174
|
+
return type(None) in get_args(annotation)
|
|
175
|
+
return False
|
|
176
|
+
|
|
177
|
+
|
|
178
|
+
def _unwrap_optional(annotation: Any) -> Any:
|
|
179
|
+
"""Extract non-None type from X | None."""
|
|
180
|
+
args = get_args(annotation)
|
|
181
|
+
non_none = [a for a in args if a is not type(None)]
|
|
182
|
+
return non_none[0] if len(non_none) == 1 else str
|
|
183
|
+
|
|
184
|
+
|
|
185
|
+
def return_to_schema(func: Callable[..., Any]) -> dict[str, Any] | None:
|
|
186
|
+
"""Generate JSON Schema from function return type annotation.
|
|
187
|
+
|
|
188
|
+
Returns None if the function has no return annotation or returns None.
|
|
189
|
+
"""
|
|
190
|
+
try:
|
|
191
|
+
hints = typing.get_type_hints(func)
|
|
192
|
+
except Exception:
|
|
193
|
+
hints = {}
|
|
194
|
+
|
|
195
|
+
ret = hints.get("return", inspect.Parameter.empty)
|
|
196
|
+
if ret is inspect.Parameter.empty or ret is type(None):
|
|
197
|
+
return None
|
|
198
|
+
|
|
199
|
+
# Unwrap Optional return types
|
|
200
|
+
if _is_optional(ret):
|
|
201
|
+
ret = _unwrap_optional(ret)
|
|
202
|
+
if ret is type(None):
|
|
203
|
+
return None
|
|
204
|
+
|
|
205
|
+
return _type_to_schema(ret)
|
|
206
|
+
|
|
207
|
+
|
|
208
|
+
def _is_context_type(annotation: Any, name: str) -> bool:
|
|
209
|
+
"""Check if an annotation refers to milo's Context type."""
|
|
210
|
+
if name == "ctx":
|
|
211
|
+
return True
|
|
212
|
+
if isinstance(annotation, type) and annotation.__name__ == "Context":
|
|
213
|
+
return True
|
|
214
|
+
return isinstance(annotation, str) and annotation in ("Context", "milo.context.Context")
|
milo/state.py
ADDED
|
@@ -0,0 +1,229 @@
|
|
|
1
|
+
"""Store, dispatch, saga runner, combine_reducers."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import hashlib
|
|
6
|
+
import threading
|
|
7
|
+
import time
|
|
8
|
+
from collections.abc import Callable
|
|
9
|
+
from concurrent.futures import ThreadPoolExecutor
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
from typing import Any
|
|
12
|
+
|
|
13
|
+
from milo._errors import ErrorCode, StateError
|
|
14
|
+
from milo._types import (
|
|
15
|
+
Action,
|
|
16
|
+
Call,
|
|
17
|
+
Delay,
|
|
18
|
+
Fork,
|
|
19
|
+
Put,
|
|
20
|
+
Quit,
|
|
21
|
+
ReducerResult,
|
|
22
|
+
Select,
|
|
23
|
+
)
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class Store:
|
|
27
|
+
"""Centralized state container with saga support.
|
|
28
|
+
|
|
29
|
+
Thread-safety: reads are lock-free (frozen state).
|
|
30
|
+
Dispatch serializes through a lock.
|
|
31
|
+
Sagas run on a ThreadPoolExecutor.
|
|
32
|
+
"""
|
|
33
|
+
|
|
34
|
+
def __init__(
|
|
35
|
+
self,
|
|
36
|
+
reducer: Callable,
|
|
37
|
+
initial_state: Any,
|
|
38
|
+
middleware: tuple[Callable, ...] = (),
|
|
39
|
+
*,
|
|
40
|
+
record: bool | str | Path = False,
|
|
41
|
+
) -> None:
|
|
42
|
+
self._reducer = reducer
|
|
43
|
+
self._state = initial_state
|
|
44
|
+
self._lock = threading.Lock()
|
|
45
|
+
self._listeners: list[Callable] = []
|
|
46
|
+
self._executor = ThreadPoolExecutor(max_workers=4)
|
|
47
|
+
self._recording: list[dict] | None = [] if record else None
|
|
48
|
+
self._record_path = record if isinstance(record, (str, Path)) else None
|
|
49
|
+
self._quit = threading.Event()
|
|
50
|
+
self._exit_code = 0
|
|
51
|
+
|
|
52
|
+
# Build middleware chain
|
|
53
|
+
self._dispatch_fn = self._base_dispatch
|
|
54
|
+
for mw in reversed(middleware):
|
|
55
|
+
self._dispatch_fn = mw(self._dispatch_fn, self._get_state)
|
|
56
|
+
|
|
57
|
+
# Initialize
|
|
58
|
+
self.dispatch(Action("@@INIT"))
|
|
59
|
+
|
|
60
|
+
def _get_state(self) -> Any:
|
|
61
|
+
return self._state
|
|
62
|
+
|
|
63
|
+
@property
|
|
64
|
+
def state(self) -> Any:
|
|
65
|
+
return self._state
|
|
66
|
+
|
|
67
|
+
def dispatch(self, action: Action) -> None:
|
|
68
|
+
"""Dispatch action through middleware -> reducer."""
|
|
69
|
+
self._dispatch_fn(action)
|
|
70
|
+
|
|
71
|
+
def _base_dispatch(self, action: Action) -> None:
|
|
72
|
+
"""Core dispatch: reducer + saga scheduling + recording."""
|
|
73
|
+
quit_signal = False
|
|
74
|
+
|
|
75
|
+
with self._lock:
|
|
76
|
+
try:
|
|
77
|
+
result = self._reducer(self._state, action)
|
|
78
|
+
except Exception as e:
|
|
79
|
+
raise StateError(ErrorCode.STA_REDUCER, f"Reducer error: {e}") from e
|
|
80
|
+
|
|
81
|
+
sagas = ()
|
|
82
|
+
|
|
83
|
+
# Unwrap Quit — may wrap a ReducerResult or plain state
|
|
84
|
+
if isinstance(result, Quit):
|
|
85
|
+
quit_signal = True
|
|
86
|
+
self._exit_code = result.code
|
|
87
|
+
sagas = result.sagas
|
|
88
|
+
result = result.state
|
|
89
|
+
|
|
90
|
+
# Unwrap ReducerResult
|
|
91
|
+
if isinstance(result, ReducerResult):
|
|
92
|
+
self._state = result.state
|
|
93
|
+
sagas = sagas + result.sagas
|
|
94
|
+
else:
|
|
95
|
+
self._state = result
|
|
96
|
+
|
|
97
|
+
# Record
|
|
98
|
+
if self._recording is not None:
|
|
99
|
+
state_hash = hashlib.sha256(repr(self._state).encode()).hexdigest()[:16]
|
|
100
|
+
self._recording.append(
|
|
101
|
+
{
|
|
102
|
+
"timestamp": time.time(),
|
|
103
|
+
"action_type": action.type,
|
|
104
|
+
"action_payload": action.payload,
|
|
105
|
+
"state_hash": state_hash,
|
|
106
|
+
}
|
|
107
|
+
)
|
|
108
|
+
|
|
109
|
+
# Notify listeners
|
|
110
|
+
for listener in self._listeners:
|
|
111
|
+
listener()
|
|
112
|
+
|
|
113
|
+
# Schedule sagas outside the lock
|
|
114
|
+
for saga_fn in sagas:
|
|
115
|
+
self.run_saga(saga_fn())
|
|
116
|
+
|
|
117
|
+
# Set quit after sagas are scheduled and listeners notified
|
|
118
|
+
if quit_signal:
|
|
119
|
+
self._quit.set()
|
|
120
|
+
|
|
121
|
+
def run_saga(self, saga: Any) -> None:
|
|
122
|
+
"""Schedule a saga on the thread pool."""
|
|
123
|
+
self._executor.submit(self._run_saga, saga)
|
|
124
|
+
|
|
125
|
+
def _run_saga(self, saga: Any) -> None:
|
|
126
|
+
"""Step through a generator saga, executing effects."""
|
|
127
|
+
try:
|
|
128
|
+
effect = next(saga)
|
|
129
|
+
while True:
|
|
130
|
+
match effect:
|
|
131
|
+
case Call(fn, args, kwargs):
|
|
132
|
+
result = fn(*args, **kwargs)
|
|
133
|
+
effect = saga.send(result)
|
|
134
|
+
case Put(action):
|
|
135
|
+
self.dispatch(action)
|
|
136
|
+
effect = next(saga)
|
|
137
|
+
case Select(selector):
|
|
138
|
+
state = self._state
|
|
139
|
+
if selector:
|
|
140
|
+
state = selector(state)
|
|
141
|
+
effect = saga.send(state)
|
|
142
|
+
case Fork(child_saga):
|
|
143
|
+
self._executor.submit(self._run_saga, child_saga)
|
|
144
|
+
effect = next(saga)
|
|
145
|
+
case Delay(seconds):
|
|
146
|
+
time.sleep(seconds)
|
|
147
|
+
effect = next(saga)
|
|
148
|
+
case _:
|
|
149
|
+
raise StateError(
|
|
150
|
+
ErrorCode.STA_SAGA,
|
|
151
|
+
f"Unknown effect type: {type(effect).__name__}",
|
|
152
|
+
)
|
|
153
|
+
except StopIteration:
|
|
154
|
+
pass
|
|
155
|
+
except StateError:
|
|
156
|
+
raise
|
|
157
|
+
except Exception as e:
|
|
158
|
+
raise StateError(ErrorCode.STA_SAGA, f"Saga error: {e}") from e
|
|
159
|
+
|
|
160
|
+
def subscribe(self, listener: Callable) -> Callable[[], None]:
|
|
161
|
+
"""Register state-change listener. Returns unsubscribe callable."""
|
|
162
|
+
self._listeners.append(listener)
|
|
163
|
+
|
|
164
|
+
def unsubscribe() -> None:
|
|
165
|
+
self._listeners.remove(listener)
|
|
166
|
+
|
|
167
|
+
return unsubscribe
|
|
168
|
+
|
|
169
|
+
@property
|
|
170
|
+
def quit_requested(self) -> bool:
|
|
171
|
+
"""True if a reducer returned Quit."""
|
|
172
|
+
return self._quit.is_set()
|
|
173
|
+
|
|
174
|
+
@property
|
|
175
|
+
def exit_code(self) -> int:
|
|
176
|
+
"""Exit code from the Quit signal (default 0)."""
|
|
177
|
+
return self._exit_code
|
|
178
|
+
|
|
179
|
+
@property
|
|
180
|
+
def recording(self) -> list[dict] | None:
|
|
181
|
+
"""Get session recording if enabled."""
|
|
182
|
+
return self._recording
|
|
183
|
+
|
|
184
|
+
def shutdown(self) -> None:
|
|
185
|
+
"""Shut down the thread pool."""
|
|
186
|
+
self._executor.shutdown(wait=False)
|
|
187
|
+
|
|
188
|
+
|
|
189
|
+
def combine_reducers(**reducers: Callable) -> Callable:
|
|
190
|
+
"""Combine multiple reducers into one that manages a dict state.
|
|
191
|
+
|
|
192
|
+
Each reducer manages a slice of state under its keyword name.
|
|
193
|
+
Sagas from ReducerResult and Quit are collected and propagated.
|
|
194
|
+
"""
|
|
195
|
+
|
|
196
|
+
def combined(state: dict | None, action: Action) -> dict | ReducerResult | Quit:
|
|
197
|
+
if state is None:
|
|
198
|
+
state = {}
|
|
199
|
+
next_state = {}
|
|
200
|
+
changed = False
|
|
201
|
+
all_sagas: list[Callable] = []
|
|
202
|
+
quit_signal: Quit | None = None
|
|
203
|
+
|
|
204
|
+
for key, reducer in reducers.items():
|
|
205
|
+
prev = state.get(key)
|
|
206
|
+
next_val = reducer(prev, action)
|
|
207
|
+
if isinstance(next_val, Quit):
|
|
208
|
+
quit_signal = next_val
|
|
209
|
+
next_state[key] = next_val.state
|
|
210
|
+
all_sagas.extend(next_val.sagas)
|
|
211
|
+
changed = True
|
|
212
|
+
elif isinstance(next_val, ReducerResult):
|
|
213
|
+
next_state[key] = next_val.state
|
|
214
|
+
all_sagas.extend(next_val.sagas)
|
|
215
|
+
changed = True
|
|
216
|
+
else:
|
|
217
|
+
next_state[key] = next_val
|
|
218
|
+
if next_state[key] is not prev:
|
|
219
|
+
changed = True
|
|
220
|
+
|
|
221
|
+
result = next_state if changed else state
|
|
222
|
+
|
|
223
|
+
if quit_signal is not None:
|
|
224
|
+
return Quit(state=result, code=quit_signal.code, sagas=tuple(all_sagas))
|
|
225
|
+
if all_sagas:
|
|
226
|
+
return ReducerResult(state=result, sagas=tuple(all_sagas))
|
|
227
|
+
return result
|
|
228
|
+
|
|
229
|
+
return combined
|
milo/streaming.py
ADDED
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
"""Streaming support for long-running CLI commands."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import inspect
|
|
6
|
+
from dataclasses import dataclass
|
|
7
|
+
from typing import Any
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
@dataclass(frozen=True, slots=True)
|
|
11
|
+
class Progress:
|
|
12
|
+
"""Progress notification from a streaming command."""
|
|
13
|
+
|
|
14
|
+
status: str
|
|
15
|
+
step: int = 0
|
|
16
|
+
total: int = 0
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def consume_generator(gen: Any) -> tuple[list[Progress], Any]:
|
|
20
|
+
"""Consume a generator, collecting Progress yields and capturing the final value.
|
|
21
|
+
|
|
22
|
+
Returns ``(progress_list, final_value)``.
|
|
23
|
+
If the generator has no ``StopIteration.value``, final_value is ``None``.
|
|
24
|
+
"""
|
|
25
|
+
progress: list[Progress] = []
|
|
26
|
+
final_value = None
|
|
27
|
+
|
|
28
|
+
try:
|
|
29
|
+
while True:
|
|
30
|
+
value = next(gen)
|
|
31
|
+
if isinstance(value, Progress):
|
|
32
|
+
progress.append(value)
|
|
33
|
+
except StopIteration as e:
|
|
34
|
+
final_value = e.value
|
|
35
|
+
|
|
36
|
+
return progress, final_value
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def is_generator_result(result: Any) -> bool:
|
|
40
|
+
"""Check if a call result is a generator that should be consumed for streaming."""
|
|
41
|
+
return inspect.isgenerator(result)
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
"""Built-in templates and template environment factory."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
from typing import Any
|
|
7
|
+
|
|
8
|
+
_TEMPLATE_DIR = Path(__file__).parent
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def get_env(**kwargs: Any) -> Any:
|
|
12
|
+
"""Create a kida Environment with the built-in template loader.
|
|
13
|
+
|
|
14
|
+
Returns a kida Environment with a chained loader:
|
|
15
|
+
user templates -> milo built-in templates -> kida components.
|
|
16
|
+
"""
|
|
17
|
+
from kida import Environment, FileSystemLoader
|
|
18
|
+
|
|
19
|
+
loaders = []
|
|
20
|
+
|
|
21
|
+
# User-provided loader
|
|
22
|
+
if "loader" in kwargs:
|
|
23
|
+
user_loader = kwargs.pop("loader")
|
|
24
|
+
if user_loader is not None:
|
|
25
|
+
loaders.append(user_loader)
|
|
26
|
+
|
|
27
|
+
# Built-in milo templates
|
|
28
|
+
loaders.append(FileSystemLoader(str(_TEMPLATE_DIR)))
|
|
29
|
+
|
|
30
|
+
if len(loaders) == 1:
|
|
31
|
+
loader = loaders[0]
|
|
32
|
+
else:
|
|
33
|
+
from kida import ChoiceLoader
|
|
34
|
+
|
|
35
|
+
loader = ChoiceLoader(loaders)
|
|
36
|
+
|
|
37
|
+
kwargs.setdefault("autoescape", "terminal")
|
|
38
|
+
return Environment(loader=loader, **kwargs)
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
{{ "error" | red | bold }}{% if code %} {{ code | yellow }}{% endif %}
|
|
2
|
+
{{ error | dim }}
|
|
3
|
+
{% if template_name %} {{ "template:" | dim }} {{ template_name }}{% endif %}
|
|
4
|
+
{% if hint %} {{ "hint:" | cyan }} {{ hint }}{% endif %}
|
|
5
|
+
{% if docs_url %} {{ "docs:" | dim }} {{ docs_url }}{% endif %}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{% if field.focused %}{{ ">" | green | bold }} {{ spec.label | bold }}: {% if field.value %}{{ "Yes" | green | bold }}{% else %}{{ "No" | dim }}{% endif %}{% else %} {{ spec.label | dim }}: {% if field.value %}{{ "Yes" | dim }}{% else %}{{ "No" | dim }}{% endif %}{% endif %}
|
|
@@ -0,0 +1,3 @@
|
|
|
1
|
+
{% if field.focused %}{{ ">" | green | bold }} {{ spec.label | bold }}:{% else %} {{ spec.label | dim }}:{% endif %}
|
|
2
|
+
{% for choice in spec.choices %} {% if loop.index0 == field.selected_index %}{{ icons.check | green }} {{ choice | bold }}{% else %} {{ choice | dim }}{% endif %}
|
|
3
|
+
{% endfor %}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{% if field.focused %}{{ ">" | green | bold }} {{ spec.label | bold }}: {{ field.value }}{% else %} {{ spec.label | dim }}: {{ field.value | dim }}{% endif %}{% if field.error %} {{ field.error | red }}{% endif %}
|
milo/templates/form.kida
ADDED
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
{% for spec, field in zip(state.specs, state.fields) %}
|
|
2
|
+
{% if field.focused %}{{ ">" | green | bold }} {{ spec.label | bold }}{% else %} {{ spec.label | dim }}{% endif %}{% if field.error %} {{ field.error | red }}{% endif %}
|
|
3
|
+
{% if spec.field_type.name == "TEXT" or spec.field_type.name == "PASSWORD" %} {% if spec.field_type.name == "PASSWORD" %}{{ "*" * (field.value | length) | dim }}{% else %}{{ field.value }}{% endif %}
|
|
4
|
+
{% elif spec.field_type.name == "SELECT" %}{% for choice in spec.choices %}
|
|
5
|
+
{% if loop.index0 == field.selected_index %}{{ icons.check | green }} {{ choice | bold }}{% else %} {{ choice | dim }}{% endif %}{% endfor %}
|
|
6
|
+
{% elif spec.field_type.name == "CONFIRM" %} {% if field.value %}{{ "Yes" | green | bold }}{% else %}{{ "No" | dim }}{% endif %}
|
|
7
|
+
{% endif %}
|
|
8
|
+
{% endfor %}
|
milo/templates/help.kida
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
{{ state.prog | bold }}{% if state.description %} {{ "-" | dim }} {{ state.description }}{% endif %}
|
|
2
|
+
|
|
3
|
+
{% for group in state.groups %}
|
|
4
|
+
{{ group.title | yellow | bold }}:
|
|
5
|
+
{% for action in group.actions %} {% if action.option_strings %}{{ action.option_strings | join(", ") | green }}{% else %}{{ action.dest | cyan }}{% endif %}{% if action.metavar %} {{ action.metavar | dim }}{% endif %} {{ action.help }}{% if action.default is not none and action.default != "==SUPPRESS==" %} {{ ("(default: " ~ action.default ~ ")") | dim }}{% endif %}
|
|
6
|
+
{% endfor %}
|
|
7
|
+
{% endfor %}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{{ state.label | default("Progress") | bold }}: {{ bar(state.progress, width=20) | green }} {{ (state.progress * 100) | int }}%
|
milo/testing/__init__.py
ADDED
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
"""Public test API: assert_renders, replay, record, MCP client."""
|
|
2
|
+
|
|
3
|
+
from milo.testing._mcp import CallResult, MCPClient, ToolInfo
|
|
4
|
+
from milo.testing._record import (
|
|
5
|
+
ActionRecord,
|
|
6
|
+
SessionRecording,
|
|
7
|
+
load_recording,
|
|
8
|
+
recording_middleware,
|
|
9
|
+
save_recording,
|
|
10
|
+
)
|
|
11
|
+
from milo.testing._replay import replay
|
|
12
|
+
from milo.testing._snapshot import assert_renders, assert_saga, assert_state
|
|
13
|
+
|
|
14
|
+
__all__ = [
|
|
15
|
+
"ActionRecord",
|
|
16
|
+
"CallResult",
|
|
17
|
+
"MCPClient",
|
|
18
|
+
"SessionRecording",
|
|
19
|
+
"ToolInfo",
|
|
20
|
+
"assert_renders",
|
|
21
|
+
"assert_saga",
|
|
22
|
+
"assert_state",
|
|
23
|
+
"load_recording",
|
|
24
|
+
"recording_middleware",
|
|
25
|
+
"replay",
|
|
26
|
+
"save_recording",
|
|
27
|
+
]
|
milo/testing/_mcp.py
ADDED
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
"""MCP test client — synchronous wrapper around milo.mcp internals."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from dataclasses import dataclass
|
|
6
|
+
from typing import Any
|
|
7
|
+
|
|
8
|
+
from milo.commands import CLI
|
|
9
|
+
from milo.mcp import _call_tool, _handle_method, _list_tools
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
@dataclass(frozen=True, slots=True)
|
|
13
|
+
class ToolInfo:
|
|
14
|
+
"""Frozen snapshot of an MCP tool descriptor."""
|
|
15
|
+
|
|
16
|
+
name: str
|
|
17
|
+
description: str
|
|
18
|
+
input_schema: dict[str, Any]
|
|
19
|
+
output_schema: dict[str, Any] | None
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
@dataclass(frozen=True, slots=True)
|
|
23
|
+
class CallResult:
|
|
24
|
+
"""Frozen snapshot of an MCP tools/call response."""
|
|
25
|
+
|
|
26
|
+
text: str
|
|
27
|
+
is_error: bool
|
|
28
|
+
structured: Any
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
class MCPClient:
|
|
32
|
+
"""Synchronous test client for exercising a CLI's MCP surface."""
|
|
33
|
+
|
|
34
|
+
def __init__(self, cli: CLI) -> None:
|
|
35
|
+
self._cli = cli
|
|
36
|
+
|
|
37
|
+
def initialize(self) -> dict[str, Any]:
|
|
38
|
+
"""Send ``initialize`` and return the server info dict."""
|
|
39
|
+
result = _handle_method(self._cli, "initialize", {})
|
|
40
|
+
assert result is not None, "initialize must return a result"
|
|
41
|
+
return result
|
|
42
|
+
|
|
43
|
+
def list_tools(self) -> list[ToolInfo]:
|
|
44
|
+
"""Return all visible tools as ToolInfo objects."""
|
|
45
|
+
raw = _list_tools(self._cli)
|
|
46
|
+
return [
|
|
47
|
+
ToolInfo(
|
|
48
|
+
name=t["name"],
|
|
49
|
+
description=t.get("description", ""),
|
|
50
|
+
input_schema=t.get("inputSchema", {}),
|
|
51
|
+
output_schema=t.get("outputSchema"),
|
|
52
|
+
)
|
|
53
|
+
for t in raw
|
|
54
|
+
]
|
|
55
|
+
|
|
56
|
+
def call(self, tool_name: str, **arguments: Any) -> CallResult:
|
|
57
|
+
"""Invoke a tool by name and return a CallResult."""
|
|
58
|
+
raw = _call_tool(self._cli, {"name": tool_name, "arguments": arguments})
|
|
59
|
+
content = raw.get("content", [])
|
|
60
|
+
text = content[0]["text"] if content else ""
|
|
61
|
+
return CallResult(
|
|
62
|
+
text=text,
|
|
63
|
+
is_error=raw.get("isError", False),
|
|
64
|
+
structured=raw.get("structuredContent"),
|
|
65
|
+
)
|
|
66
|
+
|
|
67
|
+
def list_resources(self) -> list[dict[str, Any]]:
|
|
68
|
+
"""Return all resources (requires F3 resources support)."""
|
|
69
|
+
result = _handle_method(self._cli, "resources/list", {})
|
|
70
|
+
return result.get("resources", []) if result else []
|
|
71
|
+
|
|
72
|
+
def read_resource(self, uri: str) -> dict[str, Any]:
|
|
73
|
+
"""Read a resource by URI (requires F3 resources support)."""
|
|
74
|
+
result = _handle_method(self._cli, "resources/read", {"uri": uri})
|
|
75
|
+
return result or {}
|
|
76
|
+
|
|
77
|
+
def list_prompts(self) -> list[dict[str, Any]]:
|
|
78
|
+
"""Return all prompts (requires F3 prompts support)."""
|
|
79
|
+
result = _handle_method(self._cli, "prompts/list", {})
|
|
80
|
+
return result.get("prompts", []) if result else []
|
|
81
|
+
|
|
82
|
+
def get_prompt(self, prompt_name: str, **arguments: Any) -> dict[str, Any]:
|
|
83
|
+
"""Get a prompt by name (requires F3 prompts support)."""
|
|
84
|
+
result = _handle_method(
|
|
85
|
+
self._cli, "prompts/get", {"name": prompt_name, "arguments": arguments}
|
|
86
|
+
)
|
|
87
|
+
return result or {}
|