Skip to content

feat: lazy command loading for faster CLI startup#261

Merged
tomaz-lc merged 3 commits intocli-v2from
feat/lazy-command-loading
Mar 20, 2026
Merged

feat: lazy command loading for faster CLI startup#261
tomaz-lc merged 3 commits intocli-v2from
feat/lazy-command-loading

Conversation

@tomaz-lc
Copy link
Copy Markdown
Contributor

@tomaz-lc tomaz-lc commented Mar 19, 2026

Details

Every CLI invocation currently pays the cost of eagerly importing all 49 command modules at startup, even when only one command is being run. This pulls in the full SDK, requests, yaml, ssl, and other heavy dependencies before any command logic executes.

This PR replaces eager auto-discovery with lazy command loading. A static _COMMAND_MODULE_MAP maps Click command names to their module paths, allowing list_commands() to return all names without importing anything, and get_command() to import only the specific module needed. Additionally, limacharlie.output (which pulls in jmespath, tabulate, yaml, csv) is deferred from module-level to the cli() callback, avoiding ~14ms of import overhead on fast paths. This benefits every CLI operation:

  • Single commands (sensor list, auth whoami) - only import the one module needed
  • Help (--help, --ai-help) - subcommand help only imports the target module
  • Shell completion (eval "$(limacharlie completion bash)") - faster shell startup when using eval
  • Tab completion - faster response on each TAB press
  • --version - no command modules imported at all

Additionally, __version__ is now imported directly from _version (generated by setuptools-scm) instead of from client.py, which avoids pulling in ssl, yaml, urllib, and config just to read a version string. This also fixes a bug in the published 5.1.0 where limacharlie.__version__ returned 0.0.0.dev0 instead of the real version.

How it works

The _GlobalOptionsGroup class is replaced with _LazyCommandGroup which combines:

  1. Lazy loading via static map: A hardcoded dict maps each Click command name (e.g. "external-adapter") to its module name and attribute (e.g. ("adapter", "group")). This is necessary because the mapping is not derivable from filenames alone.

  2. Global option hoisting: Same behavior as before - --oid, --output, etc. can appear anywhere on the command line.

  3. Deferred --ai-help injection: Instead of recursively walking all commands at import time, --ai-help is injected per-command on first access via get_command().

  4. Deferred limacharlie.output import: The output module (jmespath, tabulate, yaml, csv) is imported inside the cli() callback instead of at module level, avoiding the cost on fast paths like --help, --version, and --ai-help.

When a new command module is added, _COMMAND_MODULE_MAP must be updated. Lint tests in test_cli_command_map_lint.py catch this at CI time with a developer-friendly error that includes the exact line to add.

CI improvements

  • Version consistency checks added to all 7 dist steps (Python 3.9-3.14 wheels + sdist). Each step now creates a local-only dummy git tag so setuptools-scm generates a real version, then verifies importlib.metadata.version() matches limacharlie.__version__ and that __version__ is not the fallback 0.0.0.dev0.
  • PyPI stable sanity check - new Cloud Build step that installs the latest stable release from PyPI (not the PR branch) and verifies it works: package imports, CLI entry point, --help, SDK core imports, 8 key command groups, and completion generation. Catches packaging regressions in published releases.

Performance

End-to-end subprocess measurements (cold start, pytest-benchmark, min 10 rounds):

Operation Before (eager) After (lazy) Improvement
import limacharlie.cli (in-process) 2.2ms 1.2ms 47% faster
limacharlie --version (e2e) 114ms 55ms 52% faster
Unknown command error (e2e) 114ms 48ms 58% faster
limacharlie sensor list --help (e2e) 220ms 105ms 52% faster
limacharlie --help (e2e) 537ms 176ms 67% faster
limacharlie --ai-help (e2e) 481ms 205ms 57% faster

All operations are faster, including top-level --help and --ai-help which load all modules. The deferred limacharlie.output import accounts for a significant portion of the improvement.

Bug fixes included

  • --ai-help showed zero command groups: _top_level_help() iterated cli.commands directly (empty with lazy loading) instead of using list_commands() + get_command(). Fixed.
  • Silent failure on broken command modules: _import_command() now always emits a one-line stderr warning when a module fails to load, not just with LC_DEBUG. Full traceback still requires LC_DEBUG.
  • Dead imports removed: pkgutil and field (from dataclasses) in cli.py were leftovers from the removed _auto_discover_commands().
  • Weak test assertion: assert "--oid" in result.output or result.exit_code == 0 was always true due to the or.

