mirror of
https://github.com/basicmachines-co/basic-memory
synced 2026-06-21 13:47:35 +00:00
feat: add --json output to CLI commands for scripting and CI
Add machine-readable JSON output to five CLI commands: - `bm status --json` — sync report - `bm project list --json` — structured project list - `bm schema validate --json` — validation report - `bm schema infer --json` — inference report - `bm schema diff --json` — drift report Refactored `run_status()` to return data instead of printing directly, improving testability. Follows the established `bm project info --json` pattern using `print()` for clean JSON (no Rich markup). Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> Signed-off-by: phernandez <paul@basicmachines.co>
This commit is contained in:
@@ -179,6 +179,19 @@ Docker-internal paths that don't exist locally.
|
||||
All `bm tool` subcommands support `--format json` for machine-readable output, enabling
|
||||
integration with scripts and plugins.
|
||||
|
||||
### `--json` for Top-Level CLI Commands
|
||||
|
||||
Five additional CLI commands now support `--json` for machine-readable output:
|
||||
|
||||
- `bm status --json` — sync report with new/modified/deleted/moved files and skipped files
|
||||
- `bm project list --json` — structured project list with name, paths, routing mode, and defaults
|
||||
- `bm schema validate --json` — validation report with per-note pass/fail, warnings, and errors
|
||||
- `bm schema infer --json` — field frequency analysis and suggested schema definition
|
||||
- `bm schema diff --json` — drift report with new fields, dropped fields, and cardinality changes
|
||||
|
||||
This complements the existing `bm project info --json` and `bm tool --format json` support,
|
||||
making all major CLI commands scriptable for CI pipelines and automation.
|
||||
|
||||
### Cloud Promo and Analytics
|
||||
|
||||
- Cloud promo panel shown on first run or version bump with OSS discount code
|
||||
|
||||
@@ -61,6 +61,7 @@ def list_projects(
|
||||
local: bool = typer.Option(False, "--local", help="Force local routing for this command"),
|
||||
cloud: bool = typer.Option(False, "--cloud", help="Force cloud API routing"),
|
||||
workspace: str = typer.Option(None, "--workspace", help="Cloud workspace name or tenant_id"),
|
||||
json_output: bool = typer.Option(False, "--json", help="Output in JSON format"),
|
||||
) -> None:
|
||||
"""List Basic Memory projects from local and (when available) cloud."""
|
||||
try:
|
||||
@@ -96,13 +97,9 @@ def list_projects(
|
||||
|
||||
if _has_cloud_credentials(config):
|
||||
try:
|
||||
with console.status(
|
||||
"[bold blue]Fetching cloud projects...", spinner="dots"
|
||||
):
|
||||
with console.status("[bold blue]Fetching cloud projects...", spinner="dots"):
|
||||
with force_routing(cloud=True):
|
||||
cloud_result = run_with_cleanup(
|
||||
_list_projects(effective_workspace)
|
||||
)
|
||||
cloud_result = run_with_cleanup(_list_projects(effective_workspace))
|
||||
except Exception as exc: # pragma: no cover
|
||||
cloud_error = exc
|
||||
|
||||
@@ -113,9 +110,7 @@ def list_projects(
|
||||
try:
|
||||
from basic_memory.mcp.project_context import get_available_workspaces
|
||||
|
||||
with console.status(
|
||||
"[bold blue]Resolving workspace...", spinner="dots"
|
||||
):
|
||||
with console.status("[bold blue]Resolving workspace...", spinner="dots"):
|
||||
workspaces = run_with_cleanup(get_available_workspaces())
|
||||
matched = next(
|
||||
(ws for ws in workspaces if ws.tenant_id == effective_workspace),
|
||||
@@ -153,6 +148,8 @@ def list_projects(
|
||||
project_names_by_permalink[permalink] = project.name
|
||||
cloud_projects_by_permalink[permalink] = project
|
||||
|
||||
# --- Build unified project list ---
|
||||
project_rows: list[dict] = []
|
||||
for permalink in sorted(project_names_by_permalink):
|
||||
project_name = project_names_by_permalink[permalink]
|
||||
local_project = local_projects_by_permalink.get(permalink)
|
||||
@@ -182,9 +179,9 @@ def list_projects(
|
||||
else:
|
||||
cli_route = ProjectMode.LOCAL.value
|
||||
|
||||
is_default = "[X]" if config.default_project == project_name else ""
|
||||
is_default = config.default_project == project_name
|
||||
|
||||
has_sync = "[X]" if entry and entry.local_sync_path else ""
|
||||
has_sync = bool(entry and entry.local_sync_path)
|
||||
mcp_stdio_target = "local" if local_project is not None else "n/a"
|
||||
|
||||
# Show workspace name (type) for cloud-sourced projects
|
||||
@@ -192,18 +189,41 @@ def list_projects(
|
||||
if cloud_project is not None and cloud_ws_name:
|
||||
ws_label = f"{cloud_ws_name} ({cloud_ws_type})" if cloud_ws_type else cloud_ws_name
|
||||
|
||||
row = [
|
||||
project_name,
|
||||
local_path,
|
||||
cloud_path,
|
||||
ws_label,
|
||||
cli_route,
|
||||
mcp_stdio_target,
|
||||
has_sync,
|
||||
is_default,
|
||||
]
|
||||
row_data = {
|
||||
"name": project_name,
|
||||
"permalink": permalink,
|
||||
"local_path": local_path,
|
||||
"cloud_path": cloud_path,
|
||||
"cli_route": cli_route,
|
||||
"mcp_stdio": mcp_stdio_target,
|
||||
"sync": has_sync,
|
||||
"is_default": is_default,
|
||||
}
|
||||
if ws_label:
|
||||
row_data["workspace"] = cloud_ws_name or ""
|
||||
if cloud_ws_type:
|
||||
row_data["workspace_type"] = cloud_ws_type
|
||||
|
||||
table.add_row(*row)
|
||||
project_rows.append(row_data)
|
||||
|
||||
# --- JSON output ---
|
||||
if json_output:
|
||||
print(json.dumps({"projects": project_rows}, indent=2, default=str))
|
||||
return
|
||||
|
||||
# --- Rich table output ---
|
||||
for row_data in project_rows:
|
||||
table.add_row(
|
||||
row_data["name"],
|
||||
row_data["local_path"],
|
||||
row_data["cloud_path"],
|
||||
row_data.get("workspace", "")
|
||||
+ (f" ({row_data['workspace_type']})" if row_data.get("workspace_type") else ""),
|
||||
row_data["cli_route"],
|
||||
row_data["mcp_stdio"],
|
||||
"[X]" if row_data["sync"] else "",
|
||||
"[X]" if row_data["is_default"] else "",
|
||||
)
|
||||
|
||||
console.print(table)
|
||||
if cloud_error is not None:
|
||||
|
||||
@@ -173,6 +173,7 @@ def validate(
|
||||
typer.Option(help="The project name."),
|
||||
] = None,
|
||||
strict: bool = typer.Option(False, "--strict", help="Exit with error on validation failures"),
|
||||
json_output: bool = typer.Option(False, "--json", help="Output in JSON format"),
|
||||
local: bool = typer.Option(
|
||||
False, "--local", help="Force local API routing (ignore cloud mode)"
|
||||
),
|
||||
@@ -183,6 +184,7 @@ def validate(
|
||||
TARGET can be a note path (e.g., people/ada-lovelace.md) or a note type
|
||||
(e.g., person). If omitted, validates all notes that have schemas.
|
||||
|
||||
Use --json for machine-readable output.
|
||||
Use --strict to exit with error code 1 if any validation errors are found.
|
||||
Use --local to force local routing when cloud mode is enabled.
|
||||
Use --cloud to force cloud routing when cloud mode is disabled.
|
||||
@@ -211,12 +213,19 @@ def validate(
|
||||
|
||||
# Handle error responses
|
||||
if isinstance(result, dict) and "error" in result:
|
||||
console.print(f"[yellow]{result['error']}[/yellow]")
|
||||
if json_output:
|
||||
print(json.dumps(result, indent=2, default=str))
|
||||
else:
|
||||
console.print(f"[yellow]{result['error']}[/yellow]")
|
||||
return
|
||||
|
||||
# output_format="json" guarantees a dict return
|
||||
assert isinstance(result, dict)
|
||||
_render_validate_table(result)
|
||||
|
||||
if json_output:
|
||||
print(json.dumps(result, indent=2, default=str))
|
||||
else:
|
||||
_render_validate_table(result)
|
||||
|
||||
if strict and result.get("error_count", 0) > 0:
|
||||
raise typer.Exit(1)
|
||||
@@ -245,6 +254,7 @@ def infer(
|
||||
0.25, "--threshold", help="Minimum frequency for optional fields (0-1)"
|
||||
),
|
||||
save: bool = typer.Option(False, "--save", help="Save inferred schema to schema/ directory"),
|
||||
json_output: bool = typer.Option(False, "--json", help="Output in JSON format"),
|
||||
local: bool = typer.Option(
|
||||
False, "--local", help="Force local API routing (ignore cloud mode)"
|
||||
),
|
||||
@@ -258,6 +268,7 @@ def infer(
|
||||
Fields present in 95%+ of notes become required. Fields above the
|
||||
threshold (default 25%) become optional. Fields below threshold are excluded.
|
||||
|
||||
Use --json for machine-readable output.
|
||||
Use --local to force local routing when cloud mode is enabled.
|
||||
Use --cloud to force cloud routing when cloud mode is disabled.
|
||||
"""
|
||||
@@ -277,7 +288,10 @@ def infer(
|
||||
|
||||
# Handle error responses
|
||||
if isinstance(result, dict) and "error" in result:
|
||||
console.print(f"[yellow]{result['error']}[/yellow]")
|
||||
if json_output:
|
||||
print(json.dumps(result, indent=2, default=str))
|
||||
else:
|
||||
console.print(f"[yellow]{result['error']}[/yellow]")
|
||||
return
|
||||
|
||||
# output_format="json" guarantees a dict return
|
||||
@@ -285,10 +299,16 @@ def infer(
|
||||
|
||||
# Handle zero notes
|
||||
if result.get("notes_analyzed", 0) == 0:
|
||||
console.print(f"[yellow]No notes found with type: {note_type}[/yellow]")
|
||||
if json_output:
|
||||
print(json.dumps(result, indent=2, default=str))
|
||||
else:
|
||||
console.print(f"[yellow]No notes found with type: {note_type}[/yellow]")
|
||||
return
|
||||
|
||||
_render_infer_table(result)
|
||||
if json_output:
|
||||
print(json.dumps(result, indent=2, default=str))
|
||||
else:
|
||||
_render_infer_table(result)
|
||||
|
||||
if save:
|
||||
console.print(
|
||||
@@ -316,6 +336,7 @@ def diff(
|
||||
Optional[str],
|
||||
typer.Option(help="The project name."),
|
||||
] = None,
|
||||
json_output: bool = typer.Option(False, "--json", help="Output in JSON format"),
|
||||
local: bool = typer.Option(
|
||||
False, "--local", help="Force local API routing (ignore cloud mode)"
|
||||
),
|
||||
@@ -327,6 +348,7 @@ def diff(
|
||||
are actually structured. Identifies new fields,
|
||||
dropped fields, and cardinality changes.
|
||||
|
||||
Use --json for machine-readable output.
|
||||
Use --local to force local routing when cloud mode is enabled.
|
||||
Use --cloud to force cloud routing when cloud mode is disabled.
|
||||
"""
|
||||
@@ -345,12 +367,19 @@ def diff(
|
||||
|
||||
# Handle error responses
|
||||
if isinstance(result, dict) and "error" in result:
|
||||
console.print(f"[yellow]{result['error']}[/yellow]")
|
||||
if json_output:
|
||||
print(json.dumps(result, indent=2, default=str))
|
||||
else:
|
||||
console.print(f"[yellow]{result['error']}[/yellow]")
|
||||
return
|
||||
|
||||
# output_format="json" guarantees a dict return
|
||||
assert isinstance(result, dict)
|
||||
_render_diff_output(result)
|
||||
|
||||
if json_output:
|
||||
print(json.dumps(result, indent=2, default=str))
|
||||
else:
|
||||
_render_diff_output(result)
|
||||
except ValueError as e:
|
||||
console.print(f"[red]Error: {e}[/red]")
|
||||
raise typer.Exit(1)
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
"""Status command for basic-memory CLI."""
|
||||
|
||||
import json
|
||||
from typing import Set, Dict
|
||||
from typing import Annotated, Optional
|
||||
|
||||
@@ -141,21 +142,20 @@ def display_changes(
|
||||
console.print(Panel(tree, expand=False))
|
||||
|
||||
|
||||
async def run_status(project: Optional[str] = None, verbose: bool = False): # pragma: no cover
|
||||
"""Check sync status of files vs database."""
|
||||
async def run_status(
|
||||
project: Optional[str] = None,
|
||||
) -> tuple[str, SyncReportResponse]:
|
||||
"""Fetch sync status of files vs database.
|
||||
|
||||
Returns (project_name, sync_report) for the caller to render.
|
||||
"""
|
||||
# Resolve default project so get_client() can route per-project
|
||||
project = project or ConfigManager().default_project
|
||||
|
||||
try:
|
||||
async with get_client(project_name=project) as client:
|
||||
project_item = await get_active_project(client, project, None)
|
||||
sync_report = await ProjectClient(client).get_status(project_item.external_id)
|
||||
|
||||
display_changes(project_item.name, "Status", sync_report, verbose)
|
||||
|
||||
except (ValueError, ToolError) as e:
|
||||
console.print(f"[red]Error: {e}[/red]")
|
||||
raise typer.Exit(1)
|
||||
async with get_client(project_name=project) as client:
|
||||
project_item = await get_active_project(client, project, None)
|
||||
sync_report = await ProjectClient(client).get_status(project_item.external_id)
|
||||
return project_item.name, sync_report
|
||||
|
||||
|
||||
@app.command()
|
||||
@@ -165,6 +165,7 @@ def status(
|
||||
typer.Option(help="The project name."),
|
||||
] = None,
|
||||
verbose: bool = typer.Option(False, "--verbose", "-v", help="Show detailed file information"),
|
||||
json_output: bool = typer.Option(False, "--json", help="Output in JSON format"),
|
||||
local: bool = typer.Option(
|
||||
False, "--local", help="Force local API routing (ignore cloud mode)"
|
||||
),
|
||||
@@ -172,6 +173,7 @@ def status(
|
||||
):
|
||||
"""Show sync status between files and database.
|
||||
|
||||
Use --json for machine-readable output.
|
||||
Use --local to force local routing when cloud mode is enabled.
|
||||
Use --cloud to force cloud routing when cloud mode is disabled.
|
||||
"""
|
||||
@@ -187,11 +189,24 @@ def status(
|
||||
if not local and not cloud:
|
||||
local = True
|
||||
with force_routing(local=local, cloud=cloud):
|
||||
run_with_cleanup(run_status(project, verbose)) # pragma: no cover
|
||||
except ValueError as e:
|
||||
console.print(f"[red]Error: {e}[/red]")
|
||||
project_name, sync_report = run_with_cleanup(run_status(project))
|
||||
|
||||
if json_output:
|
||||
print(json.dumps(sync_report.model_dump(mode="json"), indent=2, default=str))
|
||||
else:
|
||||
display_changes(project_name, "Status", sync_report, verbose)
|
||||
except (ValueError, ToolError) as e:
|
||||
if json_output:
|
||||
print(json.dumps({"error": str(e)}, indent=2))
|
||||
else:
|
||||
console.print(f"[red]Error: {e}[/red]")
|
||||
raise typer.Exit(code=1)
|
||||
except typer.Exit:
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.error(f"Error checking status: {e}")
|
||||
typer.echo(f"Error checking status: {e}", err=True)
|
||||
if json_output:
|
||||
print(json.dumps({"error": str(e)}, indent=2))
|
||||
else:
|
||||
typer.echo(f"Error checking status: {e}", err=True)
|
||||
raise typer.Exit(code=1) # pragma: no cover
|
||||
|
||||
@@ -0,0 +1,418 @@
|
||||
"""Tests for --json output across CLI commands.
|
||||
|
||||
Each test verifies:
|
||||
- Exit code 0 (or 1 for strict mode)
|
||||
- Output is valid json.loads()-able
|
||||
- Expected keys present in the parsed data
|
||||
"""
|
||||
|
||||
import json
|
||||
from contextlib import asynccontextmanager
|
||||
from datetime import datetime
|
||||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
|
||||
import pytest
|
||||
from typer.testing import CliRunner
|
||||
|
||||
from basic_memory.cli.main import app as cli_app
|
||||
from basic_memory.mcp.clients.project import ProjectClient
|
||||
from basic_memory.schemas.project_info import ProjectList
|
||||
from basic_memory.schemas.sync_report import SyncReportResponse
|
||||
|
||||
# Importing registers subcommands on the shared app instance.
|
||||
import basic_memory.cli.commands.project as project_cmd # noqa: F401
|
||||
|
||||
runner = CliRunner()
|
||||
|
||||
|
||||
def _parse_json_output(output: str) -> dict:
|
||||
"""Extract and parse the JSON object from CLI output.
|
||||
|
||||
The CliRunner may capture log lines before the JSON payload.
|
||||
We find the first '{' and parse from there.
|
||||
"""
|
||||
start = output.index("{")
|
||||
return json.loads(output[start:])
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Shared mock helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
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
|
||||
|
||||
|
||||
SYNC_REPORT_WITH_CHANGES = SyncReportResponse(
|
||||
new={"notes/new-file.md"},
|
||||
modified={"notes/existing.md"},
|
||||
deleted={"notes/old.md"},
|
||||
moves={"notes/moved-from.md": "notes/moved-to.md"},
|
||||
checksums={"notes/new-file.md": "abc12345", "notes/existing.md": "def67890"},
|
||||
skipped_files=[],
|
||||
total=4,
|
||||
)
|
||||
|
||||
SYNC_REPORT_EMPTY = SyncReportResponse(
|
||||
new=set(),
|
||||
modified=set(),
|
||||
deleted=set(),
|
||||
moves={},
|
||||
checksums={},
|
||||
skipped_files=[],
|
||||
total=0,
|
||||
)
|
||||
|
||||
SYNC_REPORT_WITH_SKIPPED = SyncReportResponse(
|
||||
new=set(),
|
||||
modified=set(),
|
||||
deleted=set(),
|
||||
moves={},
|
||||
checksums={},
|
||||
skipped_files=[
|
||||
{
|
||||
"path": "bad/file.md",
|
||||
"reason": "parse error",
|
||||
"failure_count": 3,
|
||||
"first_failed": datetime(2025, 6, 15, 12, 0, 0),
|
||||
}
|
||||
],
|
||||
total=0,
|
||||
)
|
||||
|
||||
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"],
|
||||
}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Status --json
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
_MOCK_PROJECT_ITEM = MagicMock()
|
||||
_MOCK_PROJECT_ITEM.name = "test-project"
|
||||
_MOCK_PROJECT_ITEM.external_id = "11111111-1111-1111-1111-111111111111"
|
||||
|
||||
|
||||
@patch("basic_memory.cli.commands.status.ConfigManager")
|
||||
@patch("basic_memory.cli.commands.status.get_active_project", new_callable=AsyncMock)
|
||||
@patch("basic_memory.cli.commands.status.get_client")
|
||||
def test_status_json_outputs_sync_report(mock_get_client, mock_get_active, mock_config_cls):
|
||||
"""bm status --json outputs a valid JSON sync report with changes."""
|
||||
mock_config_cls.return_value = _mock_config_manager()
|
||||
mock_get_active.return_value = _MOCK_PROJECT_ITEM
|
||||
|
||||
mock_project_client = AsyncMock()
|
||||
mock_project_client.get_status.return_value = SYNC_REPORT_WITH_CHANGES
|
||||
|
||||
@asynccontextmanager
|
||||
async def fake_get_client(project_name=None):
|
||||
yield MagicMock()
|
||||
|
||||
mock_get_client.side_effect = fake_get_client
|
||||
|
||||
with patch.object(ProjectClient, "get_status", mock_project_client.get_status):
|
||||
result = runner.invoke(cli_app, ["status", "--json"])
|
||||
|
||||
assert result.exit_code == 0, f"CLI failed: {result.output}"
|
||||
data = _parse_json_output(result.output)
|
||||
assert data["total"] == 4
|
||||
assert "new" in data
|
||||
assert "modified" in data
|
||||
assert "deleted" in data
|
||||
assert "moves" in data
|
||||
|
||||
|
||||
@patch("basic_memory.cli.commands.status.ConfigManager")
|
||||
@patch("basic_memory.cli.commands.status.get_active_project", new_callable=AsyncMock)
|
||||
@patch("basic_memory.cli.commands.status.get_client")
|
||||
def test_status_json_no_changes(mock_get_client, mock_get_active, mock_config_cls):
|
||||
"""bm status --json with empty report outputs total: 0."""
|
||||
mock_config_cls.return_value = _mock_config_manager()
|
||||
mock_get_active.return_value = _MOCK_PROJECT_ITEM
|
||||
|
||||
mock_project_client = AsyncMock()
|
||||
mock_project_client.get_status.return_value = SYNC_REPORT_EMPTY
|
||||
|
||||
@asynccontextmanager
|
||||
async def fake_get_client(project_name=None):
|
||||
yield MagicMock()
|
||||
|
||||
mock_get_client.side_effect = fake_get_client
|
||||
|
||||
with patch.object(ProjectClient, "get_status", mock_project_client.get_status):
|
||||
result = runner.invoke(cli_app, ["status", "--json"])
|
||||
|
||||
assert result.exit_code == 0, f"CLI failed: {result.output}"
|
||||
data = _parse_json_output(result.output)
|
||||
assert data["total"] == 0
|
||||
assert data["new"] == []
|
||||
assert data["modified"] == []
|
||||
|
||||
|
||||
@patch("basic_memory.cli.commands.status.ConfigManager")
|
||||
@patch("basic_memory.cli.commands.status.get_active_project", new_callable=AsyncMock)
|
||||
@patch("basic_memory.cli.commands.status.get_client")
|
||||
def test_status_json_with_skipped_files(mock_get_client, mock_get_active, mock_config_cls):
|
||||
"""bm status --json serializes skipped_files with datetime fields."""
|
||||
mock_config_cls.return_value = _mock_config_manager()
|
||||
mock_get_active.return_value = _MOCK_PROJECT_ITEM
|
||||
|
||||
mock_project_client = AsyncMock()
|
||||
mock_project_client.get_status.return_value = SYNC_REPORT_WITH_SKIPPED
|
||||
|
||||
@asynccontextmanager
|
||||
async def fake_get_client(project_name=None):
|
||||
yield MagicMock()
|
||||
|
||||
mock_get_client.side_effect = fake_get_client
|
||||
|
||||
with patch.object(ProjectClient, "get_status", mock_project_client.get_status):
|
||||
result = runner.invoke(cli_app, ["status", "--json"])
|
||||
|
||||
assert result.exit_code == 0, f"CLI failed: {result.output}"
|
||||
data = _parse_json_output(result.output)
|
||||
assert len(data["skipped_files"]) == 1
|
||||
assert data["skipped_files"][0]["path"] == "bad/file.md"
|
||||
# datetime should be serialized as ISO string via mode="json"
|
||||
assert "2025-06-15" in data["skipped_files"][0]["first_failed"]
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Schema validate --json
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@patch("basic_memory.cli.commands.schema.ConfigManager")
|
||||
@patch(
|
||||
"basic_memory.cli.commands.schema.mcp_schema_validate",
|
||||
new_callable=AsyncMock,
|
||||
return_value=VALIDATE_REPORT,
|
||||
)
|
||||
def test_schema_validate_json(mock_mcp, mock_config_cls):
|
||||
"""bm schema validate person --json outputs the validation report as JSON."""
|
||||
mock_config_cls.return_value = _mock_config_manager()
|
||||
|
||||
result = runner.invoke(cli_app, ["schema", "validate", "person", "--json"])
|
||||
|
||||
assert result.exit_code == 0, f"CLI failed: {result.output}"
|
||||
data = _parse_json_output(result.output)
|
||||
assert data["note_type"] == "person"
|
||||
assert data["total_notes"] == 2
|
||||
assert len(data["results"]) == 2
|
||||
|
||||
|
||||
@patch("basic_memory.cli.commands.schema.ConfigManager")
|
||||
@patch(
|
||||
"basic_memory.cli.commands.schema.mcp_schema_validate",
|
||||
new_callable=AsyncMock,
|
||||
return_value={"error": "No schema found for type 'person'"},
|
||||
)
|
||||
def test_schema_validate_json_error(mock_mcp, mock_config_cls):
|
||||
"""bm schema validate --json with error dict outputs the error as JSON."""
|
||||
mock_config_cls.return_value = _mock_config_manager()
|
||||
|
||||
result = runner.invoke(cli_app, ["schema", "validate", "person", "--json"])
|
||||
|
||||
assert result.exit_code == 0, f"CLI failed: {result.output}"
|
||||
data = _parse_json_output(result.output)
|
||||
assert "error" in data
|
||||
|
||||
|
||||
@patch("basic_memory.cli.commands.schema.ConfigManager")
|
||||
@patch(
|
||||
"basic_memory.cli.commands.schema.mcp_schema_validate",
|
||||
new_callable=AsyncMock,
|
||||
return_value=VALIDATE_REPORT,
|
||||
)
|
||||
def test_schema_validate_json_strict_exit(mock_mcp, mock_config_cls):
|
||||
"""bm schema validate --json --strict exits 1 when errors present."""
|
||||
mock_config_cls.return_value = _mock_config_manager()
|
||||
|
||||
result = runner.invoke(cli_app, ["schema", "validate", "person", "--json", "--strict"])
|
||||
|
||||
assert result.exit_code == 1
|
||||
# JSON should still be valid in stdout
|
||||
data = _parse_json_output(result.output)
|
||||
assert data["error_count"] == 1
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Schema infer --json
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@patch("basic_memory.cli.commands.schema.ConfigManager")
|
||||
@patch(
|
||||
"basic_memory.cli.commands.schema.mcp_schema_infer",
|
||||
new_callable=AsyncMock,
|
||||
return_value=INFER_REPORT,
|
||||
)
|
||||
def test_schema_infer_json(mock_mcp, mock_config_cls):
|
||||
"""bm schema infer person --json outputs the inference report as JSON."""
|
||||
mock_config_cls.return_value = _mock_config_manager()
|
||||
|
||||
result = runner.invoke(cli_app, ["schema", "infer", "person", "--json"])
|
||||
|
||||
assert result.exit_code == 0, f"CLI failed: {result.output}"
|
||||
data = _parse_json_output(result.output)
|
||||
assert data["note_type"] == "person"
|
||||
assert data["notes_analyzed"] == 5
|
||||
assert "suggested_schema" in data
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Schema diff --json
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@patch("basic_memory.cli.commands.schema.ConfigManager")
|
||||
@patch(
|
||||
"basic_memory.cli.commands.schema.mcp_schema_diff",
|
||||
new_callable=AsyncMock,
|
||||
return_value=DIFF_REPORT_WITH_DRIFT,
|
||||
)
|
||||
def test_schema_diff_json(mock_mcp, mock_config_cls):
|
||||
"""bm schema diff person --json outputs the drift report as JSON."""
|
||||
mock_config_cls.return_value = _mock_config_manager()
|
||||
|
||||
result = runner.invoke(cli_app, ["schema", "diff", "person", "--json"])
|
||||
|
||||
assert result.exit_code == 0, f"CLI failed: {result.output}"
|
||||
data = _parse_json_output(result.output)
|
||||
assert data["note_type"] == "person"
|
||||
assert len(data["new_fields"]) == 1
|
||||
assert len(data["dropped_fields"]) == 1
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Project list --json
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def write_config(tmp_path, monkeypatch):
|
||||
"""Write config.json under a temporary HOME and return the file path."""
|
||||
|
||||
def _write(config_data: dict):
|
||||
from basic_memory import config as config_module
|
||||
|
||||
config_module._CONFIG_CACHE = None
|
||||
|
||||
config_dir = tmp_path / ".basic-memory"
|
||||
config_dir.mkdir(parents=True, exist_ok=True)
|
||||
config_file = config_dir / "config.json"
|
||||
config_file.write_text(json.dumps(config_data, indent=2))
|
||||
monkeypatch.setenv("HOME", str(tmp_path))
|
||||
return config_file
|
||||
|
||||
return _write
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_client(monkeypatch):
|
||||
"""Mock get_client with a no-op async context manager."""
|
||||
|
||||
@asynccontextmanager
|
||||
async def fake_get_client(workspace=None):
|
||||
yield object()
|
||||
|
||||
monkeypatch.setattr(project_cmd, "get_client", fake_get_client)
|
||||
|
||||
|
||||
def test_project_list_json_outputs_projects(write_config, mock_client, tmp_path, monkeypatch):
|
||||
"""project list --json --local outputs structured JSON with project data."""
|
||||
alpha_local = (tmp_path / "alpha-local").as_posix()
|
||||
|
||||
write_config(
|
||||
{
|
||||
"env": "dev",
|
||||
"projects": {
|
||||
"alpha": {"path": alpha_local, "mode": "local"},
|
||||
},
|
||||
"default_project": "alpha",
|
||||
}
|
||||
)
|
||||
|
||||
local_payload = {
|
||||
"projects": [
|
||||
{
|
||||
"id": 1,
|
||||
"external_id": "11111111-1111-1111-1111-111111111111",
|
||||
"name": "alpha",
|
||||
"path": alpha_local,
|
||||
"is_default": True,
|
||||
}
|
||||
],
|
||||
"default_project": "alpha",
|
||||
}
|
||||
|
||||
async def fake_list_projects(self):
|
||||
return ProjectList.model_validate(local_payload)
|
||||
|
||||
monkeypatch.setattr(ProjectClient, "list_projects", fake_list_projects)
|
||||
|
||||
result = runner.invoke(cli_app, ["project", "list", "--json", "--local"])
|
||||
|
||||
assert result.exit_code == 0, f"CLI failed: {result.output}"
|
||||
data = _parse_json_output(result.output)
|
||||
assert "projects" in data
|
||||
assert len(data["projects"]) == 1
|
||||
proj = data["projects"][0]
|
||||
assert proj["name"] == "alpha"
|
||||
assert proj["is_default"] is True
|
||||
assert "local_path" in proj
|
||||
assert "cli_route" in proj
|
||||
assert "mcp_stdio" in proj
|
||||
@@ -1,8 +1,7 @@
|
||||
"""SQLite sqlite-vec search repository tests."""
|
||||
|
||||
import json
|
||||
from datetime import datetime, timezone
|
||||
from unittest.mock import AsyncMock, MagicMock
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
import pytest
|
||||
from sqlalchemy import text
|
||||
@@ -284,7 +283,6 @@ async def test_run_vector_query_caps_k_at_sqlite_vec_limit(search_repository):
|
||||
|
||||
# Track the parameters passed to session.execute
|
||||
captured_params: list[dict] = []
|
||||
original_execute = None
|
||||
|
||||
async def capturing_execute(stmt, params=None):
|
||||
if params and "vector_k" in params:
|
||||
@@ -296,7 +294,6 @@ async def test_run_vector_query_caps_k_at_sqlite_vec_limit(search_repository):
|
||||
|
||||
async with db.scoped_session(search_repository.session_maker) as session:
|
||||
await search_repository._prepare_vector_session(session)
|
||||
original_execute = session.execute
|
||||
session.execute = capturing_execute
|
||||
|
||||
query_embedding = [0.1] * search_repository._vector_dimensions
|
||||
|
||||
Reference in New Issue
Block a user