Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions changelog.d/+lazy-cmd-defaults.fixed.md
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
Lazy commands now propagate function signature defaults to argparse. Schema defaults are JSON-safe, boolean schema defaults are respected, and a new `display_result=False` option suppresses plain-format output while preserving `--format json`.
8 changes: 4 additions & 4 deletions examples/lazyapp/app.py
Original file line number Diff line number Diff line change
Expand Up @@ -49,7 +49,7 @@ def status() -> dict:
"type": "object",
"properties": {
"target": {"type": "string"},
"dry_run": {"type": "boolean"},
"dry_run": {"type": "boolean", "default": False},
},
"required": ["target"],
},
Expand All @@ -63,7 +63,7 @@ def status() -> dict:
"type": "object",
"properties": {
"target": {"type": "string"},
"steps": {"type": "integer"},
"steps": {"type": "integer", "default": 1},
},
"required": ["target"],
},
Expand All @@ -77,8 +77,8 @@ def status() -> dict:
schema={
"type": "object",
"properties": {
"target": {"type": "string"},
"lines": {"type": "integer"},
"target": {"type": "string", "default": "production"},
"lines": {"type": "integer", "default": 20},
},
},
)
Expand Down
8 changes: 8 additions & 0 deletions src/milo/_command_defs.py
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,8 @@ class CommandDef:
"""If non-empty, prompt for confirmation before running."""
annotations: dict[str, Any] = field(default_factory=dict)
"""MCP tool annotations (readOnlyHint, destructiveHint, etc.)."""
display_result: bool = True
"""If False, suppress plain-format output (return value still available for --format json)."""


class LazyCommandDef:
Expand All @@ -80,6 +82,7 @@ class LazyCommandDef:
"annotations",
"confirm",
"description",
"display_result",
"examples",
"hidden",
"import_path",
Expand All @@ -100,6 +103,7 @@ def __init__(
examples: tuple[dict[str, Any], ...] | list[dict[str, Any]] = (),
confirm: str = "",
annotations: dict[str, Any] | None = None,
display_result: bool = True,
) -> None:
self.name = name
self.description = description
Expand All @@ -110,6 +114,7 @@ def __init__(
self.examples = tuple(examples)
self.confirm = confirm
self.annotations = annotations or {}
self.display_result = display_result
self._schema = schema
self._resolved: CommandDef | None = None
self._lock = threading.Lock()
Expand Down Expand Up @@ -159,6 +164,7 @@ def resolve(self) -> CommandDef:
examples=self.examples,
confirm=self.confirm,
annotations=self.annotations,
display_result=self.display_result,
)
return self._resolved

Expand All @@ -185,6 +191,7 @@ def _make_command_def(
examples: tuple[dict[str, Any], ...] = (),
confirm: str = "",
annotations: dict[str, Any] | None = None,
display_result: bool = True,
) -> CommandDef:
"""Build a CommandDef from a function and decorator kwargs."""
from milo.schema import function_to_schema
Expand All @@ -204,6 +211,7 @@ def _make_command_def(
examples=examples,
confirm=confirm,
annotations=annotations or {},
display_result=display_result,
)


Expand Down
43 changes: 30 additions & 13 deletions src/milo/commands.py
Original file line number Diff line number Diff line change
Expand Up @@ -220,6 +220,7 @@ def command(
examples: tuple[dict[str, Any], ...] | list[dict[str, Any]] = (),
confirm: str = "",
annotations: dict[str, Any] | None = None,
display_result: bool = True,
) -> Callable:
"""Register a function as a CLI command.

Expand All @@ -232,6 +233,8 @@ def command(
confirm: If set, prompt user with this message before executing.
annotations: MCP tool annotations (readOnlyHint, destructiveHint,
idempotentHint, openWorldHint).
display_result: If False, suppress plain-format output while still
returning data for ``--format json`` or ``--output``.
"""

