From b37bf7c05d7531527862c099d2d90d29485ea20e Mon Sep 17 00:00:00 2001 From: Alex Rockwell Date: Thu, 29 Jan 2026 01:41:41 -0500 Subject: [PATCH 1/3] feat(cli): Implement lazy loading for heavy CLI commands Heavy commands (workflow, cascade, each, ai, gui, etc.) are now only imported when actually invoked, not on every CLI call. This significantly improves startup performance. Changes: - Add LazyTyperGroup that extends Typer's group with lazy loading - Add LazyCommand placeholder that shows help without importing - Register lazy commands via global registry before app creation - Add pre-computed help strings so --help doesn't trigger imports Lazy-loaded commands: - workflow, cascade, each, run, agent, claude (execution) - ai, similar (ML/embeddings) - gdoc (external services) - gui (TUI) Co-Authored-By: Claude Opus 4.5 --- emdx/main.py | 246 ++++++++++++++--------------- emdx/utils/lazy_group.py | 255 ++++++++++++++++++++++++++++++ tests/test_lazy_loading.py | 313 +++++++++++++++++++++++++++++++++++++ 3 files changed, 686 insertions(+), 128 deletions(-) create mode 100644 emdx/utils/lazy_group.py create mode 100644 tests/test_lazy_loading.py diff --git a/emdx/main.py b/emdx/main.py index e289f30..161e71b 100644 --- a/emdx/main.py +++ b/emdx/main.py @@ -1,41 +1,58 @@ #!/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. """ from typing import Optional -import logging import os 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.similarity import app as similarity_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: """Check if EMDX is running in safe mode. @@ -50,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(): @@ -63,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") @@ -136,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) @@ -177,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 @@ -191,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( @@ -247,38 +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 safe_register_commands(target_app, source_app, prefix=""): - """Safely register commands from source app to target app""" - try: - if hasattr(source_app, 'registered_commands'): - for command in source_app.registered_commands: - if hasattr(command, 'callback') and callable(command.callback): - target_app.command(name=command.name)(command.callback) - except Exception as e: - console.print(f"[yellow]Warning: Could not register {prefix} commands: {e}[/yellow]") - - -# Register all command groups -safe_register_commands(app, core_app, "core") -safe_register_commands(app, browse_app, "browse") -safe_register_commands(app, gist_app, "gist") -safe_register_commands(app, gdoc_app, "gdoc") -safe_register_commands(app, tag_app, "tags") -safe_register_commands(app, analyze_app, "analyze") -safe_register_commands(app, maintain_app, "maintain") -safe_register_commands(app, similarity_app, "similarity") - -# Register subcommand groups -# Note: executions_app and lifecycle_app are safe commands -# claude_app is already conditionally registered above based on safe mode - -# Register standalone commands -# Note: gui is already registered above - 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/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 From 6be7738b1117fc78212740213b4d7faf0640cddf Mon Sep 17 00:00:00 2001 From: Alex Rockwell Date: Thu, 29 Jan 2026 01:45:06 -0500 Subject: [PATCH 2/3] chore: Bump version to 0.8.1 --- pyproject.toml | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index cd2a35c..525a7ca 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [tool.poetry] name = "emdx" -version = "0.9.0" +version = "0.8.1" description = "Documentation Index Management System - A powerful knowledge base for developers" authors = ["Alex Rockwell "] readme = "README.md" @@ -23,7 +23,7 @@ classifiers = [ [tool.poetry.dependencies] python = "^3.13" -typer = {extras = ["all"], version = "0.9.0"} +typer = {extras = ["all"], version = "0.8.1"} click = ">=8.0.0,<8.3.0" # Pin click to avoid breaking changes in 8.3.x rich = "^13.0.0" python-dotenv = "^1.0.0" @@ -80,13 +80,13 @@ ignore = [ ] [tool.mypy] -python_version = "0.9.0" +python_version = "0.8.1" warn_return_any = true warn_unused_configs = true disallow_untyped_defs = true [tool.pytest.ini_options] -minversion = "0.9.0" +minversion = "0.8.1" addopts = "-v --tb=short --strict-markers" testpaths = ["tests"] markers = [ From c8689046a7bb05656563205f64508b5b9733d256 Mon Sep 17 00:00:00 2001 From: Alex Rockwell Date: Thu, 29 Jan 2026 02:05:02 -0500 Subject: [PATCH 3/3] chore: Bump version to 0.9.1 --- pyproject.toml | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index c3ffe6e..7a5816f 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [tool.poetry] name = "emdx" -version = "0.8.1" +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.8.1" +python_version = "3.13" warn_return_any = true warn_unused_configs = true disallow_untyped_defs = true [tool.pytest.ini_options] -minversion = "0.8.1" +minversion = "7.0.0" addopts = "-v --tb=short --strict-markers" testpaths = ["tests"] markers = [