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/mcp.py
ADDED
|
@@ -0,0 +1,299 @@
|
|
|
1
|
+
"""MCP server — expose CLI commands as tools via JSON-RPC on stdin/stdout."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import sys
|
|
7
|
+
from typing import TYPE_CHECKING, Any
|
|
8
|
+
|
|
9
|
+
if TYPE_CHECKING:
|
|
10
|
+
from milo.commands import CLI, CommandDef, LazyCommandDef
|
|
11
|
+
|
|
12
|
+
_MCP_VERSION = "2025-11-25"
|
|
13
|
+
_SERVER_NAME = "milo"
|
|
14
|
+
_SERVER_VERSION = "0.1.0"
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def run_mcp_server(cli: CLI) -> None:
|
|
18
|
+
"""Run MCP JSON-RPC server on stdin/stdout.
|
|
19
|
+
|
|
20
|
+
Implements the MCP protocol (initialize, tools/list, tools/call,
|
|
21
|
+
resources/list, resources/read, prompts/list, prompts/get).
|
|
22
|
+
"""
|
|
23
|
+
tools = _list_tools(cli)
|
|
24
|
+
tool_names = [t["name"] for t in tools]
|
|
25
|
+
|
|
26
|
+
_stderr(f"MCP server ready — {cli.name}")
|
|
27
|
+
_stderr(f" Protocol: {_MCP_VERSION}")
|
|
28
|
+
_stderr(f" Tools: {len(tools)} ({', '.join(tool_names)})")
|
|
29
|
+
_stderr(f" Resources: {len(cli._resources)}")
|
|
30
|
+
_stderr(f" Prompts: {len(cli._prompts)}")
|
|
31
|
+
_stderr(" Transport: stdin/stdout (JSON-RPC, one request per line)")
|
|
32
|
+
_stderr("")
|
|
33
|
+
_stderr("Send requests as JSON, for example:")
|
|
34
|
+
_stderr(' {"jsonrpc":"2.0","id":1,"method":"initialize"}')
|
|
35
|
+
_stderr(' {"jsonrpc":"2.0","id":2,"method":"tools/list"}')
|
|
36
|
+
if tool_names:
|
|
37
|
+
example_tool = tool_names[0]
|
|
38
|
+
example_schema = tools[0].get("inputSchema", {})
|
|
39
|
+
example_args = dict.fromkeys(example_schema.get("properties", {}), "...")
|
|
40
|
+
args_json = json.dumps(example_args) if example_args else "{}"
|
|
41
|
+
_stderr(
|
|
42
|
+
f' {{"jsonrpc":"2.0","id":3,"method":"tools/call",'
|
|
43
|
+
f'"params":{{"name":"{example_tool}","arguments":{args_json}}}}}'
|
|
44
|
+
)
|
|
45
|
+
_stderr("")
|
|
46
|
+
_stderr("Or pipe from a file:")
|
|
47
|
+
_stderr(f" cat requests.jsonl | {cli.name} --mcp")
|
|
48
|
+
_stderr("")
|
|
49
|
+
_stderr("Press Ctrl+C to stop.")
|
|
50
|
+
_stderr("")
|
|
51
|
+
|
|
52
|
+
for line in sys.stdin:
|
|
53
|
+
line = line.strip()
|
|
54
|
+
if not line:
|
|
55
|
+
continue
|
|
56
|
+
try:
|
|
57
|
+
request = json.loads(line)
|
|
58
|
+
except json.JSONDecodeError:
|
|
59
|
+
_write_error(None, -32700, "Parse error")
|
|
60
|
+
continue
|
|
61
|
+
|
|
62
|
+
req_id = request.get("id")
|
|
63
|
+
method = request.get("method", "")
|
|
64
|
+
|
|
65
|
+
try:
|
|
66
|
+
result = _handle_method(cli, method, request.get("params", {}))
|
|
67
|
+
if result is not None:
|
|
68
|
+
_write_result(req_id, result)
|
|
69
|
+
except Exception as e:
|
|
70
|
+
_write_error(req_id, -32603, str(e))
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def _handle_method(cli: CLI, method: str, params: dict[str, Any]) -> dict[str, Any] | None:
|
|
74
|
+
"""Dispatch an MCP method."""
|
|
75
|
+
match method:
|
|
76
|
+
case "initialize":
|
|
77
|
+
return {
|
|
78
|
+
"protocolVersion": _MCP_VERSION,
|
|
79
|
+
"capabilities": {"tools": {}, "resources": {}, "prompts": {}},
|
|
80
|
+
"serverInfo": {
|
|
81
|
+
"name": cli.name or _SERVER_NAME,
|
|
82
|
+
"version": cli.version or _SERVER_VERSION,
|
|
83
|
+
"title": cli.description,
|
|
84
|
+
},
|
|
85
|
+
"instructions": cli.description,
|
|
86
|
+
}
|
|
87
|
+
case "notifications/initialized":
|
|
88
|
+
# Client confirms initialization — no response required
|
|
89
|
+
return None
|
|
90
|
+
case "tools/list":
|
|
91
|
+
return {"tools": _list_tools(cli)}
|
|
92
|
+
case "tools/call":
|
|
93
|
+
return _call_tool(cli, params)
|
|
94
|
+
case "resources/list":
|
|
95
|
+
return {"resources": _list_resources(cli)}
|
|
96
|
+
case "resources/read":
|
|
97
|
+
return _read_resource(cli, params)
|
|
98
|
+
case "prompts/list":
|
|
99
|
+
return {"prompts": _list_prompts(cli)}
|
|
100
|
+
case "prompts/get":
|
|
101
|
+
return _get_prompt(cli, params)
|
|
102
|
+
case _:
|
|
103
|
+
raise ValueError(f"Unknown method: {method!r}")
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def _list_tools(cli: CLI) -> list[dict[str, Any]]:
|
|
107
|
+
"""Generate MCP tools/list response from all commands including groups.
|
|
108
|
+
|
|
109
|
+
Group commands use dot-notation names: ``site.build``, ``site.config.show``.
|
|
110
|
+
Includes outputSchema when return type annotations are available.
|
|
111
|
+
"""
|
|
112
|
+
tools = []
|
|
113
|
+
for dotted_name, cmd in cli.walk_commands():
|
|
114
|
+
if cmd.hidden:
|
|
115
|
+
continue
|
|
116
|
+
tool: dict[str, Any] = {
|
|
117
|
+
"name": dotted_name,
|
|
118
|
+
"description": cmd.description,
|
|
119
|
+
"inputSchema": cmd.schema,
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
# title: human-readable display name from docstring or description
|
|
123
|
+
title = _tool_title(cmd)
|
|
124
|
+
if title:
|
|
125
|
+
tool["title"] = title
|
|
126
|
+
|
|
127
|
+
# outputSchema: generated from handler return type annotation
|
|
128
|
+
output_schema = _output_schema(cmd)
|
|
129
|
+
if output_schema:
|
|
130
|
+
tool["outputSchema"] = output_schema
|
|
131
|
+
|
|
132
|
+
tools.append(tool)
|
|
133
|
+
return tools
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
def _tool_title(cmd: CommandDef | LazyCommandDef) -> str:
|
|
137
|
+
"""Derive a human-readable title for a tool.
|
|
138
|
+
|
|
139
|
+
Uses the handler's docstring first line if available and different
|
|
140
|
+
from the description. Falls back to a title-cased command name.
|
|
141
|
+
"""
|
|
142
|
+
from milo.commands import LazyCommandDef
|
|
143
|
+
|
|
144
|
+
# For lazy commands, use name-derived title to avoid triggering imports
|
|
145
|
+
if isinstance(cmd, LazyCommandDef) and cmd._resolved is None:
|
|
146
|
+
return cmd.name.replace("-", " ").replace("_", " ").title()
|
|
147
|
+
|
|
148
|
+
doc = getattr(cmd.handler, "__doc__", None)
|
|
149
|
+
if doc:
|
|
150
|
+
first_line = doc.strip().split("\n")[0].strip().rstrip(".")
|
|
151
|
+
if first_line and first_line != cmd.description:
|
|
152
|
+
return first_line
|
|
153
|
+
|
|
154
|
+
return cmd.name.replace("-", " ").replace("_", " ").title()
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
def _output_schema(cmd: CommandDef | LazyCommandDef) -> dict[str, Any] | None:
|
|
158
|
+
"""Generate outputSchema from handler return type annotation.
|
|
159
|
+
|
|
160
|
+
Returns None for lazy commands that haven't been resolved (avoids imports).
|
|
161
|
+
"""
|
|
162
|
+
from milo.commands import LazyCommandDef
|
|
163
|
+
from milo.schema import return_to_schema
|
|
164
|
+
|
|
165
|
+
# Don't trigger imports on lazy commands just for output schema
|
|
166
|
+
if isinstance(cmd, LazyCommandDef) and cmd._resolved is None:
|
|
167
|
+
return None
|
|
168
|
+
|
|
169
|
+
return return_to_schema(cmd.handler)
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
def _call_tool(cli: CLI, params: dict[str, Any]) -> dict[str, Any]:
|
|
173
|
+
"""Handle tools/call by dispatching to the command handler."""
|
|
174
|
+
tool_name = params.get("name", "")
|
|
175
|
+
arguments = params.get("arguments", {})
|
|
176
|
+
|
|
177
|
+
try:
|
|
178
|
+
result = cli.call(tool_name, **arguments)
|
|
179
|
+
except Exception as e:
|
|
180
|
+
return {
|
|
181
|
+
"content": [{"type": "text", "text": f"Error: {e}"}],
|
|
182
|
+
"isError": True,
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
# Convert result to MCP content with structuredContent for non-string results
|
|
186
|
+
text = result if isinstance(result, str) else json.dumps(result, indent=2, default=str)
|
|
187
|
+
|
|
188
|
+
response: dict[str, Any] = {
|
|
189
|
+
"content": [{"type": "text", "text": text}],
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
# Include structuredContent for structured data (dict, list, number, bool)
|
|
193
|
+
if not isinstance(result, str) and result is not None:
|
|
194
|
+
response["structuredContent"] = result
|
|
195
|
+
|
|
196
|
+
return response
|
|
197
|
+
|
|
198
|
+
|
|
199
|
+
def _list_resources(cli: CLI) -> list[dict[str, Any]]:
|
|
200
|
+
"""Generate MCP resources/list response from registered resources."""
|
|
201
|
+
resources = []
|
|
202
|
+
for _uri, res in cli.walk_resources():
|
|
203
|
+
resources.append(
|
|
204
|
+
{
|
|
205
|
+
"uri": res.uri,
|
|
206
|
+
"name": res.name,
|
|
207
|
+
"description": res.description,
|
|
208
|
+
"mimeType": res.mime_type,
|
|
209
|
+
}
|
|
210
|
+
)
|
|
211
|
+
return resources
|
|
212
|
+
|
|
213
|
+
|
|
214
|
+
def _read_resource(cli: CLI, params: dict[str, Any]) -> dict[str, Any]:
|
|
215
|
+
"""Handle resources/read by calling the resource handler."""
|
|
216
|
+
uri = params.get("uri", "")
|
|
217
|
+
|
|
218
|
+
res = cli._resources.get(uri)
|
|
219
|
+
if not res:
|
|
220
|
+
return {"contents": []}
|
|
221
|
+
|
|
222
|
+
try:
|
|
223
|
+
result = res.handler()
|
|
224
|
+
except Exception as e:
|
|
225
|
+
return {"contents": [{"uri": uri, "text": f"Error: {e}", "mimeType": "text/plain"}]}
|
|
226
|
+
|
|
227
|
+
text = result if isinstance(result, str) else json.dumps(result, indent=2, default=str)
|
|
228
|
+
|
|
229
|
+
return {"contents": [{"uri": uri, "text": text, "mimeType": res.mime_type}]}
|
|
230
|
+
|
|
231
|
+
|
|
232
|
+
def _list_prompts(cli: CLI) -> list[dict[str, Any]]:
|
|
233
|
+
"""Generate MCP prompts/list response from registered prompts."""
|
|
234
|
+
prompts = []
|
|
235
|
+
for _name, p in cli.walk_prompts():
|
|
236
|
+
prompt_info: dict[str, Any] = {
|
|
237
|
+
"name": p.name,
|
|
238
|
+
"description": p.description,
|
|
239
|
+
}
|
|
240
|
+
if p.arguments:
|
|
241
|
+
prompt_info["arguments"] = list(p.arguments)
|
|
242
|
+
prompts.append(prompt_info)
|
|
243
|
+
return prompts
|
|
244
|
+
|
|
245
|
+
|
|
246
|
+
def _get_prompt(cli: CLI, params: dict[str, Any]) -> dict[str, Any]:
|
|
247
|
+
"""Handle prompts/get by calling the prompt handler."""
|
|
248
|
+
name = params.get("name", "")
|
|
249
|
+
arguments = params.get("arguments", {})
|
|
250
|
+
|
|
251
|
+
p = cli._prompts.get(name)
|
|
252
|
+
if not p:
|
|
253
|
+
return {"messages": []}
|
|
254
|
+
|
|
255
|
+
try:
|
|
256
|
+
result = p.handler(**arguments)
|
|
257
|
+
except Exception as e:
|
|
258
|
+
return {"messages": [{"role": "user", "content": {"type": "text", "text": f"Error: {e}"}}]}
|
|
259
|
+
|
|
260
|
+
# If handler returns list of dicts, treat as messages
|
|
261
|
+
if isinstance(result, list):
|
|
262
|
+
return {"messages": result}
|
|
263
|
+
|
|
264
|
+
# If handler returns a string, wrap as single user message
|
|
265
|
+
if isinstance(result, str):
|
|
266
|
+
return {"messages": [{"role": "user", "content": {"type": "text", "text": result}}]}
|
|
267
|
+
|
|
268
|
+
return {
|
|
269
|
+
"messages": [
|
|
270
|
+
{
|
|
271
|
+
"role": "user",
|
|
272
|
+
"content": {"type": "text", "text": json.dumps(result, default=str)},
|
|
273
|
+
}
|
|
274
|
+
]
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
|
|
278
|
+
def _write_result(req_id: Any, result: dict[str, Any]) -> None:
|
|
279
|
+
"""Write a JSON-RPC success response."""
|
|
280
|
+
response = {"jsonrpc": "2.0", "id": req_id, "result": result}
|
|
281
|
+
sys.stdout.write(json.dumps(response) + "\n")
|
|
282
|
+
sys.stdout.flush()
|
|
283
|
+
|
|
284
|
+
|
|
285
|
+
def _write_error(req_id: Any, code: int, message: str) -> None:
|
|
286
|
+
"""Write a JSON-RPC error response."""
|
|
287
|
+
response = {
|
|
288
|
+
"jsonrpc": "2.0",
|
|
289
|
+
"id": req_id,
|
|
290
|
+
"error": {"code": code, "message": message},
|
|
291
|
+
}
|
|
292
|
+
sys.stdout.write(json.dumps(response) + "\n")
|
|
293
|
+
sys.stdout.flush()
|
|
294
|
+
|
|
295
|
+
|
|
296
|
+
def _stderr(message: str) -> None:
|
|
297
|
+
"""Write an informational line to stderr."""
|
|
298
|
+
sys.stderr.write(message + "\n")
|
|
299
|
+
sys.stderr.flush()
|
milo/middleware.py
ADDED
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
"""Middleware stack for MCP and CLI call interception."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from collections.abc import Callable
|
|
6
|
+
from dataclasses import dataclass, field
|
|
7
|
+
from typing import Any
|
|
8
|
+
|
|
9
|
+
# Type aliases
|
|
10
|
+
MiddlewareFn = Callable[..., Any]
|
|
11
|
+
NextFn = Callable[["MCPCall"], Any]
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
@dataclass(frozen=True, slots=True)
|
|
15
|
+
class MCPCall:
|
|
16
|
+
"""Represents an interceptable MCP or CLI call."""
|
|
17
|
+
|
|
18
|
+
method: str # "tools/call", "resources/read", etc.
|
|
19
|
+
name: str # tool/resource/prompt name
|
|
20
|
+
arguments: dict[str, Any] = field(default_factory=dict)
|
|
21
|
+
metadata: dict[str, Any] = field(default_factory=dict)
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class MiddlewareStack:
|
|
25
|
+
"""Ordered middleware pipeline for intercepting calls.
|
|
26
|
+
|
|
27
|
+
Middleware signature: ``def mw(ctx, call: MCPCall, next_fn: NextFn) -> Any``
|
|
28
|
+
|
|
29
|
+
Usage::
|
|
30
|
+
|
|
31
|
+
stack = MiddlewareStack()
|
|
32
|
+
|
|
33
|
+
@stack.use
|
|
34
|
+
def log_calls(ctx, call, next_fn):
|
|
35
|
+
print(f"Calling {call.name}")
|
|
36
|
+
result = next_fn(call)
|
|
37
|
+
print(f"Done {call.name}")
|
|
38
|
+
return result
|
|
39
|
+
|
|
40
|
+
result = stack.execute(ctx, call, handler)
|
|
41
|
+
"""
|
|
42
|
+
|
|
43
|
+
def __init__(self) -> None:
|
|
44
|
+
self._middlewares: list[MiddlewareFn] = []
|
|
45
|
+
|
|
46
|
+
def use(self, fn: MiddlewareFn) -> MiddlewareFn:
|
|
47
|
+
"""Register a middleware function. Can be used as a decorator."""
|
|
48
|
+
self._middlewares.append(fn)
|
|
49
|
+
return fn
|
|
50
|
+
|
|
51
|
+
def execute(self, ctx: Any, call: MCPCall, handler: Callable[..., Any]) -> Any:
|
|
52
|
+
"""Execute the middleware chain, ending with the handler."""
|
|
53
|
+
if not self._middlewares:
|
|
54
|
+
return handler(call)
|
|
55
|
+
|
|
56
|
+
def build_next(index: int) -> NextFn:
|
|
57
|
+
if index >= len(self._middlewares):
|
|
58
|
+
return handler
|
|
59
|
+
mw = self._middlewares[index]
|
|
60
|
+
|
|
61
|
+
def next_fn(c: MCPCall) -> Any:
|
|
62
|
+
return mw(ctx, c, build_next(index + 1))
|
|
63
|
+
|
|
64
|
+
return next_fn
|
|
65
|
+
|
|
66
|
+
first = self._middlewares[0]
|
|
67
|
+
return first(ctx, call, build_next(1))
|
milo/observability.py
ADDED
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
"""Observability for MCP requests — logging, stats, correlation IDs."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import contextvars
|
|
6
|
+
import threading
|
|
7
|
+
import time
|
|
8
|
+
import uuid
|
|
9
|
+
from collections import deque
|
|
10
|
+
from dataclasses import dataclass
|
|
11
|
+
from typing import Any
|
|
12
|
+
|
|
13
|
+
# Correlation ID for request tracing
|
|
14
|
+
correlation_id: contextvars.ContextVar[str] = contextvars.ContextVar(
|
|
15
|
+
"milo_correlation_id", default=""
|
|
16
|
+
)
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def new_correlation_id() -> str:
|
|
20
|
+
"""Generate and set a new correlation ID. Returns the ID."""
|
|
21
|
+
cid = uuid.uuid4().hex[:12]
|
|
22
|
+
correlation_id.set(cid)
|
|
23
|
+
return cid
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
@dataclass(frozen=True, slots=True)
|
|
27
|
+
class RequestLog:
|
|
28
|
+
"""A single MCP request log entry."""
|
|
29
|
+
|
|
30
|
+
correlation_id: str
|
|
31
|
+
method: str
|
|
32
|
+
name: str
|
|
33
|
+
latency_ms: float
|
|
34
|
+
error: str = ""
|
|
35
|
+
cli_name: str = ""
|
|
36
|
+
timestamp: float = 0.0
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
class RequestLogger:
|
|
40
|
+
"""Thread-safe ring buffer for MCP request logs with stats computation."""
|
|
41
|
+
|
|
42
|
+
def __init__(self, max_size: int = 1000) -> None:
|
|
43
|
+
self._buffer: deque[RequestLog] = deque(maxlen=max_size)
|
|
44
|
+
self._lock = threading.Lock()
|
|
45
|
+
self._total: int = 0
|
|
46
|
+
self._errors: int = 0
|
|
47
|
+
|
|
48
|
+
def record(self, log: RequestLog) -> None:
|
|
49
|
+
"""Record a request log entry."""
|
|
50
|
+
with self._lock:
|
|
51
|
+
self._buffer.append(log)
|
|
52
|
+
self._total += 1
|
|
53
|
+
if log.error:
|
|
54
|
+
self._errors += 1
|
|
55
|
+
|
|
56
|
+
def recent(self, n: int = 20) -> list[RequestLog]:
|
|
57
|
+
"""Return the N most recent log entries."""
|
|
58
|
+
with self._lock:
|
|
59
|
+
items = list(self._buffer)
|
|
60
|
+
return items[-n:]
|
|
61
|
+
|
|
62
|
+
def stats(self) -> dict[str, Any]:
|
|
63
|
+
"""Compute aggregate statistics."""
|
|
64
|
+
with self._lock:
|
|
65
|
+
items = list(self._buffer)
|
|
66
|
+
total = self._total
|
|
67
|
+
errors = self._errors
|
|
68
|
+
|
|
69
|
+
if not items:
|
|
70
|
+
return {
|
|
71
|
+
"total": total,
|
|
72
|
+
"errors": errors,
|
|
73
|
+
"avg_latency_ms": 0.0,
|
|
74
|
+
"p99_latency_ms": 0.0,
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
latencies = sorted(log.latency_ms for log in items)
|
|
78
|
+
avg = sum(latencies) / len(latencies)
|
|
79
|
+
p99_idx = max(0, int(len(latencies) * 0.99) - 1)
|
|
80
|
+
|
|
81
|
+
return {
|
|
82
|
+
"total": total,
|
|
83
|
+
"errors": errors,
|
|
84
|
+
"avg_latency_ms": round(avg, 2),
|
|
85
|
+
"p99_latency_ms": round(latencies[p99_idx], 2),
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def log_request(
|
|
90
|
+
logger: RequestLogger,
|
|
91
|
+
method: str,
|
|
92
|
+
name: str,
|
|
93
|
+
start_time: float,
|
|
94
|
+
*,
|
|
95
|
+
error: str = "",
|
|
96
|
+
cli_name: str = "",
|
|
97
|
+
) -> RequestLog:
|
|
98
|
+
"""Create and record a request log entry. Returns the log."""
|
|
99
|
+
elapsed = (time.monotonic() - start_time) * 1000
|
|
100
|
+
cid = correlation_id.get("")
|
|
101
|
+
entry = RequestLog(
|
|
102
|
+
correlation_id=cid,
|
|
103
|
+
method=method,
|
|
104
|
+
name=name,
|
|
105
|
+
latency_ms=round(elapsed, 2),
|
|
106
|
+
error=error,
|
|
107
|
+
cli_name=cli_name,
|
|
108
|
+
timestamp=time.time(),
|
|
109
|
+
)
|
|
110
|
+
logger.record(entry)
|
|
111
|
+
return entry
|
milo/output.py
ADDED
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
"""Structured output formatting for CLI commands."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import sys
|
|
7
|
+
from typing import Any
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def format_output(data: Any, fmt: str = "plain", template: str = "") -> str:
|
|
11
|
+
"""Format command output based on requested format.
|
|
12
|
+
|
|
13
|
+
Formats:
|
|
14
|
+
plain — human-readable (default)
|
|
15
|
+
json — JSON output
|
|
16
|
+
table — tabular output (for lists of dicts)
|
|
17
|
+
template — render through a kida template
|
|
18
|
+
"""
|
|
19
|
+
match fmt:
|
|
20
|
+
case "json":
|
|
21
|
+
return _format_json(data)
|
|
22
|
+
case "table":
|
|
23
|
+
return _format_table(data)
|
|
24
|
+
case "template":
|
|
25
|
+
return _format_template(data, template)
|
|
26
|
+
case _:
|
|
27
|
+
return _format_plain(data)
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def _format_json(data: Any) -> str:
|
|
31
|
+
"""JSON output."""
|
|
32
|
+
return json.dumps(data, indent=2, default=str)
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def _format_plain(data: Any) -> str:
|
|
36
|
+
"""Human-readable plain output."""
|
|
37
|
+
if isinstance(data, list):
|
|
38
|
+
lines = []
|
|
39
|
+
for item in data:
|
|
40
|
+
if isinstance(item, dict):
|
|
41
|
+
parts = [f"{v}" for v in item.values()]
|
|
42
|
+
lines.append(" ".join(parts))
|
|
43
|
+
else:
|
|
44
|
+
lines.append(str(item))
|
|
45
|
+
return "\n".join(lines)
|
|
46
|
+
if isinstance(data, dict):
|
|
47
|
+
lines = []
|
|
48
|
+
max_key = max((len(str(k)) for k in data), default=0)
|
|
49
|
+
for k, v in data.items():
|
|
50
|
+
lines.append(f" {k!s:<{max_key}} {v}")
|
|
51
|
+
return "\n".join(lines)
|
|
52
|
+
return str(data)
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def _format_table(data: Any) -> str:
|
|
56
|
+
"""Table output using kida's table filter if available."""
|
|
57
|
+
if not isinstance(data, list):
|
|
58
|
+
return _format_plain(data)
|
|
59
|
+
|
|
60
|
+
if not data:
|
|
61
|
+
return "(empty)"
|
|
62
|
+
|
|
63
|
+
# Extract headers from first dict item
|
|
64
|
+
if isinstance(data[0], dict):
|
|
65
|
+
headers = list(data[0].keys())
|
|
66
|
+
rows = [[str(item.get(h, "")) for h in headers] for item in data]
|
|
67
|
+
else:
|
|
68
|
+
headers = None
|
|
69
|
+
rows = [[str(item)] for item in data]
|
|
70
|
+
|
|
71
|
+
try:
|
|
72
|
+
from kida import Environment
|
|
73
|
+
|
|
74
|
+
env = Environment(autoescape="terminal")
|
|
75
|
+
tmpl = env.from_string("{{ data | table(headers=headers) }}", name="table_fmt")
|
|
76
|
+
return tmpl.render(data=rows, headers=headers or [])
|
|
77
|
+
except ImportError:
|
|
78
|
+
# Fallback: simple column alignment
|
|
79
|
+
all_rows = [headers, *rows] if headers else rows
|
|
80
|
+
widths = [max(len(str(cell)) for cell in col) for col in zip(*all_rows, strict=False)]
|
|
81
|
+
lines = []
|
|
82
|
+
for row in all_rows:
|
|
83
|
+
lines.append(
|
|
84
|
+
" ".join(str(cell).ljust(w) for cell, w in zip(row, widths, strict=False))
|
|
85
|
+
)
|
|
86
|
+
if row is headers:
|
|
87
|
+
lines.append(" ".join("-" * w for w in widths))
|
|
88
|
+
return "\n".join(lines)
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def _format_template(data: Any, template_name: str) -> str:
|
|
92
|
+
"""Render through a kida template."""
|
|
93
|
+
if not template_name:
|
|
94
|
+
return _format_plain(data)
|
|
95
|
+
from milo.templates import get_env
|
|
96
|
+
|
|
97
|
+
env = get_env()
|
|
98
|
+
tmpl = env.get_template(template_name)
|
|
99
|
+
return tmpl.render(state=data)
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def write_output(data: Any, fmt: str = "plain", template: str = "") -> None:
|
|
103
|
+
"""Format and write command output to stdout."""
|
|
104
|
+
output = format_output(data, fmt=fmt, template=template)
|
|
105
|
+
sys.stdout.write(output + "\n")
|
|
106
|
+
sys.stdout.flush()
|