def decorator(func: Callable[..., Any]) -> Callable[..., Any]:
Expand All @@ -245,6 +248,7 @@ def decorator(func: Callable[..., Any]) -> Callable[..., Any]:
examples=tuple(examples),
confirm=confirm,
annotations=annotations,
display_result=display_result,
)
self._commands[name] = cmd
for alias in aliases:
Expand All @@ -267,11 +271,16 @@ def lazy_command(
examples: tuple[dict[str, Any], ...] | list[dict[str, Any]] = (),
confirm: str = "",
annotations: dict[str, Any] | None = None,
display_result: bool = True,
) -> LazyCommandDef:
"""Register a lazy-loaded command.

The handler module is not imported until the command is invoked.
This keeps CLI startup fast for large command sets.

When providing a pre-computed *schema*, include ``"default"`` fields
in properties for optional parameters so argparse receives the correct
defaults without importing the handler module.
"""
cmd = LazyCommandDef(
name=name,
Expand All @@ -284,6 +293,7 @@ def lazy_command(
examples=examples,
confirm=confirm,
annotations=annotations,
display_result=display_result,
)
self._commands[name] = cmd
for alias in aliases:
Expand Down Expand Up @@ -704,11 +714,12 @@ def _add_arguments_from_schema(
# Determine type
json_type = param_schema.get("type", "string")
if json_type == "boolean":
default = (
param.default
if param and param.default is not inspect.Parameter.empty
else False
)
if param and param.default is not inspect.Parameter.empty:
default = param.default
elif "default" in param_schema:
default = param_schema["default"]
else:
default = False
kwargs["action"] = "store_true"
kwargs["default"] = default
elif json_type == "integer":
Expand All @@ -725,9 +736,11 @@ def _add_arguments_from_schema(
else:
kwargs["type"] = str

# Set default from signature if available
# Set default from signature or schema
if param and param.default is not inspect.Parameter.empty and json_type != "boolean":
kwargs["default"] = param.default
elif "default" not in kwargs and "default" in param_schema:
kwargs["default"] = param_schema["default"]
Comment on lines +739 to +743
Copy link

Copilot AI Apr 10, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Schema defaults are now applied for non-boolean params, but boolean params still compute default as False when the signature is unavailable (lazy / schema-only mode) because the boolean branch sets kwargs["default"] before the new schema-default fallback. This means a precomputed schema like {type: "boolean", default: true} (or a handler defaulting to True captured into schema) will still parse as False when omitted. Consider using param_schema.get("default", False) for booleans when param is missing, and ensure the chosen argparse action is compatible with the intended default.

Copilot uses AI. Check for mistakes.

# Required vs optional
if param_name in required_set and json_type != "boolean":
Expand Down Expand Up @@ -879,13 +892,17 @@ def run(self, argv: list[str] | None = None) -> Any:
ctx.error(f"after_command hook failed: {type(exc).__name__}: {exc}")

# Format and output (to file or stdout)
output_file = ctx.output_file
if output_file:
formatted = format_output(result, fmt=fmt)
with open(output_file, "w") as f:
f.write(formatted + "\n")
else:
write_output(result, fmt=fmt)
# When display_result=False, suppress plain-format stdout output but
# still honor explicit --format or --output requests.
suppress = not cmd.display_result and fmt == "plain" and not ctx.output_file
if not suppress:
output_file = ctx.output_file
if output_file:
formatted = format_output(result, fmt=fmt)
with open(output_file, "w") as f:
f.write(formatted + "\n")
else:
write_output(result, fmt=fmt)

return result

Expand Down
4 changes: 4 additions & 0 deletions src/milo/groups.py
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,7 @@ def command(
hidden: bool = False,
examples: tuple[dict[str, Any], ...] | list[dict[str, Any]] = (),
confirm: str = "",
display_result: bool = True,
) -> Callable:
"""Register a function as a command within this group."""
from milo.commands import _make_command_def
Expand All @@ -81,6 +82,7 @@ def decorator(func: Callable[..., Any]) -> Callable[..., Any]:
hidden=hidden,
examples=tuple(examples),
confirm=confirm,
display_result=display_result,
)
self._commands[name] = cmd
for alias in aliases:
Expand All @@ -100,6 +102,7 @@ def lazy_command(
aliases: tuple[str, ...] | list[str] = (),
tags: tuple[str, ...] | list[str] = (),
hidden: bool = False,
display_result: bool = True,
) -> Any:
"""Register a lazy-loaded command within this group.

Expand All @@ -115,6 +118,7 @@ def lazy_command(
aliases=aliases,
tags=tags,
hidden=hidden,
display_result=display_result,
)
self._commands[name] = cmd
for alias in aliases:
Expand Down
4 changes: 4 additions & 0 deletions src/milo/schema.py
Original file line number Diff line number Diff line change
Expand Up @@ -161,6 +161,10 @@ def function_to_schema(func: Callable[..., Any]) -> dict[str, Any]:
properties[name] = prop

has_default = param.default is not inspect.Parameter.empty
if has_default and isinstance(
param.default, (str, int, float, bool, type(None), list, dict)
):
prop["default"] = param.default
if not has_default and not is_optional:
Comment on lines 163 to 168
Copy link

Copilot AI Apr 10, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

function_to_schema() now copies param.default into the JSON schema. Function defaults can be arbitrary Python objects (e.g., Enum members, Path objects, dataclass instances), which can make cmd.schema non-JSON-serializable and cause --mcp (e.g. tools/list) to crash when responses are serialized with json.dumps (no default= handler). Consider normalizing defaults to JSON-compatible values (Enum -> .value, Path -> str, dataclass -> asdict), or omitting/encoding non-serializable defaults in the schema while still keeping Python defaults for argparse/runtime dispatch.

Copilot uses AI. Check for mistakes.
required.append(name)

Expand Down
151 changes: 151 additions & 0 deletions tests/test_lazy.py
Original file line number Diff line number Diff line change
Expand Up @@ -296,6 +296,157 @@ def test_lazy_in_group_run(self):
assert result == 30


# ---------------------------------------------------------------------------
# Lazy commands: default value propagation
# ---------------------------------------------------------------------------


class TestLazyDefaults:
def test_lazy_command_uses_signature_defaults(self):
"""Lazy commands should use function defaults when args are omitted."""
cli = CLI(name="app")
cli.lazy_command(
"add",
"_lazy_handlers:add",
description="Add numbers",
)
# b has default=0 in the handler; omitting --b should use 0, not None
result = cli.run(["add", "--a", "5"])
assert result == 5

def test_lazy_command_precomputed_schema_with_defaults(self):
"""Pre-computed schemas with 'default' fields should propagate."""
cli = CLI(name="app")
cli.lazy_command(
"add",
"_lazy_handlers:add",
description="Add numbers",
schema={
"type": "object",
"properties": {
"a": {"type": "integer"},
"b": {"type": "integer", "default": 0},
},
"required": ["a"],
},
)
result = cli.run(["add", "--a", "5"])
assert result == 5

def test_lazy_command_bool_default_false(self):
"""Boolean defaults should work for lazy commands."""
cli = CLI(name="app")
cli.lazy_command(
"greet",
"_lazy_handlers:greet",
description="Say hello",
)
result = cli.run(["greet", "--name", "World"])
assert result == "Hello, World!"

def test_lazy_command_bool_default_override(self):
"""Boolean flags should be overridable for lazy commands."""
cli = CLI(name="app")
cli.lazy_command(
"greet",
"_lazy_handlers:greet",
description="Say hello",
)
result = cli.run(["greet", "--name", "World", "--loud"])
assert result == "HELLO, WORLD!"

def test_schema_defaults_are_json_serializable(self):
"""function_to_schema() should only store JSON-safe defaults."""
import json

from milo.schema import function_to_schema

def handler(name: str, count: int = 5, flag: bool = True) -> str:
return ""

schema = function_to_schema(handler)
# Should not raise
json.dumps(schema)
assert schema["properties"]["count"]["default"] == 5
assert schema["properties"]["flag"]["default"] is True

def test_schema_omits_non_serializable_defaults(self):
"""Non-JSON-serializable defaults should be omitted from schema."""
import json
from enum import Enum
from pathlib import Path

from milo.schema import function_to_schema

class Color(Enum):
RED = "red"
BLUE = "blue"

def handler(output: Path = Path("."), color: Color = Color.RED) -> str:
return ""

schema = function_to_schema(handler)
# Should not raise
json.dumps(schema)
# Non-serializable defaults should NOT be in the schema
assert "default" not in schema["properties"]["output"]
assert "default" not in schema["properties"]["color"]


# ---------------------------------------------------------------------------
# display_result suppression
# ---------------------------------------------------------------------------


class TestDisplayResult:
def test_display_result_false_suppresses_plain(self):
"""display_result=False suppresses plain stdout output."""
cli = CLI(name="app")

@cli.command("info", display_result=False)
def info() -> dict:
return {"status": "ok", "count": 42}

result = cli.invoke(["info"])
assert result.output == ""
assert result.result == {"status": "ok", "count": 42}

def test_display_result_false_allows_json(self):
"""display_result=False still outputs with --format json."""
cli = CLI(name="app")

@cli.command("info", display_result=False)
def info() -> dict:
return {"status": "ok"}

result = cli.invoke(["info", "--format", "json"])
assert '"status"' in result.output

def test_display_result_true_default(self):
"""By default, display_result=True and output is shown."""
cli = CLI(name="app")

@cli.command("info")
def info() -> str:
return "hello"

result = cli.invoke(["info"])
assert "hello" in result.output

def test_lazy_display_result_false(self):
"""Lazy commands support display_result=False."""
cli = CLI(name="app")
cli.lazy_command(
"add",
"_lazy_handlers:add",
description="Add numbers",
display_result=False,
)
result = cli.invoke(["add", "--a", "3", "--b", "7"])
assert result.output == ""
assert result.result == 10


# ---------------------------------------------------------------------------
# MCP with lazy commands
# ---------------------------------------------------------------------------
Expand Down
Loading