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/__init__.py
ADDED
|
@@ -0,0 +1,183 @@
|
|
|
1
|
+
"""Milo — Template-driven CLI applications for free-threaded Python."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
def __getattr__(name: str):
|
|
7
|
+
"""Lazy imports for public API."""
|
|
8
|
+
_imports = {
|
|
9
|
+
# Types
|
|
10
|
+
"Action": "_types",
|
|
11
|
+
"Key": "_types",
|
|
12
|
+
"SpecialKey": "_types",
|
|
13
|
+
"AppStatus": "_types",
|
|
14
|
+
"RenderTarget": "_types",
|
|
15
|
+
"FieldType": "_types",
|
|
16
|
+
"FieldSpec": "_types",
|
|
17
|
+
"FieldState": "_types",
|
|
18
|
+
"FormState": "_types",
|
|
19
|
+
"Screen": "_types",
|
|
20
|
+
"Transition": "_types",
|
|
21
|
+
"ReducerResult": "_types",
|
|
22
|
+
"Quit": "_types",
|
|
23
|
+
"Call": "_types",
|
|
24
|
+
"Put": "_types",
|
|
25
|
+
"Select": "_types",
|
|
26
|
+
"Fork": "_types",
|
|
27
|
+
"Delay": "_types",
|
|
28
|
+
"BUILTIN_ACTIONS": "_types",
|
|
29
|
+
# Errors
|
|
30
|
+
"MiloError": "_errors",
|
|
31
|
+
"InputError": "_errors",
|
|
32
|
+
"StateError": "_errors",
|
|
33
|
+
"FormError": "_errors",
|
|
34
|
+
"AppError": "_errors",
|
|
35
|
+
"FlowError": "_errors",
|
|
36
|
+
"ConfigError": "_errors",
|
|
37
|
+
"PipelineError": "_errors",
|
|
38
|
+
"PluginError": "_errors",
|
|
39
|
+
"ErrorCode": "_errors",
|
|
40
|
+
"format_error": "_errors",
|
|
41
|
+
"format_render_error": "_errors",
|
|
42
|
+
# State
|
|
43
|
+
"Store": "state",
|
|
44
|
+
"combine_reducers": "state",
|
|
45
|
+
# App
|
|
46
|
+
"App": "app",
|
|
47
|
+
"run": "app",
|
|
48
|
+
"render_html": "app",
|
|
49
|
+
# Flow
|
|
50
|
+
"FlowScreen": "flow",
|
|
51
|
+
"Flow": "flow",
|
|
52
|
+
"FlowState": "flow",
|
|
53
|
+
# Form
|
|
54
|
+
"form": "form",
|
|
55
|
+
"form_reducer": "form",
|
|
56
|
+
"make_form_reducer": "form",
|
|
57
|
+
# Help
|
|
58
|
+
"HelpRenderer": "help",
|
|
59
|
+
# Dev
|
|
60
|
+
"DevServer": "dev",
|
|
61
|
+
# Commands (AI-native)
|
|
62
|
+
"CLI": "commands",
|
|
63
|
+
"CommandDef": "commands",
|
|
64
|
+
"LazyCommandDef": "commands",
|
|
65
|
+
# Groups
|
|
66
|
+
"Group": "groups",
|
|
67
|
+
"GroupDef": "groups",
|
|
68
|
+
"GlobalOption": "commands",
|
|
69
|
+
# Context
|
|
70
|
+
"Context": "context",
|
|
71
|
+
"get_context": "context",
|
|
72
|
+
# Config
|
|
73
|
+
"Config": "config",
|
|
74
|
+
"ConfigSpec": "config",
|
|
75
|
+
# Pipeline
|
|
76
|
+
"Pipeline": "pipeline",
|
|
77
|
+
"Phase": "pipeline",
|
|
78
|
+
"PipelineState": "pipeline",
|
|
79
|
+
"PhaseStatus": "pipeline",
|
|
80
|
+
# Plugins
|
|
81
|
+
"HookRegistry": "plugins",
|
|
82
|
+
"function_to_schema": "schema",
|
|
83
|
+
"format_output": "output",
|
|
84
|
+
"write_output": "output",
|
|
85
|
+
"generate_llms_txt": "llms",
|
|
86
|
+
# Commands v2 (resources, prompts)
|
|
87
|
+
"ResourceDef": "commands",
|
|
88
|
+
"PromptDef": "commands",
|
|
89
|
+
# Middleware
|
|
90
|
+
"MCPCall": "middleware",
|
|
91
|
+
"MiddlewareStack": "middleware",
|
|
92
|
+
# Streaming
|
|
93
|
+
"Progress": "streaming",
|
|
94
|
+
# Observability
|
|
95
|
+
"RequestLog": "observability",
|
|
96
|
+
"RequestLogger": "observability",
|
|
97
|
+
}
|
|
98
|
+
if name in _imports:
|
|
99
|
+
import importlib
|
|
100
|
+
|
|
101
|
+
module = importlib.import_module(f"milo.{_imports[name]}")
|
|
102
|
+
return getattr(module, name)
|
|
103
|
+
raise AttributeError(f"module 'milo' has no attribute {name!r}")
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
# Free-threaded Python marker (PEP 703)
|
|
107
|
+
def _Py_mod_gil() -> int: # noqa: N802
|
|
108
|
+
return 0
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
__version__ = "0.1.0"
|
|
112
|
+
__all__ = [
|
|
113
|
+
"BUILTIN_ACTIONS",
|
|
114
|
+
"CLI",
|
|
115
|
+
"Action",
|
|
116
|
+
"App",
|
|
117
|
+
"AppError",
|
|
118
|
+
"AppStatus",
|
|
119
|
+
"Call",
|
|
120
|
+
"CommandDef",
|
|
121
|
+
"Config",
|
|
122
|
+
"ConfigError",
|
|
123
|
+
"ConfigSpec",
|
|
124
|
+
"Context",
|
|
125
|
+
"Delay",
|
|
126
|
+
"DevServer",
|
|
127
|
+
"ErrorCode",
|
|
128
|
+
"FieldSpec",
|
|
129
|
+
"FieldState",
|
|
130
|
+
"FieldType",
|
|
131
|
+
"Flow",
|
|
132
|
+
"FlowError",
|
|
133
|
+
"FlowScreen",
|
|
134
|
+
"FlowState",
|
|
135
|
+
"Fork",
|
|
136
|
+
"FormError",
|
|
137
|
+
"FormState",
|
|
138
|
+
"GlobalOption",
|
|
139
|
+
"Group",
|
|
140
|
+
"GroupDef",
|
|
141
|
+
"HelpRenderer",
|
|
142
|
+
"HookRegistry",
|
|
143
|
+
"InputError",
|
|
144
|
+
"Key",
|
|
145
|
+
"LazyCommandDef",
|
|
146
|
+
"MCPCall",
|
|
147
|
+
"MiddlewareStack",
|
|
148
|
+
"MiloError",
|
|
149
|
+
"Phase",
|
|
150
|
+
"PhaseStatus",
|
|
151
|
+
"Pipeline",
|
|
152
|
+
"PipelineError",
|
|
153
|
+
"PipelineState",
|
|
154
|
+
"PluginError",
|
|
155
|
+
"Progress",
|
|
156
|
+
"PromptDef",
|
|
157
|
+
"Put",
|
|
158
|
+
"Quit",
|
|
159
|
+
"ReducerResult",
|
|
160
|
+
"RenderTarget",
|
|
161
|
+
"RequestLog",
|
|
162
|
+
"RequestLogger",
|
|
163
|
+
"ResourceDef",
|
|
164
|
+
"Screen",
|
|
165
|
+
"Select",
|
|
166
|
+
"SpecialKey",
|
|
167
|
+
"StateError",
|
|
168
|
+
"Store",
|
|
169
|
+
"Transition",
|
|
170
|
+
"combine_reducers",
|
|
171
|
+
"form",
|
|
172
|
+
"form_reducer",
|
|
173
|
+
"format_error",
|
|
174
|
+
"format_output",
|
|
175
|
+
"format_render_error",
|
|
176
|
+
"function_to_schema",
|
|
177
|
+
"generate_llms_txt",
|
|
178
|
+
"get_context",
|
|
179
|
+
"make_form_reducer",
|
|
180
|
+
"render_html",
|
|
181
|
+
"run",
|
|
182
|
+
"write_output",
|
|
183
|
+
]
|
milo/_child.py
ADDED
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
"""Persistent child process for MCP gateway communication."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import subprocess
|
|
7
|
+
import threading
|
|
8
|
+
import time
|
|
9
|
+
from typing import Any
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class ChildProcess:
|
|
13
|
+
"""A persistent child process that speaks JSON-RPC on stdin/stdout.
|
|
14
|
+
|
|
15
|
+
Thread-safe: a lock serializes all calls to the same child.
|
|
16
|
+
Auto-reconnects if the child process dies.
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
def __init__(
|
|
20
|
+
self,
|
|
21
|
+
name: str,
|
|
22
|
+
command: list[str],
|
|
23
|
+
*,
|
|
24
|
+
idle_timeout: float = 300.0,
|
|
25
|
+
) -> None:
|
|
26
|
+
self.name = name
|
|
27
|
+
self.command = command
|
|
28
|
+
self.idle_timeout = idle_timeout
|
|
29
|
+
self._proc: subprocess.Popen[str] | None = None
|
|
30
|
+
self._lock = threading.Lock()
|
|
31
|
+
self._last_use = time.monotonic()
|
|
32
|
+
self._request_id = 0
|
|
33
|
+
self._initialized = False
|
|
34
|
+
|
|
35
|
+
def _spawn(self) -> None:
|
|
36
|
+
"""Start the child process with persistent pipes."""
|
|
37
|
+
self._proc = subprocess.Popen(
|
|
38
|
+
self.command,
|
|
39
|
+
stdin=subprocess.PIPE,
|
|
40
|
+
stdout=subprocess.PIPE,
|
|
41
|
+
stderr=subprocess.PIPE,
|
|
42
|
+
text=True,
|
|
43
|
+
bufsize=1, # line-buffered
|
|
44
|
+
)
|
|
45
|
+
self._initialized = False
|
|
46
|
+
self._request_id = 0
|
|
47
|
+
|
|
48
|
+
def _ensure_initialized(self) -> None:
|
|
49
|
+
"""Send initialize if not already done."""
|
|
50
|
+
if self._initialized:
|
|
51
|
+
return
|
|
52
|
+
self._request_id += 1
|
|
53
|
+
req = {
|
|
54
|
+
"jsonrpc": "2.0",
|
|
55
|
+
"id": self._request_id,
|
|
56
|
+
"method": "initialize",
|
|
57
|
+
}
|
|
58
|
+
self._write_line(json.dumps(req))
|
|
59
|
+
self._read_line() # consume initialize response
|
|
60
|
+
# Send notifications/initialized
|
|
61
|
+
notif = {"jsonrpc": "2.0", "method": "notifications/initialized"}
|
|
62
|
+
self._write_line(json.dumps(notif))
|
|
63
|
+
self._initialized = True
|
|
64
|
+
|
|
65
|
+
def ensure_alive(self) -> None:
|
|
66
|
+
"""Spawn or reconnect if the child process is dead."""
|
|
67
|
+
with self._lock:
|
|
68
|
+
if self._proc is None or self._proc.poll() is not None:
|
|
69
|
+
self._spawn()
|
|
70
|
+
self._ensure_initialized()
|
|
71
|
+
|
|
72
|
+
def send_call(self, method: str, params: dict[str, Any]) -> dict[str, Any]:
|
|
73
|
+
"""Send a JSON-RPC request and return the result. Thread-safe."""
|
|
74
|
+
with self._lock:
|
|
75
|
+
if self._proc is None or self._proc.poll() is not None:
|
|
76
|
+
self._spawn()
|
|
77
|
+
self._ensure_initialized()
|
|
78
|
+
|
|
79
|
+
self._request_id += 1
|
|
80
|
+
req_id = self._request_id
|
|
81
|
+
request = {
|
|
82
|
+
"jsonrpc": "2.0",
|
|
83
|
+
"id": req_id,
|
|
84
|
+
"method": method,
|
|
85
|
+
"params": params,
|
|
86
|
+
}
|
|
87
|
+
self._write_line(json.dumps(request))
|
|
88
|
+
response_line = self._read_line()
|
|
89
|
+
self._last_use = time.monotonic()
|
|
90
|
+
|
|
91
|
+
if not response_line:
|
|
92
|
+
return {"error": {"code": -32603, "message": f"No response from {self.name}"}}
|
|
93
|
+
|
|
94
|
+
try:
|
|
95
|
+
response = json.loads(response_line)
|
|
96
|
+
except json.JSONDecodeError:
|
|
97
|
+
return {"error": {"code": -32700, "message": "Parse error from child"}}
|
|
98
|
+
|
|
99
|
+
if "error" in response:
|
|
100
|
+
return response
|
|
101
|
+
return response.get("result", {})
|
|
102
|
+
|
|
103
|
+
def fetch_tools(self) -> list[dict[str, Any]]:
|
|
104
|
+
"""Fetch tools/list from the child process."""
|
|
105
|
+
result = self.send_call("tools/list", {})
|
|
106
|
+
return result.get("tools", [])
|
|
107
|
+
|
|
108
|
+
def is_idle(self) -> bool:
|
|
109
|
+
"""Check if the child has been idle longer than idle_timeout."""
|
|
110
|
+
return (time.monotonic() - self._last_use) > self.idle_timeout
|
|
111
|
+
|
|
112
|
+
def kill(self) -> None:
|
|
113
|
+
"""Kill the child process."""
|
|
114
|
+
with self._lock:
|
|
115
|
+
if self._proc is not None:
|
|
116
|
+
try:
|
|
117
|
+
self._proc.terminate()
|
|
118
|
+
self._proc.wait(timeout=5)
|
|
119
|
+
except ProcessLookupError:
|
|
120
|
+
pass
|
|
121
|
+
except subprocess.TimeoutExpired:
|
|
122
|
+
import contextlib
|
|
123
|
+
|
|
124
|
+
with contextlib.suppress(ProcessLookupError):
|
|
125
|
+
self._proc.kill()
|
|
126
|
+
self._proc = None
|
|
127
|
+
self._initialized = False
|
|
128
|
+
|
|
129
|
+
def _write_line(self, line: str) -> None:
|
|
130
|
+
"""Write a line to the child's stdin."""
|
|
131
|
+
assert self._proc is not None
|
|
132
|
+
assert self._proc.stdin is not None
|
|
133
|
+
self._proc.stdin.write(line + "\n")
|
|
134
|
+
self._proc.stdin.flush()
|
|
135
|
+
|
|
136
|
+
def _read_line(self) -> str:
|
|
137
|
+
"""Read a line from the child's stdout."""
|
|
138
|
+
assert self._proc is not None
|
|
139
|
+
assert self._proc.stdout is not None
|
|
140
|
+
line = self._proc.stdout.readline()
|
|
141
|
+
return line.strip()
|
milo/_errors.py
ADDED
|
@@ -0,0 +1,189 @@
|
|
|
1
|
+
"""Structured error hierarchy with format_compact() for terminal display."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from enum import Enum
|
|
6
|
+
from typing import Any
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class ErrorCode(Enum):
|
|
10
|
+
# Input errors
|
|
11
|
+
INP_RAW_MODE = "M-INP-001"
|
|
12
|
+
INP_ESCAPE_PARSE = "M-INP-002"
|
|
13
|
+
INP_READ = "M-INP-003"
|
|
14
|
+
|
|
15
|
+
# State errors
|
|
16
|
+
STA_REDUCER = "M-STA-001"
|
|
17
|
+
STA_DISPATCH = "M-STA-002"
|
|
18
|
+
STA_SAGA = "M-STA-003"
|
|
19
|
+
STA_COMBINE = "M-STA-004"
|
|
20
|
+
|
|
21
|
+
# App errors
|
|
22
|
+
APP_LIFECYCLE = "M-APP-001"
|
|
23
|
+
APP_RENDER = "M-APP-002"
|
|
24
|
+
APP_TEMPLATE = "M-APP-003"
|
|
25
|
+
|
|
26
|
+
# Form errors
|
|
27
|
+
FRM_VALIDATION = "M-FRM-001"
|
|
28
|
+
FRM_FIELD = "M-FRM-002"
|
|
29
|
+
FRM_SUBMIT = "M-FRM-003"
|
|
30
|
+
|
|
31
|
+
# Flow errors
|
|
32
|
+
FLW_TRANSITION = "M-FLW-001"
|
|
33
|
+
FLW_SCREEN = "M-FLW-002"
|
|
34
|
+
FLW_DUPLICATE = "M-FLW-003"
|
|
35
|
+
|
|
36
|
+
# Dev errors
|
|
37
|
+
DEV_WATCH = "M-DEV-001"
|
|
38
|
+
DEV_RELOAD = "M-DEV-002"
|
|
39
|
+
|
|
40
|
+
# Config errors
|
|
41
|
+
CFG_PARSE = "M-CFG-001"
|
|
42
|
+
CFG_MERGE = "M-CFG-002"
|
|
43
|
+
CFG_VALIDATE = "M-CFG-003"
|
|
44
|
+
CFG_MISSING = "M-CFG-004"
|
|
45
|
+
|
|
46
|
+
# Pipeline errors
|
|
47
|
+
PIP_PHASE = "M-PIP-001"
|
|
48
|
+
PIP_TIMEOUT = "M-PIP-002"
|
|
49
|
+
PIP_DEPENDENCY = "M-PIP-003"
|
|
50
|
+
|
|
51
|
+
# Plugin errors
|
|
52
|
+
PLG_LOAD = "M-PLG-001"
|
|
53
|
+
PLG_HOOK = "M-PLG-002"
|
|
54
|
+
|
|
55
|
+
# Command errors
|
|
56
|
+
CMD_NOT_FOUND = "M-CMD-001"
|
|
57
|
+
CMD_AMBIGUOUS = "M-CMD-002"
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
class MiloError(Exception):
|
|
61
|
+
"""Base error for all milo errors."""
|
|
62
|
+
|
|
63
|
+
def __init__(
|
|
64
|
+
self,
|
|
65
|
+
code: ErrorCode,
|
|
66
|
+
message: str,
|
|
67
|
+
*,
|
|
68
|
+
suggestion: str = "",
|
|
69
|
+
context: dict[str, Any] | None = None,
|
|
70
|
+
docs_url: str = "",
|
|
71
|
+
) -> None:
|
|
72
|
+
self.code = code
|
|
73
|
+
self.message = message
|
|
74
|
+
self.suggestion = suggestion
|
|
75
|
+
self.context = context or {}
|
|
76
|
+
self.docs_url = docs_url
|
|
77
|
+
super().__init__(f"[{code.value}] {message}")
|
|
78
|
+
|
|
79
|
+
def format_compact(self) -> str:
|
|
80
|
+
"""Format error for terminal display, consistent with kida's format_compact()."""
|
|
81
|
+
parts = [f"{self.code.value}: {self.message}"]
|
|
82
|
+
if self.suggestion:
|
|
83
|
+
parts.append(f" hint: {self.suggestion}")
|
|
84
|
+
if self.docs_url:
|
|
85
|
+
parts.append(f" docs: {self.docs_url}")
|
|
86
|
+
return "\n".join(parts)
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
class InputError(MiloError):
|
|
90
|
+
"""Input-related errors (raw mode, escape parsing)."""
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
class StateError(MiloError):
|
|
94
|
+
"""State-related errors (reducer, dispatch, saga)."""
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
class FormError(MiloError):
|
|
98
|
+
"""Form-related errors (validation, field)."""
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
class AppError(MiloError):
|
|
102
|
+
"""App lifecycle errors."""
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
class FlowError(MiloError):
|
|
106
|
+
"""Flow errors (transitions, screens)."""
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
class DevError(MiloError):
|
|
110
|
+
"""Dev server errors (watch, reload)."""
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
class ConfigError(MiloError):
|
|
114
|
+
"""Configuration errors (parse, merge, validate)."""
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
class PipelineError(MiloError):
|
|
118
|
+
"""Pipeline orchestration errors (phase, timeout, dependency)."""
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
class PluginError(MiloError):
|
|
122
|
+
"""Plugin system errors (load, hook)."""
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def format_error(error: Exception) -> str:
|
|
126
|
+
"""Format any error for terminal display.
|
|
127
|
+
|
|
128
|
+
Uses format_compact() for kida TemplateErrors and MiloErrors.
|
|
129
|
+
Falls back to str() for other exceptions.
|
|
130
|
+
"""
|
|
131
|
+
if hasattr(error, "format_compact"):
|
|
132
|
+
return error.format_compact()
|
|
133
|
+
return f"{type(error).__name__}: {error}"
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
def format_render_error(
|
|
137
|
+
error: Exception,
|
|
138
|
+
*,
|
|
139
|
+
template_name: str = "",
|
|
140
|
+
env: Any = None,
|
|
141
|
+
) -> str:
|
|
142
|
+
"""Format a render error with optional error template rendering.
|
|
143
|
+
|
|
144
|
+
Tries to render through the built-in error.txt template.
|
|
145
|
+
Falls back to format_error() if template rendering fails.
|
|
146
|
+
"""
|
|
147
|
+
compact = format_error(error)
|
|
148
|
+
|
|
149
|
+
# Try to render through error template
|
|
150
|
+
if env is not None:
|
|
151
|
+
try:
|
|
152
|
+
tmpl = env.get_template("error.kida")
|
|
153
|
+
return tmpl.render(
|
|
154
|
+
error=compact,
|
|
155
|
+
code=_get_error_code(error),
|
|
156
|
+
template_name=template_name,
|
|
157
|
+
message=str(error),
|
|
158
|
+
hint=_get_hint(error),
|
|
159
|
+
docs_url=_get_docs_url(error),
|
|
160
|
+
)
|
|
161
|
+
except Exception:
|
|
162
|
+
pass
|
|
163
|
+
|
|
164
|
+
return compact
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
def _get_error_code(error: Exception) -> str:
|
|
168
|
+
"""Extract error code string from any error."""
|
|
169
|
+
if isinstance(error, MiloError):
|
|
170
|
+
return error.code.value
|
|
171
|
+
if hasattr(error, "code") and error.code is not None:
|
|
172
|
+
return str(error.code.value) if hasattr(error.code, "value") else str(error.code)
|
|
173
|
+
return ""
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
def _get_hint(error: Exception) -> str:
|
|
177
|
+
"""Extract hint/suggestion from an error."""
|
|
178
|
+
if hasattr(error, "suggestion") and error.suggestion:
|
|
179
|
+
return str(error.suggestion)
|
|
180
|
+
return ""
|
|
181
|
+
|
|
182
|
+
|
|
183
|
+
def _get_docs_url(error: Exception) -> str:
|
|
184
|
+
"""Extract docs URL from an error."""
|
|
185
|
+
if hasattr(error, "docs_url") and error.docs_url:
|
|
186
|
+
return str(error.docs_url)
|
|
187
|
+
if hasattr(error, "code") and hasattr(error.code, "docs_url"):
|
|
188
|
+
return str(error.code.docs_url)
|
|
189
|
+
return ""
|
milo/_protocols.py
ADDED
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
"""Protocols — stdlib/typing only, no internal imports."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import Any, Protocol, runtime_checkable
|
|
6
|
+
|
|
7
|
+
from milo._types import Action, ReducerResult
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
@runtime_checkable
|
|
11
|
+
class Reducer(Protocol):
|
|
12
|
+
def __call__(self, state: Any, action: Action) -> Any | ReducerResult: ...
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
@runtime_checkable
|
|
16
|
+
class Saga(Protocol):
|
|
17
|
+
def __call__(self, *args: Any, **kwargs: Any) -> Any: ...
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
@runtime_checkable
|
|
21
|
+
class DispatchFn(Protocol):
|
|
22
|
+
def __call__(self, action: Action) -> None: ...
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
@runtime_checkable
|
|
26
|
+
class Middleware(Protocol):
|
|
27
|
+
def __call__(self, dispatch: DispatchFn, get_state: GetStateFn) -> DispatchFn: ...
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
@runtime_checkable
|
|
31
|
+
class GetStateFn(Protocol):
|
|
32
|
+
def __call__(self) -> Any: ...
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
@runtime_checkable
|
|
36
|
+
class FieldValidator(Protocol):
|
|
37
|
+
def __call__(self, value: Any) -> tuple[bool, str]: ...
|