milo-cli 0.1.1__tar.gz → 0.2.1__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.1}/PKG-INFO +2 -2
- {milo_cli-0.1.1 → milo_cli-0.2.1}/pyproject.toml +44 -3
- {milo_cli-0.1.1 → milo_cli-0.2.1}/src/milo/__init__.py +28 -1
- {milo_cli-0.1.1 → milo_cli-0.2.1}/src/milo/_child.py +35 -25
- {milo_cli-0.1.1 → milo_cli-0.2.1}/src/milo/_command_defs.py +8 -0
- {milo_cli-0.1.1 → milo_cli-0.2.1}/src/milo/_types.py +159 -1
- {milo_cli-0.1.1 → milo_cli-0.2.1}/src/milo/commands.py +149 -41
- {milo_cli-0.1.1 → milo_cli-0.2.1}/src/milo/context.py +42 -0
- {milo_cli-0.1.1 → milo_cli-0.2.1}/src/milo/gateway.py +7 -4
- {milo_cli-0.1.1 → milo_cli-0.2.1}/src/milo/groups.py +42 -0
- {milo_cli-0.1.1 → milo_cli-0.2.1}/src/milo/help.py +2 -0
- milo_cli-0.2.1/src/milo/pipeline.py +511 -0
- {milo_cli-0.1.1 → milo_cli-0.2.1}/src/milo/schema.py +75 -14
- milo_cli-0.2.1/src/milo/state.py +1010 -0
- milo_cli-0.2.1/src/milo/templates/__init__.py +70 -0
- {milo_cli-0.1.1 → milo_cli-0.2.1}/src/milo/templates/components/_defs.kida +87 -10
- {milo_cli-0.1.1 → milo_cli-0.2.1}/src/milo/templates/components/help_page.kida +14 -6
- {milo_cli-0.1.1 → milo_cli-0.2.1}/src/milo/templates/error.kida +4 -0
- milo_cli-0.2.1/src/milo/templates/form.kida +10 -0
- milo_cli-0.2.1/src/milo/templates/help.kida +30 -0
- milo_cli-0.2.1/src/milo/theme.py +214 -0
- {milo_cli-0.1.1 → milo_cli-0.2.1}/src/milo_cli.egg-info/PKG-INFO +2 -2
- {milo_cli-0.1.1 → milo_cli-0.2.1}/src/milo_cli.egg-info/SOURCES.txt +4 -0
- {milo_cli-0.1.1 → milo_cli-0.2.1}/src/milo_cli.egg-info/requires.txt +1 -1
- {milo_cli-0.1.1 → milo_cli-0.2.1}/tests/test_ai_native.py +46 -1
- {milo_cli-0.1.1 → milo_cli-0.2.1}/tests/test_child.py +96 -0
- {milo_cli-0.1.1 → milo_cli-0.2.1}/tests/test_compat.py +1 -1
- {milo_cli-0.1.1 → milo_cli-0.2.1}/tests/test_components.py +114 -0
- {milo_cli-0.1.1 → milo_cli-0.2.1}/tests/test_context.py +50 -0
- {milo_cli-0.1.1 → milo_cli-0.2.1}/tests/test_dev.py +2 -2
- milo_cli-0.2.1/tests/test_effects.py +1819 -0
- milo_cli-0.2.1/tests/test_effects_stress.py +369 -0
- milo_cli-0.2.1/tests/test_gateway.py +467 -0
- {milo_cli-0.1.1 → milo_cli-0.2.1}/tests/test_groups.py +139 -0
- {milo_cli-0.1.1 → milo_cli-0.2.1}/tests/test_lazy.py +280 -0
- {milo_cli-0.1.1 → milo_cli-0.2.1}/tests/test_milo_init.py +3 -1
- milo_cli-0.2.1/tests/test_pipeline.py +614 -0
- {milo_cli-0.1.1 → milo_cli-0.2.1}/tests/test_schema_v2.py +130 -0
- milo_cli-0.2.1/tests/test_theme.py +252 -0
- {milo_cli-0.1.1 → milo_cli-0.2.1}/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/src/milo/templates/help.kida +0 -13
- 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.1}/README.md +0 -0
- {milo_cli-0.1.1 → milo_cli-0.2.1}/setup.cfg +0 -0
- {milo_cli-0.1.1 → milo_cli-0.2.1}/src/milo/_cli_help.py +0 -0
- {milo_cli-0.1.1 → milo_cli-0.2.1}/src/milo/_compat.py +0 -0
- {milo_cli-0.1.1 → milo_cli-0.2.1}/src/milo/_errors.py +0 -0
- {milo_cli-0.1.1 → milo_cli-0.2.1}/src/milo/_jsonrpc.py +0 -0
- {milo_cli-0.1.1 → milo_cli-0.2.1}/src/milo/_mcp_router.py +0 -0
- {milo_cli-0.1.1 → milo_cli-0.2.1}/src/milo/_protocols.py +0 -0
- {milo_cli-0.1.1 → milo_cli-0.2.1}/src/milo/app.py +0 -0
- {milo_cli-0.1.1 → milo_cli-0.2.1}/src/milo/cli.py +0 -0
- {milo_cli-0.1.1 → milo_cli-0.2.1}/src/milo/completions.py +0 -0
- {milo_cli-0.1.1 → milo_cli-0.2.1}/src/milo/config.py +0 -0
- {milo_cli-0.1.1 → milo_cli-0.2.1}/src/milo/dev.py +0 -0
- {milo_cli-0.1.1 → milo_cli-0.2.1}/src/milo/doctor.py +0 -0
- {milo_cli-0.1.1 → milo_cli-0.2.1}/src/milo/flow.py +0 -0
- {milo_cli-0.1.1 → milo_cli-0.2.1}/src/milo/form.py +0 -0
- {milo_cli-0.1.1 → milo_cli-0.2.1}/src/milo/input/__init__.py +0 -0
- {milo_cli-0.1.1 → milo_cli-0.2.1}/src/milo/input/_platform.py +0 -0
- {milo_cli-0.1.1 → milo_cli-0.2.1}/src/milo/input/_reader.py +0 -0
- {milo_cli-0.1.1 → milo_cli-0.2.1}/src/milo/input/_sequences.py +0 -0
- {milo_cli-0.1.1 → milo_cli-0.2.1}/src/milo/llms.py +0 -0
- {milo_cli-0.1.1 → milo_cli-0.2.1}/src/milo/mcp.py +0 -0
- {milo_cli-0.1.1 → milo_cli-0.2.1}/src/milo/middleware.py +0 -0
- {milo_cli-0.1.1 → milo_cli-0.2.1}/src/milo/observability.py +0 -0
- {milo_cli-0.1.1 → milo_cli-0.2.1}/src/milo/output.py +0 -0
- {milo_cli-0.1.1 → milo_cli-0.2.1}/src/milo/plugins.py +0 -0
- {milo_cli-0.1.1 → milo_cli-0.2.1}/src/milo/py.typed +0 -0
- {milo_cli-0.1.1 → milo_cli-0.2.1}/src/milo/reducers.py +0 -0
- {milo_cli-0.1.1 → milo_cli-0.2.1}/src/milo/registry.py +0 -0
- {milo_cli-0.1.1 → milo_cli-0.2.1}/src/milo/streaming.py +0 -0
- {milo_cli-0.1.1 → milo_cli-0.2.1}/src/milo/templates/components/command_list.kida +0 -0
- {milo_cli-0.1.1 → milo_cli-0.2.1}/src/milo/templates/field_confirm.kida +0 -0
- {milo_cli-0.1.1 → milo_cli-0.2.1}/src/milo/templates/field_select.kida +0 -0
- {milo_cli-0.1.1 → milo_cli-0.2.1}/src/milo/templates/field_text.kida +0 -0
- {milo_cli-0.1.1 → milo_cli-0.2.1}/src/milo/templates/progress.kida +0 -0
- {milo_cli-0.1.1 → milo_cli-0.2.1}/src/milo/testing/__init__.py +0 -0
- {milo_cli-0.1.1 → milo_cli-0.2.1}/src/milo/testing/_mcp.py +0 -0
- {milo_cli-0.1.1 → milo_cli-0.2.1}/src/milo/testing/_record.py +0 -0
- {milo_cli-0.1.1 → milo_cli-0.2.1}/src/milo/testing/_replay.py +0 -0
- {milo_cli-0.1.1 → milo_cli-0.2.1}/src/milo/testing/_snapshot.py +0 -0
- {milo_cli-0.1.1 → milo_cli-0.2.1}/src/milo/version_check.py +0 -0
- {milo_cli-0.1.1 → milo_cli-0.2.1}/src/milo_cli.egg-info/dependency_links.txt +0 -0
- {milo_cli-0.1.1 → milo_cli-0.2.1}/src/milo_cli.egg-info/entry_points.txt +0 -0
- {milo_cli-0.1.1 → milo_cli-0.2.1}/src/milo_cli.egg-info/top_level.txt +0 -0
- {milo_cli-0.1.1 → milo_cli-0.2.1}/tests/test_app.py +0 -0
- {milo_cli-0.1.1 → milo_cli-0.2.1}/tests/test_bubbletea_patterns.py +0 -0
- {milo_cli-0.1.1 → milo_cli-0.2.1}/tests/test_cli.py +0 -0
- {milo_cli-0.1.1 → milo_cli-0.2.1}/tests/test_commands_middleware.py +0 -0
- {milo_cli-0.1.1 → milo_cli-0.2.1}/tests/test_completions.py +0 -0
- {milo_cli-0.1.1 → milo_cli-0.2.1}/tests/test_config.py +0 -0
- {milo_cli-0.1.1 → milo_cli-0.2.1}/tests/test_doctor.py +0 -0
- {milo_cli-0.1.1 → milo_cli-0.2.1}/tests/test_errors.py +0 -0
- {milo_cli-0.1.1 → milo_cli-0.2.1}/tests/test_flow.py +0 -0
- {milo_cli-0.1.1 → milo_cli-0.2.1}/tests/test_form.py +0 -0
- {milo_cli-0.1.1 → milo_cli-0.2.1}/tests/test_help.py +0 -0
- {milo_cli-0.1.1 → milo_cli-0.2.1}/tests/test_input.py +0 -0
- {milo_cli-0.1.1 → milo_cli-0.2.1}/tests/test_mcp_prompts.py +0 -0
- {milo_cli-0.1.1 → milo_cli-0.2.1}/tests/test_mcp_resources.py +0 -0
- {milo_cli-0.1.1 → milo_cli-0.2.1}/tests/test_mcp_router.py +0 -0
- {milo_cli-0.1.1 → milo_cli-0.2.1}/tests/test_middleware.py +0 -0
- {milo_cli-0.1.1 → milo_cli-0.2.1}/tests/test_mount.py +0 -0
- {milo_cli-0.1.1 → milo_cli-0.2.1}/tests/test_observability.py +0 -0
- {milo_cli-0.1.1 → milo_cli-0.2.1}/tests/test_plugins.py +0 -0
- {milo_cli-0.1.1 → milo_cli-0.2.1}/tests/test_protocols.py +0 -0
- {milo_cli-0.1.1 → milo_cli-0.2.1}/tests/test_reducers.py +0 -0
- {milo_cli-0.1.1 → milo_cli-0.2.1}/tests/test_registry_v2.py +0 -0
- {milo_cli-0.1.1 → milo_cli-0.2.1}/tests/test_state.py +0 -0
- {milo_cli-0.1.1 → milo_cli-0.2.1}/tests/test_streaming.py +0 -0
- {milo_cli-0.1.1 → milo_cli-0.2.1}/tests/test_templates.py +0 -0
- {milo_cli-0.1.1 → milo_cli-0.2.1}/tests/test_testing.py +0 -0
- {milo_cli-0.1.1 → milo_cli-0.2.1}/tests/test_testing_mcp.py +0 -0
- {milo_cli-0.1.1 → milo_cli-0.2.1}/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.1
|
|
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.1"
|
|
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.1"
|
|
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 ""
|
|
@@ -59,6 +59,8 @@ class CommandDef:
|
|
|
59
59
|
"""If non-empty, prompt for confirmation before running."""
|
|
60
60
|
annotations: dict[str, Any] = field(default_factory=dict)
|
|
61
61
|
"""MCP tool annotations (readOnlyHint, destructiveHint, etc.)."""
|
|
62
|
+
display_result: bool = True
|
|
63
|
+
"""If False, suppress plain-format output (return value still available for --format json)."""
|
|
62
64
|
|
|
63
65
|
|
|
64
66
|
class LazyCommandDef:
|
|
@@ -80,6 +82,7 @@ class LazyCommandDef:
|
|
|
80
82
|
"annotations",
|
|
81
83
|
"confirm",
|
|
82
84
|
"description",
|
|
85
|
+
"display_result",
|
|
83
86
|
"examples",
|
|
84
87
|
"hidden",
|
|
85
88
|
"import_path",
|
|
@@ -100,6 +103,7 @@ class LazyCommandDef:
|
|
|
100
103
|
examples: tuple[dict[str, Any], ...] | list[dict[str, Any]] = (),
|
|
101
104
|
confirm: str = "",
|
|
102
105
|
annotations: dict[str, Any] | None = None,
|
|
106
|
+
display_result: bool = True,
|
|
103
107
|
) -> None:
|
|
104
108
|
self.name = name
|
|
105
109
|
self.description = description
|
|
@@ -110,6 +114,7 @@ class LazyCommandDef:
|
|
|
110
114
|
self.examples = tuple(examples)
|
|
111
115
|
self.confirm = confirm
|
|
112
116
|
self.annotations = annotations or {}
|
|
117
|
+
self.display_result = display_result
|
|
113
118
|
self._schema = schema
|
|
114
119
|
self._resolved: CommandDef | None = None
|
|
115
120
|
self._lock = threading.Lock()
|
|
@@ -159,6 +164,7 @@ class LazyCommandDef:
|
|
|
159
164
|
examples=self.examples,
|
|
160
165
|
confirm=self.confirm,
|
|
161
166
|
annotations=self.annotations,
|
|
167
|
+
display_result=self.display_result,
|
|
162
168
|
)
|
|
163
169
|
return self._resolved
|
|
164
170
|
|
|
@@ -185,6 +191,7 @@ def _make_command_def(
|
|
|
185
191
|
examples: tuple[dict[str, Any], ...] = (),
|
|
186
192
|
confirm: str = "",
|
|
187
193
|
annotations: dict[str, Any] | None = None,
|
|
194
|
+
display_result: bool = True,
|
|
188
195
|
) -> CommandDef:
|
|
189
196
|
"""Build a CommandDef from a function and decorator kwargs."""
|
|
190
197
|
from milo.schema import function_to_schema
|
|
@@ -204,6 +211,7 @@ def _make_command_def(
|
|
|
204
211
|
examples=examples,
|
|
205
212
|
confirm=confirm,
|
|
206
213
|
annotations=annotations or {},
|
|
214
|
+
display_result=display_result,
|
|
207
215
|
)
|
|
208
216
|
|
|
209
217
|
|
|
@@ -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
|
# ---------------------------------------------------------------------------
|