Files
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

525 lines
16 KiB
Python

import json
import subprocess
from pathlib import Path
from unittest.mock import AsyncMock, patch
from basic_memory.cli.commands.ci import seed_project_update_schemas
from typer.testing import CliRunner
from basic_memory.cli.main import app as cli_app
runner = CliRunner()
def _init_github_repo(path: Path) -> None:
subprocess.run(["git", "init"], cwd=path, check=True, capture_output=True)
subprocess.run(
["git", "remote", "add", "origin", "https://github.com/basicmachines-co/demo.git"],
cwd=path,
check=True,
capture_output=True,
)
def _write_pr_event(path: Path) -> Path:
payload = {
"action": "closed",
"repository": {
"full_name": "basicmachines-co/demo",
"html_url": "https://github.com/basicmachines-co/demo",
},
"pull_request": {
"number": 7,
"title": "Add project update capture",
"body": "Closes #4",
"html_url": "https://github.com/basicmachines-co/demo/pull/7",
"merged": True,
"merged_at": "2026-06-04T18:42:00Z",
"merge_commit_sha": "abc123",
"labels": [{"name": "ci"}],
},
}
path.write_text(json.dumps(payload), encoding="utf-8")
return path
def _synthesis_payload(**overrides: object) -> dict[str, object]:
payload: dict[str, object] = {
"summary": "Auto BM records project updates.",
"story": (
"GitHub delivery moments were not leaving durable project memory. "
"Auto BM collects source facts, asks the agent for the delivery story, "
"and publishes an idempotent note."
),
"problem_addressed": "GitHub delivery context was lost after merge.",
"solution": "Publish an idempotent Basic Memory project update from CI.",
"system_impact": "Future agents can recover the project delivery narrative.",
"why_it_matters": "Future agents can recover project context.",
"components_changed": ["basic_memory.ci.project_updates"],
"complexity_introduced": [],
"refactors_or_removals": [],
"user_facing_changes": [],
"internal_changes": [],
"verification": [],
"follow_ups": [],
"decision_candidates": [],
"task_candidates": [],
}
payload.update(overrides)
return payload
@patch("basic_memory.cli.commands.ci.seed_project_update_schemas", new_callable=AsyncMock)
def test_setup_writes_workflow_config_and_prompt(
mock_seed: AsyncMock,
tmp_path: Path,
) -> None:
_init_github_repo(tmp_path)
result = runner.invoke(
cli_app,
[
"ci",
"setup",
"--project",
"team-memory",
"--repo-root",
str(tmp_path),
"--yes",
],
)
assert result.exit_code == 0, result.output
assert (tmp_path / ".github/workflows/basic-memory.yml").exists()
assert (tmp_path / ".github/basic-memory/config.yml").exists()
assert (tmp_path / ".github/basic-memory/memory-ci-capture.md").exists()
assert (tmp_path / ".github/basic-memory/SOUL.md").exists()
assert "Keep personality in service of memory" in (
tmp_path / ".github/basic-memory/SOUL.md"
).read_text(encoding="utf-8")
assert "OPENAI_API_KEY" in result.output
assert "BASIC_MEMORY_API_KEY" in result.output
mock_seed.assert_awaited_once_with(
project="team-memory",
project_id=None,
workspace=None,
refresh=False,
)
@patch("basic_memory.cli.commands.ci.seed_project_update_schemas", new_callable=AsyncMock)
def test_setup_refreshes_or_updates_existing_schema_notes_when_requested(
mock_seed: AsyncMock,
tmp_path: Path,
) -> None:
for flag in ("--refresh", "--update-schemas"):
repo_path = tmp_path / flag.removeprefix("--")
repo_path.mkdir()
_init_github_repo(repo_path)
result = runner.invoke(
cli_app,
[
"ci",
"setup",
"--project",
"team-memory",
"--repo-root",
str(repo_path),
flag,
"--yes",
],
)
assert result.exit_code == 0, result.output
assert mock_seed.await_count == 2
for seed_call in mock_seed.await_args_list:
assert seed_call.kwargs == {
"project": "team-memory",
"project_id": None,
"workspace": None,
"refresh": True,
}
@patch("basic_memory.cli.commands.ci.seed_project_update_schemas", new_callable=AsyncMock)
def test_setup_refreshes_schema_notes_when_generated_files_already_exist(
mock_seed: AsyncMock,
tmp_path: Path,
) -> None:
_init_github_repo(tmp_path)
workflow_path = tmp_path / ".github/workflows/basic-memory.yml"
config_path = tmp_path / ".github/basic-memory/config.yml"
prompt_path = tmp_path / ".github/basic-memory/memory-ci-capture.md"
soul_path = tmp_path / ".github/basic-memory/SOUL.md"
workflow_path.parent.mkdir(parents=True)
config_path.parent.mkdir(parents=True)
workflow_path.write_text("custom workflow\n", encoding="utf-8")
config_path.write_text("project: existing\n", encoding="utf-8")
prompt_path.write_text("custom prompt\n", encoding="utf-8")
soul_path.write_text("custom soul\n", encoding="utf-8")
result = runner.invoke(
cli_app,
[
"ci",
"setup",
"--project",
"team-memory",
"--repo-root",
str(tmp_path),
"--refresh-schemas",
"--yes",
],
)
assert result.exit_code == 0, result.output
assert "generated files unchanged" in result.output
assert workflow_path.read_text(encoding="utf-8") == "custom workflow\n"
assert config_path.read_text(encoding="utf-8") == "project: existing\n"
assert prompt_path.read_text(encoding="utf-8") == "custom prompt\n"
assert soul_path.read_text(encoding="utf-8") == "custom soul\n"
mock_seed.assert_awaited_once_with(
project="team-memory",
project_id=None,
workspace=None,
refresh=True,
)
@patch("basic_memory.cli.commands.ci.seed_project_update_schemas", new_callable=AsyncMock)
def test_setup_does_not_partially_write_generated_files_when_target_exists(
mock_seed: AsyncMock,
tmp_path: Path,
) -> None:
_init_github_repo(tmp_path)
config_path = tmp_path / ".github/basic-memory/config.yml"
config_path.parent.mkdir(parents=True)
config_path.write_text("project: existing\n", encoding="utf-8")
result = runner.invoke(
cli_app,
[
"ci",
"setup",
"--project",
"team-memory",
"--repo-root",
str(tmp_path),
"--yes",
],
)
assert result.exit_code == 1
assert "pass --force to overwrite" in " ".join(result.output.split())
assert not (tmp_path / ".github/workflows/basic-memory.yml").exists()
assert not (tmp_path / ".github/basic-memory/memory-ci-capture.md").exists()
mock_seed.assert_not_awaited()
@patch("basic_memory.mcp.tools.search_notes", new_callable=AsyncMock)
@patch("basic_memory.mcp.tools.write_note", new_callable=AsyncMock)
async def test_seed_project_update_schemas_skips_existing_notes_by_default(
mock_write: AsyncMock,
mock_search: AsyncMock,
) -> None:
mock_search.return_value = {
"results": [{"title": "ProjectUpdate", "file_path": "schemas/ProjectUpdate.md"}]
}
seeded = await seed_project_update_schemas(project="team-memory")
assert seeded == []
mock_write.assert_not_awaited()
@patch("basic_memory.mcp.tools.search_notes", new_callable=AsyncMock)
@patch("basic_memory.mcp.tools.write_note", new_callable=AsyncMock)
async def test_seed_project_update_schemas_refreshes_existing_notes(
mock_write: AsyncMock,
mock_search: AsyncMock,
) -> None:
mock_search.return_value = {
"results": [{"title": "Custom ProjectUpdate", "file_path": "custom/schemas/update.md"}]
}
mock_write.return_value = {"action": "updated"}
seeded = await seed_project_update_schemas(project="team-memory", refresh=True)
assert seeded == [
"ProjectUpdate",
"GitHubPullRequestUpdate",
"GitHubProductionDeployUpdate",
]
assert mock_write.await_count == 3
first_call = mock_write.await_args_list[0].kwargs
assert first_call["title"] == "Custom ProjectUpdate"
assert first_call["directory"] == "custom/schemas"
assert first_call["overwrite"] is True
def test_setup_rejects_non_github_repo(tmp_path: Path) -> None:
subprocess.run(["git", "init"], cwd=tmp_path, check=True, capture_output=True)
subprocess.run(
["git", "remote", "add", "origin", "https://example.com/basicmachines-co/demo.git"],
cwd=tmp_path,
check=True,
capture_output=True,
)
result = runner.invoke(
cli_app,
[
"ci",
"setup",
"--project",
"team-memory",
"--repo-root",
str(tmp_path),
"--yes",
],
)
assert result.exit_code == 1
assert "GitHub remote" in result.output
def test_collect_command_writes_context_and_github_outputs(tmp_path: Path) -> None:
event_path = _write_pr_event(tmp_path / "event.json")
config_path = tmp_path / "config.yml"
config_path.write_text("project: team-memory\nworkspace: product\n", encoding="utf-8")
output_path = tmp_path / "context.json"
github_output = tmp_path / "github-output.txt"
result = runner.invoke(
cli_app,
[
"ci",
"collect",
"--event-name",
"pull_request",
"--event-path",
str(event_path),
"--config",
str(config_path),
"--output",
str(output_path),
],
env={"GITHUB_OUTPUT": str(github_output)},
)
assert result.exit_code == 0, result.output
context = json.loads(output_path.read_text(encoding="utf-8"))
assert context["eligible"] is True
assert context["source_event"] == "pull_request_merged"
assert "eligible=true" in github_output.read_text(encoding="utf-8")
def test_agent_schema_command_writes_schema(tmp_path: Path) -> None:
output_path = tmp_path / "agent-synthesis.schema.json"
result = runner.invoke(cli_app, ["ci", "agent-schema", "--output", str(output_path)])
assert result.exit_code == 0, result.output
schema = json.loads(output_path.read_text(encoding="utf-8"))
assert schema["title"] == "AgentSynthesis"
@patch("basic_memory.mcp.tools.search_notes", new_callable=AsyncMock)
@patch("basic_memory.mcp.tools.write_note", new_callable=AsyncMock)
def test_publish_command_upserts_project_update_note(
mock_write: AsyncMock,
mock_search: AsyncMock,
tmp_path: Path,
) -> None:
mock_search.return_value = {"results": []}
mock_write.return_value = {
"title": "PR #7: Add project update capture",
"permalink": "project-updates/github/basicmachines-co/demo/pr-7-add-project-update-capture",
"action": "created",
}
event_path = _write_pr_event(tmp_path / "event.json")
context_path = tmp_path / "context.json"
config_path = tmp_path / "config.yml"
synthesis_path = tmp_path / "synthesis.json"
config_path.write_text("project: team-memory\nworkspace: product\n", encoding="utf-8")
collect_result = runner.invoke(
cli_app,
[
"ci",
"collect",
"--event-name",
"pull_request",
"--event-path",
str(event_path),
"--config",
str(config_path),
"--output",
str(context_path),
],
)
assert collect_result.exit_code == 0, collect_result.output
synthesis_path.write_text(
json.dumps(
_synthesis_payload(
repo="evil/repo",
)
),
encoding="utf-8",
)
result = runner.invoke(
cli_app,
[
"ci",
"publish",
"--config",
str(config_path),
"--context",
str(context_path),
"--synthesis",
str(synthesis_path),
],
)
assert result.exit_code == 0, result.output
mock_search.assert_awaited_once()
mock_write.assert_awaited_once()
assert mock_search.call_args.kwargs["project"] == "product/team-memory"
kwargs = mock_write.call_args.kwargs
assert kwargs["project"] == "product/team-memory"
assert kwargs["note_type"] == "project_update"
assert kwargs["overwrite"] is True
assert kwargs["metadata"]["repo"] == "basicmachines-co/demo"
assert kwargs["metadata"]["source_event"] == "pull_request_merged"
assert (
kwargs["metadata"]["idempotency_key"]
== "github:basicmachines-co/demo:pull_request_merged:7"
)
@patch("basic_memory.mcp.tools.search_notes", new_callable=AsyncMock)
@patch("basic_memory.mcp.tools.write_note", new_callable=AsyncMock)
def test_publish_command_preserves_existing_note_path_for_idempotency_match(
mock_write: AsyncMock,
mock_search: AsyncMock,
tmp_path: Path,
) -> None:
mock_search.return_value = {
"results": [
{
"title": "Existing PR update",
"file_path": "custom/project-updates/existing-pr-update.md",
}
]
}
mock_write.return_value = {"title": "Existing PR update", "action": "updated"}
event_path = _write_pr_event(tmp_path / "event.json")
context_path = tmp_path / "context.json"
config_path = tmp_path / "config.yml"
synthesis_path = tmp_path / "synthesis.json"
config_path.write_text("project: team-memory\n", encoding="utf-8")
collect_result = runner.invoke(
cli_app,
[
"ci",
"collect",
"--event-name",
"pull_request",
"--event-path",
str(event_path),
"--config",
str(config_path),
"--output",
str(context_path),
],
)
assert collect_result.exit_code == 0, collect_result.output
synthesis_path.write_text(
json.dumps(_synthesis_payload()),
encoding="utf-8",
)
result = runner.invoke(
cli_app,
[
"ci",
"publish",
"--config",
str(config_path),
"--context",
str(context_path),
"--synthesis",
str(synthesis_path),
],
)
assert result.exit_code == 0, result.output
kwargs = mock_write.call_args.kwargs
assert kwargs["title"] == "Existing PR update"
assert kwargs["directory"] == "custom/project-updates"
@patch("basic_memory.mcp.tools.search_notes", new_callable=AsyncMock)
@patch("basic_memory.mcp.tools.write_note", new_callable=AsyncMock)
def test_publish_command_uses_project_id_without_workspace_qualifying_project(
mock_write: AsyncMock,
mock_search: AsyncMock,
tmp_path: Path,
) -> None:
mock_search.return_value = {"results": []}
mock_write.return_value = {"title": "Project update", "action": "created"}
event_path = _write_pr_event(tmp_path / "event.json")
context_path = tmp_path / "context.json"
config_path = tmp_path / "config.yml"
synthesis_path = tmp_path / "synthesis.json"
config_path.write_text(
"project: team-memory\nproject_id: project-uuid\nworkspace: product\n",
encoding="utf-8",
)
collect_result = runner.invoke(
cli_app,
[
"ci",
"collect",
"--event-name",
"pull_request",
"--event-path",
str(event_path),
"--config",
str(config_path),
"--output",
str(context_path),
],
)
assert collect_result.exit_code == 0, collect_result.output
synthesis_path.write_text(
json.dumps(_synthesis_payload()),
encoding="utf-8",
)
result = runner.invoke(
cli_app,
[
"ci",
"publish",
"--config",
str(config_path),
"--context",
str(context_path),
"--synthesis",
str(synthesis_path),
],
)
assert result.exit_code == 0, result.output
assert mock_search.call_args.kwargs["project"] == "team-memory"
assert mock_search.call_args.kwargs["project_id"] == "project-uuid"
assert mock_write.call_args.kwargs["project"] == "team-memory"
assert mock_write.call_args.kwargs["project_id"] == "project-uuid"