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

423 lines
14 KiB
Python

"""Tests for typed API clients."""
import pytest
from unittest.mock import MagicMock
from basic_memory.mcp.clients import (
KnowledgeClient,
SearchClient,
MemoryClient,
DirectoryClient,
ResourceClient,
ProjectClient,
)
class TestKnowledgeClient:
"""Tests for KnowledgeClient."""
def test_init(self):
"""Test client initialization."""
mock_http = MagicMock()
client = KnowledgeClient(mock_http, "project-123")
assert client.http_client is mock_http
assert client.project_id == "project-123"
assert client._base_path == "/v2/projects/project-123/knowledge"
@pytest.mark.asyncio
async def test_create_entity(self, monkeypatch):
"""Test create_entity calls correct endpoint."""
mock_response = MagicMock()
mock_response.json.return_value = {
"permalink": "test",
"title": "Test",
"file_path": "test.md",
"note_type": "note",
"content_type": "text/markdown",
"observations": [],
"relations": [],
"created_at": "2024-01-01T00:00:00",
"updated_at": "2024-01-01T00:00:00",
}
async def mock_call_post(client, url, **kwargs):
assert "/v2/projects/proj-123/knowledge/entities" in url
assert kwargs.get("params") is None
return mock_response
monkeypatch.setattr("basic_memory.mcp.tools.utils.call_post", mock_call_post)
mock_http = MagicMock()
client = KnowledgeClient(mock_http, "proj-123")
result = await client.create_entity({"title": "Test"})
assert result.title == "Test"
@pytest.mark.asyncio
async def test_update_entity(self, monkeypatch):
"""Test update_entity calls correct endpoint without fast query params."""
mock_response = MagicMock()
mock_response.json.return_value = {
"permalink": "test",
"title": "Test",
"file_path": "test.md",
"note_type": "note",
"content_type": "text/markdown",
"observations": [],
"relations": [],
"created_at": "2024-01-01T00:00:00",
"updated_at": "2024-01-01T00:00:00",
}
async def mock_call_put(client, url, **kwargs):
assert "/v2/projects/proj-123/knowledge/entities/entity-123" in url
assert kwargs.get("params") is None
return mock_response
monkeypatch.setattr("basic_memory.mcp.tools.utils.call_put", mock_call_put)
mock_http = MagicMock()
client = KnowledgeClient(mock_http, "proj-123")
result = await client.update_entity("entity-123", {"title": "Test"})
assert result.title == "Test"
@pytest.mark.asyncio
async def test_patch_entity(self, monkeypatch):
"""Test patch_entity calls correct endpoint without fast query params."""
mock_response = MagicMock()
mock_response.json.return_value = {
"permalink": "test",
"title": "Test",
"file_path": "test.md",
"note_type": "note",
"content_type": "text/markdown",
"observations": [],
"relations": [],
"created_at": "2024-01-01T00:00:00",
"updated_at": "2024-01-01T00:00:00",
}
async def mock_call_patch(client, url, **kwargs):
assert "/v2/projects/proj-123/knowledge/entities/entity-123" in url
assert kwargs.get("params") is None
return mock_response
monkeypatch.setattr("basic_memory.mcp.tools.utils.call_patch", mock_call_patch)
mock_http = MagicMock()
client = KnowledgeClient(mock_http, "proj-123")
result = await client.patch_entity("entity-123", {"operation": "append"})
assert result.title == "Test"
@pytest.mark.asyncio
async def test_resolve_entity(self, monkeypatch):
"""Test resolve_entity returns external_id."""
mock_response = MagicMock()
mock_response.json.return_value = {"external_id": "entity-uuid-123"}
async def mock_call_post(client, url, **kwargs):
assert "/v2/projects/proj-123/knowledge/resolve" in url
return mock_response
monkeypatch.setattr("basic_memory.mcp.tools.utils.call_post", mock_call_post)
mock_http = MagicMock()
client = KnowledgeClient(mock_http, "proj-123")
result = await client.resolve_entity("my-note")
assert result == "entity-uuid-123"
@pytest.mark.asyncio
async def test_sync_file(self, monkeypatch):
"""Test sync_file posts the file path to the sync-file endpoint."""
mock_response = MagicMock()
mock_response.json.return_value = {
"permalink": "notes/disk-note",
"title": "Disk Note",
"file_path": "notes/disk-note.md",
"note_type": "note",
"content_type": "text/markdown",
"observations": [],
"relations": [],
"created_at": "2024-01-01T00:00:00",
"updated_at": "2024-01-01T00:00:00",
}
async def mock_call_post(client, url, **kwargs):
assert "/v2/projects/proj-123/knowledge/sync-file" in url
assert kwargs.get("json") == {"file_path": "notes/disk-note.md"}
return mock_response
monkeypatch.setattr("basic_memory.mcp.tools.utils.call_post", mock_call_post)
mock_http = MagicMock()
client = KnowledgeClient(mock_http, "proj-123")
result = await client.sync_file("notes/disk-note.md")
assert result.file_path == "notes/disk-note.md"
@pytest.mark.asyncio
async def test_get_orphans_validates_response(self, monkeypatch):
"""Orphan responses are validated into GraphNode objects."""
from basic_memory.schemas.v2.graph import GraphNode
mock_response = MagicMock()
mock_response.json.return_value = {
"entities": [
{
"external_id": "entity-uuid-123",
"title": "Orphan Note",
"file_path": "notes/orphan.md",
"note_type": "note",
}
],
"total": 1,
}
async def mock_call_get(client, url, **kwargs):
assert "/v2/projects/proj-123/knowledge/orphans" in url
return mock_response
monkeypatch.setattr("basic_memory.mcp.tools.utils.call_get", mock_call_get)
mock_http = MagicMock()
client = KnowledgeClient(mock_http, "proj-123")
result = await client.get_orphans()
assert len(result) == 1
assert isinstance(result[0], GraphNode)
assert result[0].title == "Orphan Note"
class TestSearchClient:
"""Tests for SearchClient."""
def test_init(self):
"""Test client initialization."""
mock_http = MagicMock()
client = SearchClient(mock_http, "project-123")
assert client.http_client is mock_http
assert client.project_id == "project-123"
assert client._base_path == "/v2/projects/project-123/search"
@pytest.mark.asyncio
async def test_search(self, monkeypatch):
"""Test search calls correct endpoint."""
mock_response = MagicMock()
mock_response.json.return_value = {
"results": [],
"current_page": 1,
"page_size": 10,
}
async def mock_call_post(client, url, **kwargs):
assert "/v2/projects/proj-123/search/" in url
assert kwargs.get("params") == {"page": 1, "page_size": 10}
return mock_response
monkeypatch.setattr("basic_memory.mcp.tools.utils.call_post", mock_call_post)
mock_http = MagicMock()
client = SearchClient(mock_http, "proj-123")
result = await client.search({"text": "query"}, page=1, page_size=10)
assert result.results == []
assert result.current_page == 1
class TestMemoryClient:
"""Tests for MemoryClient."""
def test_init(self):
"""Test client initialization."""
mock_http = MagicMock()
client = MemoryClient(mock_http, "project-123")
assert client.http_client is mock_http
assert client.project_id == "project-123"
assert client._base_path == "/v2/projects/project-123/memory"
@pytest.mark.asyncio
async def test_build_context(self, monkeypatch):
"""Test build_context calls correct endpoint."""
from datetime import datetime
mock_response = MagicMock()
mock_response.json.return_value = {
"results": [],
"metadata": {
"depth": 1,
"generated_at": datetime.now().isoformat(),
},
}
async def mock_call_get(client, url, **kwargs):
assert "/v2/projects/proj-123/memory/specs/search" in url
return mock_response
monkeypatch.setattr("basic_memory.mcp.tools.utils.call_get", mock_call_get)
mock_http = MagicMock()
client = MemoryClient(mock_http, "proj-123")
result = await client.build_context("specs/search")
assert result.results == []
@pytest.mark.asyncio
async def test_recent(self, monkeypatch):
"""Test recent calls correct endpoint."""
from datetime import datetime
mock_response = MagicMock()
mock_response.json.return_value = {
"results": [],
"metadata": {
"depth": 2,
"generated_at": datetime.now().isoformat(),
},
}
async def mock_call_get(client, url, **kwargs):
assert "/v2/projects/proj-123/memory/recent" in url
params = kwargs.get("params", {})
assert params.get("timeframe") == "7d"
assert params.get("depth") == 2
return mock_response
monkeypatch.setattr("basic_memory.mcp.tools.utils.call_get", mock_call_get)
mock_http = MagicMock()
client = MemoryClient(mock_http, "proj-123")
result = await client.recent(timeframe="7d", depth=2)
assert result.results == []
assert result.metadata.depth == 2
@pytest.mark.asyncio
async def test_recent_with_types(self, monkeypatch):
"""Test recent with types filter."""
from datetime import datetime
mock_response = MagicMock()
mock_response.json.return_value = {
"results": [],
"metadata": {
"depth": 1,
"generated_at": datetime.now().isoformat(),
},
}
async def mock_call_get(client, url, **kwargs):
assert "/v2/projects/proj-123/memory/recent" in url
params = kwargs.get("params", {})
assert params.get("type") == "note,spec"
return mock_response
monkeypatch.setattr("basic_memory.mcp.tools.utils.call_get", mock_call_get)
mock_http = MagicMock()
client = MemoryClient(mock_http, "proj-123")
result = await client.recent(types=["note", "spec"])
assert result.results == []
class TestDirectoryClient:
"""Tests for DirectoryClient."""
def test_init(self):
"""Test client initialization."""
mock_http = MagicMock()
client = DirectoryClient(mock_http, "project-123")
assert client.http_client is mock_http
assert client.project_id == "project-123"
assert client._base_path == "/v2/projects/project-123/directory"
@pytest.mark.asyncio
async def test_list(self, monkeypatch):
"""Test list calls correct endpoint."""
mock_response = MagicMock()
mock_response.json.return_value = [{"name": "folder", "type": "directory"}]
async def mock_call_get(client, url, **kwargs):
assert "/v2/projects/proj-123/directory/list" in url
return mock_response
monkeypatch.setattr("basic_memory.mcp.tools.utils.call_get", mock_call_get)
mock_http = MagicMock()
client = DirectoryClient(mock_http, "proj-123")
result = await client.list("/")
assert len(result) == 1
assert result[0]["name"] == "folder"
class TestResourceClient:
"""Tests for ResourceClient."""
def test_init(self):
"""Test client initialization."""
mock_http = MagicMock()
client = ResourceClient(mock_http, "project-123")
assert client.http_client is mock_http
assert client.project_id == "project-123"
assert client._base_path == "/v2/projects/project-123/resource"
@pytest.mark.asyncio
async def test_read(self, monkeypatch):
"""Test read calls correct endpoint."""
mock_response = MagicMock()
mock_response.text = "# Note content"
async def mock_call_get(client, url, **kwargs):
assert "/v2/projects/proj-123/resource/entity-123" in url
return mock_response
monkeypatch.setattr("basic_memory.mcp.tools.utils.call_get", mock_call_get)
mock_http = MagicMock()
client = ResourceClient(mock_http, "proj-123")
result = await client.read("entity-123")
assert result.text == "# Note content"
class TestProjectClient:
"""Tests for ProjectClient."""
def test_init(self):
"""Test client initialization."""
mock_http = MagicMock()
client = ProjectClient(mock_http)
assert client.http_client is mock_http
@pytest.mark.asyncio
async def test_list_projects(self, monkeypatch):
"""Test list_projects calls correct endpoint."""
mock_response = MagicMock()
mock_response.json.return_value = {
"projects": [
{
"id": 1,
"external_id": "uuid-123",
"name": "test-project",
"path": "/path/to/project",
"is_default": True,
}
],
"default_project": "test-project",
}
async def mock_call_get(client, url, **kwargs):
assert "/v2/projects" in url
return mock_response
monkeypatch.setattr("basic_memory.mcp.tools.utils.call_get", mock_call_get)
mock_http = MagicMock()
client = ProjectClient(mock_http)
result = await client.list_projects()
assert len(result.projects) == 1
assert result.projects[0].name == "test-project"
assert result.default_project == "test-project"