Lazy Loading

Deferred command imports for fast CLI startup with large command sets.

3 min read 510 words

Lazy loading defers command handler imports until Milo needs that command's schema or handler. Root help, group help, and another selected command do not resolve lazy siblings, so large command trees keep cheap interactions cheap.

lazy_command

Register a command with a dotted import path instead of a function reference:

from milo import CLI

cli = CLI(name="myapp")

cli.lazy_command(
    "deploy",
    "myapp.commands.deploy:deploy_handler",
    description="Deploy to an environment",
)

The module myapp.commands.deployis not imported while Milo renders root or group help, or while another command runs. Leaf help and execution resolve only the selected leaf.

Pre-computed schemas

By default, the schema is generated by importing the handler and inspecting its signature. To avoid even that import, provide the schema upfront:

cli.lazy_command(
    "deploy",
    "myapp.commands.deploy:deploy_handler",
    description="Deploy to an environment",
    schema={
        "type": "object",
        "properties": {
            "target": {"type": "string"},
            "dry_run": {"type": "boolean"},
        },
        "required": ["target"],
    },
)

With a pre-computed schema, --llms-txt and --mcp tools/listwork without importing any handler modules.

Theschema=value is ordinary JSON Schema and is the stable cache format. An application may keep it in Python as above or load a persisted.jsonfile:

import json
from importlib.resources import files

deploy_schema = json.loads(
    files("myapp.schemas").joinpath("deploy.json").read_text(encoding="utf-8")
)

cli.lazy_command(
    "deploy",
    "myapp.commands.deploy:deploy_handler",
    schema=deploy_schema,
)

Milo retains the supplied object on LazyCommandDef; no second schema source or Milo-specific cache encoding is involved. Keep the cached schema beside the annotated handler and test it againstfunction_to_schema()when the handler's contract changes.

CLI presentation can also be pre-computed with the same schema extension:

{
  "type": "object",
  "properties": {
    "target": {
      "type": "string",
      "x-milo-cli": {"kind": "positional", "metavar": "TARGET"}
    }
  },
  "required": ["target"]
}

Lazy commands in groups

Groups support lazy loading too:

site = cli.group("site", description="Site operations")

site.lazy_command(
    "build",
    "myapp.commands.site:build_handler",
    description="Build the site",
)

How it works

LazyCommandDefstores the import path and defers resolution:

  1. On registration, only the name, description, and optional schema are stored
  2. On first invocation,resolve()imports the module and extracts the handler
  3. The result is cached as a fullCommandDef— subsequent calls skip the import

Resolution is thread-safe (uses a lock with double-check pattern).

Ifschema=is omitted, root and group help remain metadata-only, but selected leaf help/parser construction imports that leaf to derive its schema. Full-tree discovery (--llms-txt, completions, and MCP tools/list) likewise resolves each lazy command that has no pre-computed schema. Supplying schemas makes that fallback explicit and removes those imports.

If the module or attribute cannot be imported, terminal invocation exits1 withM-CMD-004. call() and call_raw()raise the same structured MiloError; MCP returns its reason, command, importPath, and suggestion inerrorData.

When to use lazy loading

  • CLIs with many commands where only one runs per invocation
  • Commands that import heavy dependencies (cloud SDKs, ML libraries)
  • Plugin systems where third-party command modules may not be installed

For small CLIs with lightweight imports, the@cli.commanddecorator is simpler and equally fast.

Tip

Combine pre-computed schemas with--llms-txt and --mcpto let AI agents discover all your commands without triggering any imports.