milo-cli 0.2.0__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.2.0 → milo_cli-0.2.1}/PKG-INFO +1 -1
- {milo_cli-0.2.0 → milo_cli-0.2.1}/pyproject.toml +2 -2
- {milo_cli-0.2.0 → milo_cli-0.2.1}/src/milo/__init__.py +1 -1
- {milo_cli-0.2.0 → milo_cli-0.2.1}/src/milo/_command_defs.py +8 -0
- {milo_cli-0.2.0 → milo_cli-0.2.1}/src/milo/commands.py +149 -41
- {milo_cli-0.2.0 → milo_cli-0.2.1}/src/milo/groups.py +42 -0
- {milo_cli-0.2.0 → milo_cli-0.2.1}/src/milo/help.py +2 -0
- {milo_cli-0.2.0 → milo_cli-0.2.1}/src/milo/schema.py +10 -0
- milo_cli-0.2.1/src/milo/templates/help.kida +30 -0
- {milo_cli-0.2.0 → milo_cli-0.2.1}/src/milo_cli.egg-info/PKG-INFO +1 -1
- {milo_cli-0.2.0 → milo_cli-0.2.1}/tests/test_effects.py +9 -9
- {milo_cli-0.2.0 → milo_cli-0.2.1}/tests/test_groups.py +139 -0
- {milo_cli-0.2.0 → milo_cli-0.2.1}/tests/test_lazy.py +280 -0
- milo_cli-0.2.0/src/milo/templates/help.kida +0 -13
- {milo_cli-0.2.0 → milo_cli-0.2.1}/README.md +0 -0
- {milo_cli-0.2.0 → milo_cli-0.2.1}/setup.cfg +0 -0
- {milo_cli-0.2.0 → milo_cli-0.2.1}/src/milo/_child.py +0 -0
- {milo_cli-0.2.0 → milo_cli-0.2.1}/src/milo/_cli_help.py +0 -0
- {milo_cli-0.2.0 → milo_cli-0.2.1}/src/milo/_compat.py +0 -0
- {milo_cli-0.2.0 → milo_cli-0.2.1}/src/milo/_errors.py +0 -0
- {milo_cli-0.2.0 → milo_cli-0.2.1}/src/milo/_jsonrpc.py +0 -0
- {milo_cli-0.2.0 → milo_cli-0.2.1}/src/milo/_mcp_router.py +0 -0
- {milo_cli-0.2.0 → milo_cli-0.2.1}/src/milo/_protocols.py +0 -0
- {milo_cli-0.2.0 → milo_cli-0.2.1}/src/milo/_types.py +0 -0
- {milo_cli-0.2.0 → milo_cli-0.2.1}/src/milo/app.py +0 -0
- {milo_cli-0.2.0 → milo_cli-0.2.1}/src/milo/cli.py +0 -0
- {milo_cli-0.2.0 → milo_cli-0.2.1}/src/milo/completions.py +0 -0
- {milo_cli-0.2.0 → milo_cli-0.2.1}/src/milo/config.py +0 -0
- {milo_cli-0.2.0 → milo_cli-0.2.1}/src/milo/context.py +0 -0
- {milo_cli-0.2.0 → milo_cli-0.2.1}/src/milo/dev.py +0 -0
- {milo_cli-0.2.0 → milo_cli-0.2.1}/src/milo/doctor.py +0 -0
- {milo_cli-0.2.0 → milo_cli-0.2.1}/src/milo/flow.py +0 -0
- {milo_cli-0.2.0 → milo_cli-0.2.1}/src/milo/form.py +0 -0
- {milo_cli-0.2.0 → milo_cli-0.2.1}/src/milo/gateway.py +0 -0
- {milo_cli-0.2.0 → milo_cli-0.2.1}/src/milo/input/__init__.py +0 -0
- {milo_cli-0.2.0 → milo_cli-0.2.1}/src/milo/input/_platform.py +0 -0
- {milo_cli-0.2.0 → milo_cli-0.2.1}/src/milo/input/_reader.py +0 -0
- {milo_cli-0.2.0 → milo_cli-0.2.1}/src/milo/input/_sequences.py +0 -0
- {milo_cli-0.2.0 → milo_cli-0.2.1}/src/milo/llms.py +0 -0
- {milo_cli-0.2.0 → milo_cli-0.2.1}/src/milo/mcp.py +0 -0
- {milo_cli-0.2.0 → milo_cli-0.2.1}/src/milo/middleware.py +0 -0
- {milo_cli-0.2.0 → milo_cli-0.2.1}/src/milo/observability.py +0 -0
- {milo_cli-0.2.0 → milo_cli-0.2.1}/src/milo/output.py +0 -0
- {milo_cli-0.2.0 → milo_cli-0.2.1}/src/milo/pipeline.py +0 -0
- {milo_cli-0.2.0 → milo_cli-0.2.1}/src/milo/plugins.py +0 -0
- {milo_cli-0.2.0 → milo_cli-0.2.1}/src/milo/py.typed +0 -0
- {milo_cli-0.2.0 → milo_cli-0.2.1}/src/milo/reducers.py +0 -0
- {milo_cli-0.2.0 → milo_cli-0.2.1}/src/milo/registry.py +0 -0
- {milo_cli-0.2.0 → milo_cli-0.2.1}/src/milo/state.py +0 -0
- {milo_cli-0.2.0 → milo_cli-0.2.1}/src/milo/streaming.py +0 -0
- {milo_cli-0.2.0 → milo_cli-0.2.1}/src/milo/templates/__init__.py +0 -0
- {milo_cli-0.2.0 → milo_cli-0.2.1}/src/milo/templates/components/_defs.kida +0 -0
- {milo_cli-0.2.0 → milo_cli-0.2.1}/src/milo/templates/components/command_list.kida +0 -0
- {milo_cli-0.2.0 → milo_cli-0.2.1}/src/milo/templates/components/help_page.kida +0 -0
- {milo_cli-0.2.0 → milo_cli-0.2.1}/src/milo/templates/error.kida +0 -0
- {milo_cli-0.2.0 → milo_cli-0.2.1}/src/milo/templates/field_confirm.kida +0 -0
- {milo_cli-0.2.0 → milo_cli-0.2.1}/src/milo/templates/field_select.kida +0 -0
- {milo_cli-0.2.0 → milo_cli-0.2.1}/src/milo/templates/field_text.kida +0 -0
- {milo_cli-0.2.0 → milo_cli-0.2.1}/src/milo/templates/form.kida +0 -0
- {milo_cli-0.2.0 → milo_cli-0.2.1}/src/milo/templates/progress.kida +0 -0
- {milo_cli-0.2.0 → milo_cli-0.2.1}/src/milo/testing/__init__.py +0 -0
- {milo_cli-0.2.0 → milo_cli-0.2.1}/src/milo/testing/_mcp.py +0 -0
- {milo_cli-0.2.0 → milo_cli-0.2.1}/src/milo/testing/_record.py +0 -0
- {milo_cli-0.2.0 → milo_cli-0.2.1}/src/milo/testing/_replay.py +0 -0
- {milo_cli-0.2.0 → milo_cli-0.2.1}/src/milo/testing/_snapshot.py +0 -0
- {milo_cli-0.2.0 → milo_cli-0.2.1}/src/milo/theme.py +0 -0
- {milo_cli-0.2.0 → milo_cli-0.2.1}/src/milo/version_check.py +0 -0
- {milo_cli-0.2.0 → milo_cli-0.2.1}/src/milo_cli.egg-info/SOURCES.txt +0 -0
- {milo_cli-0.2.0 → milo_cli-0.2.1}/src/milo_cli.egg-info/dependency_links.txt +0 -0
- {milo_cli-0.2.0 → milo_cli-0.2.1}/src/milo_cli.egg-info/entry_points.txt +0 -0
- {milo_cli-0.2.0 → milo_cli-0.2.1}/src/milo_cli.egg-info/requires.txt +0 -0
- {milo_cli-0.2.0 → milo_cli-0.2.1}/src/milo_cli.egg-info/top_level.txt +0 -0
- {milo_cli-0.2.0 → milo_cli-0.2.1}/tests/test_ai_native.py +0 -0
- {milo_cli-0.2.0 → milo_cli-0.2.1}/tests/test_app.py +0 -0
- {milo_cli-0.2.0 → milo_cli-0.2.1}/tests/test_bubbletea_patterns.py +0 -0
- {milo_cli-0.2.0 → milo_cli-0.2.1}/tests/test_child.py +0 -0
- {milo_cli-0.2.0 → milo_cli-0.2.1}/tests/test_cli.py +0 -0
- {milo_cli-0.2.0 → milo_cli-0.2.1}/tests/test_commands_middleware.py +0 -0
- {milo_cli-0.2.0 → milo_cli-0.2.1}/tests/test_compat.py +0 -0
- {milo_cli-0.2.0 → milo_cli-0.2.1}/tests/test_completions.py +0 -0
- {milo_cli-0.2.0 → milo_cli-0.2.1}/tests/test_components.py +0 -0
- {milo_cli-0.2.0 → milo_cli-0.2.1}/tests/test_config.py +0 -0
- {milo_cli-0.2.0 → milo_cli-0.2.1}/tests/test_context.py +0 -0
- {milo_cli-0.2.0 → milo_cli-0.2.1}/tests/test_dev.py +0 -0
- {milo_cli-0.2.0 → milo_cli-0.2.1}/tests/test_doctor.py +0 -0
- {milo_cli-0.2.0 → milo_cli-0.2.1}/tests/test_effects_stress.py +0 -0
- {milo_cli-0.2.0 → milo_cli-0.2.1}/tests/test_errors.py +0 -0
- {milo_cli-0.2.0 → milo_cli-0.2.1}/tests/test_flow.py +0 -0
- {milo_cli-0.2.0 → milo_cli-0.2.1}/tests/test_form.py +0 -0
- {milo_cli-0.2.0 → milo_cli-0.2.1}/tests/test_gateway.py +0 -0
- {milo_cli-0.2.0 → milo_cli-0.2.1}/tests/test_help.py +0 -0
- {milo_cli-0.2.0 → milo_cli-0.2.1}/tests/test_input.py +0 -0
- {milo_cli-0.2.0 → milo_cli-0.2.1}/tests/test_mcp_prompts.py +0 -0
- {milo_cli-0.2.0 → milo_cli-0.2.1}/tests/test_mcp_resources.py +0 -0
- {milo_cli-0.2.0 → milo_cli-0.2.1}/tests/test_mcp_router.py +0 -0
- {milo_cli-0.2.0 → milo_cli-0.2.1}/tests/test_middleware.py +0 -0
- {milo_cli-0.2.0 → milo_cli-0.2.1}/tests/test_milo_init.py +0 -0
- {milo_cli-0.2.0 → milo_cli-0.2.1}/tests/test_mount.py +0 -0
- {milo_cli-0.2.0 → milo_cli-0.2.1}/tests/test_observability.py +0 -0
- {milo_cli-0.2.0 → milo_cli-0.2.1}/tests/test_pipeline.py +0 -0
- {milo_cli-0.2.0 → milo_cli-0.2.1}/tests/test_plugins.py +0 -0
- {milo_cli-0.2.0 → milo_cli-0.2.1}/tests/test_protocols.py +0 -0
- {milo_cli-0.2.0 → milo_cli-0.2.1}/tests/test_reducers.py +0 -0
- {milo_cli-0.2.0 → milo_cli-0.2.1}/tests/test_registry_v2.py +0 -0
- {milo_cli-0.2.0 → milo_cli-0.2.1}/tests/test_schema_v2.py +0 -0
- {milo_cli-0.2.0 → milo_cli-0.2.1}/tests/test_state.py +0 -0
- {milo_cli-0.2.0 → milo_cli-0.2.1}/tests/test_streaming.py +0 -0
- {milo_cli-0.2.0 → milo_cli-0.2.1}/tests/test_templates.py +0 -0
- {milo_cli-0.2.0 → milo_cli-0.2.1}/tests/test_testing.py +0 -0
- {milo_cli-0.2.0 → milo_cli-0.2.1}/tests/test_testing_mcp.py +0 -0
- {milo_cli-0.2.0 → milo_cli-0.2.1}/tests/test_theme.py +0 -0
- {milo_cli-0.2.0 → milo_cli-0.2.1}/tests/test_types.py +0 -0
- {milo_cli-0.2.0 → milo_cli-0.2.1}/tests/test_version_check.py +0 -0
|
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "milo-cli"
|
|
7
|
-
version = "0.2.
|
|
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"
|
|
@@ -136,7 +136,7 @@ directory = "changelog.d"
|
|
|
136
136
|
filename = "CHANGELOG.md"
|
|
137
137
|
package = "milo"
|
|
138
138
|
package_dir = "src"
|
|
139
|
-
title_format = "##
|
|
139
|
+
title_format = "## {version} — {project_date}"
|
|
140
140
|
issue_format = "[#{issue}](https://github.com/lbliii/milo-cli/issues/{issue})"
|
|
141
141
|
underlines = ["", "", ""]
|
|
142
142
|
|
|
@@ -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
|
|
|
@@ -9,6 +9,7 @@ import io
|
|
|
9
9
|
import os
|
|
10
10
|
import sys
|
|
11
11
|
from collections.abc import Callable
|
|
12
|
+
from dataclasses import dataclass
|
|
12
13
|
from typing import TYPE_CHECKING, Any, NoReturn
|
|
13
14
|
|
|
14
15
|
from milo._command_defs import (
|
|
@@ -41,6 +42,39 @@ __all__ = [
|
|
|
41
42
|
]
|
|
42
43
|
|
|
43
44
|
|
|
45
|
+
# ---------------------------------------------------------------------------
|
|
46
|
+
# Resolve result types — discriminated union for command resolution
|
|
47
|
+
# ---------------------------------------------------------------------------
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
@dataclass(frozen=True, slots=True)
|
|
51
|
+
class ResolvedCommand:
|
|
52
|
+
"""A command was found and should be dispatched."""
|
|
53
|
+
|
|
54
|
+
command: CommandDef | LazyCommandDef
|
|
55
|
+
fmt: str
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
@dataclass(frozen=True, slots=True)
|
|
59
|
+
class ResolvedGroup:
|
|
60
|
+
"""A group was invoked without a subcommand — show its help."""
|
|
61
|
+
|
|
62
|
+
group: Group
|
|
63
|
+
fmt: str
|
|
64
|
+
prog: str = ""
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
@dataclass(frozen=True, slots=True)
|
|
68
|
+
class ResolvedNothing:
|
|
69
|
+
"""No command or group matched — may offer did-you-mean."""
|
|
70
|
+
|
|
71
|
+
attempted: str | None
|
|
72
|
+
fmt: str
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
ResolveResult = ResolvedCommand | ResolvedGroup | ResolvedNothing
|
|
76
|
+
|
|
77
|
+
|
|
44
78
|
class _MiloArgumentParser(argparse.ArgumentParser):
|
|
45
79
|
"""ArgumentParser subclass that provides did-you-mean suggestions."""
|
|
46
80
|
|
|
@@ -186,6 +220,7 @@ class CLI:
|
|
|
186
220
|
examples: tuple[dict[str, Any], ...] | list[dict[str, Any]] = (),
|
|
187
221
|
confirm: str = "",
|
|
188
222
|
annotations: dict[str, Any] | None = None,
|
|
223
|
+
display_result: bool = True,
|
|
189
224
|
) -> Callable:
|
|
190
225
|
"""Register a function as a CLI command.
|
|
191
226
|
|
|
@@ -198,6 +233,8 @@ class CLI:
|
|
|
198
233
|
confirm: If set, prompt user with this message before executing.
|
|
199
234
|
annotations: MCP tool annotations (readOnlyHint, destructiveHint,
|
|
200
235
|
idempotentHint, openWorldHint).
|
|
236
|
+
display_result: If False, suppress plain-format output while still
|
|
237
|
+
returning data for ``--format json`` or ``--output-file``.
|
|
201
238
|
"""
|
|
202
239
|
|
|
203
240
|
def decorator(func: Callable[..., Any]) -> Callable[..., Any]:
|
|
@@ -211,6 +248,7 @@ class CLI:
|
|
|
211
248
|
examples=tuple(examples),
|
|
212
249
|
confirm=confirm,
|
|
213
250
|
annotations=annotations,
|
|
251
|
+
display_result=display_result,
|
|
214
252
|
)
|
|
215
253
|
self._commands[name] = cmd
|
|
216
254
|
for alias in aliases:
|
|
@@ -233,11 +271,16 @@ class CLI:
|
|
|
233
271
|
examples: tuple[dict[str, Any], ...] | list[dict[str, Any]] = (),
|
|
234
272
|
confirm: str = "",
|
|
235
273
|
annotations: dict[str, Any] | None = None,
|
|
274
|
+
display_result: bool = True,
|
|
236
275
|
) -> LazyCommandDef:
|
|
237
276
|
"""Register a lazy-loaded command.
|
|
238
277
|
|
|
239
278
|
The handler module is not imported until the command is invoked.
|
|
240
279
|
This keeps CLI startup fast for large command sets.
|
|
280
|
+
|
|
281
|
+
When providing a pre-computed *schema*, include ``"default"`` fields
|
|
282
|
+
in properties for optional parameters so argparse receives the correct
|
|
283
|
+
defaults without importing the handler module.
|
|
241
284
|
"""
|
|
242
285
|
cmd = LazyCommandDef(
|
|
243
286
|
name=name,
|
|
@@ -250,6 +293,7 @@ class CLI:
|
|
|
250
293
|
examples=examples,
|
|
251
294
|
confirm=confirm,
|
|
252
295
|
annotations=annotations,
|
|
296
|
+
display_result=display_result,
|
|
253
297
|
)
|
|
254
298
|
self._commands[name] = cmd
|
|
255
299
|
for alias in aliases:
|
|
@@ -620,7 +664,7 @@ class CLI:
|
|
|
620
664
|
"--format",
|
|
621
665
|
choices=["plain", "json", "table"],
|
|
622
666
|
default="plain",
|
|
623
|
-
help="Output format
|
|
667
|
+
help="Output format",
|
|
624
668
|
)
|
|
625
669
|
|
|
626
670
|
def _add_groups_to_subparsers(
|
|
@@ -670,13 +714,21 @@ class CLI:
|
|
|
670
714
|
# Determine type
|
|
671
715
|
json_type = param_schema.get("type", "string")
|
|
672
716
|
if json_type == "boolean":
|
|
673
|
-
default
|
|
674
|
-
param.default
|
|
675
|
-
|
|
676
|
-
|
|
677
|
-
|
|
678
|
-
|
|
717
|
+
if param and param.default is not inspect.Parameter.empty:
|
|
718
|
+
default = param.default
|
|
719
|
+
elif "default" in param_schema:
|
|
720
|
+
default = param_schema["default"]
|
|
721
|
+
else:
|
|
722
|
+
default = False
|
|
679
723
|
kwargs["default"] = default
|
|
724
|
+
if default is True:
|
|
725
|
+
# default=True → --no-xxx flag to disable
|
|
726
|
+
flag = f"--no-{param_name.replace('_', '-')}"
|
|
727
|
+
kwargs["action"] = "store_false"
|
|
728
|
+
parser.add_argument(flag, dest=param_name, **kwargs)
|
|
729
|
+
continue
|
|
730
|
+
else:
|
|
731
|
+
kwargs["action"] = "store_true"
|
|
680
732
|
elif json_type == "integer":
|
|
681
733
|
kwargs["type"] = int
|
|
682
734
|
elif json_type == "number":
|
|
@@ -691,9 +743,15 @@ class CLI:
|
|
|
691
743
|
else:
|
|
692
744
|
kwargs["type"] = str
|
|
693
745
|
|
|
694
|
-
#
|
|
746
|
+
# Enum choices from schema
|
|
747
|
+
if "enum" in param_schema:
|
|
748
|
+
kwargs["choices"] = param_schema["enum"]
|
|
749
|
+
|
|
750
|
+
# Set default from signature or schema
|
|
695
751
|
if param and param.default is not inspect.Parameter.empty and json_type != "boolean":
|
|
696
752
|
kwargs["default"] = param.default
|
|
753
|
+
elif "default" not in kwargs and "default" in param_schema:
|
|
754
|
+
kwargs["default"] = param_schema["default"]
|
|
697
755
|
|
|
698
756
|
# Required vs optional
|
|
699
757
|
if param_name in required_set and json_type != "boolean":
|
|
@@ -710,6 +768,7 @@ class CLI:
|
|
|
710
768
|
def run(self, argv: list[str] | None = None) -> Any:
|
|
711
769
|
"""Parse args and dispatch to the appropriate command."""
|
|
712
770
|
parser = self.build_parser()
|
|
771
|
+
self._parser = parser
|
|
713
772
|
args = parser.parse_args(argv)
|
|
714
773
|
|
|
715
774
|
# --completions mode
|
|
@@ -747,20 +806,26 @@ class CLI:
|
|
|
747
806
|
ctx = self._build_context(args)
|
|
748
807
|
|
|
749
808
|
# Resolve command from args (may be nested in groups)
|
|
750
|
-
|
|
751
|
-
|
|
752
|
-
|
|
753
|
-
|
|
754
|
-
|
|
755
|
-
|
|
809
|
+
result = self._resolve_command_from_args(args)
|
|
810
|
+
|
|
811
|
+
if isinstance(result, ResolvedGroup):
|
|
812
|
+
result.group.format_help(result.prog)
|
|
813
|
+
return None
|
|
814
|
+
|
|
815
|
+
if isinstance(result, ResolvedNothing):
|
|
816
|
+
if result.attempted:
|
|
817
|
+
suggestion = self.suggest_command(result.attempted)
|
|
756
818
|
if suggestion:
|
|
757
819
|
sys.stderr.write(
|
|
758
|
-
f"Unknown command: {
|
|
820
|
+
f"Unknown command: {result.attempted!r}. Did you mean {suggestion!r}?\n"
|
|
759
821
|
)
|
|
760
822
|
return None
|
|
761
|
-
|
|
823
|
+
self._format_root_help()
|
|
762
824
|
return None
|
|
763
825
|
|
|
826
|
+
found = result.command
|
|
827
|
+
fmt = result.fmt
|
|
828
|
+
|
|
764
829
|
# Resolve lazy commands
|
|
765
830
|
cmd = found.resolve() if isinstance(found, LazyCommandDef) else found
|
|
766
831
|
|
|
@@ -838,13 +903,17 @@ class CLI:
|
|
|
838
903
|
ctx.error(f"after_command hook failed: {type(exc).__name__}: {exc}")
|
|
839
904
|
|
|
840
905
|
# Format and output (to file or stdout)
|
|
841
|
-
|
|
842
|
-
|
|
843
|
-
|
|
844
|
-
|
|
845
|
-
|
|
846
|
-
|
|
847
|
-
|
|
906
|
+
# When display_result=False, suppress plain-format stdout output but
|
|
907
|
+
# still honor explicit --format or --output-file requests.
|
|
908
|
+
suppress = not cmd.display_result and fmt == "plain" and not ctx.output_file
|
|
909
|
+
if not suppress:
|
|
910
|
+
output_file = ctx.output_file
|
|
911
|
+
if output_file:
|
|
912
|
+
formatted = format_output(result, fmt=fmt)
|
|
913
|
+
with open(output_file, "w") as f:
|
|
914
|
+
f.write(formatted + "\n")
|
|
915
|
+
else:
|
|
916
|
+
write_output(result, fmt=fmt)
|
|
848
917
|
|
|
849
918
|
return result
|
|
850
919
|
|
|
@@ -914,55 +983,94 @@ class CLI:
|
|
|
914
983
|
globals=user_globals,
|
|
915
984
|
)
|
|
916
985
|
|
|
917
|
-
def
|
|
918
|
-
|
|
919
|
-
|
|
920
|
-
|
|
986
|
+
def _format_root_help(self) -> None:
|
|
987
|
+
"""Render root help from command/group registries and the actual parser."""
|
|
988
|
+
from milo.help import HelpState
|
|
989
|
+
from milo.templates import get_env
|
|
990
|
+
|
|
991
|
+
commands = tuple(
|
|
992
|
+
[
|
|
993
|
+
{"name": cmd.name, "help": getattr(cmd, "description", "")}
|
|
994
|
+
for cmd in self._commands.values()
|
|
995
|
+
if not getattr(cmd, "hidden", False)
|
|
996
|
+
]
|
|
997
|
+
+ [
|
|
998
|
+
{"name": g.name, "help": g.description}
|
|
999
|
+
for g in self._groups.values()
|
|
1000
|
+
if not g.hidden
|
|
1001
|
+
]
|
|
1002
|
+
)
|
|
1003
|
+
|
|
1004
|
+
# Derive options from the actual parser so new flags are never missed
|
|
1005
|
+
options: list[dict[str, Any]] = []
|
|
1006
|
+
for action in self._parser._actions:
|
|
1007
|
+
if isinstance(action, argparse._SubParsersAction):
|
|
1008
|
+
continue
|
|
1009
|
+
flags = ", ".join(action.option_strings) if action.option_strings else ""
|
|
1010
|
+
if not flags:
|
|
1011
|
+
continue
|
|
1012
|
+
entry: dict[str, Any] = {"flags": flags, "help": action.help or ""}
|
|
1013
|
+
if action.metavar:
|
|
1014
|
+
entry["metavar"] = action.metavar
|
|
1015
|
+
options.append(entry)
|
|
1016
|
+
|
|
1017
|
+
state = HelpState(
|
|
1018
|
+
prog=self.name,
|
|
1019
|
+
description=self.description,
|
|
1020
|
+
commands=commands,
|
|
1021
|
+
options=tuple(options),
|
|
1022
|
+
)
|
|
1023
|
+
env = get_env()
|
|
1024
|
+
template = env.get_template("help.kida")
|
|
1025
|
+
sys.stdout.write(template.render(state=state) + "\n")
|
|
1026
|
+
sys.stdout.flush()
|
|
1027
|
+
|
|
1028
|
+
def _resolve_command_from_args(self, args: argparse.Namespace) -> ResolveResult:
|
|
1029
|
+
"""Walk the parsed args to find the leaf command, group, or nothing."""
|
|
921
1030
|
fmt = getattr(args, "format", "plain")
|
|
922
1031
|
|
|
923
|
-
# Check top-level command
|
|
924
1032
|
cmd_name = getattr(args, "_command", None)
|
|
925
1033
|
if not cmd_name:
|
|
926
|
-
return None, fmt
|
|
1034
|
+
return ResolvedNothing(attempted=None, fmt=fmt)
|
|
927
1035
|
|
|
928
|
-
#
|
|
1036
|
+
# Direct command?
|
|
929
1037
|
cmd = self.get_command(cmd_name)
|
|
930
1038
|
if cmd:
|
|
931
|
-
return cmd, fmt
|
|
1039
|
+
return ResolvedCommand(command=cmd, fmt=fmt)
|
|
932
1040
|
|
|
933
|
-
#
|
|
1041
|
+
# Group? Walk into it.
|
|
934
1042
|
group = self._groups.get(cmd_name)
|
|
935
1043
|
if group is None:
|
|
936
1044
|
resolved = self._group_alias_map.get(cmd_name)
|
|
937
1045
|
if resolved:
|
|
938
1046
|
group = self._groups.get(resolved)
|
|
939
1047
|
if group is None:
|
|
940
|
-
return
|
|
1048
|
+
return ResolvedNothing(attempted=cmd_name, fmt=fmt)
|
|
941
1049
|
|
|
942
|
-
return self._resolve_group_command(group, args, fmt)
|
|
1050
|
+
return self._resolve_group_command(group, args, fmt, prog=self.name)
|
|
943
1051
|
|
|
944
1052
|
def _resolve_group_command(
|
|
945
1053
|
self,
|
|
946
1054
|
group: Group,
|
|
947
1055
|
args: argparse.Namespace,
|
|
948
1056
|
fmt: str,
|
|
949
|
-
|
|
1057
|
+
prog: str = "",
|
|
1058
|
+
) -> ResolveResult:
|
|
950
1059
|
"""Recursively resolve a command within a group from parsed args."""
|
|
1060
|
+
group_prog = f"{prog} {group.name}".strip()
|
|
951
1061
|
sub_name = getattr(args, f"_command_{group.name}", None)
|
|
952
1062
|
if not sub_name:
|
|
953
|
-
return
|
|
1063
|
+
return ResolvedGroup(group=group, fmt=fmt, prog=group_prog)
|
|
954
1064
|
|
|
955
|
-
# Check if it's a command in this group
|
|
956
1065
|
cmd = group.get_command(sub_name)
|
|
957
1066
|
if cmd:
|
|
958
|
-
return cmd, fmt
|
|
1067
|
+
return ResolvedCommand(command=cmd, fmt=fmt)
|
|
959
1068
|
|
|
960
|
-
# Check if it's a nested sub-group
|
|
961
1069
|
sub_group = group.get_group(sub_name)
|
|
962
1070
|
if sub_group:
|
|
963
|
-
return self._resolve_group_command(sub_group, args, fmt)
|
|
1071
|
+
return self._resolve_group_command(sub_group, args, fmt, prog=group_prog)
|
|
964
1072
|
|
|
965
|
-
return
|
|
1073
|
+
return ResolvedNothing(attempted=sub_name, fmt=fmt)
|
|
966
1074
|
|
|
967
1075
|
def call(self, command_name: str, **kwargs: Any) -> Any:
|
|
968
1076
|
"""Programmatically call a command by name or dotted path.
|
|
@@ -67,6 +67,7 @@ class Group:
|
|
|
67
67
|
hidden: bool = False,
|
|
68
68
|
examples: tuple[dict[str, Any], ...] | list[dict[str, Any]] = (),
|
|
69
69
|
confirm: str = "",
|
|
70
|
+
display_result: bool = True,
|
|
70
71
|
) -> Callable:
|
|
71
72
|
"""Register a function as a command within this group."""
|
|
72
73
|
from milo.commands import _make_command_def
|
|
@@ -81,6 +82,7 @@ class Group:
|
|
|
81
82
|
hidden=hidden,
|
|
82
83
|
examples=tuple(examples),
|
|
83
84
|
confirm=confirm,
|
|
85
|
+
display_result=display_result,
|
|
84
86
|
)
|
|
85
87
|
self._commands[name] = cmd
|
|
86
88
|
for alias in aliases:
|
|
@@ -100,6 +102,10 @@ class Group:
|
|
|
100
102
|
aliases: tuple[str, ...] | list[str] = (),
|
|
101
103
|
tags: tuple[str, ...] | list[str] = (),
|
|
102
104
|
hidden: bool = False,
|
|
105
|
+
examples: tuple[dict[str, Any], ...] | list[dict[str, Any]] = (),
|
|
106
|
+
confirm: str = "",
|
|
107
|
+
annotations: dict[str, Any] | None = None,
|
|
108
|
+
display_result: bool = True,
|
|
103
109
|
) -> Any:
|
|
104
110
|
"""Register a lazy-loaded command within this group.
|
|
105
111
|
|
|
@@ -115,6 +121,10 @@ class Group:
|
|
|
115
121
|
aliases=aliases,
|
|
116
122
|
tags=tags,
|
|
117
123
|
hidden=hidden,
|
|
124
|
+
examples=examples,
|
|
125
|
+
confirm=confirm,
|
|
126
|
+
annotations=annotations,
|
|
127
|
+
display_result=display_result,
|
|
118
128
|
)
|
|
119
129
|
self._commands[name] = cmd
|
|
120
130
|
for alias in aliases:
|
|
@@ -181,6 +191,38 @@ class Group:
|
|
|
181
191
|
hidden=self.hidden,
|
|
182
192
|
)
|
|
183
193
|
|
|
194
|
+
def format_help(self, prog_prefix: str = "") -> str:
|
|
195
|
+
"""Render help from this group's command/group registries.
|
|
196
|
+
|
|
197
|
+
Writes rendered help to stdout and returns the output string.
|
|
198
|
+
"""
|
|
199
|
+
import sys
|
|
200
|
+
|
|
201
|
+
from milo.help import HelpState
|
|
202
|
+
from milo.templates import get_env
|
|
203
|
+
|
|
204
|
+
prog = f"{prog_prefix} {self.name}".strip() if prog_prefix else self.name
|
|
205
|
+
commands = tuple(
|
|
206
|
+
[
|
|
207
|
+
{"name": cmd.name, "help": getattr(cmd, "description", "")}
|
|
208
|
+
for cmd in self._commands.values()
|
|
209
|
+
if not getattr(cmd, "hidden", False)
|
|
210
|
+
]
|
|
211
|
+
+ [
|
|
212
|
+
{"name": g.name, "help": g.description}
|
|
213
|
+
for g in self._groups.values()
|
|
214
|
+
if not g.hidden
|
|
215
|
+
]
|
|
216
|
+
)
|
|
217
|
+
|
|
218
|
+
state = HelpState(prog=prog, description=self.description, commands=commands)
|
|
219
|
+
env = get_env()
|
|
220
|
+
template = env.get_template("help.kida")
|
|
221
|
+
output = template.render(state=state)
|
|
222
|
+
sys.stdout.write(output + "\n")
|
|
223
|
+
sys.stdout.flush()
|
|
224
|
+
return output
|
|
225
|
+
|
|
184
226
|
def walk_commands(self, prefix: str = ""):
|
|
185
227
|
"""Yield (dotted_path, CommandDef) for all commands in this tree."""
|
|
186
228
|
path_prefix = f"{prefix}{self.name}." if prefix else f"{self.name}."
|
|
@@ -17,6 +17,8 @@ class HelpState:
|
|
|
17
17
|
usage: str = ""
|
|
18
18
|
groups: tuple[dict[str, Any], ...] = ()
|
|
19
19
|
examples: tuple[dict[str, Any], ...] = ()
|
|
20
|
+
commands: tuple[dict[str, Any], ...] = ()
|
|
21
|
+
options: tuple[dict[str, Any], ...] = ()
|
|
20
22
|
|
|
21
23
|
|
|
22
24
|
class HelpRenderer(argparse.HelpFormatter):
|
|
@@ -6,6 +6,7 @@ import dataclasses
|
|
|
6
6
|
import enum
|
|
7
7
|
import functools
|
|
8
8
|
import inspect
|
|
9
|
+
import json
|
|
9
10
|
import re as _re
|
|
10
11
|
import types
|
|
11
12
|
import typing
|
|
@@ -161,6 +162,15 @@ def function_to_schema(func: Callable[..., Any]) -> dict[str, Any]:
|
|
|
161
162
|
properties[name] = prop
|
|
162
163
|
|
|
163
164
|
has_default = param.default is not inspect.Parameter.empty
|
|
165
|
+
if has_default and isinstance(
|
|
166
|
+
param.default, (str, int, float, bool, type(None), list, dict)
|
|
167
|
+
):
|
|
168
|
+
try:
|
|
169
|
+
json.dumps(param.default)
|
|
170
|
+
except TypeError, ValueError:
|
|
171
|
+
pass # non-serializable nested value — omit default
|
|
172
|
+
else:
|
|
173
|
+
prop["default"] = param.default
|
|
164
174
|
if not has_default and not is_optional:
|
|
165
175
|
required.append(name)
|
|
166
176
|
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
{{ state.prog | bold }}{% if state.description %} {{ "-" | dim }} {{ state.description }}{% endif %}
|
|
2
|
+
{%- if state.commands %}
|
|
3
|
+
|
|
4
|
+
{{ "commands" | yellow | bold }}:
|
|
5
|
+
{%- for cmd in state.commands %}
|
|
6
|
+
{{ cmd.name | cyan }} {{ cmd.help }}
|
|
7
|
+
{%- endfor %}
|
|
8
|
+
{%- endif %}
|
|
9
|
+
{%- for group in state.groups %}
|
|
10
|
+
|
|
11
|
+
{{ group.title | yellow | bold }}:
|
|
12
|
+
{%- for action in group.actions %}
|
|
13
|
+
{% if action.option_strings %}{{ action.option_strings | join(", ") | green }}{% if action.metavar %} {{ action.metavar | dim }}{% endif %} {{ action.help }}{% if action.default is not none and action.default != "==SUPPRESS==" and action.default is not false and action.default != 0 and action.default != "" %} {{ ("(default: " ~ action.default ~ ")") | dim }}{% endif %}{% else %}{{ action.dest | cyan }}{% if action.metavar %} {{ action.metavar | dim }}{% endif %} {{ action.help }}{% if action.default is not none and action.default != "==SUPPRESS==" and action.default is not false and action.default != 0 and action.default != "" %} {{ ("(default: " ~ action.default ~ ")") | dim }}{% endif %}{% endif %}
|
|
14
|
+
{%- endfor %}
|
|
15
|
+
{%- endfor %}
|
|
16
|
+
{%- if state.options %}
|
|
17
|
+
|
|
18
|
+
{{ "options" | yellow | bold }}:
|
|
19
|
+
{%- for opt in state.options %}
|
|
20
|
+
{{ opt.flags | green }}{% if opt.metavar %} {{ opt.metavar | dim }}{% endif %} {{ opt.help }}{% if opt.default %} {{ ("(default: " ~ opt.default ~ ")") | dim }}{% endif %}
|
|
21
|
+
{%- endfor %}
|
|
22
|
+
{%- endif %}
|
|
23
|
+
{%- if state.examples %}
|
|
24
|
+
|
|
25
|
+
{{ "examples" | yellow | bold }}:
|
|
26
|
+
{%- for ex in state.examples %}
|
|
27
|
+
{{ ("$ " ~ ex.command) | green }}{% if ex.description %}
|
|
28
|
+
{{ ex.description | dim }}{% endif %}
|
|
29
|
+
{%- endfor %}
|
|
30
|
+
{%- endif %}
|
|
@@ -992,14 +992,14 @@ class TestDebounceEffect:
|
|
|
992
992
|
yield Put(Action("DEBOUNCED_FIRE"))
|
|
993
993
|
|
|
994
994
|
def parent():
|
|
995
|
-
# First debounce — 0.
|
|
996
|
-
yield Debounce(seconds=0.
|
|
995
|
+
# First debounce — 0.2s
|
|
996
|
+
yield Debounce(seconds=0.2, saga=inner_saga)
|
|
997
997
|
# Wait a bit, then retrigger before first fires
|
|
998
998
|
yield Delay(seconds=0.05)
|
|
999
999
|
# Second debounce — resets the timer
|
|
1000
|
-
yield Debounce(seconds=0.
|
|
1000
|
+
yield Debounce(seconds=0.2, saga=inner_saga)
|
|
1001
1001
|
# Wait long enough for second to fire
|
|
1002
|
-
yield Delay(seconds=0.
|
|
1002
|
+
yield Delay(seconds=0.5)
|
|
1003
1003
|
|
|
1004
1004
|
def reducer(state, action):
|
|
1005
1005
|
actions.append(action.type)
|
|
@@ -1007,7 +1007,7 @@ class TestDebounceEffect:
|
|
|
1007
1007
|
|
|
1008
1008
|
store = Store(reducer, None)
|
|
1009
1009
|
store.run_saga(parent())
|
|
1010
|
-
time.sleep(0
|
|
1010
|
+
time.sleep(1.0)
|
|
1011
1011
|
store._executor.shutdown(wait=True)
|
|
1012
1012
|
|
|
1013
1013
|
# Should fire exactly once (first timer cancelled, second fires)
|
|
@@ -1460,10 +1460,10 @@ class TestEffectComposition:
|
|
|
1460
1460
|
def parent():
|
|
1461
1461
|
# Simulate: receive 3 rapid keys, debounce should fire once
|
|
1462
1462
|
for _ in range(3):
|
|
1463
|
-
yield Take("@@KEY", timeout=
|
|
1464
|
-
yield Debounce(seconds=0.
|
|
1463
|
+
yield Take("@@KEY", timeout=2.0)
|
|
1464
|
+
yield Debounce(seconds=0.5, saga=search_saga)
|
|
1465
1465
|
# Wait for debounce to fire
|
|
1466
|
-
yield Delay(seconds=0
|
|
1466
|
+
yield Delay(seconds=1.0)
|
|
1467
1467
|
|
|
1468
1468
|
def reducer(state, action):
|
|
1469
1469
|
actions.append(action.type)
|
|
@@ -1478,7 +1478,7 @@ class TestEffectComposition:
|
|
|
1478
1478
|
store.dispatch(Action("@@KEY", payload="b"))
|
|
1479
1479
|
time.sleep(0.02)
|
|
1480
1480
|
store.dispatch(Action("@@KEY", payload="c"))
|
|
1481
|
-
time.sleep(
|
|
1481
|
+
time.sleep(1.5)
|
|
1482
1482
|
store._executor.shutdown(wait=True)
|
|
1483
1483
|
|
|
1484
1484
|
# Debounce should have fired exactly once (last one)
|