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.
Files changed (118) hide show
  1. {milo_cli-0.1.1 → milo_cli-0.2.0}/PKG-INFO +2 -2
  2. {milo_cli-0.1.1 → milo_cli-0.2.0}/pyproject.toml +44 -3
  3. {milo_cli-0.1.1 → milo_cli-0.2.0}/src/milo/__init__.py +28 -1
  4. {milo_cli-0.1.1 → milo_cli-0.2.0}/src/milo/_child.py +35 -25
  5. {milo_cli-0.1.1 → milo_cli-0.2.0}/src/milo/_types.py +159 -1
  6. {milo_cli-0.1.1 → milo_cli-0.2.0}/src/milo/context.py +42 -0
  7. {milo_cli-0.1.1 → milo_cli-0.2.0}/src/milo/gateway.py +7 -4
  8. milo_cli-0.2.0/src/milo/pipeline.py +511 -0
  9. {milo_cli-0.1.1 → milo_cli-0.2.0}/src/milo/schema.py +65 -14
  10. milo_cli-0.2.0/src/milo/state.py +1010 -0
  11. milo_cli-0.2.0/src/milo/templates/__init__.py +70 -0
  12. {milo_cli-0.1.1 → milo_cli-0.2.0}/src/milo/templates/components/_defs.kida +87 -10
  13. {milo_cli-0.1.1 → milo_cli-0.2.0}/src/milo/templates/components/help_page.kida +14 -6
  14. {milo_cli-0.1.1 → milo_cli-0.2.0}/src/milo/templates/error.kida +4 -0
  15. milo_cli-0.2.0/src/milo/templates/form.kida +10 -0
  16. milo_cli-0.2.0/src/milo/theme.py +214 -0
  17. {milo_cli-0.1.1 → milo_cli-0.2.0}/src/milo_cli.egg-info/PKG-INFO +2 -2
  18. {milo_cli-0.1.1 → milo_cli-0.2.0}/src/milo_cli.egg-info/SOURCES.txt +4 -0
  19. {milo_cli-0.1.1 → milo_cli-0.2.0}/src/milo_cli.egg-info/requires.txt +1 -1
  20. {milo_cli-0.1.1 → milo_cli-0.2.0}/tests/test_ai_native.py +46 -1
  21. {milo_cli-0.1.1 → milo_cli-0.2.0}/tests/test_child.py +96 -0
  22. {milo_cli-0.1.1 → milo_cli-0.2.0}/tests/test_compat.py +1 -1
  23. {milo_cli-0.1.1 → milo_cli-0.2.0}/tests/test_components.py +114 -0
  24. {milo_cli-0.1.1 → milo_cli-0.2.0}/tests/test_context.py +50 -0
  25. {milo_cli-0.1.1 → milo_cli-0.2.0}/tests/test_dev.py +2 -2
  26. milo_cli-0.2.0/tests/test_effects.py +1819 -0
  27. milo_cli-0.2.0/tests/test_effects_stress.py +369 -0
  28. milo_cli-0.2.0/tests/test_gateway.py +467 -0
  29. {milo_cli-0.1.1 → milo_cli-0.2.0}/tests/test_milo_init.py +3 -1
  30. milo_cli-0.2.0/tests/test_pipeline.py +614 -0
  31. {milo_cli-0.1.1 → milo_cli-0.2.0}/tests/test_schema_v2.py +130 -0
  32. milo_cli-0.2.0/tests/test_theme.py +252 -0
  33. {milo_cli-0.1.1 → milo_cli-0.2.0}/tests/test_types.py +4 -1
  34. milo_cli-0.1.1/src/milo/pipeline.py +0 -278
  35. milo_cli-0.1.1/src/milo/state.py +0 -394
  36. milo_cli-0.1.1/src/milo/templates/__init__.py +0 -38
  37. milo_cli-0.1.1/src/milo/templates/form.kida +0 -8
  38. milo_cli-0.1.1/tests/test_effects.py +0 -176
  39. milo_cli-0.1.1/tests/test_pipeline.py +0 -299
  40. {milo_cli-0.1.1 → milo_cli-0.2.0}/README.md +0 -0
  41. {milo_cli-0.1.1 → milo_cli-0.2.0}/setup.cfg +0 -0
  42. {milo_cli-0.1.1 → milo_cli-0.2.0}/src/milo/_cli_help.py +0 -0
  43. {milo_cli-0.1.1 → milo_cli-0.2.0}/src/milo/_command_defs.py +0 -0
  44. {milo_cli-0.1.1 → milo_cli-0.2.0}/src/milo/_compat.py +0 -0
  45. {milo_cli-0.1.1 → milo_cli-0.2.0}/src/milo/_errors.py +0 -0
  46. {milo_cli-0.1.1 → milo_cli-0.2.0}/src/milo/_jsonrpc.py +0 -0
  47. {milo_cli-0.1.1 → milo_cli-0.2.0}/src/milo/_mcp_router.py +0 -0
  48. {milo_cli-0.1.1 → milo_cli-0.2.0}/src/milo/_protocols.py +0 -0
  49. {milo_cli-0.1.1 → milo_cli-0.2.0}/src/milo/app.py +0 -0
  50. {milo_cli-0.1.1 → milo_cli-0.2.0}/src/milo/cli.py +0 -0
  51. {milo_cli-0.1.1 → milo_cli-0.2.0}/src/milo/commands.py +0 -0
  52. {milo_cli-0.1.1 → milo_cli-0.2.0}/src/milo/completions.py +0 -0
  53. {milo_cli-0.1.1 → milo_cli-0.2.0}/src/milo/config.py +0 -0
  54. {milo_cli-0.1.1 → milo_cli-0.2.0}/src/milo/dev.py +0 -0
  55. {milo_cli-0.1.1 → milo_cli-0.2.0}/src/milo/doctor.py +0 -0
  56. {milo_cli-0.1.1 → milo_cli-0.2.0}/src/milo/flow.py +0 -0
  57. {milo_cli-0.1.1 → milo_cli-0.2.0}/src/milo/form.py +0 -0
  58. {milo_cli-0.1.1 → milo_cli-0.2.0}/src/milo/groups.py +0 -0
  59. {milo_cli-0.1.1 → milo_cli-0.2.0}/src/milo/help.py +0 -0
  60. {milo_cli-0.1.1 → milo_cli-0.2.0}/src/milo/input/__init__.py +0 -0
  61. {milo_cli-0.1.1 → milo_cli-0.2.0}/src/milo/input/_platform.py +0 -0
  62. {milo_cli-0.1.1 → milo_cli-0.2.0}/src/milo/input/_reader.py +0 -0
  63. {milo_cli-0.1.1 → milo_cli-0.2.0}/src/milo/input/_sequences.py +0 -0
  64. {milo_cli-0.1.1 → milo_cli-0.2.0}/src/milo/llms.py +0 -0
  65. {milo_cli-0.1.1 → milo_cli-0.2.0}/src/milo/mcp.py +0 -0
  66. {milo_cli-0.1.1 → milo_cli-0.2.0}/src/milo/middleware.py +0 -0
  67. {milo_cli-0.1.1 → milo_cli-0.2.0}/src/milo/observability.py +0 -0
  68. {milo_cli-0.1.1 → milo_cli-0.2.0}/src/milo/output.py +0 -0
  69. {milo_cli-0.1.1 → milo_cli-0.2.0}/src/milo/plugins.py +0 -0
  70. {milo_cli-0.1.1 → milo_cli-0.2.0}/src/milo/py.typed +0 -0
  71. {milo_cli-0.1.1 → milo_cli-0.2.0}/src/milo/reducers.py +0 -0
  72. {milo_cli-0.1.1 → milo_cli-0.2.0}/src/milo/registry.py +0 -0
  73. {milo_cli-0.1.1 → milo_cli-0.2.0}/src/milo/streaming.py +0 -0
  74. {milo_cli-0.1.1 → milo_cli-0.2.0}/src/milo/templates/components/command_list.kida +0 -0
  75. {milo_cli-0.1.1 → milo_cli-0.2.0}/src/milo/templates/field_confirm.kida +0 -0
  76. {milo_cli-0.1.1 → milo_cli-0.2.0}/src/milo/templates/field_select.kida +0 -0
  77. {milo_cli-0.1.1 → milo_cli-0.2.0}/src/milo/templates/field_text.kida +0 -0
  78. {milo_cli-0.1.1 → milo_cli-0.2.0}/src/milo/templates/help.kida +0 -0
  79. {milo_cli-0.1.1 → milo_cli-0.2.0}/src/milo/templates/progress.kida +0 -0
  80. {milo_cli-0.1.1 → milo_cli-0.2.0}/src/milo/testing/__init__.py +0 -0
  81. {milo_cli-0.1.1 → milo_cli-0.2.0}/src/milo/testing/_mcp.py +0 -0
  82. {milo_cli-0.1.1 → milo_cli-0.2.0}/src/milo/testing/_record.py +0 -0
  83. {milo_cli-0.1.1 → milo_cli-0.2.0}/src/milo/testing/_replay.py +0 -0
  84. {milo_cli-0.1.1 → milo_cli-0.2.0}/src/milo/testing/_snapshot.py +0 -0
  85. {milo_cli-0.1.1 → milo_cli-0.2.0}/src/milo/version_check.py +0 -0
  86. {milo_cli-0.1.1 → milo_cli-0.2.0}/src/milo_cli.egg-info/dependency_links.txt +0 -0
  87. {milo_cli-0.1.1 → milo_cli-0.2.0}/src/milo_cli.egg-info/entry_points.txt +0 -0
  88. {milo_cli-0.1.1 → milo_cli-0.2.0}/src/milo_cli.egg-info/top_level.txt +0 -0
  89. {milo_cli-0.1.1 → milo_cli-0.2.0}/tests/test_app.py +0 -0
  90. {milo_cli-0.1.1 → milo_cli-0.2.0}/tests/test_bubbletea_patterns.py +0 -0
  91. {milo_cli-0.1.1 → milo_cli-0.2.0}/tests/test_cli.py +0 -0
  92. {milo_cli-0.1.1 → milo_cli-0.2.0}/tests/test_commands_middleware.py +0 -0
  93. {milo_cli-0.1.1 → milo_cli-0.2.0}/tests/test_completions.py +0 -0
  94. {milo_cli-0.1.1 → milo_cli-0.2.0}/tests/test_config.py +0 -0
  95. {milo_cli-0.1.1 → milo_cli-0.2.0}/tests/test_doctor.py +0 -0
  96. {milo_cli-0.1.1 → milo_cli-0.2.0}/tests/test_errors.py +0 -0
  97. {milo_cli-0.1.1 → milo_cli-0.2.0}/tests/test_flow.py +0 -0
  98. {milo_cli-0.1.1 → milo_cli-0.2.0}/tests/test_form.py +0 -0
  99. {milo_cli-0.1.1 → milo_cli-0.2.0}/tests/test_groups.py +0 -0
  100. {milo_cli-0.1.1 → milo_cli-0.2.0}/tests/test_help.py +0 -0
  101. {milo_cli-0.1.1 → milo_cli-0.2.0}/tests/test_input.py +0 -0
  102. {milo_cli-0.1.1 → milo_cli-0.2.0}/tests/test_lazy.py +0 -0
  103. {milo_cli-0.1.1 → milo_cli-0.2.0}/tests/test_mcp_prompts.py +0 -0
  104. {milo_cli-0.1.1 → milo_cli-0.2.0}/tests/test_mcp_resources.py +0 -0
  105. {milo_cli-0.1.1 → milo_cli-0.2.0}/tests/test_mcp_router.py +0 -0
  106. {milo_cli-0.1.1 → milo_cli-0.2.0}/tests/test_middleware.py +0 -0
  107. {milo_cli-0.1.1 → milo_cli-0.2.0}/tests/test_mount.py +0 -0
  108. {milo_cli-0.1.1 → milo_cli-0.2.0}/tests/test_observability.py +0 -0
  109. {milo_cli-0.1.1 → milo_cli-0.2.0}/tests/test_plugins.py +0 -0
  110. {milo_cli-0.1.1 → milo_cli-0.2.0}/tests/test_protocols.py +0 -0
  111. {milo_cli-0.1.1 → milo_cli-0.2.0}/tests/test_reducers.py +0 -0
  112. {milo_cli-0.1.1 → milo_cli-0.2.0}/tests/test_registry_v2.py +0 -0
  113. {milo_cli-0.1.1 → milo_cli-0.2.0}/tests/test_state.py +0 -0
  114. {milo_cli-0.1.1 → milo_cli-0.2.0}/tests/test_streaming.py +0 -0
  115. {milo_cli-0.1.1 → milo_cli-0.2.0}/tests/test_templates.py +0 -0
  116. {milo_cli-0.1.1 → milo_cli-0.2.0}/tests/test_testing.py +0 -0
  117. {milo_cli-0.1.1 → milo_cli-0.2.0}/tests/test_testing_mcp.py +0 -0
  118. {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.1.1
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.3.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.1.1"
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.3.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 may use print for diagnostics
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.1.1"
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(self, method: str, params: dict[str, Any]) -> dict[str, Any]:
76
- """Send a JSON-RPC request and return the result. Thread-safe."""
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
- response_line = self._read_line()
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
- if self._proc is not None:
119
- try:
120
- self._proc.terminate()
121
- self._proc.wait(timeout=5)
122
- except ProcessLookupError:
123
- pass
124
- except subprocess.TimeoutExpired:
125
- import contextlib
126
-
127
- with contextlib.suppress(ProcessLookupError):
128
- self._proc.kill()
129
- self._proc = None
130
- self._initialized = False
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=_READ_TIMEOUT)
166
+ reader.join(timeout=effective_timeout)
154
167
  if reader.is_alive():
155
- # Timed out — kill the child to unblock the reader thread
156
- with contextlib.suppress(OSError):
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
- pass
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
- pass
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