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/_types.py ADDED
@@ -0,0 +1,234 @@
1
+ """Frozen dataclasses, enums, and type aliases — no internal imports."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from collections.abc import Callable, Generator
6
+ from dataclasses import dataclass, field
7
+ from enum import Enum, auto
8
+ from typing import Any
9
+
10
+ # ---------------------------------------------------------------------------
11
+ # Keys
12
+ # ---------------------------------------------------------------------------
13
+
14
+
15
+ class SpecialKey(Enum):
16
+ ENTER = auto()
17
+ TAB = auto()
18
+ BACKSPACE = auto()
19
+ DELETE = auto()
20
+ ESCAPE = auto()
21
+ UP = auto()
22
+ DOWN = auto()
23
+ LEFT = auto()
24
+ RIGHT = auto()
25
+ HOME = auto()
26
+ END = auto()
27
+ PAGE_UP = auto()
28
+ PAGE_DOWN = auto()
29
+ INSERT = auto()
30
+ F1 = auto()
31
+ F2 = auto()
32
+ F3 = auto()
33
+ F4 = auto()
34
+ F5 = auto()
35
+ F6 = auto()
36
+ F7 = auto()
37
+ F8 = auto()
38
+ F9 = auto()
39
+ F10 = auto()
40
+ F11 = auto()
41
+ F12 = auto()
42
+
43
+
44
+ @dataclass(frozen=True, slots=True)
45
+ class Key:
46
+ """Single keypress."""
47
+
48
+ char: str = ""
49
+ name: SpecialKey | None = None
50
+ ctrl: bool = False
51
+ alt: bool = False
52
+ shift: bool = False
53
+
54
+
55
+ # ---------------------------------------------------------------------------
56
+ # Actions
57
+ # ---------------------------------------------------------------------------
58
+
59
+
60
+ @dataclass(frozen=True, slots=True)
61
+ class Action:
62
+ """Event dispatched to a reducer."""
63
+
64
+ type: str
65
+ payload: Any = None
66
+
67
+
68
+ BUILTIN_ACTIONS: frozenset[str] = frozenset(
69
+ {
70
+ "@@INIT",
71
+ "@@KEY",
72
+ "@@TICK",
73
+ "@@RESIZE",
74
+ "@@EFFECT_RESULT",
75
+ "@@QUIT",
76
+ "@@NAVIGATE",
77
+ "@@HOT_RELOAD",
78
+ "@@PIPELINE_START",
79
+ "@@PIPELINE_COMPLETE",
80
+ "@@PHASE_START",
81
+ "@@PHASE_COMPLETE",
82
+ "@@PHASE_FAILED",
83
+ }
84
+ )
85
+
86
+
87
+ # ---------------------------------------------------------------------------
88
+ # App
89
+ # ---------------------------------------------------------------------------
90
+
91
+
92
+ class AppStatus(Enum):
93
+ IDLE = auto()
94
+ RUNNING = auto()
95
+ PAUSED = auto()
96
+ STOPPED = auto()
97
+
98
+
99
+ class RenderTarget(Enum):
100
+ TERMINAL = auto()
101
+ HTML = auto()
102
+
103
+
104
+ # ---------------------------------------------------------------------------
105
+ # Screens / Flows
106
+ # ---------------------------------------------------------------------------
107
+
108
+
109
+ @dataclass(frozen=True, slots=True)
110
+ class Screen:
111
+ """Named screen config: template name + reducer reference."""
112
+
113
+ name: str
114
+ template: str
115
+ reducer: Callable # Reducer protocol
116
+
117
+
118
+ @dataclass(frozen=True, slots=True)
119
+ class Transition:
120
+ """Flow edge between screens."""
121
+
122
+ from_screen: str
123
+ to_screen: str
124
+ on_action: str
125
+
126
+
127
+ # ---------------------------------------------------------------------------
128
+ # Fields / Forms
129
+ # ---------------------------------------------------------------------------
130
+
131
+
132
+ class FieldType(Enum):
133
+ TEXT = auto()
134
+ SELECT = auto()
135
+ CONFIRM = auto()
136
+ PASSWORD = auto()
137
+
138
+
139
+ @dataclass(frozen=True, slots=True)
140
+ class FieldSpec:
141
+ """Declarative field configuration."""
142
+
143
+ name: str
144
+ label: str
145
+ field_type: FieldType = FieldType.TEXT
146
+ choices: tuple[str, ...] = ()
147
+ default: Any = None
148
+ validator: Callable | None = None
149
+ placeholder: str = ""
150
+
151
+
152
+ @dataclass(frozen=True, slots=True)
153
+ class FieldState:
154
+ """Runtime state for a single field."""
155
+
156
+ value: Any = ""
157
+ cursor: int = 0
158
+ error: str = ""
159
+ focused: bool = False
160
+ selected_index: int = 0
161
+
162
+
163
+ @dataclass(frozen=True, slots=True)
164
+ class FormState:
165
+ """Full form state."""
166
+
167
+ fields: tuple[FieldState, ...] = ()
168
+ specs: tuple[FieldSpec, ...] = ()
169
+ active_index: int = 0
170
+ submitted: bool = False
171
+
172
+
173
+ # ---------------------------------------------------------------------------
174
+ # Effects (sagas)
175
+ # ---------------------------------------------------------------------------
176
+
177
+
178
+ @dataclass(frozen=True, slots=True)
179
+ class Call:
180
+ """Call a function, resume saga with its return value."""
181
+
182
+ fn: Callable
183
+ args: tuple = ()
184
+ kwargs: dict = field(default_factory=dict)
185
+
186
+
187
+ @dataclass(frozen=True, slots=True)
188
+ class Put:
189
+ """Dispatch an action back to the store."""
190
+
191
+ action: Action
192
+
193
+
194
+ @dataclass(frozen=True, slots=True)
195
+ class Select:
196
+ """Read current state, resume saga with it."""
197
+
198
+ selector: Callable | None = None
199
+
200
+
201
+ @dataclass(frozen=True, slots=True)
202
+ class Fork:
203
+ """Run another saga concurrently."""
204
+
205
+ saga: Callable | Generator
206
+
207
+
208
+ @dataclass(frozen=True, slots=True)
209
+ class Delay:
210
+ """Sleep for N seconds."""
211
+
212
+ seconds: float
213
+
214
+
215
+ # ---------------------------------------------------------------------------
216
+ # Reducer result (state + optional sagas)
217
+ # ---------------------------------------------------------------------------
218
+
219
+
220
+ @dataclass(frozen=True, slots=True)
221
+ class ReducerResult:
222
+ """Reducer can return this to trigger side effects."""
223
+
224
+ state: Any
225
+ sagas: tuple[Callable, ...] = ()
226
+
227
+
228
+ @dataclass(frozen=True, slots=True)
229
+ class Quit:
230
+ """Signal the app to exit. Return from a reducer to stop the event loop."""
231
+
232
+ state: Any
233
+ code: int = 0
234
+ sagas: tuple[Callable, ...] = ()
milo/app.py ADDED
@@ -0,0 +1,353 @@
1
+ """App event loop and terminal rendering."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import contextlib
6
+ import os
7
+ import shutil
8
+ import signal
9
+ import sys
10
+ import threading
11
+ from collections.abc import Callable
12
+ from pathlib import Path
13
+ from typing import Any
14
+
15
+ from milo._errors import AppError, ErrorCode, format_render_error
16
+ from milo._types import Action, AppStatus, RenderTarget
17
+ from milo.flow import Flow, FlowState
18
+ from milo.input._platform import is_tty
19
+ from milo.input._reader import KeyReader
20
+ from milo.state import Store
21
+
22
+
23
+ class _TerminalRenderer:
24
+ """In-place terminal renderer using alternate screen buffer.
25
+
26
+ Uses cursor-home redraws with line clearing to avoid flicker
27
+ and prevent frame stacking.
28
+ """
29
+
30
+ def __init__(self) -> None:
31
+ self._prev_lines = 0
32
+ self._started = False
33
+
34
+ def start(self) -> None:
35
+ """Enter alternate screen buffer and hide cursor."""
36
+ sys.stdout.write("\033[?1049h") # Enter alternate screen
37
+ sys.stdout.write("\033[?25l") # Hide cursor
38
+ sys.stdout.flush()
39
+ self._started = True
40
+
41
+ def update(self, output: str) -> None:
42
+ """Redraw the screen with new output."""
43
+ if not self._started:
44
+ return
45
+ cols = shutil.get_terminal_size().columns
46
+ lines = output.split("\n")
47
+
48
+ # Move cursor to home position
49
+ sys.stdout.write("\033[H")
50
+
51
+ # Write each line, clearing to end of line
52
+ for line in lines:
53
+ # Truncate to terminal width to avoid wrapping artifacts
54
+ sys.stdout.write(line[:cols])
55
+ sys.stdout.write("\033[K\n") # Clear to end of line
56
+
57
+ # Clear any leftover lines from previous frame
58
+ if self._prev_lines > len(lines):
59
+ for _ in range(self._prev_lines - len(lines)):
60
+ sys.stdout.write("\033[K\n")
61
+
62
+ self._prev_lines = len(lines)
63
+ sys.stdout.flush()
64
+
65
+ def stop(self) -> None:
66
+ """Show cursor and leave alternate screen buffer."""
67
+ if not self._started:
68
+ return
69
+ self._started = False
70
+ sys.stdout.write("\033[?25h") # Show cursor
71
+ sys.stdout.write("\033[?1049l") # Leave alternate screen
72
+ sys.stdout.flush()
73
+
74
+
75
+ class App:
76
+ """Main application event loop.
77
+
78
+ Integrates the Store, KeyReader, and kida LiveRenderer
79
+ into a unified event loop.
80
+ """
81
+
82
+ def __init__(
83
+ self,
84
+ *,
85
+ template: str | Any = "",
86
+ reducer: Callable | None = None,
87
+ initial_state: Any = None,
88
+ middleware: tuple[Callable, ...] = (),
89
+ tick_rate: float = 0.0,
90
+ transient: bool = False,
91
+ target: RenderTarget = RenderTarget.TERMINAL,
92
+ record: bool | str | Path = False,
93
+ env: Any = None,
94
+ flow: Flow | None = None,
95
+ exit_template: str = "",
96
+ ) -> None:
97
+ self._target = target
98
+ self._tick_rate = tick_rate
99
+ self._transient = transient
100
+ self._env = env
101
+ self._flow = flow
102
+ self._template_name = template
103
+ self._exit_template = exit_template
104
+ self._status = AppStatus.IDLE
105
+ self._stop = threading.Event()
106
+
107
+ # Flow mode: build reducer from flow
108
+ if flow is not None:
109
+ self._reducer = flow.build_reducer()
110
+ self._initial_state = None
111
+ self._template_map = flow.template_map
112
+ else:
113
+ if reducer is None:
114
+ raise AppError(ErrorCode.APP_LIFECYCLE, "Either reducer or flow is required")
115
+ self._reducer = reducer
116
+ self._initial_state = initial_state
117
+ self._template_map = None
118
+
119
+ self._middleware = middleware
120
+ self._record = record
121
+
122
+ @classmethod
123
+ def from_flow(cls, flow: Flow, **kwargs: Any) -> App:
124
+ """Create App from a declarative Flow."""
125
+ return cls(flow=flow, **kwargs)
126
+
127
+ @classmethod
128
+ def render(cls, template: str, state: Any = None, *, env: Any = None) -> str:
129
+ """One-shot render of a template with state. Returns the rendered string."""
130
+ if env is None:
131
+ from milo.templates import get_env
132
+
133
+ env = get_env()
134
+ tmpl = env.get_template(template)
135
+ return tmpl.render(state=state)
136
+
137
+ def run(self) -> Any:
138
+ """Run the event loop. Returns final state."""
139
+ store = Store(
140
+ self._reducer,
141
+ self._initial_state,
142
+ self._middleware,
143
+ record=self._record,
144
+ )
145
+
146
+ if self._target == RenderTarget.HTML or not is_tty():
147
+ # Single render pass, no input
148
+ self._render_once(store.state)
149
+ if self._exit_template:
150
+ env = self._get_env()
151
+ try:
152
+ self._render_exit(store.state, env)
153
+ except Exception as e:
154
+ msg = format_render_error(e, template_name=self._exit_template, env=env)
155
+ sys.stderr.write(f"[milo] {msg}\n")
156
+ return store.state
157
+
158
+ self._status = AppStatus.RUNNING
159
+ self._stop.clear()
160
+
161
+ # Set up signal handler for resize
162
+ original_sigwinch = None
163
+ if hasattr(signal, "SIGWINCH"):
164
+
165
+ def _on_resize(signum: int, frame: Any) -> None:
166
+ try:
167
+ cols, rows = os.get_terminal_size()
168
+ store.dispatch(Action("@@RESIZE", payload=(cols, rows)))
169
+ except OSError:
170
+ pass
171
+
172
+ original_sigwinch = signal.getsignal(signal.SIGWINCH)
173
+ signal.signal(signal.SIGWINCH, _on_resize)
174
+
175
+ # Set up tick timer
176
+ tick_thread = None
177
+ if self._tick_rate > 0:
178
+ stop_tick = threading.Event()
179
+
180
+ def _tick_loop() -> None:
181
+ while not stop_tick.is_set():
182
+ stop_tick.wait(self._tick_rate)
183
+ if not stop_tick.is_set() and not self._stop.is_set():
184
+ store.dispatch(Action("@@TICK"))
185
+
186
+ tick_thread = threading.Thread(target=_tick_loop, daemon=True)
187
+ tick_thread.start()
188
+
189
+ env = self._get_env()
190
+ renderer = _TerminalRenderer()
191
+ quit_dispatched = False
192
+
193
+ try:
194
+ renderer.start()
195
+
196
+ # Subscribe to state changes for re-rendering
197
+ def _on_state_change() -> None:
198
+ self._render_state(store.state, env, renderer)
199
+
200
+ unsubscribe = store.subscribe(_on_state_change)
201
+
202
+ # Initial render
203
+ self._render_state(store.state, env, renderer)
204
+
205
+ # Input loop
206
+ with KeyReader() as keys:
207
+ for key in keys:
208
+ if self._stop.is_set() or store.quit_requested:
209
+ break
210
+
211
+ # Ctrl+C: first dispatches @@QUIT, second force-exits
212
+ if key.ctrl and key.char == "c":
213
+ if quit_dispatched:
214
+ break
215
+ quit_dispatched = True
216
+ store.dispatch(Action("@@QUIT"))
217
+ if store.quit_requested:
218
+ break
219
+ continue
220
+
221
+ store.dispatch(Action("@@KEY", payload=key))
222
+
223
+ if store.quit_requested:
224
+ break
225
+
226
+ self._status = AppStatus.STOPPED
227
+ self._stop.set()
228
+ unsubscribe()
229
+
230
+ finally:
231
+ if tick_thread is not None:
232
+ stop_tick.set()
233
+ with contextlib.suppress(Exception):
234
+ renderer.stop()
235
+ if original_sigwinch is not None:
236
+ signal.signal(signal.SIGWINCH, original_sigwinch)
237
+ store.shutdown()
238
+
239
+ final_state = store.state
240
+
241
+ # Render exit template if provided
242
+ if self._exit_template:
243
+ try:
244
+ self._render_exit(final_state, env)
245
+ except Exception as e:
246
+ msg = format_render_error(e, template_name=self._exit_template, env=env)
247
+ sys.stderr.write(f"[milo] {msg}\n")
248
+
249
+ return final_state
250
+
251
+ def _get_env(self) -> Any:
252
+ """Get or create the kida Environment."""
253
+ if self._env is not None:
254
+ return self._env
255
+ from milo.templates import get_env
256
+
257
+ return get_env()
258
+
259
+ def _get_template_name(self, state: Any) -> str:
260
+ """Get the template name for the current state."""
261
+ if self._template_map and isinstance(state, FlowState):
262
+ return self._template_map.get(state.current_screen, self._template_name)
263
+ return self._template_name
264
+
265
+ def _render_state(self, state: Any, env: Any, renderer: _TerminalRenderer) -> None:
266
+ """Render current state through the template."""
267
+ try:
268
+ template_name = self._get_template_name(state)
269
+ template = env.get_template(template_name)
270
+
271
+ # For flow state, pass the current screen's state
272
+ render_state = state
273
+ if isinstance(state, FlowState):
274
+ render_state = state.screen_states.get(state.current_screen, state)
275
+
276
+ output = template.render(state=render_state)
277
+ renderer.update(output)
278
+ except Exception as e:
279
+ template_name = self._get_template_name(state)
280
+ msg = format_render_error(e, template_name=template_name, env=env)
281
+ sys.stderr.write(f"[milo] {msg}\n")
282
+
283
+ def _render_exit(self, state: Any, env: Any) -> None:
284
+ """Render the exit template once to stdout."""
285
+ template = env.get_template(self._exit_template)
286
+ render_state = state
287
+ if isinstance(state, FlowState):
288
+ # For flows, pass all screen states so exit template can reference any data
289
+ render_state = state.screen_states
290
+ output = template.render(state=render_state)
291
+ sys.stdout.write(output + "\n")
292
+ sys.stdout.flush()
293
+
294
+ def _render_once(self, state: Any) -> None:
295
+ """Single render pass (non-TTY or HTML mode)."""
296
+ try:
297
+ env = self._get_env()
298
+ template_name = self._get_template_name(state)
299
+ template = env.get_template(template_name)
300
+
301
+ render_state = state
302
+ if isinstance(state, FlowState):
303
+ render_state = state.screen_states.get(state.current_screen, state)
304
+
305
+ output = template.render(state=render_state)
306
+ sys.stdout.write(output + "\n")
307
+ sys.stdout.flush()
308
+ except Exception as e:
309
+ template_name = self._get_template_name(state)
310
+ msg = format_render_error(e, template_name=template_name)
311
+ sys.stderr.write(f"[milo] {msg}\n")
312
+
313
+
314
+ def run(*, template: str, reducer: Callable, initial_state: Any, **kwargs: Any) -> Any:
315
+ """Shorthand: App(...).run()"""
316
+ return App(template=template, reducer=reducer, initial_state=initial_state, **kwargs).run()
317
+
318
+
319
+ def render_html(
320
+ state: Any,
321
+ template: str | Any,
322
+ *,
323
+ title: str = "",
324
+ css: str = "",
325
+ env: Any = None,
326
+ ) -> str:
327
+ """One-shot HTML render of state through template."""
328
+ if env is None:
329
+ from milo.templates import get_env
330
+
331
+ env = get_env(autoescape=True)
332
+
333
+ tmpl = env.get_template(template) if isinstance(template, str) else template
334
+
335
+ body = tmpl.render(state=state)
336
+
337
+ default_css = """
338
+ body { background: #1e1e1e; color: #d4d4d4; font-family: monospace; padding: 2em; }
339
+ .dim { opacity: 0.6; }
340
+ strong { font-weight: bold; }
341
+ """
342
+
343
+ return f"""<!DOCTYPE html>
344
+ <html>
345
+ <head>
346
+ <meta charset="utf-8">
347
+ <title>{title}</title>
348
+ <style>{css or default_css}</style>
349
+ </head>
350
+ <body>
351
+ <pre>{body}</pre>
352
+ </body>
353
+ </html>"""