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/cli.py ADDED
@@ -0,0 +1,134 @@
1
+ """CLI entry point — milo dev, milo replay."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import argparse
6
+ import importlib
7
+ import sys
8
+ from pathlib import Path
9
+
10
+
11
+ def _load_app(app_path: str):
12
+ """Load an App instance from a module:attribute path like 'myapp:app'."""
13
+ if ":" not in app_path:
14
+ print(f"Error: expected format 'module:attribute', got '{app_path}'", file=sys.stderr)
15
+ sys.exit(1)
16
+
17
+ module_path, attr_name = app_path.rsplit(":", 1)
18
+
19
+ # Add cwd to sys.path so local modules can be found
20
+ cwd = str(Path.cwd())
21
+ if cwd not in sys.path:
22
+ sys.path.insert(0, cwd)
23
+
24
+ try:
25
+ module = importlib.import_module(module_path)
26
+ except ModuleNotFoundError as e:
27
+ print(f"Error: could not import '{module_path}': {e}", file=sys.stderr)
28
+ sys.exit(1)
29
+
30
+ try:
31
+ app = getattr(module, attr_name)
32
+ except AttributeError:
33
+ print(f"Error: '{module_path}' has no attribute '{attr_name}'", file=sys.stderr)
34
+ sys.exit(1)
35
+
36
+ return app
37
+
38
+
39
+ def _cmd_dev(args: argparse.Namespace) -> None:
40
+ """Run the dev server with hot-reload."""
41
+ from milo.dev import DevServer
42
+
43
+ app = _load_app(args.app)
44
+ watch_dirs = tuple(args.watch) if args.watch else ()
45
+ server = DevServer(app, watch_dirs=watch_dirs, poll_interval=args.poll)
46
+ server.run()
47
+
48
+
49
+ def _cmd_replay(args: argparse.Namespace) -> None:
50
+ """Replay a recorded session."""
51
+ from milo.testing._record import load_recording
52
+ from milo.testing._replay import replay
53
+
54
+ recording = load_recording(args.session)
55
+
56
+ def default_reducer(state, action):
57
+ return state
58
+
59
+ reducer = default_reducer
60
+
61
+ # Try to load a reducer if specified
62
+ if args.reducer:
63
+ reducer = _load_app(args.reducer)
64
+
65
+ def on_state(state, action):
66
+ if args.diff:
67
+ print(f"[{action.type}] -> {state!r}")
68
+
69
+ final = replay(
70
+ recording,
71
+ reducer,
72
+ speed=args.speed,
73
+ step=args.step,
74
+ on_state=on_state if args.diff else None,
75
+ assert_hashes=args.assert_hashes,
76
+ )
77
+
78
+ if args.assert_hashes:
79
+ print("All state hashes match.", file=sys.stderr)
80
+ else:
81
+ print(f"Replay complete. Final state: {final!r}")
82
+
83
+
84
+ def main(argv: list[str] | None = None) -> None:
85
+ """Main CLI entry point."""
86
+ parser = argparse.ArgumentParser(
87
+ prog="milo",
88
+ description="Template-driven CLI applications for free-threaded Python",
89
+ )
90
+ parser.add_argument("--version", action="version", version="%(prog)s 0.1.0")
91
+ subparsers = parser.add_subparsers(dest="command")
92
+
93
+ # milo dev
94
+ dev_parser = subparsers.add_parser("dev", help="Run app with hot-reload")
95
+ dev_parser.add_argument("app", help="App path as 'module:attribute'")
96
+ dev_parser.add_argument(
97
+ "--watch", "-w", action="append", default=[], help="Directories to watch for changes"
98
+ )
99
+ dev_parser.add_argument(
100
+ "--poll", type=float, default=0.5, help="Poll interval in seconds (default: 0.5)"
101
+ )
102
+
103
+ # milo replay
104
+ replay_parser = subparsers.add_parser("replay", help="Replay a recorded session")
105
+ replay_parser.add_argument("session", help="Path to session JSONL file")
106
+ replay_parser.add_argument("--reducer", "-r", help="Reducer path as 'module:attribute'")
107
+ replay_parser.add_argument(
108
+ "--speed", "-s", type=float, default=1.0, help="Playback speed multiplier (default: 1.0)"
109
+ )
110
+ replay_parser.add_argument(
111
+ "--step", action="store_true", help="Step-by-step mode (press Enter to advance)"
112
+ )
113
+ replay_parser.add_argument(
114
+ "--diff", "-d", action="store_true", help="Show state diff between transitions"
115
+ )
116
+ replay_parser.add_argument(
117
+ "--assert",
118
+ dest="assert_hashes",
119
+ action="store_true",
120
+ help="Exit non-zero if state hashes don't match (CI use)",
121
+ )
122
+
123
+ args = parser.parse_args(argv)
124
+
125
+ if args.command == "dev":
126
+ _cmd_dev(args)
127
+ elif args.command == "replay":
128
+ _cmd_replay(args)
129
+ else:
130
+ parser.print_help()
131
+
132
+
133
+ if __name__ == "__main__":
134
+ main()