Trade-offs / potential drawbacks

  1. _COMMAND_MODULE_MAP must be manually updated when adding/removing/renaming command modules. If a developer forgets, the new command silently won't appear at runtime. Mitigated: lint tests in test_cli_command_map_lint.py scan the filesystem and fail CI with a developer-friendly message that includes the exact map entry to add (copy-paste ready).

  2. Broken command modules surface at invocation time, not startup. With eager loading a syntax error in any module fails immediately. With lazy loading it only fails when that command is run. Mitigated: test_static_map_all_importable imports every module in CI, the existing unit test suite exercises all commands, and a stderr warning is now always emitted for broken modules.

  3. cli.commands dict is no longer populated at import time. Code that directly accesses cli.commands["sensor"] instead of using Click's get_command() API will see an empty dict until commands are accessed. The proper Click API works fine.

  4. Import order side effects with register_explain(). Command modules call register_explain() at import time to populate the explain registry. With lazy loading, the registry is only populated for commands that have been imported. In practice this only matters for --ai-help which always goes through get_command() first, so it works correctly.

Blast radius / isolation

  • limacharlie/cli.py - _GlobalOptionsGroup replaced with _LazyCommandGroup, _auto_discover_commands() removed, inject_ai_help(cli) replaced with per-command injection, limacharlie.output import deferred to callback
  • limacharlie/ai_help.py - _top_level_help() uses list_commands() + get_command() instead of cli.commands dict
  • limacharlie/__init__.py - __version__ imported from _version instead of client.py (fixes 0.0.0.dev0 bug)
  • cloudbuild_pr.yaml - version consistency checks added to all dist steps, new PyPI sanity check step
  • No changes to any command module or SDK code
  • All existing unit tests pass unchanged

Notable contracts / APIs

  • _COMMAND_MODULE_MAP is a new static dict that must be kept in sync when adding/removing command modules. Lint tests enforce this.
  • _GlobalOptionsGroup is renamed to _LazyCommandGroup. Any code referencing the old name (unlikely outside cli.py) would need updating.
  • External behavior is identical - all commands, options, help text, and completion output are unchanged.

Test plan

  • 839 regression tests covering full CLI surface (command registration, subcommand structure, module mapping, global options, --help for every command, --ai-help injection/output, explain registry, discovery profiles, completion, context propagation, lazy loading behavior)
  • 52 lint tests validating _COMMAND_MODULE_MAP completeness, correctness, and sync with filesystem
  • 25 benchmark/e2e tests (subprocess benchmarks, in-process benchmarks, e2e regression tests in fresh processes, import isolation tests)
  • Regression tests for --ai-help command group listing, broken module warnings, import hygiene, and deferred output import
  • Version consistency checks in all 7 Cloud Build dist steps
  • PyPI stable sanity check in Cloud Build
  • All 2818 existing unit tests pass unchanged

Related PRs

🤖 Generated with Claude Code

@tomaz-lc tomaz-lc force-pushed the feat/lazy-command-loading branch 5 times, most recently from 16dd979 to 9228db8 Compare March 19, 2026 11:43
Replace eager auto-discovery with lazy command loading using a static
command-to-module map. Command modules are only imported when the
specific command is invoked, not at CLI import time.

Key changes:
- _GlobalOptionsGroup replaced with _LazyCommandGroup that combines
  lazy loading with global option hoisting
- Static _COMMAND_MODULE_MAP provides O(1) command name to module
  resolution without importing any command modules
- list_commands() returns names from the static map (no imports)
- get_command() imports only the requested module on first access
- --ai-help injection deferred to per-command first access
- Import __version__ from _version directly instead of client.py
  to avoid pulling in ssl, yaml, urllib at import time

Performance (end-to-end subprocess, cold start):
- limacharlie --version: ~55ms (was ~280ms)
- limacharlie sensor list --help: ~67ms (was ~265ms)
- limacharlie completion bash: ~58ms (was ~280ms)
- limacharlie --help (all commands): ~134ms (loads all modules)

Tests:
- 816 regression tests covering full CLI surface: command registration,
  subcommand structure, module mapping, global options, --help for every
  command, --ai-help injection and output, explain registry, discovery
  profiles, completion, and context propagation
- End-to-end subprocess benchmarks and regression tests
- In-process microbenchmarks for import, help, completion, resolution

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
@tomaz-lc tomaz-lc force-pushed the feat/lazy-command-loading branch from 9228db8 to ae1147f Compare March 19, 2026 11:46
@tomaz-lc tomaz-lc marked this pull request as ready for review March 19, 2026 11:51
@tomaz-lc tomaz-lc requested a review from maximelb March 19, 2026 11:51
Copy link
Copy Markdown
Contributor

@maximelb maximelb left a comment

Choose a reason for hiding this comment

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

@tomaz-lc Thorough review below. One confirmed bug that needs fixing before merge.


