Files
basicmachines-co-basic-memory/tests/cli/test_cli_exit.py
T
phernandez 0247ef0ead fix(cli): defer FastAPI and app imports out of CLI startup
Every basic-memory CLI invocation paid roughly 2 seconds of module-import
cost before any work started, which blew the Claude Code plugin's
SessionStart hook budget on cold machines (#886). The cost came from
module-level imports that pulled the entire server stack into CLI startup:

- mcp/async_client.py imported FastAPI at module level, so every consumer
  of get_client() loaded FastAPI even for cloud-routed or help-only paths.
- mcp/clients/*.py imported call_* helpers from basic_memory.mcp.tools.utils,
  which executes the whole tools package __init__ — every MCP tool module
  plus fastmcp and the mcp SDK.
- mcp/project_context.py imported fastmcp.Context and ToolError eagerly.
- CLI command modules (tool, ci, schema) imported MCP tool functions at
  module level; db and the import_* commands pulled SQLAlchemy/Alembic and
  the markdown/file-service stack; status/doctor/orphans/command_utils
  imported ToolError (the mcp SDK) and basic_memory.db.
- schemas/base.py imported dateparser (~0.13s) for one helper function.

The fix only defers imports to the point of use (no behavior changes):
FastAPI now loads inside _resolve_local_asgi_database alongside the
existing lazy api.app import, so it is only paid when a request actually
routes through the in-process ASGI transport; the typed clients import
call_* per method; project_context uses PEP 563 annotations with Context
under TYPE_CHECKING; the CLI command modules import their heavy
dependencies inside the command bodies. Tests that patched the old
module-level aliases now patch the source modules instead.

Measured on a warm cache (python -X importtime / wall time):
- import basic_memory.cli.main: 1.92s -> 0.45s
- bm --help: 2.40s -> 0.52s
- bm tool search-notes --help: 2.40s -> 0.86s

A regression test asserts that importing the CLI entry module with full
command registration leaves fastapi, sqlalchemy, alembic, fastmcp, mcp,
basic_memory.api.app, basic_memory.db, basic_memory.markdown,
basic_memory.mcp.tools, and basic_memory.services out of sys.modules.

Fixes #886

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Signed-off-by: phernandez <paul@basicmachines.co>
2026-06-12 09:03:08 -05:00

150 lines
5.2 KiB
Python

"""Regression tests for CLI command exit behavior.
These tests verify that CLI commands exit cleanly without hanging,
which was a bug fixed in the database initialization refactor.
"""
import subprocess
from pathlib import Path
def test_bm_version_exits_cleanly():
"""Test that 'bm --version' exits cleanly within timeout."""
# Use uv run to ensure correct environment
result = subprocess.run(
["uv", "run", "bm", "--version"],
capture_output=True,
text=True,
# `uv run` startup under full-suite load (especially on Windows runners)
# can exceed a tight 10s budget even though --version short-circuits
# before any heavy work. This test guards against hangs, not a startup
# performance budget, so match the looser --help timeout below.
timeout=20,
cwd=Path(__file__).parent.parent.parent, # Project root
)
assert result.returncode == 0
assert "Basic Memory version:" in result.stdout
def test_bm_help_exits_cleanly():
"""Test that 'bm --help' exits cleanly within timeout."""
result = subprocess.run(
["uv", "run", "bm", "--help"],
capture_output=True,
text=True,
# Help builds the full command tree, so use a looser timeout than the
# version fast path. This test is guarding against hangs, not enforcing
# a tight performance budget under full-suite load.
timeout=20,
cwd=Path(__file__).parent.parent.parent,
)
assert result.returncode == 0
assert "Basic Memory" in result.stdout
def test_bm_tool_help_exits_cleanly():
"""Test that 'bm tool --help' exits cleanly within timeout."""
result = subprocess.run(
["uv", "run", "bm", "tool", "--help"],
capture_output=True,
text=True,
timeout=10,
cwd=Path(__file__).parent.parent.parent,
)
assert result.returncode == 0
assert "tool" in result.stdout.lower()
def test_bm_version_does_not_import_heavy_modules():
"""Regression test: 'bm --version' must not import heavy modules.
The fast-path guard in cli/main.py skips command registration when
argv is exactly ['--version']. This test verifies that modules like
basic_memory.mcp (which pull in FastAPI, SQLAlchemy, etc.) are NOT
loaded during a version-only invocation.
"""
# Run a Python snippet that imports main.py the same way the entrypoint does,
# then checks sys.modules for heavy imports
check_script = (
"import sys; "
"sys.argv = ['bm', '--version']; "
"import basic_memory.cli.main; "
"heavy = [m for m in sys.modules if m.startswith('basic_memory.mcp')]; "
"print(','.join(heavy) if heavy else 'CLEAN')"
)
result = subprocess.run(
["uv", "run", "python", "-c", check_script],
capture_output=True,
text=True,
timeout=10,
cwd=Path(__file__).parent.parent.parent,
)
assert result.returncode == 0
# The fast path should NOT have loaded any mcp modules
assert "CLEAN" in result.stdout, (
f"Heavy modules loaded during --version: {result.stdout.strip()}"
)
def test_bm_cli_import_does_not_load_heavy_stack():
"""Regression test (#886): registering all CLI commands must stay lightweight.
Importing basic_memory.cli.main with a normal argv registers every command
module. None of them may pull FastAPI, the API app, SQLAlchemy/Alembic, the
MCP tool stack, or the markdown/services layers in at import time — those
must load lazily when a command actually runs.
"""
heavy_modules = (
"fastapi",
"sqlalchemy",
"alembic",
"fastmcp",
"mcp",
"basic_memory.api.app",
"basic_memory.db",
"basic_memory.markdown",
"basic_memory.mcp.tools",
"basic_memory.services",
)
check_script = (
"import sys; "
"sys.argv = ['bm', 'tool', 'search-notes', '--help']; "
"import basic_memory.cli.main; "
f"heavy = [m for m in {heavy_modules!r} if m in sys.modules]; "
"print(','.join(heavy) if heavy else 'CLEAN')"
)
result = subprocess.run(
["uv", "run", "python", "-c", check_script],
capture_output=True,
text=True,
timeout=20,
cwd=Path(__file__).parent.parent.parent,
)
assert result.returncode == 0
assert "CLEAN" in result.stdout, (
f"Heavy modules loaded during CLI import: {result.stdout.strip()}"
)
def test_bm_help_does_not_import_api_app():
"""Regression test: 'bm --help' must not build the FastAPI app graph."""
check_script = (
"import sys; "
"sys.argv = ['bm', '--help']; "
"import basic_memory.cli.main; "
"heavy = [m for m in sys.modules "
"if m == 'basic_memory.api.app' or m.startswith('basic_memory.api.v2.routers')]; "
"print(','.join(heavy) if heavy else 'CLEAN')"
)
result = subprocess.run(
["uv", "run", "python", "-c", check_script],
capture_output=True,
text=True,
timeout=10,
cwd=Path(__file__).parent.parent.parent,
)
assert result.returncode == 0
assert "CLEAN" in result.stdout, (
f"API app modules loaded during --help: {result.stdout.strip()}"
)