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 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]: ...