Bug: limacharlie --ai-help shows zero command groups in fresh process

Severity: High — user-visible breakage of an existing feature.

_top_level_help() in ai_help.py:125 iterates cli.commands (the eagerly-populated dict) directly:

for name in sorted(cli.commands):
    sub = cli.commands[name]

With lazy loading, cli.commands is empty until get_command() is called. Confirmed locally:

$ python -c "
from limacharlie.cli import cli
from click.testing import CliRunner
r = CliRunner().invoke(cli, ['--ai-help'])
section = r.output.split('## All Command Groups')
if len(section) > 1:
    groups_section = section[1].split('## ')[0]
    group_lines = [l for l in groups_section.strip().split('\n') if l.startswith('- **')]
    print(f'Number of command groups listed: {len(group_lines)}')
"
# Output: Number of command groups listed: 0

Meanwhile, --help works fine because Click's format_help() goes through list_commands() + get_command().

Why tests don't catch it: test_cli_lazy_loading_regression.py:36-38 eagerly loads all commands at module import time before any tests run. The e2e test (test_e2e_ai_help_works) only asserts "LimaCharlie CLI" in result.stdout — that string is in the header, not the command list.

Fix: _top_level_help() should use cli.list_commands(ctx) + cli.get_command(ctx, name) instead of accessing cli.commands directly. Or the show_ai_help callback could trigger a full load before rendering.


Minor Issues

1. Unused import pkgutilcli.py:17

Was used by the removed _auto_discover_commands(). Dead import now.

2. Unused field importcli.py:19

from dataclasses import dataclass, field

field is never used.

3. Weak test assertiontest_cli_lazy_loading_regression.py:370

assert "--oid" in result.output or result.exit_code == 0

The or makes this always true since exit_code == 0 was already asserted on line 369.

4. Silent failure on broken command modules

_import_command() catches all exceptions and returns None unless LC_DEBUG is set. A module with a syntax error silently vanishes — user sees "No such command 'foo'" with no hint the module exists but failed to load. Consider at minimum a one-line stderr warning even without LC_DEBUG.

5. Regression tests don't test lazy loading in realistic conditions

The eager loading at test_cli_lazy_loading_regression.py:36-38:

_ctx = click.Context(cli)
for _name in cli.list_commands(_ctx):
    cli.get_command(_ctx, _name)

...defeats lazy loading for all tests below it. This is why the --ai-help bug slipped through. The TestAiHelpOutput tests run against a fully-loaded CLI. Consider having the --ai-help e2e test actually verify command groups appear in the output from a fresh subprocess.

6. Duplicated CI bash blocks in cloudbuild_pr.yaml

The 7 dist steps are nearly identical ~30-line bash blocks. A shared shell script taking the Python version as argument would cut ~200 lines of duplication and make maintenance easier. Not a blocker.


What's Good

  • _COMMAND_MODULE_MAP approach is clean; the lint tests in test_cli_command_map_lint.py give excellent DX (tells developer exactly what line to add).
  • __version__ fix (importing from _version instead of client.py) is a real improvement for both startup perf and correctness.
  • Option hoisting + shadowed-opts detection logic is carefully preserved.
  • CI version consistency checks and PyPI stable sanity check are smart permanent additions.

Bottom line: fix the --ai-help bug and add an e2e test that verifies command groups appear in the output from a fresh process, then this is good to go.

tomaz-lc and others added 2 commits March 20, 2026 10:41
_top_level_help() iterated cli.commands directly which is empty with
lazy loading. Use list_commands() + get_command() instead, matching
how Click itself resolves commands for --help.

Also: remove dead pkgutil/field imports from cli.py, always emit
stderr warning for broken command modules (not just with LC_DEBUG),
fix weak test assertion, and add regression tests + microbenchmarks
for --ai-help.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
limacharlie.output pulls in jmespath, tabulate, yaml, and csv which
adds significant import overhead. Deferring the import from module
level into the cli() callback avoids this cost on fast paths like
--help, --version, and --ai-help that never render command output.

Benchmark results (e2e subprocess, cold process):
- cli import: 2.2ms -> 1.2ms (47% faster)
- --version: 114ms -> 55ms (52% faster)
- --help: 537ms -> 176ms (67% faster)
- --ai-help: 481ms -> 205ms (57% faster)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
@tomaz-lc
Copy link
Copy Markdown
Contributor Author

@maximelb Thanks for the review.

The bug has been fixed and other things have been improved in adb68ac.

