mirror of
https://github.com/basicmachines-co/basic-memory
synced 2026-06-21 13:47:35 +00:00
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:
committed by
Paul Hernandez
parent
253e240d68
commit
0247ef0ead
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
)
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,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,
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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}",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 = (
|
||||
|
||||
@@ -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'"},
|
||||
)
|
||||
|
||||
@@ -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},
|
||||
)
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user