mirror of
https://github.com/basicmachines-co/basic-memory
synced 2026-06-21 13:47:35 +00:00
0247ef0ead
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>
303 lines
9.2 KiB
Python
303 lines
9.2 KiB
Python
"""Tests for CLI schema commands (Rich output).
|
|
|
|
Tests mock the MCP tool functions and verify Rich-formatted output.
|
|
"""
|
|
|
|
from unittest.mock import AsyncMock, MagicMock, patch
|
|
|
|
from typer.testing import CliRunner
|
|
|
|
from basic_memory.cli.main import app as cli_app
|
|
|
|
runner = CliRunner()
|
|
|
|
|
|
# --- Shared mock data ---
|
|
|
|
VALIDATE_REPORT = {
|
|
"note_type": "person",
|
|
"total_notes": 2,
|
|
"total_entities": 2,
|
|
"valid_count": 1,
|
|
"warning_count": 1,
|
|
"error_count": 1,
|
|
"results": [
|
|
{
|
|
"note_identifier": "people/alice",
|
|
"schema_entity": "person",
|
|
"passed": True,
|
|
"warnings": [],
|
|
"errors": [],
|
|
},
|
|
{
|
|
"note_identifier": "people/bob",
|
|
"schema_entity": "person",
|
|
"passed": False,
|
|
"warnings": ["Missing optional field: role"],
|
|
"errors": ["Missing required field: name"],
|
|
},
|
|
],
|
|
}
|
|
|
|
INFER_REPORT = {
|
|
"note_type": "person",
|
|
"notes_analyzed": 5,
|
|
"field_frequencies": [
|
|
{"name": "name", "source": "observation", "count": 5, "total": 5, "percentage": 1.0},
|
|
{"name": "role", "source": "observation", "count": 3, "total": 5, "percentage": 0.6},
|
|
],
|
|
"suggested_schema": {"name": "string, full name", "role?": "string, job title"},
|
|
"suggested_required": ["name"],
|
|
"suggested_optional": ["role"],
|
|
"excluded": [],
|
|
}
|
|
|
|
DIFF_REPORT_WITH_DRIFT = {
|
|
"note_type": "person",
|
|
"schema_found": True,
|
|
"new_fields": [
|
|
{"name": "email", "source": "observation", "count": 3, "total": 5, "percentage": 0.6}
|
|
],
|
|
"dropped_fields": [
|
|
{"name": "phone", "source": "observation", "count": 0, "total": 5, "percentage": 0.0}
|
|
],
|
|
"cardinality_changes": ["role: single -> array"],
|
|
}
|
|
|
|
DIFF_REPORT_NO_DRIFT = {
|
|
"note_type": "person",
|
|
"schema_found": True,
|
|
"new_fields": [],
|
|
"dropped_fields": [],
|
|
"cardinality_changes": [],
|
|
}
|
|
|
|
|
|
def _mock_config_manager():
|
|
"""Create a mock ConfigManager that avoids reading real config."""
|
|
mock_cm = MagicMock()
|
|
mock_cm.config = MagicMock()
|
|
mock_cm.default_project = "test-project"
|
|
mock_cm.get_project.return_value = ("test-project", "/tmp/test")
|
|
return mock_cm
|
|
|
|
|
|
# --- validate ---
|
|
|
|
|
|
@patch("basic_memory.cli.commands.schema.ConfigManager")
|
|
@patch(
|
|
"basic_memory.mcp.tools.schema_validate",
|
|
new_callable=AsyncMock,
|
|
return_value=VALIDATE_REPORT,
|
|
)
|
|
def test_validate_renders_table(mock_mcp, mock_config_cls):
|
|
"""bm schema validate renders a Rich table with results."""
|
|
mock_config_cls.return_value = _mock_config_manager()
|
|
|
|
result = runner.invoke(cli_app, ["schema", "validate", "person"])
|
|
|
|
assert result.exit_code == 0, f"CLI failed: {result.output}"
|
|
assert "Schema Validation" in result.output
|
|
assert "people/alice" in result.output
|
|
assert "people/bob" in result.output
|
|
assert "1/2 valid" in result.output
|
|
mock_mcp.assert_called_once()
|
|
assert mock_mcp.call_args.kwargs["output_format"] == "json"
|
|
|
|
|
|
@patch("basic_memory.cli.commands.schema.ConfigManager")
|
|
@patch(
|
|
"basic_memory.mcp.tools.schema_validate",
|
|
new_callable=AsyncMock,
|
|
return_value=VALIDATE_REPORT,
|
|
)
|
|
def test_validate_strict_exits_on_errors(mock_mcp, mock_config_cls):
|
|
"""bm schema validate --strict exits with code 1 when errors exist."""
|
|
mock_config_cls.return_value = _mock_config_manager()
|
|
|
|
result = runner.invoke(cli_app, ["schema", "validate", "person", "--strict"])
|
|
|
|
assert result.exit_code == 1
|
|
|
|
|
|
@patch("basic_memory.cli.commands.schema.ConfigManager")
|
|
@patch(
|
|
"basic_memory.mcp.tools.schema_validate",
|
|
new_callable=AsyncMock,
|
|
return_value={"error": "No notes found of type 'person'"},
|
|
)
|
|
def test_validate_error_response(mock_mcp, mock_config_cls):
|
|
"""bm schema validate shows error message from MCP tool."""
|
|
mock_config_cls.return_value = _mock_config_manager()
|
|
|
|
result = runner.invoke(cli_app, ["schema", "validate", "person"])
|
|
|
|
assert result.exit_code == 0
|
|
assert "No notes found" in result.output
|
|
|
|
|
|
@patch("basic_memory.cli.commands.schema.ConfigManager")
|
|
@patch(
|
|
"basic_memory.mcp.tools.schema_validate",
|
|
new_callable=AsyncMock,
|
|
return_value=VALIDATE_REPORT,
|
|
)
|
|
def test_validate_identifier_heuristic(mock_mcp, mock_config_cls):
|
|
"""bm schema validate treats target with / as identifier."""
|
|
mock_config_cls.return_value = _mock_config_manager()
|
|
|
|
result = runner.invoke(cli_app, ["schema", "validate", "people/alice.md"])
|
|
|
|
assert result.exit_code == 0, f"CLI failed: {result.output}"
|
|
assert mock_mcp.call_args.kwargs["identifier"] == "people/alice.md"
|
|
assert mock_mcp.call_args.kwargs["note_type"] is None
|
|
|
|
|
|
# --- infer ---
|
|
|
|
|
|
@patch("basic_memory.cli.commands.schema.ConfigManager")
|
|
@patch(
|
|
"basic_memory.mcp.tools.schema_infer",
|
|
new_callable=AsyncMock,
|
|
return_value=INFER_REPORT,
|
|
)
|
|
def test_infer_renders_table(mock_mcp, mock_config_cls):
|
|
"""bm schema infer renders frequency table and suggested schema."""
|
|
mock_config_cls.return_value = _mock_config_manager()
|
|
|
|
result = runner.invoke(cli_app, ["schema", "infer", "person"])
|
|
|
|
assert result.exit_code == 0, f"CLI failed: {result.output}"
|
|
assert "Field Frequencies" in result.output
|
|
assert "name" in result.output
|
|
assert "Suggested schema" in result.output
|
|
mock_mcp.assert_called_once()
|
|
assert mock_mcp.call_args.kwargs["output_format"] == "json"
|
|
|
|
|
|
@patch("basic_memory.cli.commands.schema.ConfigManager")
|
|
@patch(
|
|
"basic_memory.mcp.tools.schema_infer",
|
|
new_callable=AsyncMock,
|
|
return_value=INFER_REPORT,
|
|
)
|
|
def test_infer_threshold_passthrough(mock_mcp, mock_config_cls):
|
|
"""bm schema infer passes --threshold through to MCP tool."""
|
|
mock_config_cls.return_value = _mock_config_manager()
|
|
|
|
result = runner.invoke(cli_app, ["schema", "infer", "person", "--threshold", "0.5"])
|
|
|
|
assert result.exit_code == 0, f"CLI failed: {result.output}"
|
|
assert mock_mcp.call_args.kwargs["threshold"] == 0.5
|
|
|
|
|
|
@patch("basic_memory.cli.commands.schema.ConfigManager")
|
|
@patch(
|
|
"basic_memory.mcp.tools.schema_infer",
|
|
new_callable=AsyncMock,
|
|
return_value={"error": "No schema pattern found for 'person' (threshold: 25%)"},
|
|
)
|
|
def test_infer_error_response(mock_mcp, mock_config_cls):
|
|
"""bm schema infer shows error message from MCP tool."""
|
|
mock_config_cls.return_value = _mock_config_manager()
|
|
|
|
result = runner.invoke(cli_app, ["schema", "infer", "person"])
|
|
|
|
assert result.exit_code == 0
|
|
assert "No schema pattern found" in result.output
|
|
|
|
|
|
@patch("basic_memory.cli.commands.schema.ConfigManager")
|
|
@patch(
|
|
"basic_memory.mcp.tools.schema_infer",
|
|
new_callable=AsyncMock,
|
|
return_value={
|
|
"note_type": "person",
|
|
"notes_analyzed": 0,
|
|
"field_frequencies": [],
|
|
"suggested_schema": {},
|
|
"suggested_required": [],
|
|
"suggested_optional": [],
|
|
"excluded": [],
|
|
},
|
|
)
|
|
def test_infer_zero_notes(mock_mcp, mock_config_cls):
|
|
"""bm schema infer shows message when zero notes found."""
|
|
mock_config_cls.return_value = _mock_config_manager()
|
|
|
|
result = runner.invoke(cli_app, ["schema", "infer", "person"])
|
|
|
|
assert result.exit_code == 0
|
|
assert "No notes found" in result.output
|
|
|
|
|
|
# --- diff ---
|
|
|
|
|
|
@patch("basic_memory.cli.commands.schema.ConfigManager")
|
|
@patch(
|
|
"basic_memory.mcp.tools.schema_diff",
|
|
new_callable=AsyncMock,
|
|
return_value=DIFF_REPORT_WITH_DRIFT,
|
|
)
|
|
def test_diff_renders_drift(mock_mcp, mock_config_cls):
|
|
"""bm schema diff shows new/dropped fields and cardinality changes."""
|
|
mock_config_cls.return_value = _mock_config_manager()
|
|
|
|
result = runner.invoke(cli_app, ["schema", "diff", "person"])
|
|
|
|
assert result.exit_code == 0, f"CLI failed: {result.output}"
|
|
assert "drift detected" in result.output
|
|
assert "email" in result.output
|
|
assert "phone" in result.output
|
|
assert "role: single -> array" in result.output
|
|
mock_mcp.assert_called_once()
|
|
assert mock_mcp.call_args.kwargs["output_format"] == "json"
|
|
|
|
|
|
@patch("basic_memory.cli.commands.schema.ConfigManager")
|
|
@patch(
|
|
"basic_memory.mcp.tools.schema_diff",
|
|
new_callable=AsyncMock,
|
|
return_value=DIFF_REPORT_NO_DRIFT,
|
|
)
|
|
def test_diff_no_drift(mock_mcp, mock_config_cls):
|
|
"""bm schema diff shows success message when no drift found."""
|
|
mock_config_cls.return_value = _mock_config_manager()
|
|
|
|
result = runner.invoke(cli_app, ["schema", "diff", "person"])
|
|
|
|
assert result.exit_code == 0, f"CLI failed: {result.output}"
|
|
assert "No drift detected" in result.output
|
|
|
|
|
|
@patch("basic_memory.cli.commands.schema.ConfigManager")
|
|
@patch(
|
|
"basic_memory.mcp.tools.schema_diff",
|
|
new_callable=AsyncMock,
|
|
return_value={"error": "No schema found for type 'person'"},
|
|
)
|
|
def test_diff_error_response(mock_mcp, mock_config_cls):
|
|
"""bm schema diff shows error message from MCP tool."""
|
|
mock_config_cls.return_value = _mock_config_manager()
|
|
|
|
result = runner.invoke(cli_app, ["schema", "diff", "person"])
|
|
|
|
assert result.exit_code == 0
|
|
assert "No schema found" in result.output
|
|
|
|
|
|
# --- Routing flags ---
|
|
|
|
|
|
def test_schema_routing_both_flags_error():
|
|
"""Schema commands exit with error when both --local and --cloud are specified."""
|
|
result = runner.invoke(
|
|
cli_app,
|
|
["schema", "validate", "person", "--local", "--cloud"],
|
|
)
|
|
|
|
assert result.exit_code == 1
|