@tomaz-lc tomaz-lc merged commit 8331fe7 into cli-v2 Mar 20, 2026
1 check passed
@tomaz-lc tomaz-lc deleted the feat/lazy-command-loading branch March 20, 2026 17:16
tomaz-lc added a commit that referenced this pull request Mar 20, 2026
- Add config_cmd to _COMMAND_MODULE_MAP in cli.py (missing after #257
  and #261 were merged independently)
- Add config to EXPECTED_TOP_LEVEL_COMMANDS, EXPECTED_MODULE_MAP, and
  EXPECTED_SUBCOMMANDS in regression tests
- Fix test_cli_import_does_not_load_output to handle third-party deps
  already loaded by other tests in the same pytest process
- Add ci.yml GHA workflow that runs unit tests and dist checks on every
  push and PR

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
tomaz-lc added a commit that referenced this pull request Mar 20, 2026
- Add config_cmd to _COMMAND_MODULE_MAP in cli.py (missing after #257
  and #261 were merged independently)
- Add config to EXPECTED_TOP_LEVEL_COMMANDS, EXPECTED_MODULE_MAP, and
  EXPECTED_SUBCOMMANDS in regression tests
- Fix test_cli_import_does_not_load_output to handle third-party deps
  already loaded by other tests in the same pytest process
- Add ci.yml GHA workflow that runs unit tests and dist checks on every
  push and PR

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
tomaz-lc added a commit that referenced this pull request Mar 20, 2026
- Add config_cmd to _COMMAND_MODULE_MAP in cli.py (missing after #257
  and #261 were merged independently)
- Add config to EXPECTED_TOP_LEVEL_COMMANDS, EXPECTED_MODULE_MAP, and
  EXPECTED_SUBCOMMANDS in regression tests
- Fix test_cli_import_does_not_load_output to handle third-party deps
  already loaded by other tests in the same pytest process
- Add ci.yml GHA workflow that runs unit tests and dist checks on every
  push and PR

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
tomaz-lc added a commit that referenced this pull request Mar 20, 2026
- Add config_cmd to _COMMAND_MODULE_MAP in cli.py (missing after #257
  and #261 were merged independently)
- Add config to EXPECTED_TOP_LEVEL_COMMANDS, EXPECTED_MODULE_MAP, and
  EXPECTED_SUBCOMMANDS in regression tests
- Fix test_cli_import_does_not_load_output to handle third-party deps
  already loaded by other tests in the same pytest process
- Add ci.yml GHA workflow that runs unit tests and dist checks on every
  push and PR

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
tomaz-lc added a commit that referenced this pull request Mar 20, 2026
- Add config_cmd to _COMMAND_MODULE_MAP in cli.py (missing after #257
  and #261 were merged independently)
- Add config to EXPECTED_TOP_LEVEL_COMMANDS, EXPECTED_MODULE_MAP, and
  EXPECTED_SUBCOMMANDS in regression tests
- Fix test_cli_import_does_not_load_output to handle third-party deps
  already loaded by other tests in the same pytest process
- Add ci.yml GHA workflow that runs unit tests and dist checks on every
  push and PR

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
tomaz-lc added a commit that referenced this pull request Mar 20, 2026
- Add config_cmd to _COMMAND_MODULE_MAP in cli.py (missing after #257
  and #261 were merged independently)
- Add config to EXPECTED_TOP_LEVEL_COMMANDS, EXPECTED_MODULE_MAP, and
  EXPECTED_SUBCOMMANDS in regression tests
- Fix test_cli_import_does_not_load_output to handle third-party deps
  already loaded by other tests in the same pytest process
- Add ci.yml GHA workflow that runs unit tests and dist checks on every
  push and PR

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
tomaz-lc added a commit that referenced this pull request Mar 20, 2026
- Add config_cmd to _COMMAND_MODULE_MAP in cli.py (missing after #257
  and #261 were merged independently)
- Add config to EXPECTED_TOP_LEVEL_COMMANDS, EXPECTED_MODULE_MAP, and
  EXPECTED_SUBCOMMANDS in regression tests
- Fix test_cli_import_does_not_load_output to handle third-party deps
  already loaded by other tests in the same pytest process
- Add ci.yml GHA workflow that runs unit tests and dist checks on every
  push and PR

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
tomaz-lc added a commit that referenced this pull request Mar 20, 2026
…265)

- Add config_cmd to _COMMAND_MODULE_MAP in cli.py (missing after #257
  and #261 were merged independently)
- Add config to EXPECTED_TOP_LEVEL_COMMANDS, EXPECTED_MODULE_MAP, and
  EXPECTED_SUBCOMMANDS in regression tests
- Fix test_cli_import_does_not_load_output to handle third-party deps
  already loaded by other tests in the same pytest process
- Add ci.yml GHA workflow that runs unit tests and dist checks on every
  push and PR

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants