milo-cli 0.1.1__tar.gz → 0.2.0__tar.gz
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-0.1.1 → milo_cli-0.2.0}/PKG-INFO +2 -2
- {milo_cli-0.1.1 → milo_cli-0.2.0}/pyproject.toml +44 -3
- {milo_cli-0.1.1 → milo_cli-0.2.0}/src/milo/__init__.py +28 -1
- {milo_cli-0.1.1 → milo_cli-0.2.0}/src/milo/_child.py +35 -25
- {milo_cli-0.1.1 → milo_cli-0.2.0}/src/milo/_types.py +159 -1
- {milo_cli-0.1.1 → milo_cli-0.2.0}/src/milo/context.py +42 -0
- {milo_cli-0.1.1 → milo_cli-0.2.0}/src/milo/gateway.py +7 -4
- milo_cli-0.2.0/src/milo/pipeline.py +511 -0
- {milo_cli-0.1.1 → milo_cli-0.2.0}/src/milo/schema.py +65 -14
- milo_cli-0.2.0/src/milo/state.py +1010 -0
- milo_cli-0.2.0/src/milo/templates/__init__.py +70 -0
- {milo_cli-0.1.1 → milo_cli-0.2.0}/src/milo/templates/components/_defs.kida +87 -10
- {milo_cli-0.1.1 → milo_cli-0.2.0}/src/milo/templates/components/help_page.kida +14 -6
- {milo_cli-0.1.1 → milo_cli-0.2.0}/src/milo/templates/error.kida +4 -0
- milo_cli-0.2.0/src/milo/templates/form.kida +10 -0
- milo_cli-0.2.0/src/milo/theme.py +214 -0
- {milo_cli-0.1.1 → milo_cli-0.2.0}/src/milo_cli.egg-info/PKG-INFO +2 -2
- {milo_cli-0.1.1 → milo_cli-0.2.0}/src/milo_cli.egg-info/SOURCES.txt +4 -0
- {milo_cli-0.1.1 → milo_cli-0.2.0}/src/milo_cli.egg-info/requires.txt +1 -1
- {milo_cli-0.1.1 → milo_cli-0.2.0}/tests/test_ai_native.py +46 -1
- {milo_cli-0.1.1 → milo_cli-0.2.0}/tests/test_child.py +96 -0
- {milo_cli-0.1.1 → milo_cli-0.2.0}/tests/test_compat.py +1 -1
- {milo_cli-0.1.1 → milo_cli-0.2.0}/tests/test_components.py +114 -0
- {milo_cli-0.1.1 → milo_cli-0.2.0}/tests/test_context.py +50 -0
- {milo_cli-0.1.1 → milo_cli-0.2.0}/tests/test_dev.py +2 -2
- milo_cli-0.2.0/tests/test_effects.py +1819 -0
- milo_cli-0.2.0/tests/test_effects_stress.py +369 -0
- milo_cli-0.2.0/tests/test_gateway.py +467 -0
- {milo_cli-0.1.1 → milo_cli-0.2.0}/tests/test_milo_init.py +3 -1
- milo_cli-0.2.0/tests/test_pipeline.py +614 -0
- {milo_cli-0.1.1 → milo_cli-0.2.0}/tests/test_schema_v2.py +130 -0
- milo_cli-0.2.0/tests/test_theme.py +252 -0
- {milo_cli-0.1.1 → milo_cli-0.2.0}/tests/test_types.py +4 -1
- milo_cli-0.1.1/src/milo/pipeline.py +0 -278
- milo_cli-0.1.1/src/milo/state.py +0 -394
- milo_cli-0.1.1/src/milo/templates/__init__.py +0 -38
- milo_cli-0.1.1/src/milo/templates/form.kida +0 -8
- milo_cli-0.1.1/tests/test_effects.py +0 -176
- milo_cli-0.1.1/tests/test_pipeline.py +0 -299
- {milo_cli-0.1.1 → milo_cli-0.2.0}/README.md +0 -0
- {milo_cli-0.1.1 → milo_cli-0.2.0}/setup.cfg +0 -0
- {milo_cli-0.1.1 → milo_cli-0.2.0}/src/milo/_cli_help.py +0 -0
- {milo_cli-0.1.1 → milo_cli-0.2.0}/src/milo/_command_defs.py +0 -0
- {milo_cli-0.1.1 → milo_cli-0.2.0}/src/milo/_compat.py +0 -0
- {milo_cli-0.1.1 → milo_cli-0.2.0}/src/milo/_errors.py +0 -0
- {milo_cli-0.1.1 → milo_cli-0.2.0}/src/milo/_jsonrpc.py +0 -0
- {milo_cli-0.1.1 → milo_cli-0.2.0}/src/milo/_mcp_router.py +0 -0
- {milo_cli-0.1.1 → milo_cli-0.2.0}/src/milo/_protocols.py +0 -0
- {milo_cli-0.1.1 → milo_cli-0.2.0}/src/milo/app.py +0 -0
- {milo_cli-0.1.1 → milo_cli-0.2.0}/src/milo/cli.py +0 -0
- {milo_cli-0.1.1 → milo_cli-0.2.0}/src/milo/commands.py +0 -0
- {milo_cli-0.1.1 → milo_cli-0.2.0}/src/milo/completions.py +0 -0
- {milo_cli-0.1.1 → milo_cli-0.2.0}/src/milo/config.py +0 -0
- {milo_cli-0.1.1 → milo_cli-0.2.0}/src/milo/dev.py +0 -0
- {milo_cli-0.1.1 → milo_cli-0.2.0}/src/milo/doctor.py +0 -0
- {milo_cli-0.1.1 → milo_cli-0.2.0}/src/milo/flow.py +0 -0
- {milo_cli-0.1.1 → milo_cli-0.2.0}/src/milo/form.py +0 -0
- {milo_cli-0.1.1 → milo_cli-0.2.0}/src/milo/groups.py +0 -0
- {milo_cli-0.1.1 → milo_cli-0.2.0}/src/milo/help.py +0 -0
- {milo_cli-0.1.1 → milo_cli-0.2.0}/src/milo/input/__init__.py +0 -0
- {milo_cli-0.1.1 → milo_cli-0.2.0}/src/milo/input/_platform.py +0 -0
- {milo_cli-0.1.1 → milo_cli-0.2.0}/src/milo/input/_reader.py +0 -0
- {milo_cli-0.1.1 → milo_cli-0.2.0}/src/milo/input/_sequences.py +0 -0
- {milo_cli-0.1.1 → milo_cli-0.2.0}/src/milo/llms.py +0 -0
- {milo_cli-0.1.1 → milo_cli-0.2.0}/src/milo/mcp.py +0 -0
- {milo_cli-0.1.1 → milo_cli-0.2.0}/src/milo/middleware.py +0 -0
- {milo_cli-0.1.1 → milo_cli-0.2.0}/src/milo/observability.py +0 -0
- {milo_cli-0.1.1 → milo_cli-0.2.0}/src/milo/output.py +0 -0
- {milo_cli-0.1.1 → milo_cli-0.2.0}/src/milo/plugins.py +0 -0
- {milo_cli-0.1.1 → milo_cli-0.2.0}/src/milo/py.typed +0 -0
- {milo_cli-0.1.1 → milo_cli-0.2.0}/src/milo/reducers.py +0 -0
- {milo_cli-0.1.1 → milo_cli-0.2.0}/src/milo/registry.py +0 -0
- {milo_cli-0.1.1 → milo_cli-0.2.0}/src/milo/streaming.py +0 -0
- {milo_cli-0.1.1 → milo_cli-0.2.0}/src/milo/templates/components/command_list.kida +0 -0
- {milo_cli-0.1.1 → milo_cli-0.2.0}/src/milo/templates/field_confirm.kida +0 -0
- {milo_cli-0.1.1 → milo_cli-0.2.0}/src/milo/templates/field_select.kida +0 -0
- {milo_cli-0.1.1 → milo_cli-0.2.0}/src/milo/templates/field_text.kida +0 -0
- {milo_cli-0.1.1 → milo_cli-0.2.0}/src/milo/templates/help.kida +0 -0
- {milo_cli-0.1.1 → milo_cli-0.2.0}/src/milo/templates/progress.kida +0 -0
- {milo_cli-0.1.1 → milo_cli-0.2.0}/src/milo/testing/__init__.py +0 -0
- {milo_cli-0.1.1 → milo_cli-0.2.0}/src/milo/testing/_mcp.py +0 -0
- {milo_cli-0.1.1 → milo_cli-0.2.0}/src/milo/testing/_record.py +0 -0
- {milo_cli-0.1.1 → milo_cli-0.2.0}/src/milo/testing/_replay.py +0 -0
- {milo_cli-0.1.1 → milo_cli-0.2.0}/src/milo/testing/_snapshot.py +0 -0
- {milo_cli-0.1.1 → milo_cli-0.2.0}/src/milo/version_check.py +0 -0
- {milo_cli-0.1.1 → milo_cli-0.2.0}/src/milo_cli.egg-info/dependency_links.txt +0 -0
- {milo_cli-0.1.1 → milo_cli-0.2.0}/src/milo_cli.egg-info/entry_points.txt +0 -0
- {milo_cli-0.1.1 → milo_cli-0.2.0}/src/milo_cli.egg-info/top_level.txt +0 -0
- {milo_cli-0.1.1 → milo_cli-0.2.0}/tests/test_app.py +0 -0
- {milo_cli-0.1.1 → milo_cli-0.2.0}/tests/test_bubbletea_patterns.py +0 -0
- {milo_cli-0.1.1 → milo_cli-0.2.0}/tests/test_cli.py +0 -0
- {milo_cli-0.1.1 → milo_cli-0.2.0}/tests/test_commands_middleware.py +0 -0
- {milo_cli-0.1.1 → milo_cli-0.2.0}/tests/test_completions.py +0 -0
- {milo_cli-0.1.1 → milo_cli-0.2.0}/tests/test_config.py +0 -0
- {milo_cli-0.1.1 → milo_cli-0.2.0}/tests/test_doctor.py +0 -0
- {milo_cli-0.1.1 → milo_cli-0.2.0}/tests/test_errors.py +0 -0
- {milo_cli-0.1.1 → milo_cli-0.2.0}/tests/test_flow.py +0 -0
- {milo_cli-0.1.1 → milo_cli-0.2.0}/tests/test_form.py +0 -0
- {milo_cli-0.1.1 → milo_cli-0.2.0}/tests/test_groups.py +0 -0
- {milo_cli-0.1.1 → milo_cli-0.2.0}/tests/test_help.py +0 -0
- {milo_cli-0.1.1 → milo_cli-0.2.0}/tests/test_input.py +0 -0
- {milo_cli-0.1.1 → milo_cli-0.2.0}/tests/test_lazy.py +0 -0
- {milo_cli-0.1.1 → milo_cli-0.2.0}/tests/test_mcp_prompts.py +0 -0
- {milo_cli-0.1.1 → milo_cli-0.2.0}/tests/test_mcp_resources.py +0 -0
- {milo_cli-0.1.1 → milo_cli-0.2.0}/tests/test_mcp_router.py +0 -0
- {milo_cli-0.1.1 → milo_cli-0.2.0}/tests/test_middleware.py +0 -0
- {milo_cli-0.1.1 → milo_cli-0.2.0}/tests/test_mount.py +0 -0
- {milo_cli-0.1.1 → milo_cli-0.2.0}/tests/test_observability.py +0 -0
- {milo_cli-0.1.1 → milo_cli-0.2.0}/tests/test_plugins.py +0 -0
- {milo_cli-0.1.1 → milo_cli-0.2.0}/tests/test_protocols.py +0 -0
- {milo_cli-0.1.1 → milo_cli-0.2.0}/tests/test_reducers.py +0 -0
- {milo_cli-0.1.1 → milo_cli-0.2.0}/tests/test_registry_v2.py +0 -0
- {milo_cli-0.1.1 → milo_cli-0.2.0}/tests/test_state.py +0 -0
- {milo_cli-0.1.1 → milo_cli-0.2.0}/tests/test_streaming.py +0 -0
- {milo_cli-0.1.1 → milo_cli-0.2.0}/tests/test_templates.py +0 -0
- {milo_cli-0.1.1 → milo_cli-0.2.0}/tests/test_testing.py +0 -0
- {milo_cli-0.1.1 → milo_cli-0.2.0}/tests/test_testing_mcp.py +0 -0
- {milo_cli-0.1.1 → milo_cli-0.2.0}/tests/test_version_check.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: milo-cli
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.2.0
|
|
4
4
|
Summary: Template-driven CLI applications for free-threaded Python
|
|
5
5
|
License-Expression: MIT
|
|
6
6
|
Project-URL: Homepage, https://lbliii.github.io/milo-cli/
|
|
@@ -18,7 +18,7 @@ Classifier: Topic :: Software Development :: Libraries :: Python Modules
|
|
|
18
18
|
Classifier: Typing :: Typed
|
|
19
19
|
Requires-Python: >=3.14
|
|
20
20
|
Description-Content-Type: text/markdown
|
|
21
|
-
Requires-Dist: kida-templates>=0.
|
|
21
|
+
Requires-Dist: kida-templates>=0.4.0
|
|
22
22
|
Provides-Extra: docs
|
|
23
23
|
Requires-Dist: bengal>=0.2.6; extra == "docs"
|
|
24
24
|
Provides-Extra: yaml
|
|
@@ -4,12 +4,12 @@ build-backend = "setuptools.build_meta"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "milo-cli"
|
|
7
|
-
version = "0.
|
|
7
|
+
version = "0.2.0"
|
|
8
8
|
description = "Template-driven CLI applications for free-threaded Python"
|
|
9
9
|
readme = "README.md"
|
|
10
10
|
requires-python = ">=3.14"
|
|
11
11
|
license = "MIT"
|
|
12
|
-
dependencies = ["kida-templates>=0.
|
|
12
|
+
dependencies = ["kida-templates>=0.4.0"]
|
|
13
13
|
keywords = ["cli", "terminal", "forms", "free-threading", "template", "elm"]
|
|
14
14
|
classifiers = [
|
|
15
15
|
"Development Status :: 3 - Alpha",
|
|
@@ -129,7 +129,46 @@ ignore = [
|
|
|
129
129
|
"src/milo/version_check.py" = ["S110", "S310"] # best-effort cache reads + known PyPI URL
|
|
130
130
|
"src/milo/mcp.py" = ["ARG002"] # MCP protocol methods require params signature
|
|
131
131
|
"src/milo/gateway.py" = ["ARG002", "S110"] # MCP protocol methods + best-effort discovery
|
|
132
|
-
"benchmarks/**/*.py" = ["T201"] # benchmarks
|
|
132
|
+
"benchmarks/**/*.py" = ["T201", "ARG", "S101"] # benchmarks: print, unused args, assert
|
|
133
|
+
|
|
134
|
+
[tool.towncrier]
|
|
135
|
+
directory = "changelog.d"
|
|
136
|
+
filename = "CHANGELOG.md"
|
|
137
|
+
package = "milo"
|
|
138
|
+
package_dir = "src"
|
|
139
|
+
title_format = "## [{version}] - {project_date}"
|
|
140
|
+
issue_format = "[#{issue}](https://github.com/lbliii/milo-cli/issues/{issue})"
|
|
141
|
+
underlines = ["", "", ""]
|
|
142
|
+
|
|
143
|
+
[[tool.towncrier.type]]
|
|
144
|
+
directory = "added"
|
|
145
|
+
name = "Added"
|
|
146
|
+
showcontent = true
|
|
147
|
+
|
|
148
|
+
[[tool.towncrier.type]]
|
|
149
|
+
directory = "changed"
|
|
150
|
+
name = "Changed"
|
|
151
|
+
showcontent = true
|
|
152
|
+
|
|
153
|
+
[[tool.towncrier.type]]
|
|
154
|
+
directory = "deprecated"
|
|
155
|
+
name = "Deprecated"
|
|
156
|
+
showcontent = true
|
|
157
|
+
|
|
158
|
+
[[tool.towncrier.type]]
|
|
159
|
+
directory = "removed"
|
|
160
|
+
name = "Removed"
|
|
161
|
+
showcontent = true
|
|
162
|
+
|
|
163
|
+
[[tool.towncrier.type]]
|
|
164
|
+
directory = "fixed"
|
|
165
|
+
name = "Fixed"
|
|
166
|
+
showcontent = true
|
|
167
|
+
|
|
168
|
+
[[tool.towncrier.type]]
|
|
169
|
+
directory = "security"
|
|
170
|
+
name = "Security"
|
|
171
|
+
showcontent = true
|
|
133
172
|
|
|
134
173
|
[dependency-groups]
|
|
135
174
|
dev = [
|
|
@@ -145,4 +184,6 @@ dev = [
|
|
|
145
184
|
"ruff>=0.15.1",
|
|
146
185
|
# Pre-commit
|
|
147
186
|
"pre-commit>=4.0.0",
|
|
187
|
+
# Changelog
|
|
188
|
+
"towncrier>=24.0",
|
|
148
189
|
]
|
|
@@ -26,6 +26,12 @@ def __getattr__(name: str):
|
|
|
26
26
|
"Fork": "_types",
|
|
27
27
|
"Delay": "_types",
|
|
28
28
|
"Retry": "_types",
|
|
29
|
+
"Timeout": "_types",
|
|
30
|
+
"TryCall": "_types",
|
|
31
|
+
"Race": "_types",
|
|
32
|
+
"All": "_types",
|
|
33
|
+
"Take": "_types",
|
|
34
|
+
"Debounce": "_types",
|
|
29
35
|
"Cmd": "_types",
|
|
30
36
|
"Batch": "_types",
|
|
31
37
|
"Sequence": "_types",
|
|
@@ -48,6 +54,8 @@ def __getattr__(name: str):
|
|
|
48
54
|
"format_render_error": "_errors",
|
|
49
55
|
# State
|
|
50
56
|
"Store": "state",
|
|
57
|
+
"SagaContext": "state",
|
|
58
|
+
"EffectResult": "state",
|
|
51
59
|
"combine_reducers": "state",
|
|
52
60
|
# App
|
|
53
61
|
"App": "app",
|
|
@@ -84,8 +92,10 @@ def __getattr__(name: str):
|
|
|
84
92
|
# Pipeline
|
|
85
93
|
"Pipeline": "pipeline",
|
|
86
94
|
"Phase": "pipeline",
|
|
95
|
+
"PhasePolicy": "pipeline",
|
|
87
96
|
"PipelineState": "pipeline",
|
|
88
97
|
"PhaseStatus": "pipeline",
|
|
98
|
+
"CycleError": "pipeline",
|
|
89
99
|
# Plugins
|
|
90
100
|
"HookRegistry": "plugins",
|
|
91
101
|
"function_to_schema": "schema",
|
|
@@ -97,6 +107,11 @@ def __getattr__(name: str):
|
|
|
97
107
|
"Le": "schema",
|
|
98
108
|
"Pattern": "schema",
|
|
99
109
|
"Description": "schema",
|
|
110
|
+
# Theme
|
|
111
|
+
"ThemeStyle": "theme",
|
|
112
|
+
"ThemeProxy": "theme",
|
|
113
|
+
"DEFAULT_THEME": "theme",
|
|
114
|
+
"make_style_filter": "theme",
|
|
100
115
|
"format_output": "output",
|
|
101
116
|
"write_output": "output",
|
|
102
117
|
"generate_llms_txt": "llms",
|
|
@@ -139,11 +154,13 @@ def _Py_mod_gil() -> int: # noqa: N802
|
|
|
139
154
|
return 0
|
|
140
155
|
|
|
141
156
|
|
|
142
|
-
__version__ = "0.
|
|
157
|
+
__version__ = "0.2.0"
|
|
143
158
|
__all__ = [
|
|
144
159
|
"BUILTIN_ACTIONS",
|
|
145
160
|
"CLI",
|
|
161
|
+
"DEFAULT_THEME",
|
|
146
162
|
"Action",
|
|
163
|
+
"All",
|
|
147
164
|
"App",
|
|
148
165
|
"AppError",
|
|
149
166
|
"AppStatus",
|
|
@@ -157,6 +174,8 @@ __all__ = [
|
|
|
157
174
|
"ConfigError",
|
|
158
175
|
"ConfigSpec",
|
|
159
176
|
"Context",
|
|
177
|
+
"CycleError",
|
|
178
|
+
"Debounce",
|
|
160
179
|
"Delay",
|
|
161
180
|
"Description",
|
|
162
181
|
"DevServer",
|
|
@@ -192,6 +211,7 @@ __all__ = [
|
|
|
192
211
|
"MinLen",
|
|
193
212
|
"Pattern",
|
|
194
213
|
"Phase",
|
|
214
|
+
"PhasePolicy",
|
|
195
215
|
"PhaseStatus",
|
|
196
216
|
"Pipeline",
|
|
197
217
|
"PipelineError",
|
|
@@ -201,6 +221,7 @@ __all__ = [
|
|
|
201
221
|
"PromptDef",
|
|
202
222
|
"Put",
|
|
203
223
|
"Quit",
|
|
224
|
+
"Race",
|
|
204
225
|
"ReducerResult",
|
|
205
226
|
"RenderTarget",
|
|
206
227
|
"RequestLog",
|
|
@@ -213,8 +234,13 @@ __all__ = [
|
|
|
213
234
|
"SpecialKey",
|
|
214
235
|
"StateError",
|
|
215
236
|
"Store",
|
|
237
|
+
"Take",
|
|
238
|
+
"ThemeProxy",
|
|
239
|
+
"ThemeStyle",
|
|
216
240
|
"TickCmd",
|
|
241
|
+
"Timeout",
|
|
217
242
|
"Transition",
|
|
243
|
+
"TryCall",
|
|
218
244
|
"VersionInfo",
|
|
219
245
|
"ViewState",
|
|
220
246
|
"check_version",
|
|
@@ -231,6 +257,7 @@ __all__ = [
|
|
|
231
257
|
"get_context",
|
|
232
258
|
"install_completions",
|
|
233
259
|
"make_form_reducer",
|
|
260
|
+
"make_style_filter",
|
|
234
261
|
"quit_on",
|
|
235
262
|
"render_html",
|
|
236
263
|
"run",
|
|
@@ -9,8 +9,6 @@ import threading
|
|
|
9
9
|
import time
|
|
10
10
|
from typing import Any
|
|
11
11
|
|
|
12
|
-
_READ_TIMEOUT = 30.0 # seconds
|
|
13
|
-
|
|
14
12
|
|
|
15
13
|
class ChildProcess:
|
|
16
14
|
"""A persistent child process that speaks JSON-RPC on stdin/stdout.
|
|
@@ -25,10 +23,12 @@ class ChildProcess:
|
|
|
25
23
|
command: list[str],
|
|
26
24
|
*,
|
|
27
25
|
idle_timeout: float = 300.0,
|
|
26
|
+
request_timeout: float = 30.0,
|
|
28
27
|
) -> None:
|
|
29
28
|
self.name = name
|
|
30
29
|
self.command = command
|
|
31
30
|
self.idle_timeout = idle_timeout
|
|
31
|
+
self.request_timeout = request_timeout
|
|
32
32
|
self._proc: subprocess.Popen[str] | None = None
|
|
33
33
|
self._lock = threading.Lock()
|
|
34
34
|
self._last_use = time.monotonic()
|
|
@@ -72,8 +72,13 @@ class ChildProcess:
|
|
|
72
72
|
self._spawn()
|
|
73
73
|
self._ensure_initialized()
|
|
74
74
|
|
|
75
|
-
def send_call(
|
|
76
|
-
|
|
75
|
+
def send_call(
|
|
76
|
+
self, method: str, params: dict[str, Any], *, timeout: float | None = None
|
|
77
|
+
) -> dict[str, Any]:
|
|
78
|
+
"""Send a JSON-RPC request and return the result. Thread-safe.
|
|
79
|
+
|
|
80
|
+
*timeout* overrides the instance ``request_timeout`` for this call.
|
|
81
|
+
"""
|
|
77
82
|
with self._lock:
|
|
78
83
|
if self._proc is None or self._proc.poll() is not None:
|
|
79
84
|
self._spawn()
|
|
@@ -88,7 +93,8 @@ class ChildProcess:
|
|
|
88
93
|
"params": params,
|
|
89
94
|
}
|
|
90
95
|
self._write_line(json.dumps(request))
|
|
91
|
-
|
|
96
|
+
effective_timeout = timeout if timeout is not None else self.request_timeout
|
|
97
|
+
response_line = self._read_line(timeout=effective_timeout)
|
|
92
98
|
self._last_use = time.monotonic()
|
|
93
99
|
|
|
94
100
|
if not response_line:
|
|
@@ -115,19 +121,25 @@ class ChildProcess:
|
|
|
115
121
|
def kill(self) -> None:
|
|
116
122
|
"""Kill the child process."""
|
|
117
123
|
with self._lock:
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
124
|
+
self._graceful_kill()
|
|
125
|
+
|
|
126
|
+
def _graceful_kill(self) -> None:
|
|
127
|
+
"""SIGTERM with grace period, then SIGKILL if needed.
|
|
128
|
+
|
|
129
|
+
Must be called while holding ``self._lock``.
|
|
130
|
+
"""
|
|
131
|
+
if self._proc is None:
|
|
132
|
+
return
|
|
133
|
+
try:
|
|
134
|
+
self._proc.terminate() # SIGTERM
|
|
135
|
+
self._proc.wait(timeout=5) # Grace period
|
|
136
|
+
except ProcessLookupError:
|
|
137
|
+
pass # Already dead
|
|
138
|
+
except subprocess.TimeoutExpired:
|
|
139
|
+
with contextlib.suppress(ProcessLookupError):
|
|
140
|
+
self._proc.kill() # SIGKILL
|
|
141
|
+
self._proc = None
|
|
142
|
+
self._initialized = False
|
|
131
143
|
|
|
132
144
|
def _write_line(self, line: str) -> None:
|
|
133
145
|
"""Write a line to the child's stdin."""
|
|
@@ -136,12 +148,13 @@ class ChildProcess:
|
|
|
136
148
|
self._proc.stdin.write(line + "\n")
|
|
137
149
|
self._proc.stdin.flush()
|
|
138
150
|
|
|
139
|
-
def _read_line(self) -> str:
|
|
151
|
+
def _read_line(self, *, timeout: float | None = None) -> str:
|
|
140
152
|
"""Read a line from the child's stdout with timeout.
|
|
141
153
|
|
|
142
154
|
Uses a background thread so we can enforce a deadline even
|
|
143
155
|
when the child blocks without writing a newline.
|
|
144
156
|
"""
|
|
157
|
+
effective_timeout = timeout if timeout is not None else self.request_timeout
|
|
145
158
|
assert self._proc is not None
|
|
146
159
|
assert self._proc.stdout is not None
|
|
147
160
|
result: list[str] = []
|
|
@@ -150,12 +163,9 @@ class ChildProcess:
|
|
|
150
163
|
daemon=True,
|
|
151
164
|
)
|
|
152
165
|
reader.start()
|
|
153
|
-
reader.join(timeout=
|
|
166
|
+
reader.join(timeout=effective_timeout)
|
|
154
167
|
if reader.is_alive():
|
|
155
|
-
# Timed out —
|
|
156
|
-
|
|
157
|
-
self._proc.kill()
|
|
158
|
-
self._proc = None
|
|
159
|
-
self._initialized = False
|
|
168
|
+
# Timed out — graceful shutdown: SIGTERM → grace → SIGKILL
|
|
169
|
+
self._graceful_kill()
|
|
160
170
|
return ""
|
|
161
171
|
return result[0].strip() if result else ""
|
|
@@ -80,7 +80,10 @@ BUILTIN_ACTIONS: frozenset[str] = frozenset(
|
|
|
80
80
|
"@@PHASE_START",
|
|
81
81
|
"@@PHASE_COMPLETE",
|
|
82
82
|
"@@PHASE_FAILED",
|
|
83
|
+
"@@PHASE_SKIPPED",
|
|
84
|
+
"@@PHASE_RETRY",
|
|
83
85
|
"@@SAGA_ERROR",
|
|
86
|
+
"@@SAGA_CANCELLED",
|
|
84
87
|
"@@CMD_ERROR",
|
|
85
88
|
}
|
|
86
89
|
)
|
|
@@ -202,9 +205,16 @@ class Select:
|
|
|
202
205
|
|
|
203
206
|
@dataclass(frozen=True, slots=True)
|
|
204
207
|
class Fork:
|
|
205
|
-
"""Run another saga concurrently.
|
|
208
|
+
"""Run another saga concurrently.
|
|
209
|
+
|
|
210
|
+
By default forks are *detached*: the child gets its own cancellation
|
|
211
|
+
scope and is not cancelled when the parent is. Set ``attached=True``
|
|
212
|
+
to inherit the parent's cancellation scope — cancelling the parent
|
|
213
|
+
will transitively cancel the child.
|
|
214
|
+
"""
|
|
206
215
|
|
|
207
216
|
saga: Callable | Generator
|
|
217
|
+
attached: bool = False
|
|
208
218
|
|
|
209
219
|
|
|
210
220
|
@dataclass(frozen=True, slots=True)
|
|
@@ -232,6 +242,154 @@ class Retry:
|
|
|
232
242
|
max_delay: float = 30.0
|
|
233
243
|
|
|
234
244
|
|
|
245
|
+
@dataclass(frozen=True, slots=True)
|
|
246
|
+
class Timeout:
|
|
247
|
+
"""Wrap a blocking effect with a deadline.
|
|
248
|
+
|
|
249
|
+
Usage in a saga::
|
|
250
|
+
|
|
251
|
+
result = yield Timeout(Call(fetch_data, args=(url,)), seconds=5)
|
|
252
|
+
|
|
253
|
+
Raises ``TimeoutError`` if the effect doesn't complete in time.
|
|
254
|
+
Only wraps blocking effects (``Call`` and ``Retry``).
|
|
255
|
+
"""
|
|
256
|
+
|
|
257
|
+
effect: Call | Retry
|
|
258
|
+
seconds: float
|
|
259
|
+
|
|
260
|
+
|
|
261
|
+
@dataclass(frozen=True, slots=True)
|
|
262
|
+
class TryCall:
|
|
263
|
+
"""Call a function, returning (result, None) on success or (None, error) on failure.
|
|
264
|
+
|
|
265
|
+
Unlike ``Call``, exceptions do not crash the saga::
|
|
266
|
+
|
|
267
|
+
result, error = yield TryCall(fn=might_fail)
|
|
268
|
+
if error:
|
|
269
|
+
yield Put(Action("FETCH_FAILED", payload=str(error)))
|
|
270
|
+
else:
|
|
271
|
+
yield Put(Action("FETCH_OK", payload=result))
|
|
272
|
+
"""
|
|
273
|
+
|
|
274
|
+
fn: Callable
|
|
275
|
+
args: tuple = ()
|
|
276
|
+
kwargs: dict = field(default_factory=dict)
|
|
277
|
+
|
|
278
|
+
|
|
279
|
+
@dataclass(frozen=True, slots=True)
|
|
280
|
+
class Race:
|
|
281
|
+
"""Run multiple sagas concurrently, return the first result.
|
|
282
|
+
|
|
283
|
+
Losers are cancelled via their cancel events as soon as a winner
|
|
284
|
+
completes. If all racers fail, the first error is thrown into
|
|
285
|
+
the parent saga::
|
|
286
|
+
|
|
287
|
+
winner = yield Race(sagas=(fetch_primary(), fetch_fallback()))
|
|
288
|
+
|
|
289
|
+
Raises ``StateError`` if *sagas* is empty.
|
|
290
|
+
"""
|
|
291
|
+
|
|
292
|
+
sagas: tuple
|
|
293
|
+
|
|
294
|
+
|
|
295
|
+
@dataclass(frozen=True, slots=True)
|
|
296
|
+
class All:
|
|
297
|
+
"""Run multiple sagas concurrently, wait for all to complete.
|
|
298
|
+
|
|
299
|
+
Returns a tuple of results in the same order as the input sagas.
|
|
300
|
+
Fail-fast: if any saga raises, remaining sagas are cancelled and
|
|
301
|
+
the error is thrown into the parent::
|
|
302
|
+
|
|
303
|
+
a, b = yield All(sagas=(fetch_users(), fetch_roles()))
|
|
304
|
+
|
|
305
|
+
An empty tuple returns ``()`` immediately.
|
|
306
|
+
"""
|
|
307
|
+
|
|
308
|
+
sagas: tuple
|
|
309
|
+
|
|
310
|
+
|
|
311
|
+
@dataclass(frozen=True, slots=True)
|
|
312
|
+
class Take:
|
|
313
|
+
"""Pause the saga until a matching action is dispatched.
|
|
314
|
+
|
|
315
|
+
Waits for *future* actions only — actions dispatched before the
|
|
316
|
+
Take is yielded are not matched. Returns the full ``Action``
|
|
317
|
+
object so the saga can inspect both type and payload::
|
|
318
|
+
|
|
319
|
+
action = yield Take("USER_CONFIRMED")
|
|
320
|
+
name = action.payload["name"]
|
|
321
|
+
|
|
322
|
+
An optional *timeout* (in seconds) raises ``TimeoutError`` if the
|
|
323
|
+
action is not dispatched in time.
|
|
324
|
+
"""
|
|
325
|
+
|
|
326
|
+
action_type: str
|
|
327
|
+
timeout: float | None = None
|
|
328
|
+
|
|
329
|
+
|
|
330
|
+
@dataclass(frozen=True, slots=True)
|
|
331
|
+
class Debounce:
|
|
332
|
+
"""Delay-then-fork: start a timer, fork *saga* when it expires.
|
|
333
|
+
|
|
334
|
+
If the parent saga yields another ``Debounce`` before the timer
|
|
335
|
+
fires, the previous timer is cancelled and restarted. The parent
|
|
336
|
+
continues immediately (non-blocking)::
|
|
337
|
+
|
|
338
|
+
# In a keystroke handler saga:
|
|
339
|
+
while True:
|
|
340
|
+
key = yield Take("@@KEY")
|
|
341
|
+
yield Debounce(seconds=0.3, saga=search_saga)
|
|
342
|
+
|
|
343
|
+
The debounced saga runs independently; use ``Take`` if the parent
|
|
344
|
+
needs the result.
|
|
345
|
+
"""
|
|
346
|
+
|
|
347
|
+
seconds: float
|
|
348
|
+
saga: Callable
|
|
349
|
+
|
|
350
|
+
|
|
351
|
+
@dataclass(frozen=True, slots=True)
|
|
352
|
+
class TakeEvery:
|
|
353
|
+
"""Fork a new saga for every matching action (auto-restart pattern).
|
|
354
|
+
|
|
355
|
+
Blocks the parent saga until cancelled. For each dispatched action
|
|
356
|
+
whose type matches *action_type*, a new saga is forked with the
|
|
357
|
+
action as argument::
|
|
358
|
+
|
|
359
|
+
# Fork a handler for every CLICK action:
|
|
360
|
+
yield TakeEvery("CLICK", handle_click)
|
|
361
|
+
|
|
362
|
+
def handle_click(action):
|
|
363
|
+
url = action.payload["url"]
|
|
364
|
+
result = yield Call(fetch, args=(url,))
|
|
365
|
+
yield Put(Action("FETCHED", payload=result))
|
|
366
|
+
|
|
367
|
+
The watcher loop runs until the parent saga is cancelled.
|
|
368
|
+
"""
|
|
369
|
+
|
|
370
|
+
action_type: str
|
|
371
|
+
saga: Callable # (action: Action) -> Generator
|
|
372
|
+
|
|
373
|
+
|
|
374
|
+
@dataclass(frozen=True, slots=True)
|
|
375
|
+
class TakeLatest:
|
|
376
|
+
"""Fork a saga for the latest matching action, cancelling the previous.
|
|
377
|
+
|
|
378
|
+
Like :class:`TakeEvery` but only the most recent saga runs — when a
|
|
379
|
+
new matching action arrives, the previous fork is cancelled before
|
|
380
|
+
the new one starts::
|
|
381
|
+
|
|
382
|
+
# Only the latest search runs:
|
|
383
|
+
yield TakeLatest("SEARCH", run_search)
|
|
384
|
+
|
|
385
|
+
Useful for typeahead/autocomplete patterns where earlier results
|
|
386
|
+
are obsolete.
|
|
387
|
+
"""
|
|
388
|
+
|
|
389
|
+
action_type: str
|
|
390
|
+
saga: Callable # (action: Action) -> Generator
|
|
391
|
+
|
|
392
|
+
|
|
235
393
|
# ---------------------------------------------------------------------------
|
|
236
394
|
# Commands (lightweight alternative to sagas)
|
|
237
395
|
# ---------------------------------------------------------------------------
|
|
@@ -132,6 +132,48 @@ class Context:
|
|
|
132
132
|
"""True when -vv (or higher) was passed."""
|
|
133
133
|
return self.verbosity >= 2
|
|
134
134
|
|
|
135
|
+
def render(
|
|
136
|
+
self,
|
|
137
|
+
template: str,
|
|
138
|
+
*,
|
|
139
|
+
env: Any = None,
|
|
140
|
+
**kwargs: Any,
|
|
141
|
+
) -> str:
|
|
142
|
+
"""Render a kida template and return the result string.
|
|
143
|
+
|
|
144
|
+
Convenience method for commands that need styled template output
|
|
145
|
+
without the full App event loop::
|
|
146
|
+
|
|
147
|
+
@cli.command("report", description="Show report")
|
|
148
|
+
def report(ctx: Context = None) -> str:
|
|
149
|
+
data = [{"name": "Alice", "score": 95}]
|
|
150
|
+
return ctx.render("report.kida", items=data)
|
|
151
|
+
|
|
152
|
+
Inline templates are supported with the ``string:`` prefix::
|
|
153
|
+
|
|
154
|
+
ctx.render("string:{{ x | bold }}", x="hello")
|
|
155
|
+
|
|
156
|
+
Args:
|
|
157
|
+
template: Template name (e.g. ``"report.kida"``) or an inline
|
|
158
|
+
template prefixed with ``"string:"``.
|
|
159
|
+
env: Optional pre-configured kida Environment.
|
|
160
|
+
**kwargs: Template context variables.
|
|
161
|
+
|
|
162
|
+
Returns:
|
|
163
|
+
Rendered string.
|
|
164
|
+
"""
|
|
165
|
+
if env is None:
|
|
166
|
+
from milo.templates import get_env
|
|
167
|
+
|
|
168
|
+
env = get_env()
|
|
169
|
+
|
|
170
|
+
if template.startswith("string:"):
|
|
171
|
+
tmpl = env.from_string(template[7:], name="inline")
|
|
172
|
+
else:
|
|
173
|
+
tmpl = env.get_template(template)
|
|
174
|
+
|
|
175
|
+
return tmpl.render(**kwargs)
|
|
176
|
+
|
|
135
177
|
def run_app(
|
|
136
178
|
self,
|
|
137
179
|
reducer: Callable,
|
|
@@ -18,6 +18,7 @@ Can also be run directly for debugging:
|
|
|
18
18
|
from __future__ import annotations
|
|
19
19
|
|
|
20
20
|
import json
|
|
21
|
+
import logging
|
|
21
22
|
import sys
|
|
22
23
|
import threading
|
|
23
24
|
import time
|
|
@@ -31,6 +32,8 @@ from milo._jsonrpc import _stderr, _write_error, _write_result
|
|
|
31
32
|
from milo._mcp_router import dispatch
|
|
32
33
|
from milo.registry import list_clis
|
|
33
34
|
|
|
35
|
+
_logger = logging.getLogger("milo.gateway")
|
|
36
|
+
|
|
34
37
|
|
|
35
38
|
def main() -> None:
|
|
36
39
|
"""Entry point for ``python -m milo.gateway``."""
|
|
@@ -184,14 +187,14 @@ def _discover_one_child(
|
|
|
184
187
|
try:
|
|
185
188
|
result = child.send_call("resources/list", {})
|
|
186
189
|
resources = result.get("resources", [])
|
|
187
|
-
except Exception:
|
|
188
|
-
|
|
190
|
+
except Exception as e:
|
|
191
|
+
_logger.warning("Failed to discover resources from %s: %s", cli_name, e)
|
|
189
192
|
|
|
190
193
|
try:
|
|
191
194
|
result = child.send_call("prompts/list", {})
|
|
192
195
|
prompts = result.get("prompts", [])
|
|
193
|
-
except Exception:
|
|
194
|
-
|
|
196
|
+
except Exception as e:
|
|
197
|
+
_logger.warning("Failed to discover prompts from %s: %s", cli_name, e)
|
|
195
198
|
|
|
196
199
|
return cli_name, tools, resources, prompts
|
|
197
200
|
|