diff --git a/emdx/main.py b/emdx/main.py index 4b5c974..d34b392 100644 --- a/emdx/main.py +++ b/emdx/main.py @@ -1,6 +1,11 @@ #!/usr/bin/env python3 """ Main CLI entry point for emdx + +This module uses lazy loading for heavy commands to improve startup performance. +Core KB commands (save, find, view, tag, list) are imported eagerly since they're +fast. Heavy commands (workflow, cascade, each, ai, gui) are only imported when +actually invoked. """ import os @@ -8,30 +13,45 @@ import typer from emdx import __build_id__, __version__ -from emdx.commands.analyze import app as analyze_app -from emdx.commands.browse import app as browse_app -from emdx.commands.claude_execute import app as claude_app -from emdx.commands.core import app as core_app -from emdx.commands.executions import app as executions_app -from emdx.commands.export import app as export_app -from emdx.commands.export_profiles import app as export_profiles_app -from emdx.commands.gdoc import app as gdoc_app -from emdx.commands.gist import app as gist_app -from emdx.commands.lifecycle import app as lifecycle_app -from emdx.commands.maintain import app as maintain_app -from emdx.commands.tags import app as tag_app -from emdx.commands.tasks import app as tasks_app -from emdx.commands.workflows import app as workflows_app -from emdx.commands.keybindings import app as keybindings_app -from emdx.commands.run import run as run_command -from emdx.commands.agent import agent as agent_command -from emdx.commands.groups import app as groups_app -from emdx.commands.ask import app as ask_app -from emdx.commands.each import app as each_app -from emdx.commands.cascade import app as cascade_app -from emdx.commands.prime import prime as prime_command -from emdx.commands.status import status as status_command -from emdx.ui.gui import gui +from emdx.utils.lazy_group import LazyTyperGroup, register_lazy_commands +from emdx.utils.output import console + +# ============================================================================= +# LAZY COMMANDS - Heavy features (defer import until invoked) +# ============================================================================= +# Format: "command_name": "module.path:object_name" +# IMPORTANT: Register BEFORE any Typer app creation +LAZY_SUBCOMMANDS = { + # Execution/orchestration (imports subprocess, async, executor) + "workflow": "emdx.commands.workflows:app", + "cascade": "emdx.commands.cascade:app", + "each": "emdx.commands.each:app", + "run": "emdx.commands.run:run", + "agent": "emdx.commands.agent:agent", + "claude": "emdx.commands.claude_execute:app", + # AI features (imports ML libraries, can be slow) + "ai": "emdx.commands.ask:app", + # Similarity (imports scikit-learn) + "similar": "emdx.commands.similarity:app", + # External services (imports google API libs) + "gdoc": "emdx.commands.gdoc:app", + # TUI (imports textual, can be slow) + "gui": "emdx.ui.gui:gui", +} + +# Pre-computed help strings so --help doesn't trigger imports +LAZY_HELP = { + "workflow": "Manage and run multi-stage workflows", + "cascade": "Cascade ideas through stages to working code", + "each": "Create and run reusable parallel commands", + "run": "Quick task execution (parallel, worktree isolation)", + "agent": "Run Claude sub-agent with EMDX tracking", + "claude": "Execute documents with Claude", + "ai": "AI-powered Q&A and semantic search", + "similar": "Find similar documents using TF-IDF", + "gdoc": "Google Docs integration", + "gui": "Launch interactive TUI browser", +} def is_safe_mode() -> bool: @@ -47,6 +67,22 @@ def is_safe_mode() -> bool: UNSAFE_COMMANDS = {"cascade", "run", "each", "agent", "workflow", "claude"} +def get_lazy_subcommands() -> dict[str, str]: + """Get lazy subcommands, with safe mode commands excluded.""" + if is_safe_mode(): + # In safe mode, exclude unsafe commands from lazy loading + # They'll be added as disabled commands eagerly instead + return {k: v for k, v in LAZY_SUBCOMMANDS.items() if k not in UNSAFE_COMMANDS} + return LAZY_SUBCOMMANDS + + +def get_lazy_help() -> dict[str, str]: + """Get lazy help strings, filtering for safe mode.""" + if is_safe_mode(): + return {k: v for k, v in LAZY_HELP.items() if k not in UNSAFE_COMMANDS} + return LAZY_HELP + + def create_disabled_command(name: str): """Create a command that shows a disabled message in safe mode.""" def disabled_command(): @@ -60,67 +96,75 @@ def disabled_command(): disabled_command.__doc__ = f"[DISABLED in safe mode] Execute {name} operations" return disabled_command -# Create main app + +# Register lazy commands BEFORE importing any Typer apps +# This ensures the registry is populated when LazyTyperGroup is instantiated +register_lazy_commands(get_lazy_subcommands(), get_lazy_help()) + +# ============================================================================= +# EAGER IMPORTS - Core KB commands (fast, always needed) +# ============================================================================= +from emdx.commands.core import app as core_app +from emdx.commands.browse import app as browse_app +from emdx.commands.tags import app as tag_app +from emdx.commands.executions import app as executions_app +from emdx.commands.lifecycle import app as lifecycle_app +from emdx.commands.tasks import app as tasks_app +from emdx.commands.groups import app as groups_app +from emdx.commands.export import app as export_app +from emdx.commands.export_profiles import app as export_profiles_app +from emdx.commands.keybindings import app as keybindings_app +from emdx.commands.prime import prime as prime_command +from emdx.commands.status import status as status_command +from emdx.commands.analyze import app as analyze_app +from emdx.commands.maintain import app as maintain_app +from emdx.commands.gist import app as gist_app + + +# Create main app with lazy loading support app = typer.Typer( name="emdx", help="Documentation Index Management System - A powerful knowledge base for developers", add_completion=True, rich_markup_mode="rich", + cls=LazyTyperGroup, ) -# Add subcommand groups -# Core commands are added directly to the main app +# We need to set these after creation because Typer's __init__ doesn't pass them through +# to the underlying Click group properly +app_info = app.info +app_info.cls = LazyTyperGroup + + +# ============================================================================= +# Register eager commands +# ============================================================================= + +# Core commands (save, find, view, edit, delete, etc.) for command in core_app.registered_commands: app.registered_commands.append(command) -# Browse commands are added directly to the main app +# Browse commands (list, recent, stats) for command in browse_app.registered_commands: app.registered_commands.append(command) -# Gist commands are added directly to the main app +# Gist commands for command in gist_app.registered_commands: app.registered_commands.append(command) -# Google Docs commands are added directly to the main app -for command in gdoc_app.registered_commands: - app.registered_commands.append(command) - -# Tag commands are added directly to the main app +# Tag commands for command in tag_app.registered_commands: app.registered_commands.append(command) # Add executions as a subcommand group app.add_typer(executions_app, name="exec", help="Manage Claude executions") -# Add claude execution as a subcommand group (disabled in safe mode) -if is_safe_mode(): - disabled_claude_app = typer.Typer() - disabled_claude_app.command(name="execute")(create_disabled_command("claude")) - app.add_typer(disabled_claude_app, name="claude", help="[DISABLED] Execute documents with Claude") -else: - app.add_typer(claude_app, name="claude", help="Execute documents with Claude") - -# Add the new unified analyze command -app.command(name="analyze")(analyze_app.registered_commands[0].callback) - -# Add the new unified maintain command -app.command(name="maintain")(maintain_app.registered_commands[0].callback) - -# Add lifecycle as a subcommand group (keeping this as-is) +# Add lifecycle as a subcommand group app.add_typer(lifecycle_app, name="lifecycle", help="Track document lifecycles") # Add tasks as a subcommand group app.add_typer(tasks_app, name="task", help="Task management") -# Add workflows as a subcommand group (disabled in safe mode) -if is_safe_mode(): - disabled_workflow_app = typer.Typer() - disabled_workflow_app.command(name="run")(create_disabled_command("workflow")) - disabled_workflow_app.command(name="list")(create_disabled_command("workflow")) - app.add_typer(disabled_workflow_app, name="workflow", help="[DISABLED] Manage and run multi-stage workflows") -else: - app.add_typer(workflows_app, name="workflow", help="Manage and run multi-stage workflows") - # Add groups as a subcommand group app.add_typer(groups_app, name="group", help="Organize documents into hierarchical groups") @@ -133,40 +177,13 @@ def disabled_command(): # Add keybindings as a subcommand group app.add_typer(keybindings_app, name="keybindings", help="Manage TUI keybindings") -# Add AI-powered features (ask, semantic search, embeddings) -app.add_typer(ask_app, name="ai", help="AI-powered Q&A and semantic search") - -# Add the run command for quick task execution (disabled in safe mode) -if is_safe_mode(): - app.command(name="run")(create_disabled_command("run")) -else: - app.command(name="run")(run_command) - -# Add the agent command for sub-agent execution with EMDX tracking (disabled in safe mode) -if is_safe_mode(): - app.command(name="agent")(create_disabled_command("agent")) -else: - app.command(name="agent")(agent_command) +# Add analyze commands (maintain_app has multiple commands like cleanup, cleanup-dirs) +for command in analyze_app.registered_commands: + app.registered_commands.append(command) -# Add each command for reusable parallel commands (disabled in safe mode) -if is_safe_mode(): - disabled_each_app = typer.Typer() - disabled_each_app.command(name="run")(create_disabled_command("each")) - disabled_each_app.command(name="create")(create_disabled_command("each")) - disabled_each_app.command(name="list")(create_disabled_command("each")) - app.add_typer(disabled_each_app, name="each", help="[DISABLED] Create and run reusable parallel commands") -else: - app.add_typer(each_app, name="each", help="Create and run reusable parallel commands") - -# Add cascade command for autonomous document transformation (disabled in safe mode) -if is_safe_mode(): - disabled_cascade_app = typer.Typer() - disabled_cascade_app.command(name="add")(create_disabled_command("cascade")) - disabled_cascade_app.command(name="run")(create_disabled_command("cascade")) - disabled_cascade_app.command(name="status")(create_disabled_command("cascade")) - app.add_typer(disabled_cascade_app, name="cascade", help="[DISABLED] Cascade ideas through stages to working code") -else: - app.add_typer(cascade_app, name="cascade", help="Cascade ideas through stages to working code") +# Add maintain commands (maintain_app has maintain, cleanup, cleanup-dirs) +for command in maintain_app.registered_commands: + app.registered_commands.append(command) # Add the prime command for Claude session priming app.command(name="prime")(prime_command) @@ -174,8 +191,15 @@ def disabled_command(): # Add the status command for consolidated project overview app.command(name="status")(status_command) -# Add the gui command -app.command()(gui) + +# ============================================================================= +# Handle safe mode for unsafe commands +# ============================================================================= +if is_safe_mode(): + # Add disabled versions of unsafe commands that would otherwise be lazy-loaded + for cmd_name in UNSAFE_COMMANDS: + if cmd_name in LAZY_SUBCOMMANDS: + app.command(name=cmd_name)(create_disabled_command(cmd_name)) # Version command @@ -188,8 +212,9 @@ def version(): # Callback for global options -@app.callback() +@app.callback(invoke_without_command=True) def main( + ctx: typer.Context, verbose: bool = typer.Option(False, "--verbose", "-v", help="Enable verbose output"), quiet: bool = typer.Option(False, "--quiet", "-q", help="Suppress non-error output"), db_url: Optional[str] = typer.Option( @@ -244,9 +269,6 @@ def main( if safe_mode: os.environ["EMDX_SAFE_MODE"] = "1" - # Note: Database connections are established per-command as needed - # Note: Logging is configured per-module as needed - def run(): """Entry point for the CLI. diff --git a/emdx/utils/lazy_group.py b/emdx/utils/lazy_group.py new file mode 100644 index 0000000..5660dfd --- /dev/null +++ b/emdx/utils/lazy_group.py @@ -0,0 +1,255 @@ +"""Lazy loading support for Typer CLI commands. + +This module provides a LazyTyperGroup class that extends Typer's group +to support lazy loading of subcommands. This significantly improves +startup performance for CLI applications with many heavy dependencies. + +Heavy commands (workflow, cascade, each, ai, gui) are only imported +when actually invoked, not on every CLI call. +""" + +import importlib +from typing import Any, Callable + +import click +from typer.core import TyperGroup + +# Module-level registry for lazy commands +# This is populated by the main module and read by LazyTyperGroup instances +_LAZY_REGISTRY: dict[str, dict[str, str]] = { + "subcommands": {}, + "help": {}, +} + + +def register_lazy_commands( + subcommands: dict[str, str], + help_strings: dict[str, str], +) -> None: + """Register lazy commands in the global registry. + + This should be called once at module load time by the main CLI module. + + Args: + subcommands: Dict mapping command name to import path. + Format: "module.path:object_name" + help_strings: Dict mapping command name to help text. + """ + _LAZY_REGISTRY["subcommands"] = subcommands + _LAZY_REGISTRY["help"] = help_strings + + +def get_lazy_registry() -> tuple[dict[str, str], dict[str, str]]: + """Get the current lazy command registry. + + Returns: + Tuple of (subcommands dict, help dict) + """ + return _LAZY_REGISTRY["subcommands"], _LAZY_REGISTRY["help"] + + +class LazyCommand(click.MultiCommand): + """A placeholder command that loads the real command on invocation. + + This command appears in help listings with pre-defined help text, + but only loads the actual module when the command is invoked. + """ + + def __init__( + self, + name: str, + import_path: str, + help_text: str, + parent_group: "LazyTyperGroup", + ) -> None: + super().__init__(name=name, help=help_text) + self.import_path = import_path + self.help_text = help_text + self.short_help = help_text # For --help listings + self.parent_group = parent_group + self._real_command: click.BaseCommand | None = None + + def _load_real_command(self) -> click.BaseCommand: + """Load the actual command.""" + if self._real_command is not None: + return self._real_command + + # Parse import path: "module.path:object_name" + if ":" in self.import_path: + modname, obj_name = self.import_path.rsplit(":", 1) + else: + # Legacy format: "module.path.object_name" + modname, obj_name = self.import_path.rsplit(".", 1) + + try: + mod = importlib.import_module(modname) + cmd_object = getattr(mod, obj_name) + self._real_command = self._convert_to_click_command(cmd_object) + return self._real_command + except ImportError as e: + # Create an error command + @click.command(name=self.name) + def error_cmd() -> None: + click.echo(f"Command '{self.name}' is not available: {e}", err=True) + click.echo( + "This might be due to missing optional dependencies.", + err=True, + ) + raise SystemExit(1) + + self._real_command = error_cmd + return self._real_command + except Exception as e: + @click.command(name=self.name) + def error_cmd() -> None: + click.echo(f"Command '{self.name}' failed to load: {e}", err=True) + raise SystemExit(1) + + self._real_command = error_cmd + return self._real_command + + def _convert_to_click_command(self, cmd_object: Any) -> click.BaseCommand: + """Convert a command object to a Click command.""" + import typer + + # Check if it's a Typer app + if isinstance(cmd_object, typer.Typer): + from typer.main import get_command, get_group + + # Check if it has multiple commands (use group) or single (use command) + if ( + len(cmd_object.registered_commands) > 1 + or cmd_object.registered_groups + ): + cmd = get_group(cmd_object) + else: + cmd = get_command(cmd_object) + cmd.name = self.name + return cmd + + # Check if it's already a Click command + if isinstance(cmd_object, click.BaseCommand): + cmd_object.name = self.name + return cmd_object + + # Check if it's a callable (function decorated for Typer) + if callable(cmd_object): + # Wrap the function in a Typer command + temp_app = typer.Typer() + temp_app.command(name=self.name)(cmd_object) + from typer.main import get_command + + return get_command(temp_app) + + raise ValueError( + f"Cannot convert {type(cmd_object)} to Click command. " + f"Expected Typer app, Click command, or callable." + ) + + def list_commands(self, ctx: click.Context) -> list[str]: + """List subcommands (delegates to real command if it's a group).""" + real_cmd = self._load_real_command() + if isinstance(real_cmd, click.MultiCommand): + return real_cmd.list_commands(ctx) + return [] + + def get_command(self, ctx: click.Context, cmd_name: str) -> click.Command | None: + """Get a subcommand (delegates to real command if it's a group).""" + real_cmd = self._load_real_command() + if isinstance(real_cmd, click.MultiCommand): + return real_cmd.get_command(ctx, cmd_name) + return None + + def invoke(self, ctx: click.Context) -> Any: + """Invoke the command (loads the real command first).""" + real_cmd = self._load_real_command() + # Update the parent group's cache + self.parent_group._loaded_commands[self.name or ""] = real_cmd + # Delegate to the real command + return real_cmd.invoke(ctx) + + def get_params(self, ctx: click.Context) -> list[click.Parameter]: + """Get parameters (loads the real command first for accurate params).""" + real_cmd = self._load_real_command() + return real_cmd.get_params(ctx) + + def main(self, *args: Any, **kwargs: Any) -> Any: + """Run as main entry point.""" + real_cmd = self._load_real_command() + return real_cmd.main(*args, **kwargs) + + +class LazyTyperGroup(TyperGroup): + """A Typer-compatible Group with lazy subcommand loading. + + This class allows subcommands to be specified as import paths rather than + actual command objects. The commands are only imported when they are + invoked, not when the CLI is started. + + The lazy commands are registered via the module-level registry using + `register_lazy_commands()`. + """ + + def __init__( + self, + *args: Any, + lazy_subcommands: dict[str, str] | None = None, + lazy_help: dict[str, str] | None = None, + **kwargs: Any, + ) -> None: + """Initialize the lazy group. + + Args: + lazy_subcommands: Dict mapping command name to import path. + If not provided, uses the global registry. + lazy_help: Dict mapping command name to help text. + If not provided, uses the global registry. + """ + super().__init__(*args, **kwargs) + + # Use provided values or fall back to global registry + if lazy_subcommands is not None: + self.lazy_subcommands = lazy_subcommands + else: + self.lazy_subcommands = _LAZY_REGISTRY["subcommands"].copy() + + if lazy_help is not None: + self.lazy_help = lazy_help + else: + self.lazy_help = _LAZY_REGISTRY["help"].copy() + + self._loaded_commands: dict[str, click.BaseCommand] = {} + self._lazy_placeholders: dict[str, LazyCommand] = {} + + def list_commands(self, ctx: click.Context) -> list[str]: + """Return list of all commands (eager + lazy).""" + base = super().list_commands(ctx) + lazy = sorted(self.lazy_subcommands.keys()) + # Remove duplicates while preserving order + all_commands = base + [cmd for cmd in lazy if cmd not in base] + return sorted(all_commands) + + def get_command(self, ctx: click.Context, cmd_name: str) -> click.BaseCommand | None: + """Get command, returning a lazy placeholder if needed. + + For lazy commands, this returns a LazyCommand placeholder that: + - Has the correct help text (for --help listings) + - Only loads the actual module when invoked + """ + # Check if we've already loaded the real command + if cmd_name in self._loaded_commands: + return self._loaded_commands[cmd_name] + + # Check if this is a lazy command + if cmd_name in self.lazy_subcommands: + # Return or create a placeholder + if cmd_name not in self._lazy_placeholders: + self._lazy_placeholders[cmd_name] = LazyCommand( + name=cmd_name, + import_path=self.lazy_subcommands[cmd_name], + help_text=self.lazy_help.get(cmd_name, ""), + parent_group=self, + ) + return self._lazy_placeholders[cmd_name] + + return super().get_command(ctx, cmd_name) diff --git a/pyproject.toml b/pyproject.toml index 90aa1d1..7a5816f 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [tool.poetry] name = "emdx" -version = "0.9.0" +version = "0.9.1" description = "Documentation Index Management System - A powerful knowledge base for developers" authors = ["Alex Rockwell "] readme = "README.md" @@ -80,13 +80,13 @@ ignore = [ ] [tool.mypy] -python_version = "0.9.0" +python_version = "3.13" warn_return_any = true warn_unused_configs = true disallow_untyped_defs = true [tool.pytest.ini_options] -minversion = "0.9.0" +minversion = "7.0.0" addopts = "-v --tb=short --strict-markers" testpaths = ["tests"] markers = [ diff --git a/tests/test_lazy_loading.py b/tests/test_lazy_loading.py new file mode 100644 index 0000000..1ceab69 --- /dev/null +++ b/tests/test_lazy_loading.py @@ -0,0 +1,313 @@ +"""Tests for lazy loading CLI commands.""" + +import sys +from unittest.mock import MagicMock, patch + +import click +import pytest +from typer.testing import CliRunner + +from emdx.utils.lazy_group import ( + LazyCommand, + LazyTyperGroup, + register_lazy_commands, + get_lazy_registry, +) + + +runner = CliRunner() + + +class TestLazyTyperGroup: + """Test the LazyTyperGroup class.""" + + def test_list_commands_includes_lazy(self): + """Test that list_commands returns both eager and lazy commands.""" + # Create a group with one eager command + @click.command() + def eager(): + pass + + group = LazyTyperGroup( + commands={"eager": eager}, + lazy_subcommands={"lazy1": "some.module:cmd", "lazy2": "another.module:cmd"}, + lazy_help={"lazy1": "Help for lazy1", "lazy2": "Help for lazy2"}, + ) + + ctx = click.Context(group) + commands = group.list_commands(ctx) + + assert "eager" in commands + assert "lazy1" in commands + assert "lazy2" in commands + assert len(commands) == 3 + + def test_get_command_returns_placeholder_for_lazy(self): + """Test that get_command returns a LazyCommand placeholder for lazy commands.""" + group = LazyTyperGroup( + lazy_subcommands={"lazy": "some.module:cmd"}, + lazy_help={"lazy": "Help for lazy"}, + ) + + ctx = click.Context(group) + cmd = group.get_command(ctx, "lazy") + + assert isinstance(cmd, LazyCommand) + assert cmd.name == "lazy" + assert cmd.help == "Help for lazy" + + def test_get_command_returns_eager_command(self): + """Test that get_command returns eager commands directly.""" + @click.command() + def eager(): + """Help for eager.""" + pass + + group = LazyTyperGroup( + commands={"eager": eager}, + lazy_subcommands={}, + lazy_help={}, + ) + + ctx = click.Context(group) + cmd = group.get_command(ctx, "eager") + + assert cmd is eager + assert not isinstance(cmd, LazyCommand) + + def test_lazy_command_not_loaded_until_invoked(self): + """Test that lazy commands don't import their modules until invoked.""" + group = LazyTyperGroup( + lazy_subcommands={"test_cmd": "emdx.commands.workflows:app"}, + lazy_help={"test_cmd": "Test help"}, + ) + + ctx = click.Context(group) + + # Getting the command should NOT load the module + cmd = group.get_command(ctx, "test_cmd") + assert isinstance(cmd, LazyCommand) + assert cmd._real_command is None + + def test_uses_global_registry_by_default(self): + """Test that LazyTyperGroup uses the global registry by default.""" + # Register some commands + register_lazy_commands( + {"registered": "some.module:cmd"}, + {"registered": "Registered help"}, + ) + + group = LazyTyperGroup() + + assert "registered" in group.lazy_subcommands + assert group.lazy_help.get("registered") == "Registered help" + + def test_explicit_config_overrides_registry(self): + """Test that explicit config overrides the global registry.""" + register_lazy_commands( + {"registered": "some.module:cmd"}, + {"registered": "Registered help"}, + ) + + group = LazyTyperGroup( + lazy_subcommands={"explicit": "other.module:cmd"}, + lazy_help={"explicit": "Explicit help"}, + ) + + assert "explicit" in group.lazy_subcommands + assert "registered" not in group.lazy_subcommands + + +class TestLazyCommand: + """Test the LazyCommand class.""" + + def test_help_text_without_loading(self): + """Test that LazyCommand has help text without loading the real command.""" + group = LazyTyperGroup() + cmd = LazyCommand( + name="test", + import_path="some.fake.module:cmd", + help_text="Test help text", + parent_group=group, + ) + + assert cmd.help == "Test help text" + assert cmd._real_command is None + + def test_short_help_matches_help(self): + """Test that short_help matches the provided help text.""" + group = LazyTyperGroup() + cmd = LazyCommand( + name="test", + import_path="some.fake.module:cmd", + help_text="Test help text", + parent_group=group, + ) + + # short_help should use the help text + assert cmd.short_help == "Test help text" + + def test_load_real_command_on_invoke(self): + """Test that invoke loads the real command.""" + group = LazyTyperGroup() + cmd = LazyCommand( + name="workflow", + import_path="emdx.commands.workflows:app", + help_text="Test help", + parent_group=group, + ) + + # Before invoke, real command is not loaded + assert cmd._real_command is None + + # Load the real command + real = cmd._load_real_command() + + # After loading, real command exists + assert cmd._real_command is not None + assert real is not None + + def test_graceful_degradation_on_import_error(self): + """Test that import errors create an error command.""" + group = LazyTyperGroup() + cmd = LazyCommand( + name="broken", + import_path="nonexistent.module:cmd", + help_text="Test help", + parent_group=group, + ) + + # Loading should not raise, should return error command + real = cmd._load_real_command() + + assert real is not None + assert cmd._real_command is not None + + +class TestCLIIntegration: + """Test lazy loading in the actual CLI.""" + + def test_help_does_not_load_lazy_modules(self): + """Test that --help doesn't load lazy modules.""" + # Track which modules are loaded + lazy_modules = [ + 'emdx.commands.workflows', + 'emdx.commands.cascade', + 'emdx.commands.each', + 'emdx.commands.run', + 'emdx.commands.agent', + 'emdx.commands.claude_execute', + 'emdx.commands.ask', + 'emdx.commands.similarity', + 'emdx.commands.gdoc', + 'emdx.ui.gui', + ] + + # Clear any cached imports + for mod in lazy_modules: + if mod in sys.modules: + del sys.modules[mod] + + before = set(sys.modules.keys()) + + # Import and run help - reimport to ensure fresh registry + import importlib + import emdx.main + importlib.reload(emdx.main) + from emdx.main import app + result = runner.invoke(app, ["--help"]) + + after = set(sys.modules.keys()) + loaded = after - before + + # None of the lazy modules should be loaded + loaded_lazy = [m for m in lazy_modules if m in loaded] + assert loaded_lazy == [], f"Lazy modules were loaded: {loaded_lazy}" + assert result.exit_code == 0 + + def test_lazy_commands_appear_in_help(self): + """Test that lazy commands appear in --help output.""" + # Reimport to ensure fresh registry + import importlib + import emdx.main + importlib.reload(emdx.main) + from emdx.main import app + + result = runner.invoke(app, ["--help"]) + + assert result.exit_code == 0 + assert "workflow" in result.output + assert "cascade" in result.output + assert "ai" in result.output + assert "gui" in result.output + + def test_lazy_help_text_in_output(self): + """Test that lazy commands show their pre-defined help text.""" + from emdx.main import app + + result = runner.invoke(app, ["--help"]) + + assert result.exit_code == 0 + # Check that our lazy help text appears (not the actual module help) + assert "Manage and run multi-stage workflows" in result.output + assert "Cascade ideas through stages" in result.output + assert "Google Docs integration" in result.output + + def test_lazy_command_works_when_invoked(self): + """Test that lazy commands work when actually invoked.""" + from emdx.main import app + + result = runner.invoke(app, ["workflow", "--help"]) + + assert result.exit_code == 0 + # Should show the actual workflow subcommands + assert "run" in result.output.lower() + assert "list" in result.output.lower() + + def test_core_commands_still_work(self): + """Test that core (eager) commands still work.""" + from emdx.main import app + + result = runner.invoke(app, ["save", "--help"]) + + assert result.exit_code == 0 + assert "Save content" in result.output + + def test_find_command_still_works(self): + """Test that find command works.""" + from emdx.main import app + + result = runner.invoke(app, ["find", "--help"]) + + assert result.exit_code == 0 + assert "Search" in result.output or "find" in result.output.lower() + + +class TestLazyRegistry: + """Test the lazy command registry.""" + + def test_register_and_get(self): + """Test registering and getting lazy commands.""" + register_lazy_commands( + {"cmd1": "mod1:app", "cmd2": "mod2:app"}, + {"cmd1": "Help 1", "cmd2": "Help 2"}, + ) + + subcommands, help_strings = get_lazy_registry() + + assert "cmd1" in subcommands + assert "cmd2" in subcommands + assert help_strings["cmd1"] == "Help 1" + assert help_strings["cmd2"] == "Help 2" + + def test_registry_is_global(self): + """Test that the registry is global.""" + register_lazy_commands( + {"global_cmd": "mod:app"}, + {"global_cmd": "Global help"}, + ) + + # Create a new group - should pick up global registry + group = LazyTyperGroup() + + assert "global_cmd" in group.lazy_subcommands