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>
This commit is contained in:
phernandez
2026-06-12 00:19:03 -05:00
committed by Paul Hernandez
parent 253e240d68
commit 0247ef0ead
30 changed files with 442 additions and 179 deletions
+12 -2
View File
@@ -34,8 +34,10 @@ from basic_memory.ci.project_updates import (
from basic_memory.cli.app import app
from basic_memory.cli.commands.command_utils import run_with_cleanup
from basic_memory.cli.commands.routing import force_routing, validate_routing_flags
from basic_memory.mcp.tools import search_notes as mcp_search_notes
from basic_memory.mcp.tools import write_note as mcp_write_note
# MCP tool functions are imported inside the async helpers below: importing
# basic_memory.mcp.tools loads the entire tool stack (fastmcp, mcp SDK,
# SQLAlchemy), which would slow every CLI invocation, including --help (#886).
console = Console()
@@ -252,6 +254,10 @@ async def seed_project_update_schemas(
refresh: bool = False,
) -> list[str]:
"""Seed Auto BM schema notes without overwriting customized schemas."""
# Deferred: loading the MCP tool stack at module import slows CLI startup (#886).
from basic_memory.mcp.tools import search_notes as mcp_search_notes
from basic_memory.mcp.tools import write_note as mcp_write_note
seeded: list[str] = []
routed_project = _routed_project(project=project, project_id=project_id, workspace=workspace)
for spec in schema_seed_specs():
@@ -295,6 +301,10 @@ async def publish_project_update_note(
note: ProjectUpdateNote,
) -> dict[str, Any]:
"""Search by idempotency key and then upsert the deterministic note path."""
# Deferred: loading the MCP tool stack at module import slows CLI startup (#886).
from basic_memory.mcp.tools import search_notes as mcp_search_notes
from basic_memory.mcp.tools import write_note as mcp_write_note
routed_project = _routed_project(
project=config.project,
project_id=config.project_id,
@@ -3,12 +3,10 @@
import asyncio
from typing import Optional, TypeVar, Coroutine, Any
from mcp.server.fastmcp.exceptions import ToolError
import typer
from rich.console import Console
from basic_memory import db
from basic_memory.config import ConfigManager
from basic_memory.mcp.async_client import get_client
from basic_memory.mcp.clients import ProjectClient
@@ -31,6 +29,9 @@ def run_with_cleanup(coro: Coroutine[Any, Any, T]) -> T:
Returns:
The result of the coroutine
"""
# Deferred: basic_memory.db pulls SQLAlchemy + Alembic, which must not load
# at CLI import time — only when a command actually runs (#886).
from basic_memory import db
async def _with_cleanup() -> T:
try:
@@ -53,6 +54,8 @@ async def run_sync(
force_full: If True, force a full scan bypassing watermark optimization
run_in_background: If True, return immediately; if False, wait for completion
"""
# Deferred: ToolError lives in the mcp SDK, which must not load at CLI startup (#886).
from mcp.server.fastmcp.exceptions import ToolError
# Resolve default project so get_client() can route per-project
project = project or ConfigManager().default_project
@@ -86,6 +89,9 @@ async def run_sync(
async def get_project_info(project: str):
"""Get project information via API endpoint."""
# Deferred: ToolError lives in the mcp SDK, which must not load at CLI startup (#886).
from mcp.server.fastmcp.exceptions import ToolError
try:
async with get_client(project_name=project) as client:
project_item = await get_active_project(client, project, None)
+28 -7
View File
@@ -1,24 +1,27 @@
"""Database management commands."""
# PEP 563 lazy annotations let signatures reference IndexProgress without importing
# the indexing stack at module load; reset/reindex import their heavy database and
# sync dependencies at call time so CLI startup stays fast (#886).
from __future__ import annotations
import os
from dataclasses import dataclass
from pathlib import Path, PurePosixPath, PureWindowsPath
from typing import TYPE_CHECKING
import psutil
import typer
from loguru import logger
from rich.console import Console
from rich.progress import Progress, SpinnerColumn, TextColumn, BarColumn, TaskProgressColumn
from sqlalchemy.exc import OperationalError
from basic_memory import db
from basic_memory.cli.app import app
from basic_memory.cli.commands.command_utils import run_with_cleanup
from basic_memory.config import ConfigManager, ProjectMode
from basic_memory.indexing import IndexProgress
from basic_memory.repository import ProjectRepository
from basic_memory.services.initialization import reconcile_projects_with_config
from basic_memory.sync.sync_service import get_sync_service
if TYPE_CHECKING:
from basic_memory.indexing import IndexProgress
console = Console()
@@ -159,6 +162,13 @@ async def _reindex_projects(app_config):
This ensures all database operations use the same event loop,
and proper cleanup happens when the function completes.
"""
# Deferred: SQLAlchemy, repositories, and the sync stack load only when a
# reindex actually runs, not on every CLI start (#886).
from basic_memory import db
from basic_memory.repository import ProjectRepository
from basic_memory.services.initialization import reconcile_projects_with_config
from basic_memory.sync.sync_service import get_sync_service
try:
await reconcile_projects_with_config(app_config)
@@ -197,6 +207,12 @@ def reset(
),
): # pragma: no cover
"""Reset database (drop all tables and recreate)."""
# Deferred: SQLAlchemy and the db module load only when a reset actually
# runs, not on every CLI start (#886).
from sqlalchemy.exc import OperationalError
from basic_memory import db
console.print(
"[yellow]Note:[/yellow] This only deletes the index database. "
"Your markdown note files will not be affected.\n"
@@ -320,10 +336,15 @@ async def _reindex(
project: str | None,
):
"""Run reindex operations."""
from basic_memory.repository import EntityRepository
# Deferred: SQLAlchemy, repositories, and the sync stack load only when a
# reindex actually runs, not on every CLI start (#886).
from basic_memory import db
from basic_memory.repository import EntityRepository, ProjectRepository
from basic_memory.repository.search_repository import create_search_repository
from basic_memory.services.initialization import reconcile_projects_with_config
from basic_memory.services.search_service import SearchService
from basic_memory.services.file_service import FileService
from basic_memory.sync.sync_service import get_sync_service
from basic_memory.markdown.markdown_processor import MarkdownProcessor
from basic_memory.markdown.entity_parser import EntityParser
+9 -4
View File
@@ -7,16 +7,12 @@ import uuid
from pathlib import Path
from loguru import logger
from mcp.server.fastmcp.exceptions import ToolError
from rich.console import Console
import typer
from basic_memory.cli.app import app
from basic_memory.cli.commands.command_utils import run_with_cleanup
from basic_memory.cli.commands.routing import force_routing, validate_routing_flags
from basic_memory.markdown.entity_parser import EntityParser
from basic_memory.markdown.markdown_processor import MarkdownProcessor
from basic_memory.markdown.schemas import EntityFrontmatter, EntityMarkdown
from basic_memory.mcp.async_client import get_client
from basic_memory.mcp.clients import KnowledgeClient, ProjectClient, SearchClient
from basic_memory.schemas.base import Entity
@@ -29,6 +25,12 @@ console = Console()
async def run_doctor() -> None:
"""Run local consistency checks for file <-> database flows."""
# Deferred: the markdown parsing stack is only needed while the checks run,
# and importing it at module level slows every CLI invocation (#886).
from basic_memory.markdown.entity_parser import EntityParser
from basic_memory.markdown.markdown_processor import MarkdownProcessor
from basic_memory.markdown.schemas import EntityFrontmatter, EntityMarkdown
console.print("[blue]Running Basic Memory doctor checks...[/blue]")
project_name = f"doctor-{uuid.uuid4().hex[:8]}"
@@ -140,6 +142,9 @@ def doctor(
cloud: bool = typer.Option(False, "--cloud", help="Force cloud API routing"),
) -> None:
"""Run local consistency checks to verify file/database sync."""
# Deferred: ToolError lives in the mcp SDK, which must not load at CLI startup (#886).
from mcp.server.fastmcp.exceptions import ToolError
try:
validate_routing_flags(local, cloud)
# Doctor runs local filesystem checks — always default to local routing
@@ -1,25 +1,34 @@
"""Import command for ChatGPT conversations."""
# PEP 563 lazy annotations keep heavy importer types out of module import (#886).
from __future__ import annotations
import json
from pathlib import Path
from typing import Annotated, Tuple
from typing import TYPE_CHECKING, Annotated, Tuple
import typer
from basic_memory.cli.app import import_app
from basic_memory.cli.commands.command_utils import run_with_cleanup
from basic_memory.config import ConfigManager, get_project_config
from basic_memory.importers import ChatGPTImporter
from basic_memory.markdown import EntityParser, MarkdownProcessor
from basic_memory.services.file_service import FileService
from loguru import logger
from rich.console import Console
from rich.panel import Panel
if TYPE_CHECKING:
from basic_memory.markdown import MarkdownProcessor
from basic_memory.services.file_service import FileService
console = Console()
async def get_importer_dependencies() -> Tuple[MarkdownProcessor, FileService]:
"""Get MarkdownProcessor and FileService instances for importers."""
# Deferred: the markdown/file-service stack pulls SQLAlchemy and must load
# only when an import actually runs, not on every CLI start (#886).
from basic_memory.markdown import EntityParser, MarkdownProcessor
from basic_memory.services.file_service import FileService
config = get_project_config()
app_config = ConfigManager().config
entity_parser = EntityParser(config.home)
@@ -60,6 +69,9 @@ def import_chatgpt(
console.print(f"\nImporting chats from {conversations_json}...writing to {base_path}")
# Create importer and run import
# Deferred: importer stack loads at import-command run time only (#886).
from basic_memory.importers import ChatGPTImporter
importer = ChatGPTImporter(
config.home, markdown_processor, file_service, project_name=config.name
)
@@ -1,25 +1,34 @@
"""Import command for basic-memory CLI to import chat data from conversations2.json format."""
# PEP 563 lazy annotations keep heavy importer types out of module import (#886).
from __future__ import annotations
import json
from pathlib import Path
from typing import Annotated, Tuple
from typing import TYPE_CHECKING, Annotated, Tuple
import typer
from basic_memory.cli.app import claude_app
from basic_memory.cli.commands.command_utils import run_with_cleanup
from basic_memory.config import ConfigManager, get_project_config
from basic_memory.importers.claude_conversations_importer import ClaudeConversationsImporter
from basic_memory.markdown import EntityParser, MarkdownProcessor
from basic_memory.services.file_service import FileService
from loguru import logger
from rich.console import Console
from rich.panel import Panel
if TYPE_CHECKING:
from basic_memory.markdown import MarkdownProcessor
from basic_memory.services.file_service import FileService
console = Console()
async def get_importer_dependencies() -> Tuple[MarkdownProcessor, FileService]:
"""Get MarkdownProcessor and FileService instances for importers."""
# Deferred: the markdown/file-service stack pulls SQLAlchemy and must load
# only when an import actually runs, not on every CLI start (#886).
from basic_memory.markdown import EntityParser, MarkdownProcessor
from basic_memory.services.file_service import FileService
config = get_project_config()
app_config = ConfigManager().config
entity_parser = EntityParser(config.home)
@@ -57,6 +66,9 @@ def import_claude(
markdown_processor, file_service = run_with_cleanup(get_importer_dependencies())
# Create the importer
# Deferred: importer stack loads at import-command run time only (#886).
from basic_memory.importers.claude_conversations_importer import ClaudeConversationsImporter
importer = ClaudeConversationsImporter(
config.home, markdown_processor, file_service, project_name=config.name
)
@@ -1,25 +1,34 @@
"""Import command for basic-memory CLI to import project data from Claude.ai."""
# PEP 563 lazy annotations keep heavy importer types out of module import (#886).
from __future__ import annotations
import json
from pathlib import Path
from typing import Annotated, Tuple
from typing import TYPE_CHECKING, Annotated, Tuple
import typer
from basic_memory.cli.app import claude_app
from basic_memory.cli.commands.command_utils import run_with_cleanup
from basic_memory.config import ConfigManager, get_project_config
from basic_memory.importers.claude_projects_importer import ClaudeProjectsImporter
from basic_memory.markdown import EntityParser, MarkdownProcessor
from basic_memory.services.file_service import FileService
from loguru import logger
from rich.console import Console
from rich.panel import Panel
if TYPE_CHECKING:
from basic_memory.markdown import MarkdownProcessor
from basic_memory.services.file_service import FileService
console = Console()
async def get_importer_dependencies() -> Tuple[MarkdownProcessor, FileService]:
"""Get MarkdownProcessor and FileService instances for importers."""
# Deferred: the markdown/file-service stack pulls SQLAlchemy and must load
# only when an import actually runs, not on every CLI start (#886).
from basic_memory.markdown import EntityParser, MarkdownProcessor
from basic_memory.services.file_service import FileService
config = get_project_config()
app_config = ConfigManager().config
entity_parser = EntityParser(config.home)
@@ -56,6 +65,9 @@ def import_projects(
markdown_processor, file_service = run_with_cleanup(get_importer_dependencies())
# Create the importer
# Deferred: importer stack loads at import-command run time only (#886).
from basic_memory.importers.claude_projects_importer import ClaudeProjectsImporter
importer = ClaudeProjectsImporter(
config.home, markdown_processor, file_service, project_name=config.name
)
@@ -1,25 +1,34 @@
"""Import command for basic-memory CLI to import from JSON memory format."""
# PEP 563 lazy annotations keep heavy importer types out of module import (#886).
from __future__ import annotations
import json
from pathlib import Path
from typing import Annotated, Tuple
from typing import TYPE_CHECKING, Annotated, Tuple
import typer
from basic_memory.cli.app import import_app
from basic_memory.cli.commands.command_utils import run_with_cleanup
from basic_memory.config import ConfigManager, get_project_config
from basic_memory.importers.memory_json_importer import MemoryJsonImporter
from basic_memory.markdown import EntityParser, MarkdownProcessor
from basic_memory.services.file_service import FileService
from loguru import logger
from rich.console import Console
from rich.panel import Panel
if TYPE_CHECKING:
from basic_memory.markdown import MarkdownProcessor
from basic_memory.services.file_service import FileService
console = Console()
async def get_importer_dependencies() -> Tuple[MarkdownProcessor, FileService]:
"""Get MarkdownProcessor and FileService instances for importers."""
# Deferred: the markdown/file-service stack pulls SQLAlchemy and must load
# only when an import actually runs, not on every CLI start (#886).
from basic_memory.markdown import EntityParser, MarkdownProcessor
from basic_memory.services.file_service import FileService
config = get_project_config()
app_config = ConfigManager().config
entity_parser = EntityParser(config.home)
@@ -55,6 +64,9 @@ def memory_json(
markdown_processor, file_service = run_with_cleanup(get_importer_dependencies())
# Create the importer
# Deferred: importer stack loads at import-command run time only (#886).
from basic_memory.importers.memory_json_importer import MemoryJsonImporter
importer = MemoryJsonImporter(
config.home, markdown_processor, file_service, project_name=config.name
)
+3 -1
View File
@@ -5,7 +5,6 @@ from typing import Annotated, Optional
import typer
from loguru import logger
from mcp.server.fastmcp.exceptions import ToolError
from rich.console import Console
from rich.table import Table
@@ -50,6 +49,9 @@ def orphans(
"""
from basic_memory.cli.commands.command_utils import run_with_cleanup
# Deferred: ToolError lives in the mcp SDK, which must not load at CLI startup (#886).
from mcp.server.fastmcp.exceptions import ToolError
try:
validate_routing_flags(local, cloud)
with force_routing(local=local, cloud=cloud):
+13 -3
View File
@@ -20,9 +20,10 @@ from basic_memory.cli.app import app
from basic_memory.cli.commands.command_utils import run_with_cleanup
from basic_memory.cli.commands.routing import force_routing, validate_routing_flags
from basic_memory.config import ConfigManager
from basic_memory.mcp.tools import schema_diff as mcp_schema_diff
from basic_memory.mcp.tools import schema_infer as mcp_schema_infer
from basic_memory.mcp.tools import schema_validate as mcp_schema_validate
# MCP tool functions are imported inside each command: importing
# basic_memory.mcp.tools loads the entire tool stack (fastmcp, mcp SDK,
# SQLAlchemy), which would slow every CLI invocation, including --help (#886).
console = Console()
@@ -189,6 +190,9 @@ def validate(
Use --local to force local routing when cloud mode is enabled.
Use --cloud to force cloud routing when cloud mode is disabled.
"""
# Deferred: loading the MCP tool stack at module import slows CLI startup (#886).
from basic_memory.mcp.tools import schema_validate as mcp_schema_validate
try:
validate_routing_flags(local, cloud)
project_name = _resolve_project_name(project)
@@ -272,6 +276,9 @@ def infer(
Use --local to force local routing when cloud mode is enabled.
Use --cloud to force cloud routing when cloud mode is disabled.
"""
# Deferred: loading the MCP tool stack at module import slows CLI startup (#886).
from basic_memory.mcp.tools import schema_infer as mcp_schema_infer
try:
validate_routing_flags(local, cloud)
project_name = _resolve_project_name(project)
@@ -352,6 +359,9 @@ def diff(
Use --local to force local routing when cloud mode is enabled.
Use --cloud to force cloud routing when cloud mode is disabled.
"""
# Deferred: loading the MCP tool stack at module import slows CLI startup (#886).
from basic_memory.mcp.tools import schema_diff as mcp_schema_diff
try:
validate_routing_flags(local, cloud)
project_name = _resolve_project_name(project)
+3 -1
View File
@@ -5,7 +5,6 @@ import json
import time
from typing import Annotated, Dict, Optional, Set
from mcp.server.fastmcp.exceptions import ToolError
import typer
from loguru import logger
from rich.console import Console
@@ -231,6 +230,9 @@ def status(
"""
from basic_memory.cli.commands.command_utils import run_with_cleanup
# Deferred: ToolError lives in the mcp SDK, which must not load at CLI startup (#886).
from mcp.server.fastmcp.exceptions import ToolError
# Trigger: --wait with a negative --timeout
# Why: a negative deadline times out on the very first poll, producing a confusing
# "Timed out after -5s" message instead of flagging the bad input. Raised
+40 -12
View File
@@ -14,18 +14,10 @@ from loguru import logger
from basic_memory.cli.app import app
from basic_memory.cli.commands.command_utils import run_with_cleanup
from basic_memory.cli.commands.routing import force_routing, validate_routing_flags
from basic_memory.mcp.tools import build_context as mcp_build_context
from basic_memory.mcp.tools import delete_note as mcp_delete_note
from basic_memory.mcp.tools import edit_note as mcp_edit_note
from basic_memory.mcp.tools import list_memory_projects as mcp_list_projects
from basic_memory.mcp.tools import list_workspaces as mcp_list_workspaces
from basic_memory.mcp.tools import read_note as mcp_read_note
from basic_memory.mcp.tools import recent_activity as mcp_recent_activity
from basic_memory.mcp.tools import schema_diff as mcp_schema_diff
from basic_memory.mcp.tools import schema_infer as mcp_schema_infer
from basic_memory.mcp.tools import schema_validate as mcp_schema_validate
from basic_memory.mcp.tools import search_notes as mcp_search
from basic_memory.mcp.tools import write_note as mcp_write_note
# MCP tool functions are imported inside each command: importing
# basic_memory.mcp.tools loads the entire tool stack (fastmcp, mcp SDK,
# SQLAlchemy), which would slow every CLI invocation, including --help (#886).
tool_app = typer.Typer()
app.add_typer(tool_app, name="tool", help="Access to MCP tools via CLI")
@@ -120,6 +112,9 @@ def write_note(
bm tool write-note --title "My Note" --folder "notes" --overwrite
bm tool write-note --title "My Note" --folder "notes" --local
"""
# Deferred: loading the MCP tool stack at module import slows CLI startup (#886).
from basic_memory.mcp.tools import write_note as mcp_write_note
try:
validate_routing_flags(local, cloud)
@@ -206,6 +201,9 @@ def read_note(
bm tool read-note my-note
bm tool read-note my-note --include-frontmatter
"""
# Deferred: loading the MCP tool stack at module import slows CLI startup (#886).
from basic_memory.mcp.tools import read_note as mcp_read_note
try:
validate_routing_flags(local, cloud)
@@ -272,6 +270,9 @@ def delete_note(
bm tool delete-note notes/old-draft
bm tool delete-note docs/archive --is-directory
"""
# Deferred: loading the MCP tool stack at module import slows CLI startup (#886).
from basic_memory.mcp.tools import delete_note as mcp_delete_note
try:
validate_routing_flags(local, cloud)
@@ -346,6 +347,9 @@ def edit_note(
bm tool edit-note my-note --operation find_replace --find-text "old" --content "new"
bm tool edit-note my-note --operation replace_section --section "## Notes" --content "updated"
"""
# Deferred: loading the MCP tool stack at module import slows CLI startup (#886).
from basic_memory.mcp.tools import edit_note as mcp_edit_note
try:
validate_routing_flags(local, cloud)
@@ -413,6 +417,9 @@ def build_context(
bm tool build-context memory://specs/search
bm tool build-context specs/search --depth 2 --timeframe 30d
"""
# Deferred: loading the MCP tool stack at module import slows CLI startup (#886).
from basic_memory.mcp.tools import build_context as mcp_build_context
try:
validate_routing_flags(local, cloud)
@@ -476,6 +483,9 @@ def recent_activity(
bm tool recent-activity --timeframe 30d --page-size 20
bm tool recent-activity --type entity --type observation
"""
# Deferred: loading the MCP tool stack at module import slows CLI startup (#886).
from basic_memory.mcp.tools import recent_activity as mcp_recent_activity
try:
validate_routing_flags(local, cloud)
@@ -582,6 +592,9 @@ def search_notes(
bm tool search-notes --meta status=draft
bm tool search-notes "auth" --entity-type observation --category requirement
"""
# Deferred: loading the MCP tool stack at module import slows CLI startup (#886).
from basic_memory.mcp.tools import search_notes as mcp_search
try:
validate_routing_flags(local, cloud)
@@ -687,6 +700,9 @@ def list_projects(
bm tool list-projects
bm tool list-projects --local
"""
# Deferred: loading the MCP tool stack at module import slows CLI startup (#886).
from basic_memory.mcp.tools import list_memory_projects as mcp_list_projects
try:
validate_routing_flags(local, cloud)
@@ -720,6 +736,9 @@ def list_workspaces(
bm tool list-workspaces
bm tool list-workspaces --cloud
"""
# Deferred: loading the MCP tool stack at module import slows CLI startup (#886).
from basic_memory.mcp.tools import list_workspaces as mcp_list_workspaces
try:
validate_routing_flags(local, cloud)
@@ -772,6 +791,9 @@ def schema_validate(
bm tool schema-validate people/ada-lovelace.md
bm tool schema-validate --project research
"""
# Deferred: loading the MCP tool stack at module import slows CLI startup (#886).
from basic_memory.mcp.tools import schema_validate as mcp_schema_validate
try:
validate_routing_flags(local, cloud)
@@ -840,6 +862,9 @@ def schema_infer(
bm tool schema-infer meeting --threshold 0.5
bm tool schema-infer person --project research
"""
# Deferred: loading the MCP tool stack at module import slows CLI startup (#886).
from basic_memory.mcp.tools import schema_infer as mcp_schema_infer
try:
validate_routing_flags(local, cloud)
@@ -896,6 +921,9 @@ def schema_diff(
bm tool schema-diff person
bm tool schema-diff person --project research
"""
# Deferred: loading the MCP tool stack at module import slows CLI startup (#886).
from basic_memory.mcp.tools import schema_diff as mcp_schema_diff
try:
validate_routing_flags(local, cloud)
+15 -11
View File
@@ -5,7 +5,6 @@ from dataclasses import dataclass
from threading import RLock
from typing import TYPE_CHECKING, Annotated, Any, AsyncIterator, Callable, Optional
from fastapi import Depends, FastAPI, Request
from httpx import ASGITransport, AsyncClient, Timeout
from loguru import logger
@@ -13,6 +12,9 @@ import logfire
from basic_memory.config import ConfigManager, ProjectMode, has_cloud_credentials
if TYPE_CHECKING:
# FastAPI is only needed when a request routes through the local ASGI
# transport; importing it at module level costs ~0.1s on every CLI start (#886).
from fastapi import FastAPI
from sqlalchemy.ext.asyncio import AsyncEngine, AsyncSession, async_sessionmaker
LocalDatabaseState = tuple["AsyncEngine", "async_sessionmaker[AsyncSession]"]
@@ -28,8 +30,8 @@ class _PreparedLocalAsgiDatabase:
_prepared_local_asgi_database_lock = RLock()
_prepared_local_asgi_database_prepare_locks: dict[FastAPI, Lock] = {}
_prepared_local_asgi_databases: dict[FastAPI, _PreparedLocalAsgiDatabase] = {}
_prepared_local_asgi_database_prepare_locks: dict["FastAPI", Lock] = {}
_prepared_local_asgi_databases: dict["FastAPI", _PreparedLocalAsgiDatabase] = {}
def _force_local_mode() -> bool:
@@ -57,7 +59,7 @@ def _build_timeout() -> Timeout:
)
def _build_asgi_client(app: FastAPI, timeout: Timeout) -> AsyncClient:
def _build_asgi_client(app: "FastAPI", timeout: Timeout) -> AsyncClient:
"""Create a local ASGI client for an already-prepared FastAPI app."""
from basic_memory.workspace_context import workspace_permalink_headers
@@ -71,7 +73,7 @@ def _build_asgi_client(app: FastAPI, timeout: Timeout) -> AsyncClient:
)
def _get_prepared_local_asgi_database_prepare_lock(app: FastAPI) -> Lock:
def _get_prepared_local_asgi_database_prepare_lock(app: "FastAPI") -> Lock:
"""Get the async lock that serializes first-time DB preparation for an app."""
with _prepared_local_asgi_database_lock:
prepare_lock = _prepared_local_asgi_database_prepare_locks.get(app)
@@ -82,8 +84,10 @@ def _get_prepared_local_asgi_database_prepare_lock(app: FastAPI) -> Lock:
@asynccontextmanager
async def _resolve_local_asgi_database(app: FastAPI) -> AsyncIterator[LocalDatabaseState]:
async def _resolve_local_asgi_database(app: "FastAPI") -> AsyncIterator[LocalDatabaseState]:
"""Resolve database state for a local ASGI request."""
# Imported on first local-ASGI use so CLI startup never pays for FastAPI (#886).
from fastapi import Depends, Request
from fastapi.dependencies.utils import get_dependant, solve_dependencies
from basic_memory.deps import get_engine_factory
@@ -127,7 +131,7 @@ async def _resolve_local_asgi_database(app: FastAPI) -> AsyncIterator[LocalDatab
yield await resolve_database_state(**solved.values)
def _retain_prepared_local_asgi_database(app: FastAPI) -> bool:
def _retain_prepared_local_asgi_database(app: "FastAPI") -> bool:
"""Retain an active local ASGI database preparation if one exists."""
with _prepared_local_asgi_database_lock:
active = _prepared_local_asgi_databases.get(app)
@@ -139,7 +143,7 @@ def _retain_prepared_local_asgi_database(app: FastAPI) -> bool:
def _install_prepared_local_asgi_database(
app: FastAPI,
app: "FastAPI",
database_state: LocalDatabaseState,
dependency_context: AbstractAsyncContextManager[LocalDatabaseState],
) -> None:
@@ -163,7 +167,7 @@ def _install_prepared_local_asgi_database(
)
def _restore_local_asgi_state_attribute(app: FastAPI, name: str, previous_value: object) -> None:
def _restore_local_asgi_state_attribute(app: "FastAPI", name: str, previous_value: object) -> None:
"""Restore a FastAPI app.state attribute captured before local ASGI preparation."""
if previous_value is _MISSING_STATE_VALUE:
if hasattr(app.state, name):
@@ -173,7 +177,7 @@ def _restore_local_asgi_state_attribute(app: FastAPI, name: str, previous_value:
def _release_prepared_local_asgi_database(
app: FastAPI,
app: "FastAPI",
) -> AbstractAsyncContextManager[LocalDatabaseState] | None:
"""Release local ASGI database state after a client context exits."""
with _prepared_local_asgi_database_lock:
@@ -196,7 +200,7 @@ def _release_prepared_local_asgi_database(
@asynccontextmanager
async def _prepared_local_asgi_database(app: FastAPI) -> AsyncIterator[None]:
async def _prepared_local_asgi_database(app: "FastAPI") -> AsyncIterator[None]:
"""Initialize local ASGI database state before the first request."""
prepare_lock = _get_prepared_local_asgi_database_prepare_lock(app)
async with prepare_lock:
+5 -1
View File
@@ -7,7 +7,9 @@ from typing import Optional, Any
from httpx import AsyncClient
from basic_memory.mcp.tools.utils import call_get
# call_* helpers live in basic_memory.mcp.tools.utils; importing that at module
# level executes the whole tools package (fastmcp + mcp SDK) during CLI startup,
# so each method defers the import to call time instead (#886).
class DirectoryClient:
@@ -55,6 +57,8 @@ class DirectoryClient:
Raises:
ToolError: If the request fails
"""
from basic_memory.mcp.tools.utils import call_get
params: dict = {
"dir_name": dir_name,
"depth": depth,
+26 -1
View File
@@ -8,7 +8,10 @@ from typing import Any
from httpx import AsyncClient
import logfire
from basic_memory.mcp.tools.utils import call_get, call_post, call_put, call_patch, call_delete
# call_* helpers live in basic_memory.mcp.tools.utils; importing that at module
# level executes the whole tools package (fastmcp + mcp SDK) during CLI startup,
# so each method defers the import to call time instead (#886).
from basic_memory.schemas.response import (
EntityResponse,
DeleteEntitiesResponse,
@@ -57,6 +60,8 @@ class KnowledgeClient:
Raises:
ToolError: If the request fails
"""
from basic_memory.mcp.tools.utils import call_post
with logfire.span(
"mcp.client.knowledge.create_entity",
client_name="knowledge",
@@ -89,6 +94,8 @@ class KnowledgeClient:
Raises:
ToolError: If the request fails
"""
from basic_memory.mcp.tools.utils import call_put
with logfire.span(
"mcp.client.knowledge.update_entity",
client_name="knowledge",
@@ -116,6 +123,8 @@ class KnowledgeClient:
Raises:
ToolError: If the entity is not found or request fails
"""
from basic_memory.mcp.tools.utils import call_get
with logfire.span(
"mcp.client.knowledge.get_entity",
client_name="knowledge",
@@ -147,6 +156,8 @@ class KnowledgeClient:
Raises:
ToolError: If the request fails
"""
from basic_memory.mcp.tools.utils import call_patch
with logfire.span(
"mcp.client.knowledge.patch_entity",
client_name="knowledge",
@@ -174,6 +185,8 @@ class KnowledgeClient:
Raises:
ToolError: If the entity is not found or request fails
"""
from basic_memory.mcp.tools.utils import call_delete
with logfire.span(
"mcp.client.knowledge.delete_entity",
client_name="knowledge",
@@ -201,6 +214,8 @@ class KnowledgeClient:
Raises:
ToolError: If the request fails
"""
from basic_memory.mcp.tools.utils import call_put
with logfire.span(
"mcp.client.knowledge.move_entity",
client_name="knowledge",
@@ -231,6 +246,8 @@ class KnowledgeClient:
Raises:
ToolError: If the request fails
"""
from basic_memory.mcp.tools.utils import call_post
with logfire.span(
"mcp.client.knowledge.move_directory",
client_name="knowledge",
@@ -261,6 +278,8 @@ class KnowledgeClient:
Raises:
ToolError: If the request fails
"""
from basic_memory.mcp.tools.utils import call_post
with logfire.span(
"mcp.client.knowledge.delete_directory",
client_name="knowledge",
@@ -290,6 +309,8 @@ class KnowledgeClient:
Raises:
ToolError: If the file does not exist on disk or indexing fails
"""
from basic_memory.mcp.tools.utils import call_post
with logfire.span(
"mcp.client.knowledge.sync_file",
client_name="knowledge",
@@ -309,6 +330,8 @@ class KnowledgeClient:
async def get_orphans(self) -> list[GraphNode]:
"""Get entities that have no incoming or outgoing relations."""
from basic_memory.mcp.tools.utils import call_get
with logfire.span(
"mcp.client.knowledge.get_orphans",
client_name="knowledge",
@@ -338,6 +361,8 @@ class KnowledgeClient:
Raises:
ToolError: If the identifier cannot be resolved
"""
from basic_memory.mcp.tools.utils import call_post
with logfire.span(
"mcp.client.knowledge.resolve_entity",
client_name="knowledge",
+8 -1
View File
@@ -8,7 +8,10 @@ from typing import Optional
from httpx import AsyncClient
import logfire
from basic_memory.mcp.tools.utils import call_get
# call_* helpers live in basic_memory.mcp.tools.utils; importing that at module
# level executes the whole tools package (fastmcp + mcp SDK) during CLI startup,
# so each method defers the import to call time instead (#886).
from basic_memory.schemas.memory import GraphContext
@@ -63,6 +66,8 @@ class MemoryClient:
Raises:
ToolError: If the request fails
"""
from basic_memory.mcp.tools.utils import call_get
params: dict = {
"depth": depth,
"page": page,
@@ -113,6 +118,8 @@ class MemoryClient:
Raises:
ToolError: If the request fails
"""
from basic_memory.mcp.tools.utils import call_get
params: dict = {
"timeframe": timeframe,
"depth": depth,
+21 -7
View File
@@ -7,13 +7,9 @@ from typing import Any
from httpx import AsyncClient
from basic_memory.mcp.tools.utils import (
call_delete,
call_get,
call_patch,
call_post,
call_put,
)
# call_* helpers live in basic_memory.mcp.tools.utils; importing that at module
# level executes the whole tools package (fastmcp + mcp SDK) during CLI startup,
# so each method defers the import to call time instead (#886).
from basic_memory.schemas import ProjectInfoResponse, SyncReportResponse
from basic_memory.schemas.project_info import ProjectList, ProjectStatusResponse
from basic_memory.schemas.v2 import ProjectResolveResponse
@@ -53,6 +49,8 @@ class ProjectClient:
Raises:
ToolError: If the request fails
"""
from basic_memory.mcp.tools.utils import call_get
response = await call_get(
self.http_client,
"/v2/projects/",
@@ -71,6 +69,8 @@ class ProjectClient:
Raises:
ToolError: If the request fails
"""
from basic_memory.mcp.tools.utils import call_post
response = await call_post(
self.http_client,
"/v2/projects/",
@@ -93,6 +93,8 @@ class ProjectClient:
Raises:
ToolError: If the request fails
"""
from basic_memory.mcp.tools.utils import call_delete
url = f"/v2/projects/{project_external_id}"
if delete_notes:
url += "?delete_notes=true"
@@ -114,6 +116,8 @@ class ProjectClient:
Raises:
ToolError: If the request fails
"""
from basic_memory.mcp.tools.utils import call_post
response = await call_post(
self.http_client,
"/v2/projects/resolve",
@@ -133,6 +137,8 @@ class ProjectClient:
Raises:
ToolError: If the request fails
"""
from basic_memory.mcp.tools.utils import call_put
response = await call_put(
self.http_client,
f"/v2/projects/{project_external_id}/default",
@@ -154,6 +160,8 @@ class ProjectClient:
Raises:
ToolError: If the request fails
"""
from basic_memory.mcp.tools.utils import call_patch
response = await call_patch(
self.http_client,
f"/v2/projects/{project_external_id}",
@@ -181,6 +189,8 @@ class ProjectClient:
Raises:
ToolError: If the request fails
"""
from basic_memory.mcp.tools.utils import call_post
url = f"/v2/projects/{project_external_id}/sync"
params = []
if force_full:
@@ -204,6 +214,8 @@ class ProjectClient:
Raises:
ToolError: If the request fails
"""
from basic_memory.mcp.tools.utils import call_post
response = await call_post(
self.http_client,
f"/v2/projects/{project_external_id}/status",
@@ -222,6 +234,8 @@ class ProjectClient:
Raises:
ToolError: If the request fails
"""
from basic_memory.mcp.tools.utils import call_get
response = await call_get(
self.http_client,
f"/v2/projects/{project_external_id}/info",
+5 -1
View File
@@ -6,7 +6,9 @@ Encapsulates all /v2/projects/{project_id}/resource/* endpoints.
from httpx import AsyncClient, Response
import logfire
from basic_memory.mcp.tools.utils import call_get
# call_* helpers live in basic_memory.mcp.tools.utils; importing that at module
# level executes the whole tools package (fastmcp + mcp SDK) during CLI startup,
# so each method defers the import to call time instead (#886).
class ResourceClient:
@@ -49,6 +51,8 @@ class ResourceClient:
Raises:
ToolError: If the resource is not found or request fails
"""
from basic_memory.mcp.tools.utils import call_get
with logfire.span(
"mcp.client.resource.read",
client_name="resource",
+9 -1
View File
@@ -5,7 +5,9 @@ Encapsulates all /v2/projects/{project_id}/schema/* endpoints.
from httpx import AsyncClient
from basic_memory.mcp.tools.utils import call_post, call_get
# call_* helpers live in basic_memory.mcp.tools.utils; importing that at module
# level executes the whole tools package (fastmcp + mcp SDK) during CLI startup,
# so each method defers the import to call time instead (#886).
from basic_memory.schemas.schema import (
ValidationReport,
InferenceReport,
@@ -56,6 +58,8 @@ class SchemaClient:
Raises:
ToolError: If the request fails
"""
from basic_memory.mcp.tools.utils import call_post
params: dict[str, str] = {}
if note_type:
params["note_type"] = note_type
@@ -87,6 +91,8 @@ class SchemaClient:
Raises:
ToolError: If the request fails
"""
from basic_memory.mcp.tools.utils import call_post
response = await call_post(
self.http_client,
f"{self._base_path}/infer",
@@ -106,6 +112,8 @@ class SchemaClient:
Raises:
ToolError: If the request fails
"""
from basic_memory.mcp.tools.utils import call_get
response = await call_get(
self.http_client,
f"{self._base_path}/diff/{note_type}",
+6 -1
View File
@@ -8,7 +8,10 @@ from typing import Any
from httpx import AsyncClient
import logfire
from basic_memory.mcp.tools.utils import call_post
# call_* helpers live in basic_memory.mcp.tools.utils; importing that at module
# level executes the whole tools package (fastmcp + mcp SDK) during CLI startup,
# so each method defers the import to call time instead (#886).
from basic_memory.schemas.search import SearchResponse
@@ -57,6 +60,8 @@ class SearchClient:
Raises:
ToolError: If the request fails
"""
from basic_memory.mcp.tools.utils import call_post
with logfire.span(
"mcp.client.search.search",
client_name="search",
+24 -3
View File
@@ -8,10 +8,24 @@ The resolve_project_parameter function is a thin wrapper for backwards
compatibility with existing MCP tools.
"""
# PEP 563 lazy annotations keep `Context` usable in signatures without importing
# fastmcp at module load — the fastmcp/mcp stack costs ~0.5s of CLI startup (#886).
from __future__ import annotations
import asyncio
from contextlib import asynccontextmanager, nullcontext
from typing import (
TYPE_CHECKING,
AsyncIterator,
Awaitable,
Callable,
List,
Optional,
Sequence,
Tuple,
cast,
)
from dataclasses import dataclass, field
from typing import AsyncIterator, Awaitable, Callable, List, Optional, Sequence, Tuple, cast
from uuid import UUID
from httpx import AsyncClient
@@ -19,8 +33,6 @@ from httpx._types import (
HeaderTypes,
)
from loguru import logger
from fastmcp import Context
from mcp.server.fastmcp.exceptions import ToolError
import logfire
from basic_memory.config import BasicMemoryConfig, ConfigManager, ProjectMode, has_cloud_credentials
@@ -46,6 +58,9 @@ from basic_memory.workspace_context import (
workspace_permalink_context,
)
if TYPE_CHECKING:
from fastmcp import Context
# --- Workspace provider injection ---
# Mirrors the set_client_factory() pattern in async_client.py.
# The cloud MCP server sets a provider that queries its own database directly,
@@ -1332,6 +1347,9 @@ async def resolve_project_and_path(
# Why: allow project-scoped memory URLs without requiring a separate project parameter
# Outcome: attempt to resolve the prefix as a project and route to it
if project_prefix:
# Deferred: ToolError lives in the mcp SDK, which must not load at CLI startup (#886).
from mcp.server.fastmcp.exceptions import ToolError
if cached_project and _project_matches_identifier(cached_project, project_prefix):
resolved_project = await resolve_project_parameter(project_prefix, context=context)
if resolved_project and generate_permalink(resolved_project) != generate_permalink(
@@ -1558,6 +1576,9 @@ async def get_project_client(
is_factory_mode,
)
# Deferred: ToolError lives in the mcp SDK, which must not load at CLI startup (#886).
from mcp.server.fastmcp.exceptions import ToolError
# When project_id (UUID) is provided, prefer it as the resolution identifier.
# external_id is unambiguous across workspaces; project name can collide.
project_identifier = project_id if project_id else project
+4 -1
View File
@@ -19,7 +19,6 @@ from pathlib import Path
from typing import List, Optional, Annotated, Dict
from annotated_types import MinLen, MaxLen
from dateparser import parse
from pydantic import BaseModel, BeforeValidator, Field, model_validator, computed_field
@@ -92,6 +91,10 @@ def parse_timeframe(timeframe: str) -> datetime:
parse_timeframe('1d') -> 2025-06-04 14:50:00-07:00 (24 hours ago with local timezone)
parse_timeframe('1 week ago') -> 2025-05-29 14:50:00-07:00 (1 week ago with local timezone)
"""
# Deferred: dateparser costs ~0.13s to import; schemas load on every CLI
# start, but timeframe parsing only happens per request (#886).
from dateparser import parse
if timeframe.lower() == "today":
# For "today", return 1 day ago to ensure we capture recent activity across timezones
# This handles the case where client and server are in different timezones
+10 -10
View File
@@ -219,8 +219,8 @@ def test_setup_does_not_partially_write_generated_files_when_target_exists(
mock_seed.assert_not_awaited()
@patch("basic_memory.cli.commands.ci.mcp_search_notes", new_callable=AsyncMock)
@patch("basic_memory.cli.commands.ci.mcp_write_note", new_callable=AsyncMock)
@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,
@@ -235,8 +235,8 @@ async def test_seed_project_update_schemas_skips_existing_notes_by_default(
mock_write.assert_not_awaited()
@patch("basic_memory.cli.commands.ci.mcp_search_notes", new_callable=AsyncMock)
@patch("basic_memory.cli.commands.ci.mcp_write_note", new_callable=AsyncMock)
@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,
@@ -327,8 +327,8 @@ def test_agent_schema_command_writes_schema(tmp_path: Path) -> None:
assert schema["title"] == "AgentSynthesis"
@patch("basic_memory.cli.commands.ci.mcp_search_notes", new_callable=AsyncMock)
@patch("basic_memory.cli.commands.ci.mcp_write_note", new_callable=AsyncMock)
@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,
@@ -401,8 +401,8 @@ def test_publish_command_upserts_project_update_note(
)
@patch("basic_memory.cli.commands.ci.mcp_search_notes", new_callable=AsyncMock)
@patch("basic_memory.cli.commands.ci.mcp_write_note", new_callable=AsyncMock)
@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,
@@ -464,8 +464,8 @@ def test_publish_command_preserves_existing_note_path_for_idempotency_match(
assert kwargs["directory"] == "custom/project-updates"
@patch("basic_memory.cli.commands.ci.mcp_search_notes", new_callable=AsyncMock)
@patch("basic_memory.cli.commands.ci.mcp_write_note", new_callable=AsyncMock)
@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,
+40
View File
@@ -86,6 +86,46 @@ def test_bm_version_does_not_import_heavy_modules():
)
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 = (
+11 -11
View File
@@ -87,7 +87,7 @@ def _mock_config_manager():
@patch("basic_memory.cli.commands.schema.ConfigManager")
@patch(
"basic_memory.cli.commands.schema.mcp_schema_validate",
"basic_memory.mcp.tools.schema_validate",
new_callable=AsyncMock,
return_value=VALIDATE_REPORT,
)
@@ -108,7 +108,7 @@ def test_validate_renders_table(mock_mcp, mock_config_cls):
@patch("basic_memory.cli.commands.schema.ConfigManager")
@patch(
"basic_memory.cli.commands.schema.mcp_schema_validate",
"basic_memory.mcp.tools.schema_validate",
new_callable=AsyncMock,
return_value=VALIDATE_REPORT,
)
@@ -123,7 +123,7 @@ def test_validate_strict_exits_on_errors(mock_mcp, mock_config_cls):
@patch("basic_memory.cli.commands.schema.ConfigManager")
@patch(
"basic_memory.cli.commands.schema.mcp_schema_validate",
"basic_memory.mcp.tools.schema_validate",
new_callable=AsyncMock,
return_value={"error": "No notes found of type 'person'"},
)
@@ -139,7 +139,7 @@ def test_validate_error_response(mock_mcp, mock_config_cls):
@patch("basic_memory.cli.commands.schema.ConfigManager")
@patch(
"basic_memory.cli.commands.schema.mcp_schema_validate",
"basic_memory.mcp.tools.schema_validate",
new_callable=AsyncMock,
return_value=VALIDATE_REPORT,
)
@@ -159,7 +159,7 @@ def test_validate_identifier_heuristic(mock_mcp, mock_config_cls):
@patch("basic_memory.cli.commands.schema.ConfigManager")
@patch(
"basic_memory.cli.commands.schema.mcp_schema_infer",
"basic_memory.mcp.tools.schema_infer",
new_callable=AsyncMock,
return_value=INFER_REPORT,
)
@@ -179,7 +179,7 @@ def test_infer_renders_table(mock_mcp, mock_config_cls):
@patch("basic_memory.cli.commands.schema.ConfigManager")
@patch(
"basic_memory.cli.commands.schema.mcp_schema_infer",
"basic_memory.mcp.tools.schema_infer",
new_callable=AsyncMock,
return_value=INFER_REPORT,
)
@@ -195,7 +195,7 @@ def test_infer_threshold_passthrough(mock_mcp, mock_config_cls):
@patch("basic_memory.cli.commands.schema.ConfigManager")
@patch(
"basic_memory.cli.commands.schema.mcp_schema_infer",
"basic_memory.mcp.tools.schema_infer",
new_callable=AsyncMock,
return_value={"error": "No schema pattern found for 'person' (threshold: 25%)"},
)
@@ -211,7 +211,7 @@ def test_infer_error_response(mock_mcp, mock_config_cls):
@patch("basic_memory.cli.commands.schema.ConfigManager")
@patch(
"basic_memory.cli.commands.schema.mcp_schema_infer",
"basic_memory.mcp.tools.schema_infer",
new_callable=AsyncMock,
return_value={
"note_type": "person",
@@ -238,7 +238,7 @@ def test_infer_zero_notes(mock_mcp, mock_config_cls):
@patch("basic_memory.cli.commands.schema.ConfigManager")
@patch(
"basic_memory.cli.commands.schema.mcp_schema_diff",
"basic_memory.mcp.tools.schema_diff",
new_callable=AsyncMock,
return_value=DIFF_REPORT_WITH_DRIFT,
)
@@ -259,7 +259,7 @@ def test_diff_renders_drift(mock_mcp, mock_config_cls):
@patch("basic_memory.cli.commands.schema.ConfigManager")
@patch(
"basic_memory.cli.commands.schema.mcp_schema_diff",
"basic_memory.mcp.tools.schema_diff",
new_callable=AsyncMock,
return_value=DIFF_REPORT_NO_DRIFT,
)
@@ -275,7 +275,7 @@ def test_diff_no_drift(mock_mcp, mock_config_cls):
@patch("basic_memory.cli.commands.schema.ConfigManager")
@patch(
"basic_memory.cli.commands.schema.mcp_schema_diff",
"basic_memory.mcp.tools.schema_diff",
new_callable=AsyncMock,
return_value={"error": "No schema found for type 'person'"},
)
+33 -33
View File
@@ -98,7 +98,7 @@ SEARCH_RESULT = {
@patch(
"basic_memory.cli.commands.tool.mcp_write_note",
"basic_memory.mcp.tools.write_note",
new_callable=AsyncMock,
return_value=WRITE_NOTE_RESULT,
)
@@ -129,7 +129,7 @@ def test_write_note_json_output(mock_mcp_write):
@patch(
"basic_memory.cli.commands.tool.mcp_write_note",
"basic_memory.mcp.tools.write_note",
new_callable=AsyncMock,
return_value=WRITE_NOTE_RESULT,
)
@@ -161,7 +161,7 @@ def test_write_note_project_id_passthrough(mock_mcp_write):
@patch(
"basic_memory.cli.commands.tool.mcp_write_note",
"basic_memory.mcp.tools.write_note",
new_callable=AsyncMock,
return_value=WRITE_NOTE_RESULT,
)
@@ -190,7 +190,7 @@ def test_write_note_with_tags(mock_mcp_write):
@patch(
"basic_memory.cli.commands.tool.mcp_write_note",
"basic_memory.mcp.tools.write_note",
new_callable=AsyncMock,
return_value=WRITE_NOTE_RESULT,
)
@@ -217,7 +217,7 @@ def test_write_note_type_passthrough(mock_mcp_write):
@patch(
"basic_memory.cli.commands.tool.mcp_write_note",
"basic_memory.mcp.tools.write_note",
new_callable=AsyncMock,
return_value=WRITE_NOTE_RESULT,
)
@@ -245,7 +245,7 @@ def test_write_note_type_defaults_to_note(mock_mcp_write):
@patch(
"basic_memory.cli.commands.tool.mcp_read_note",
"basic_memory.mcp.tools.read_note",
new_callable=AsyncMock,
return_value=READ_NOTE_RESULT,
)
@@ -267,7 +267,7 @@ def test_read_note_json_output(mock_mcp_read):
@patch(
"basic_memory.cli.commands.tool.mcp_read_note",
"basic_memory.mcp.tools.read_note",
new_callable=AsyncMock,
return_value=READ_NOTE_RESULT,
)
@@ -286,7 +286,7 @@ def test_read_note_include_frontmatter(mock_mcp_read):
@patch(
"basic_memory.cli.commands.tool.mcp_delete_note",
"basic_memory.mcp.tools.delete_note",
new_callable=AsyncMock,
return_value=DELETE_NOTE_RESULT,
)
@@ -307,7 +307,7 @@ def test_delete_note_json_output(mock_mcp_delete: AsyncMock) -> None:
@patch(
"basic_memory.cli.commands.tool.mcp_delete_note",
"basic_memory.mcp.tools.delete_note",
new_callable=AsyncMock,
return_value=DELETE_DIRECTORY_RESULT,
)
@@ -325,7 +325,7 @@ def test_delete_note_directory_flag(mock_mcp_delete: AsyncMock) -> None:
@patch(
"basic_memory.cli.commands.tool.mcp_delete_note",
"basic_memory.mcp.tools.delete_note",
new_callable=AsyncMock,
return_value={
"deleted": False,
@@ -349,7 +349,7 @@ def test_delete_note_not_found_outputs_json(mock_mcp_delete: AsyncMock) -> None:
@patch(
"basic_memory.cli.commands.tool.mcp_delete_note",
"basic_memory.mcp.tools.delete_note",
new_callable=AsyncMock,
return_value={
"deleted": False,
@@ -371,7 +371,7 @@ def test_delete_note_error_response(mock_mcp_delete: AsyncMock) -> None:
@patch(
"basic_memory.cli.commands.tool.mcp_delete_note",
"basic_memory.mcp.tools.delete_note",
new_callable=AsyncMock,
return_value={
"deleted": False,
@@ -398,7 +398,7 @@ def test_delete_note_directory_partial_failure_exits_nonzero(
@patch(
"basic_memory.cli.commands.tool.mcp_delete_note",
"basic_memory.mcp.tools.delete_note",
new_callable=AsyncMock,
return_value=DELETE_NOTE_RESULT,
)
@@ -418,7 +418,7 @@ def test_delete_note_project_id_passthrough(mock_mcp_delete: AsyncMock) -> None:
@patch(
"basic_memory.cli.commands.tool.mcp_edit_note",
"basic_memory.mcp.tools.edit_note",
new_callable=AsyncMock,
return_value=EDIT_NOTE_RESULT,
)
@@ -446,7 +446,7 @@ def test_edit_note_json_output(mock_mcp_edit):
@patch(
"basic_memory.cli.commands.tool.mcp_edit_note",
"basic_memory.mcp.tools.edit_note",
new_callable=AsyncMock,
return_value={"title": "Test", "permalink": "test", "error": "Edit failed: not found"},
)
@@ -472,7 +472,7 @@ def test_edit_note_error_response(mock_mcp_edit):
@patch(
"basic_memory.cli.commands.tool.mcp_build_context",
"basic_memory.mcp.tools.build_context",
new_callable=AsyncMock,
return_value=BUILD_CONTEXT_RESULT,
)
@@ -491,7 +491,7 @@ def test_build_context_json_output(mock_build_ctx):
@patch(
"basic_memory.cli.commands.tool.mcp_build_context",
"basic_memory.mcp.tools.build_context",
new_callable=AsyncMock,
return_value=BUILD_CONTEXT_RESULT,
)
@@ -526,7 +526,7 @@ def test_build_context_with_options(mock_build_ctx):
@patch(
"basic_memory.cli.commands.tool.mcp_recent_activity",
"basic_memory.mcp.tools.recent_activity",
new_callable=AsyncMock,
return_value=RECENT_ACTIVITY_RESULT,
)
@@ -548,7 +548,7 @@ def test_recent_activity_json_output(mock_mcp_recent):
@patch(
"basic_memory.cli.commands.tool.mcp_recent_activity",
"basic_memory.mcp.tools.recent_activity",
new_callable=AsyncMock,
return_value=RECENT_ACTIVITY_RESULT,
)
@@ -566,7 +566,7 @@ def test_recent_activity_pagination(mock_mcp_recent):
@patch(
"basic_memory.cli.commands.tool.mcp_recent_activity",
"basic_memory.mcp.tools.recent_activity",
new_callable=AsyncMock,
return_value=[],
)
@@ -586,7 +586,7 @@ def test_recent_activity_empty(mock_mcp_recent):
@patch(
"basic_memory.cli.commands.tool.mcp_search",
"basic_memory.mcp.tools.search_notes",
new_callable=AsyncMock,
return_value=SEARCH_RESULT,
)
@@ -606,7 +606,7 @@ def test_search_notes_json_output(mock_mcp_search):
@patch(
"basic_memory.cli.commands.tool.mcp_search",
"basic_memory.mcp.tools.search_notes",
new_callable=AsyncMock,
return_value=SEARCH_RESULT,
)
@@ -622,7 +622,7 @@ def test_search_notes_with_meta_filter(mock_mcp_search):
@patch(
"basic_memory.cli.commands.tool.mcp_search",
"basic_memory.mcp.tools.search_notes",
new_callable=AsyncMock,
return_value=SEARCH_RESULT,
)
@@ -638,7 +638,7 @@ def test_search_notes_permalink_mode(mock_mcp_search):
@patch(
"basic_memory.cli.commands.tool.mcp_search",
"basic_memory.mcp.tools.search_notes",
new_callable=AsyncMock,
return_value="Error: search failed",
)
@@ -699,7 +699,7 @@ SCHEMA_VALIDATE_RESULT = {
@patch(
"basic_memory.cli.commands.tool.mcp_schema_validate",
"basic_memory.mcp.tools.schema_validate",
new_callable=AsyncMock,
return_value=SCHEMA_VALIDATE_RESULT,
)
@@ -719,7 +719,7 @@ def test_schema_validate_json_output(mock_mcp):
@patch(
"basic_memory.cli.commands.tool.mcp_schema_validate",
"basic_memory.mcp.tools.schema_validate",
new_callable=AsyncMock,
return_value=SCHEMA_VALIDATE_RESULT,
)
@@ -736,7 +736,7 @@ def test_schema_validate_identifier_heuristic(mock_mcp):
@patch(
"basic_memory.cli.commands.tool.mcp_schema_validate",
"basic_memory.mcp.tools.schema_validate",
new_callable=AsyncMock,
return_value={"error": "No notes found of type 'person'"},
)
@@ -769,7 +769,7 @@ SCHEMA_INFER_RESULT = {
@patch(
"basic_memory.cli.commands.tool.mcp_schema_infer",
"basic_memory.mcp.tools.schema_infer",
new_callable=AsyncMock,
return_value=SCHEMA_INFER_RESULT,
)
@@ -789,7 +789,7 @@ def test_schema_infer_json_output(mock_mcp):
@patch(
"basic_memory.cli.commands.tool.mcp_schema_infer",
"basic_memory.mcp.tools.schema_infer",
new_callable=AsyncMock,
return_value=SCHEMA_INFER_RESULT,
)
@@ -818,7 +818,7 @@ SCHEMA_DIFF_RESULT = {
@patch(
"basic_memory.cli.commands.tool.mcp_schema_diff",
"basic_memory.mcp.tools.schema_diff",
new_callable=AsyncMock,
return_value=SCHEMA_DIFF_RESULT,
)
@@ -860,7 +860,7 @@ LIST_PROJECTS_RESULT = {
@patch(
"basic_memory.cli.commands.tool.mcp_list_projects",
"basic_memory.mcp.tools.list_memory_projects",
new_callable=AsyncMock,
return_value=LIST_PROJECTS_RESULT,
)
@@ -898,7 +898,7 @@ LIST_WORKSPACES_RESULT = {
@patch(
"basic_memory.cli.commands.tool.mcp_list_workspaces",
"basic_memory.mcp.tools.list_workspaces",
new_callable=AsyncMock,
return_value=LIST_WORKSPACES_RESULT,
)
@@ -919,7 +919,7 @@ def test_list_workspaces_json_output(mock_mcp):
@patch(
"basic_memory.cli.commands.tool.mcp_list_workspaces",
"basic_memory.mcp.tools.list_workspaces",
new_callable=AsyncMock,
return_value={"workspaces": [], "count": 0},
)
+20 -11
View File
@@ -195,15 +195,21 @@ async def test_reindex_project_full_passes_force_full_to_sync_and_reports_mode(m
if "total" in kwargs:
self.tasks[task_id].total = kwargs["total"]
monkeypatch.setattr(db_cmd, "reconcile_projects_with_config", AsyncMock())
# _reindex imports its database/sync dependencies at call time (#886),
# so stubs target the source modules instead of db_cmd attributes.
monkeypatch.setattr(
db_cmd.db,
"get_or_create_db",
"basic_memory.services.initialization.reconcile_projects_with_config", AsyncMock()
)
monkeypatch.setattr(
"basic_memory.db.get_or_create_db",
AsyncMock(return_value=(None, session_maker)),
)
monkeypatch.setattr(db_cmd.db, "shutdown_db", AsyncMock())
monkeypatch.setattr(db_cmd, "ProjectRepository", StubProjectRepository)
monkeypatch.setattr(db_cmd, "get_sync_service", AsyncMock(return_value=sync_service))
monkeypatch.setattr("basic_memory.db.shutdown_db", AsyncMock())
monkeypatch.setattr("basic_memory.repository.ProjectRepository", StubProjectRepository)
monkeypatch.setattr(
"basic_memory.sync.sync_service.get_sync_service",
AsyncMock(return_value=sync_service),
)
monkeypatch.setattr(db_cmd, "Progress", SilentProgress)
monkeypatch.setattr(
db_cmd.console,
@@ -277,14 +283,17 @@ async def test_reindex_embeddings_only_full_passes_force_full_to_vector_reindex(
if "total" in kwargs:
self.tasks[task_id].total = kwargs["total"]
monkeypatch.setattr(db_cmd, "reconcile_projects_with_config", AsyncMock())
# _reindex imports its database/sync dependencies at call time (#886),
# so stubs target the source modules instead of db_cmd attributes.
monkeypatch.setattr(
db_cmd.db,
"get_or_create_db",
"basic_memory.services.initialization.reconcile_projects_with_config", AsyncMock()
)
monkeypatch.setattr(
"basic_memory.db.get_or_create_db",
AsyncMock(return_value=(None, session_maker)),
)
monkeypatch.setattr(db_cmd.db, "shutdown_db", AsyncMock())
monkeypatch.setattr(db_cmd, "ProjectRepository", StubProjectRepository)
monkeypatch.setattr("basic_memory.db.shutdown_db", AsyncMock())
monkeypatch.setattr("basic_memory.repository.ProjectRepository", StubProjectRepository)
monkeypatch.setattr(
"basic_memory.repository.search_repository.create_search_repository",
lambda *args, **kwargs: object(),
+5 -5
View File
@@ -368,7 +368,7 @@ def test_status_wait_json_timeout_emits_error(
@patch("basic_memory.cli.commands.schema.ConfigManager")
@patch(
"basic_memory.cli.commands.schema.mcp_schema_validate",
"basic_memory.mcp.tools.schema_validate",
new_callable=AsyncMock,
return_value=VALIDATE_REPORT,
)
@@ -387,7 +387,7 @@ def test_schema_validate_json(mock_mcp, mock_config_cls):
@patch("basic_memory.cli.commands.schema.ConfigManager")
@patch(
"basic_memory.cli.commands.schema.mcp_schema_validate",
"basic_memory.mcp.tools.schema_validate",
new_callable=AsyncMock,
return_value={"error": "No schema found for type 'person'"},
)
@@ -404,7 +404,7 @@ def test_schema_validate_json_error(mock_mcp, mock_config_cls):
@patch("basic_memory.cli.commands.schema.ConfigManager")
@patch(
"basic_memory.cli.commands.schema.mcp_schema_validate",
"basic_memory.mcp.tools.schema_validate",
new_callable=AsyncMock,
return_value=VALIDATE_REPORT,
)
@@ -427,7 +427,7 @@ def test_schema_validate_json_strict_exit(mock_mcp, mock_config_cls):
@patch("basic_memory.cli.commands.schema.ConfigManager")
@patch(
"basic_memory.cli.commands.schema.mcp_schema_infer",
"basic_memory.mcp.tools.schema_infer",
new_callable=AsyncMock,
return_value=INFER_REPORT,
)
@@ -451,7 +451,7 @@ def test_schema_infer_json(mock_mcp, mock_config_cls):
@patch("basic_memory.cli.commands.schema.ConfigManager")
@patch(
"basic_memory.cli.commands.schema.mcp_schema_diff",
"basic_memory.mcp.tools.schema_diff",
new_callable=AsyncMock,
return_value=DIFF_REPORT_WITH_DRIFT,
)
+13 -26
View File
@@ -27,7 +27,6 @@ class TestKnowledgeClient:
@pytest.mark.asyncio
async def test_create_entity(self, monkeypatch):
"""Test create_entity calls correct endpoint."""
from basic_memory.mcp.clients import knowledge as knowledge_mod
mock_response = MagicMock()
mock_response.json.return_value = {
@@ -47,7 +46,7 @@ class TestKnowledgeClient:
assert kwargs.get("params") is None
return mock_response
monkeypatch.setattr(knowledge_mod, "call_post", mock_call_post)
monkeypatch.setattr("basic_memory.mcp.tools.utils.call_post", mock_call_post)
mock_http = MagicMock()
client = KnowledgeClient(mock_http, "proj-123")
@@ -57,7 +56,6 @@ class TestKnowledgeClient:
@pytest.mark.asyncio
async def test_update_entity(self, monkeypatch):
"""Test update_entity calls correct endpoint without fast query params."""
from basic_memory.mcp.clients import knowledge as knowledge_mod
mock_response = MagicMock()
mock_response.json.return_value = {
@@ -77,7 +75,7 @@ class TestKnowledgeClient:
assert kwargs.get("params") is None
return mock_response
monkeypatch.setattr(knowledge_mod, "call_put", mock_call_put)
monkeypatch.setattr("basic_memory.mcp.tools.utils.call_put", mock_call_put)
mock_http = MagicMock()
client = KnowledgeClient(mock_http, "proj-123")
@@ -87,7 +85,6 @@ class TestKnowledgeClient:
@pytest.mark.asyncio
async def test_patch_entity(self, monkeypatch):
"""Test patch_entity calls correct endpoint without fast query params."""
from basic_memory.mcp.clients import knowledge as knowledge_mod
mock_response = MagicMock()
mock_response.json.return_value = {
@@ -107,7 +104,7 @@ class TestKnowledgeClient:
assert kwargs.get("params") is None
return mock_response
monkeypatch.setattr(knowledge_mod, "call_patch", mock_call_patch)
monkeypatch.setattr("basic_memory.mcp.tools.utils.call_patch", mock_call_patch)
mock_http = MagicMock()
client = KnowledgeClient(mock_http, "proj-123")
@@ -117,7 +114,6 @@ class TestKnowledgeClient:
@pytest.mark.asyncio
async def test_resolve_entity(self, monkeypatch):
"""Test resolve_entity returns external_id."""
from basic_memory.mcp.clients import knowledge as knowledge_mod
mock_response = MagicMock()
mock_response.json.return_value = {"external_id": "entity-uuid-123"}
@@ -126,7 +122,7 @@ class TestKnowledgeClient:
assert "/v2/projects/proj-123/knowledge/resolve" in url
return mock_response
monkeypatch.setattr(knowledge_mod, "call_post", mock_call_post)
monkeypatch.setattr("basic_memory.mcp.tools.utils.call_post", mock_call_post)
mock_http = MagicMock()
client = KnowledgeClient(mock_http, "proj-123")
@@ -136,7 +132,6 @@ class TestKnowledgeClient:
@pytest.mark.asyncio
async def test_sync_file(self, monkeypatch):
"""Test sync_file posts the file path to the sync-file endpoint."""
from basic_memory.mcp.clients import knowledge as knowledge_mod
mock_response = MagicMock()
mock_response.json.return_value = {
@@ -156,7 +151,7 @@ class TestKnowledgeClient:
assert kwargs.get("json") == {"file_path": "notes/disk-note.md"}
return mock_response
monkeypatch.setattr(knowledge_mod, "call_post", mock_call_post)
monkeypatch.setattr("basic_memory.mcp.tools.utils.call_post", mock_call_post)
mock_http = MagicMock()
client = KnowledgeClient(mock_http, "proj-123")
@@ -166,7 +161,6 @@ class TestKnowledgeClient:
@pytest.mark.asyncio
async def test_get_orphans_validates_response(self, monkeypatch):
"""Orphan responses are validated into GraphNode objects."""
from basic_memory.mcp.clients import knowledge as knowledge_mod
from basic_memory.schemas.v2.graph import GraphNode
mock_response = MagicMock()
@@ -186,7 +180,7 @@ class TestKnowledgeClient:
assert "/v2/projects/proj-123/knowledge/orphans" in url
return mock_response
monkeypatch.setattr(knowledge_mod, "call_get", mock_call_get)
monkeypatch.setattr("basic_memory.mcp.tools.utils.call_get", mock_call_get)
mock_http = MagicMock()
client = KnowledgeClient(mock_http, "proj-123")
@@ -211,7 +205,6 @@ class TestSearchClient:
@pytest.mark.asyncio
async def test_search(self, monkeypatch):
"""Test search calls correct endpoint."""
from basic_memory.mcp.clients import search as search_mod
mock_response = MagicMock()
mock_response.json.return_value = {
@@ -225,7 +218,7 @@ class TestSearchClient:
assert kwargs.get("params") == {"page": 1, "page_size": 10}
return mock_response
monkeypatch.setattr(search_mod, "call_post", mock_call_post)
monkeypatch.setattr("basic_memory.mcp.tools.utils.call_post", mock_call_post)
mock_http = MagicMock()
client = SearchClient(mock_http, "proj-123")
@@ -248,7 +241,6 @@ class TestMemoryClient:
@pytest.mark.asyncio
async def test_build_context(self, monkeypatch):
"""Test build_context calls correct endpoint."""
from basic_memory.mcp.clients import memory as memory_mod
from datetime import datetime
mock_response = MagicMock()
@@ -264,7 +256,7 @@ class TestMemoryClient:
assert "/v2/projects/proj-123/memory/specs/search" in url
return mock_response
monkeypatch.setattr(memory_mod, "call_get", mock_call_get)
monkeypatch.setattr("basic_memory.mcp.tools.utils.call_get", mock_call_get)
mock_http = MagicMock()
client = MemoryClient(mock_http, "proj-123")
@@ -274,7 +266,6 @@ class TestMemoryClient:
@pytest.mark.asyncio
async def test_recent(self, monkeypatch):
"""Test recent calls correct endpoint."""
from basic_memory.mcp.clients import memory as memory_mod
from datetime import datetime
mock_response = MagicMock()
@@ -293,7 +284,7 @@ class TestMemoryClient:
assert params.get("depth") == 2
return mock_response
monkeypatch.setattr(memory_mod, "call_get", mock_call_get)
monkeypatch.setattr("basic_memory.mcp.tools.utils.call_get", mock_call_get)
mock_http = MagicMock()
client = MemoryClient(mock_http, "proj-123")
@@ -304,7 +295,6 @@ class TestMemoryClient:
@pytest.mark.asyncio
async def test_recent_with_types(self, monkeypatch):
"""Test recent with types filter."""
from basic_memory.mcp.clients import memory as memory_mod
from datetime import datetime
mock_response = MagicMock()
@@ -322,7 +312,7 @@ class TestMemoryClient:
assert params.get("type") == "note,spec"
return mock_response
monkeypatch.setattr(memory_mod, "call_get", mock_call_get)
monkeypatch.setattr("basic_memory.mcp.tools.utils.call_get", mock_call_get)
mock_http = MagicMock()
client = MemoryClient(mock_http, "proj-123")
@@ -344,7 +334,6 @@ class TestDirectoryClient:
@pytest.mark.asyncio
async def test_list(self, monkeypatch):
"""Test list calls correct endpoint."""
from basic_memory.mcp.clients import directory as directory_mod
mock_response = MagicMock()
mock_response.json.return_value = [{"name": "folder", "type": "directory"}]
@@ -353,7 +342,7 @@ class TestDirectoryClient:
assert "/v2/projects/proj-123/directory/list" in url
return mock_response
monkeypatch.setattr(directory_mod, "call_get", mock_call_get)
monkeypatch.setattr("basic_memory.mcp.tools.utils.call_get", mock_call_get)
mock_http = MagicMock()
client = DirectoryClient(mock_http, "proj-123")
@@ -376,7 +365,6 @@ class TestResourceClient:
@pytest.mark.asyncio
async def test_read(self, monkeypatch):
"""Test read calls correct endpoint."""
from basic_memory.mcp.clients import resource as resource_mod
mock_response = MagicMock()
mock_response.text = "# Note content"
@@ -385,7 +373,7 @@ class TestResourceClient:
assert "/v2/projects/proj-123/resource/entity-123" in url
return mock_response
monkeypatch.setattr(resource_mod, "call_get", mock_call_get)
monkeypatch.setattr("basic_memory.mcp.tools.utils.call_get", mock_call_get)
mock_http = MagicMock()
client = ResourceClient(mock_http, "proj-123")
@@ -405,7 +393,6 @@ class TestProjectClient:
@pytest.mark.asyncio
async def test_list_projects(self, monkeypatch):
"""Test list_projects calls correct endpoint."""
from basic_memory.mcp.clients import project as project_mod
mock_response = MagicMock()
mock_response.json.return_value = {
@@ -425,7 +412,7 @@ class TestProjectClient:
assert "/v2/projects" in url
return mock_response
monkeypatch.setattr(project_mod, "call_get", mock_call_get)
monkeypatch.setattr("basic_memory.mcp.tools.utils.call_get", mock_call_get)
mock_http = MagicMock()
client = ProjectClient(mock_http)
+7 -7
View File
@@ -63,7 +63,7 @@ class TestSchemaClientValidate:
assert kwargs.get("params") == {}
return mock_response
monkeypatch.setattr("basic_memory.mcp.clients.schema.call_post", mock_call_post)
monkeypatch.setattr("basic_memory.mcp.tools.utils.call_post", mock_call_post)
result = await schema_client.validate()
assert isinstance(result, ValidationReport)
@@ -88,7 +88,7 @@ class TestSchemaClientValidate:
assert kwargs["params"]["note_type"] == "person"
return mock_response
monkeypatch.setattr("basic_memory.mcp.clients.schema.call_post", mock_call_post)
monkeypatch.setattr("basic_memory.mcp.tools.utils.call_post", mock_call_post)
result = await schema_client.validate(note_type="person")
assert result.note_type == "person"
@@ -113,7 +113,7 @@ class TestSchemaClientValidate:
assert kwargs["params"]["identifier"] == "people/alice"
return mock_response
monkeypatch.setattr("basic_memory.mcp.clients.schema.call_post", mock_call_post)
monkeypatch.setattr("basic_memory.mcp.tools.utils.call_post", mock_call_post)
result = await schema_client.validate(identifier="people/alice")
assert result.total_notes == 1
@@ -144,7 +144,7 @@ class TestSchemaClientInfer:
assert kwargs["params"]["threshold"] == 0.25
return mock_response
monkeypatch.setattr("basic_memory.mcp.clients.schema.call_post", mock_call_post)
monkeypatch.setattr("basic_memory.mcp.tools.utils.call_post", mock_call_post)
result = await schema_client.infer("person")
assert isinstance(result, InferenceReport)
@@ -171,7 +171,7 @@ class TestSchemaClientInfer:
assert kwargs["params"]["threshold"] == 0.5
return mock_response
monkeypatch.setattr("basic_memory.mcp.clients.schema.call_post", mock_call_post)
monkeypatch.setattr("basic_memory.mcp.tools.utils.call_post", mock_call_post)
result = await schema_client.infer("meeting", threshold=0.5)
assert result.note_type == "meeting"
@@ -197,7 +197,7 @@ class TestSchemaClientDiff:
assert url == "/v2/projects/test-project-id/schema/diff/person"
return mock_response
monkeypatch.setattr("basic_memory.mcp.clients.schema.call_get", mock_call_get)
monkeypatch.setattr("basic_memory.mcp.tools.utils.call_get", mock_call_get)
result = await schema_client.diff("person")
assert isinstance(result, DriftReport)
@@ -235,7 +235,7 @@ class TestSchemaClientDiff:
async def mock_call_get(client, url, **kwargs):
return mock_response
monkeypatch.setattr("basic_memory.mcp.clients.schema.call_get", mock_call_get)
monkeypatch.setattr("basic_memory.mcp.tools.utils.call_get", mock_call_get)
result = await schema_client.diff("person")
assert len(result.new_fields) == 1