mirror of
https://github.com/basicmachines-co/basic-memory
synced 2026-06-21 13:47:35 +00:00
Compare commits
14 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 7ebf16a95d | |||
| c05075f8d4 | |||
| 4cef9281ca | |||
| 6888effef2 | |||
| 38616c345d | |||
| f3c1aa895c | |||
| d978aba09b | |||
| 2aaee734c9 | |||
| 369ad37b3d | |||
| 4e5f701d22 | |||
| 9d9ea4d61c | |||
| 7a502e6474 | |||
| c7835a9d5c | |||
| 85835ae533 |
@@ -78,6 +78,34 @@ The GitHub Actions workflow (`.github/workflows/release.yml`) then:
|
||||
2. Verify formula version matches release
|
||||
3. Test Homebrew installation: `brew install basicmachines-co/basic-memory/basic-memory`
|
||||
|
||||
#### MCP Registry Publication
|
||||
|
||||
After PyPI release is published, update the MCP registry:
|
||||
|
||||
1. **Verify PyPI Release**
|
||||
- Confirm package is live: https://pypi.org/project/basic-memory/<version>/
|
||||
- The `server.json` version was auto-updated by `just release`
|
||||
|
||||
2. **Publish to MCP Registry**
|
||||
```bash
|
||||
cd /Users/drew/code/basic-memory
|
||||
mcp-publisher publish
|
||||
```
|
||||
|
||||
If not authenticated:
|
||||
```bash
|
||||
mcp-publisher login github
|
||||
# Follow device authentication flow
|
||||
mcp-publisher publish
|
||||
```
|
||||
|
||||
3. **Verify Publication**
|
||||
```bash
|
||||
curl "https://registry.modelcontextprotocol.io/v0.1/servers?search=basic-memory"
|
||||
```
|
||||
|
||||
**Note:** The `mcp-publisher` CLI can be installed via Homebrew (`brew install mcp-publisher`) or from GitHub releases.
|
||||
|
||||
#### Website Updates
|
||||
|
||||
**1. basicmachines.co** (`/Users/drew/code/basicmachines.co`)
|
||||
@@ -145,6 +173,7 @@ Before starting, verify:
|
||||
📋 GitHub Release: https://github.com/basicmachines-co/basic-memory/releases/tag/v0.13.2
|
||||
📦 PyPI: https://pypi.org/project/basic-memory/0.13.2/
|
||||
🍺 Homebrew: https://github.com/basicmachines-co/homebrew-basic-memory
|
||||
🔌 MCP Registry: https://registry.modelcontextprotocol.io
|
||||
🚀 GitHub Actions: Completed
|
||||
|
||||
Install with pip/uv:
|
||||
@@ -162,8 +191,9 @@ Users can now upgrade:
|
||||
- This creates production releases used by end users
|
||||
- Must pass all quality gates before proceeding
|
||||
- Uses the automated justfile target for consistency
|
||||
- Version is automatically updated in `__init__.py`
|
||||
- Version is automatically updated in `__init__.py` and `server.json`
|
||||
- Triggers automated GitHub release with changelog
|
||||
- Package is published to PyPI for `pip` and `uv` users
|
||||
- Homebrew formula is automatically updated for stable releases
|
||||
- MCP Registry is updated manually via `mcp-publisher publish`
|
||||
- Supports multiple installation methods (uv, pip, Homebrew)
|
||||
@@ -54,3 +54,4 @@ ENV/
|
||||
claude-output
|
||||
**/.claude/settings.local.json
|
||||
.mcp.json
|
||||
.mcpregistry_*
|
||||
|
||||
@@ -1,5 +1,42 @@
|
||||
# CHANGELOG
|
||||
|
||||
## v0.17.8 (2026-01-24)
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- **#521**: Fix `get_default_project()` returning multiple results
|
||||
([`6888eff`](https://github.com/basicmachines-co/basic-memory/commit/6888eff))
|
||||
- Query incorrectly matched any project with non-NULL `is_default` (both True and False)
|
||||
- Now correctly checks for `is_default=True` only
|
||||
|
||||
## v0.17.7 (2026-01-24)
|
||||
|
||||
### Features
|
||||
|
||||
- **#476**: Add SPEC-29 Phase 3 bucket snapshot CLI commands
|
||||
([`369ad37`](https://github.com/basicmachines-co/basic-memory/commit/369ad37))
|
||||
- New `basic-memory cloud snapshot` commands for managing cloud snapshots
|
||||
- Commands: `create`, `list`, `delete`, `show`, `browse`
|
||||
|
||||
- **#515**: Add MCP registry publication files
|
||||
([`7a502e6`](https://github.com/basicmachines-co/basic-memory/commit/7a502e6))
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- **#520**: Read default project from database in cloud mode
|
||||
([`38616c3`](https://github.com/basicmachines-co/basic-memory/commit/38616c3))
|
||||
|
||||
- **#513**: Ensure external_id is set on entity creation
|
||||
([`c7835a9`](https://github.com/basicmachines-co/basic-memory/commit/c7835a9))
|
||||
|
||||
### Internal
|
||||
|
||||
- **#514**: Remove OpenPanel telemetry
|
||||
([`85835ae`](https://github.com/basicmachines-co/basic-memory/commit/85835ae))
|
||||
|
||||
- Update README links to point to basicmemory.com
|
||||
([`2aaee73`](https://github.com/basicmachines-co/basic-memory/commit/2aaee73))
|
||||
|
||||
## v0.17.6 (2026-01-17)
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
@@ -240,6 +240,8 @@ See SPEC-16 for full context manager refactor details.
|
||||
- Logout: `basic-memory cloud logout`
|
||||
- Check cloud status: `basic-memory cloud status`
|
||||
- Setup cloud sync: `basic-memory cloud setup`
|
||||
- Manage snapshots: `basic-memory cloud snapshot [create|list|delete|show|browse]`
|
||||
- Restore from snapshot: `basic-memory cloud restore <path> --snapshot <id>`
|
||||
|
||||
### MCP Capabilities
|
||||
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
<!-- mcp-name: io.github.basicmachines-co/basic-memory -->
|
||||
[](https://www.gnu.org/licenses/agpl-3.0)
|
||||
[](https://badge.fury.io/py/basic-memory)
|
||||
[](https://www.python.org/downloads/)
|
||||
@@ -5,7 +6,6 @@
|
||||
[](https://github.com/astral-sh/ruff)
|
||||

|
||||

|
||||
[](https://smithery.ai/server/@basicmachines-co/basic-memory)
|
||||
|
||||
## 🚀 Basic Memory Cloud is Live!
|
||||
|
||||
@@ -13,7 +13,7 @@
|
||||
- **Early Supporter Pricing:** Early users get 25% off forever.
|
||||
The open source project continues as always. Cloud just makes it work everywhere.
|
||||
|
||||
[Sign up now →](https://basicmemory.com/beta)
|
||||
[Sign up now →](https://basicmemory.com)
|
||||
|
||||
with a 7 day free trial
|
||||
|
||||
@@ -23,8 +23,8 @@ Basic Memory lets you build persistent knowledge through natural conversations w
|
||||
Claude, while keeping everything in simple Markdown files on your computer. It uses the Model Context Protocol (MCP) to
|
||||
enable any compatible LLM to read and write to your local knowledge base.
|
||||
|
||||
- Website: https://basicmachines.co
|
||||
- Documentation: https://memory.basicmachines.co
|
||||
- Website: https://basicmemory.com
|
||||
- Documentation: https://docs.basicmemory.com
|
||||
|
||||
## Pick up your conversation right where you left off
|
||||
|
||||
@@ -62,24 +62,6 @@ uv tool install basic-memory
|
||||
|
||||
You can view shared context via files in `~/basic-memory` (default directory location).
|
||||
|
||||
### Alternative Installation via Smithery
|
||||
|
||||
You can use [Smithery](https://smithery.ai/server/@basicmachines-co/basic-memory) to automatically configure Basic
|
||||
Memory for Claude Desktop:
|
||||
|
||||
```bash
|
||||
npx -y @smithery/cli install @basicmachines-co/basic-memory --client claude
|
||||
```
|
||||
|
||||
This installs and configures Basic Memory without requiring manual edits to the Claude Desktop configuration file. The
|
||||
Smithery server hosts the MCP server component, while your data remains stored locally as Markdown files.
|
||||
|
||||
### Glama.ai
|
||||
|
||||
<a href="https://glama.ai/mcp/servers/o90kttu9ym">
|
||||
<img width="380" height="200" src="https://glama.ai/mcp/servers/o90kttu9ym/badge" alt="basic-memory MCP server" />
|
||||
</a>
|
||||
|
||||
## Why Basic Memory?
|
||||
|
||||
Most LLM interactions are ephemeral - you ask a question, get an answer, and everything is forgotten. Each conversation
|
||||
@@ -425,7 +407,7 @@ canvas(nodes, edges, title, folder) - Generate knowledge visualizations
|
||||
|
||||
## Futher info
|
||||
|
||||
See the [Documentation](https://memory.basicmachines.co/) for more info, including:
|
||||
See the [Documentation](https://docs.basicmemory.com) for more info, including:
|
||||
|
||||
- [Complete User Guide](https://docs.basicmemory.com/user-guide/)
|
||||
- [CLI tools](https://docs.basicmemory.com/guides/cli-reference/)
|
||||
@@ -466,39 +448,6 @@ tail -f ~/.basic-memory/basic-memory.log
|
||||
BASIC_MEMORY_CLOUD_MODE=true uvicorn basic_memory.api.app:app
|
||||
```
|
||||
|
||||
## Telemetry
|
||||
|
||||
Basic Memory collects anonymous usage statistics to help improve the software. This follows the [Homebrew model](https://docs.brew.sh/Analytics) - telemetry is on by default with easy opt-out.
|
||||
|
||||
**What we collect:**
|
||||
- App version, Python version, OS, architecture
|
||||
- Feature usage (which MCP tools and CLI commands are used)
|
||||
- Error types (sanitized - no file paths or personal data)
|
||||
|
||||
**What we NEVER collect:**
|
||||
- Note content, file names, or paths
|
||||
- Personal information
|
||||
- IP addresses
|
||||
|
||||
**Opting out:**
|
||||
```bash
|
||||
# Disable telemetry
|
||||
basic-memory telemetry disable
|
||||
|
||||
# Check status
|
||||
basic-memory telemetry status
|
||||
|
||||
# Re-enable
|
||||
basic-memory telemetry enable
|
||||
```
|
||||
|
||||
Or set the environment variable:
|
||||
```bash
|
||||
export BASIC_MEMORY_TELEMETRY_ENABLED=false
|
||||
```
|
||||
|
||||
For more details, see the [Telemetry documentation](https://basicmemory.com/telemetry).
|
||||
|
||||
## Development
|
||||
|
||||
### Running Tests
|
||||
|
||||
@@ -8,9 +8,8 @@ To keep the default CI signal **stable and meaningful**, the default `pytest` co
|
||||
- highly environment-dependent (OS/DB tuning)
|
||||
- inherently interactive (CLI)
|
||||
- background-task orchestration (watchers/sync runners)
|
||||
- external analytics
|
||||
|
||||
### What’s excluded (and why)
|
||||
### What's excluded (and why)
|
||||
|
||||
Coverage excludes are configured in `pyproject.toml` under `[tool.coverage.report].omit`.
|
||||
|
||||
@@ -19,7 +18,6 @@ Current exclusions include:
|
||||
- `src/basic_memory/db.py`: platform/backend tuning paths (SQLite/Postgres/Windows), covered by integration tests and targeted runs.
|
||||
- `src/basic_memory/services/initialization.py`: startup orchestration/background tasks; covered indirectly by app/MCP entrypoints.
|
||||
- `src/basic_memory/sync/sync_service.py`: heavy filesystem↔DB integration; validated in integration suite (not enforced in unit coverage).
|
||||
- `src/basic_memory/telemetry.py`: external analytics; exercised lightly but excluded from strict coverage gate.
|
||||
|
||||
### Recommended additional runs
|
||||
|
||||
|
||||
@@ -204,9 +204,14 @@ release version:
|
||||
echo "📝 Updating version in __init__.py..."
|
||||
sed -i.bak "s/__version__ = \".*\"/__version__ = \"$VERSION_NUM\"/" src/basic_memory/__init__.py
|
||||
rm -f src/basic_memory/__init__.py.bak
|
||||
|
||||
|
||||
# Update version in server.json (MCP registry metadata)
|
||||
echo "📝 Updating version in server.json..."
|
||||
sed -i.bak "s/\"version\": \"[^\"]*\"/\"version\": \"$VERSION_NUM\"/g" server.json
|
||||
rm -f server.json.bak
|
||||
|
||||
# Commit version update
|
||||
git add src/basic_memory/__init__.py
|
||||
git add src/basic_memory/__init__.py server.json
|
||||
git commit -m "chore: update version to $VERSION_NUM for {{version}} release"
|
||||
|
||||
# Create and push tag
|
||||
@@ -221,9 +226,10 @@ release version:
|
||||
echo "📦 GitHub Actions will build and publish to PyPI"
|
||||
echo "🔗 Monitor at: https://github.com/basicmachines-co/basic-memory/actions"
|
||||
echo ""
|
||||
echo "📝 REMINDER: Update documentation sites after release is published:"
|
||||
echo "📝 REMINDER: Post-release tasks:"
|
||||
echo " 1. docs.basicmemory.com - Add release notes to src/pages/latest-releases.mdx"
|
||||
echo " 2. basicmachines.co - Update version in src/components/sections/hero.tsx"
|
||||
echo " 3. MCP Registry - Run: mcp-publisher publish"
|
||||
echo " See: .claude/commands/release/release.md for detailed instructions"
|
||||
|
||||
# Create a beta release (e.g., just beta v0.13.2b1)
|
||||
@@ -269,9 +275,14 @@ beta version:
|
||||
echo "📝 Updating version in __init__.py..."
|
||||
sed -i.bak "s/__version__ = \".*\"/__version__ = \"$VERSION_NUM\"/" src/basic_memory/__init__.py
|
||||
rm -f src/basic_memory/__init__.py.bak
|
||||
|
||||
|
||||
# Update version in server.json (MCP registry metadata)
|
||||
echo "📝 Updating version in server.json..."
|
||||
sed -i.bak "s/\"version\": \"[^\"]*\"/\"version\": \"$VERSION_NUM\"/g" server.json
|
||||
rm -f server.json.bak
|
||||
|
||||
# Commit version update
|
||||
git add src/basic_memory/__init__.py
|
||||
git add src/basic_memory/__init__.py server.json
|
||||
git commit -m "chore: update version to $VERSION_NUM for {{version}} beta release"
|
||||
|
||||
# Create and push tag
|
||||
|
||||
@@ -41,7 +41,6 @@ dependencies = [
|
||||
"mdformat>=0.7.22",
|
||||
"mdformat-gfm>=0.3.7",
|
||||
"mdformat-frontmatter>=2.0.8",
|
||||
"openpanel>=0.0.1", # Anonymous usage telemetry (Homebrew-style opt-out)
|
||||
"sniffio>=1.3.1",
|
||||
"anyio>=4.10.0",
|
||||
"httpx>=0.28.0",
|
||||
@@ -143,6 +142,5 @@ omit = [
|
||||
"*/db.py", # Backend/runtime-dependent (sqlite/postgres/windows tuning); validated via integration tests
|
||||
"*/services/initialization.py", # Startup orchestration + background tasks (watchers); exercised indirectly in entrypoints
|
||||
"*/sync/sync_service.py", # Heavy filesystem/db integration; covered by integration suite, not enforced in unit coverage
|
||||
"*/telemetry.py", # External analytics; tested lightly, excluded from strict coverage target
|
||||
"*/services/migration_service.py", # Complex migration scenarios
|
||||
]
|
||||
|
||||
+25
@@ -0,0 +1,25 @@
|
||||
{
|
||||
"$schema": "https://static.modelcontextprotocol.io/schemas/2025-12-11/server.schema.json",
|
||||
"name": "io.github.basicmachines-co/basic-memory",
|
||||
"description": "Local-first knowledge management with bi-directional LLM sync via Markdown files.",
|
||||
"repository": {
|
||||
"url": "https://github.com/basicmachines-co/basic-memory.git",
|
||||
"source": "github"
|
||||
},
|
||||
"version": "0.17.8",
|
||||
"packages": [
|
||||
{
|
||||
"registryType": "pypi",
|
||||
"identifier": "basic-memory",
|
||||
"version": "0.17.8",
|
||||
"runtimeHint": "uvx",
|
||||
"runtimeArguments": [
|
||||
{"type": "positional", "value": "basic-memory"},
|
||||
{"type": "positional", "value": "mcp"}
|
||||
],
|
||||
"transport": {
|
||||
"type": "stdio"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
"""basic-memory - Local-first knowledge management combining Zettelkasten with knowledge graphs"""
|
||||
|
||||
# Package version - updated by release automation
|
||||
__version__ = "0.17.6"
|
||||
__version__ = "0.17.8"
|
||||
|
||||
# API version for FastAPI - independent of package version
|
||||
__api_version__ = "v0"
|
||||
|
||||
@@ -322,7 +322,14 @@ async def remove_project(
|
||||
) # pragma: no cover
|
||||
|
||||
# Check if trying to delete the default project
|
||||
if name == project_service.default_project:
|
||||
# In cloud mode, database is source of truth; in local mode, check config
|
||||
config_default = project_service.default_project
|
||||
db_default = await project_service.repository.get_default_project()
|
||||
|
||||
# Use database default if available, otherwise fall back to config default
|
||||
default_project_name = db_default.name if db_default else config_default
|
||||
|
||||
if name == default_project_name:
|
||||
available_projects = await project_service.list_projects()
|
||||
other_projects = [p.name for p in available_projects if p.name != name]
|
||||
detail = f"Cannot delete default project '{name}'. "
|
||||
@@ -418,8 +425,13 @@ async def get_default_project(
|
||||
Returns:
|
||||
Response with project default information
|
||||
"""
|
||||
# Get the old default project
|
||||
default_name = project_service.default_project
|
||||
# Get the default project
|
||||
# In cloud mode, database is source of truth; in local mode, check config
|
||||
config_default = project_service.default_project
|
||||
db_default = await project_service.repository.get_default_project()
|
||||
|
||||
# Use database default if available, otherwise fall back to config default
|
||||
default_name = db_default.name if db_default else config_default
|
||||
default_project = await project_service.get_project(default_name)
|
||||
if not default_project: # pragma: no cover
|
||||
raise HTTPException( # pragma: no cover
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
"""Routes for getting entity content."""
|
||||
|
||||
import tempfile
|
||||
import uuid
|
||||
from pathlib import Path
|
||||
from typing import Annotated, Union
|
||||
|
||||
@@ -218,7 +219,9 @@ async def write_resource(
|
||||
status_code = 200
|
||||
else:
|
||||
# Create a new entity model
|
||||
# Explicitly set external_id to ensure NOT NULL constraint is satisfied (fixes #512)
|
||||
entity = EntityModel(
|
||||
external_id=str(uuid.uuid4()),
|
||||
title=file_name,
|
||||
entity_type=entity_type,
|
||||
content_type=content_type,
|
||||
|
||||
@@ -9,6 +9,7 @@ Key differences from v1:
|
||||
- More RESTful: POST for create, PUT for update, GET for read
|
||||
"""
|
||||
|
||||
import uuid
|
||||
from pathlib import Path as PathLib
|
||||
|
||||
from fastapi import APIRouter, HTTPException, Response, Path
|
||||
@@ -147,7 +148,9 @@ async def create_resource(
|
||||
entity_type = "canvas" if data.file_path.endswith(".canvas") else "file"
|
||||
|
||||
# Create a new entity model
|
||||
# Explicitly set external_id to ensure NOT NULL constraint is satisfied (fixes #512)
|
||||
entity = EntityModel(
|
||||
external_id=str(uuid.uuid4()),
|
||||
title=file_name,
|
||||
entity_type=entity_type,
|
||||
content_type=content_type,
|
||||
|
||||
@@ -10,7 +10,6 @@ import typer # noqa: E402
|
||||
|
||||
from basic_memory.cli.container import CliContainer, set_container # noqa: E402
|
||||
from basic_memory.config import init_cli_logging # noqa: E402
|
||||
from basic_memory.telemetry import show_notice_if_needed, track_app_started # noqa: E402
|
||||
|
||||
|
||||
def version_callback(value: bool) -> None:
|
||||
@@ -47,13 +46,6 @@ def app_callback(
|
||||
container = CliContainer.create()
|
||||
set_container(container)
|
||||
|
||||
# Show telemetry notice and track CLI startup
|
||||
# Skip for 'mcp' command - it handles its own telemetry in lifespan
|
||||
# Skip for 'telemetry' command - avoid issues when user is managing telemetry
|
||||
if ctx.invoked_subcommand not in {"mcp", "telemetry"}:
|
||||
show_notice_if_needed()
|
||||
track_app_started("cli")
|
||||
|
||||
# Run initialization for commands that don't use the API
|
||||
# Skip for 'mcp' command - it has its own lifespan that handles initialization
|
||||
# Skip for API-using commands (status, sync, etc.) - they handle initialization via deps.py
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
"""CLI commands for basic-memory."""
|
||||
|
||||
from . import status, db, import_memory_json, mcp, import_claude_conversations
|
||||
from . import import_claude_projects, import_chatgpt, tool, project, format, telemetry
|
||||
from . import import_claude_projects, import_chatgpt, tool, project, format
|
||||
|
||||
__all__ = [
|
||||
"status",
|
||||
@@ -14,5 +14,4 @@ __all__ = [
|
||||
"tool",
|
||||
"project",
|
||||
"format",
|
||||
"telemetry",
|
||||
]
|
||||
|
||||
@@ -1,6 +1,16 @@
|
||||
"""Cloud commands package."""
|
||||
|
||||
from basic_memory.cli.app import cloud_app
|
||||
|
||||
# Import all commands to register them with typer
|
||||
from basic_memory.cli.commands.cloud.core_commands import * # noqa: F401,F403
|
||||
from basic_memory.cli.commands.cloud.api_client import get_authenticated_headers, get_cloud_config # noqa: F401
|
||||
from basic_memory.cli.commands.cloud.upload_command import * # noqa: F401,F403
|
||||
|
||||
# Register snapshot sub-command group
|
||||
from basic_memory.cli.commands.cloud.snapshot import snapshot_app
|
||||
|
||||
cloud_app.add_typer(snapshot_app, name="snapshot")
|
||||
|
||||
# Register restore command (directly on cloud_app via decorator)
|
||||
from basic_memory.cli.commands.cloud.restore import restore # noqa: F401, E402
|
||||
|
||||
@@ -0,0 +1,159 @@
|
||||
"""Restore CLI commands for Basic Memory Cloud.
|
||||
|
||||
SPEC-29 Phase 3: CLI commands for restoring files from Tigris bucket snapshots.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
|
||||
import typer
|
||||
from rich.console import Console
|
||||
|
||||
from basic_memory.cli.app import cloud_app
|
||||
from basic_memory.cli.commands.cloud.api_client import (
|
||||
CloudAPIError,
|
||||
SubscriptionRequiredError,
|
||||
make_api_request,
|
||||
)
|
||||
from basic_memory.cli.commands.cloud.schemas import BucketSnapshotBrowseResponse
|
||||
from basic_memory.config import ConfigManager
|
||||
|
||||
console = Console()
|
||||
|
||||
|
||||
@cloud_app.command("restore")
|
||||
def restore(
|
||||
path: str = typer.Argument(
|
||||
...,
|
||||
help="Path to restore (file or folder, e.g., 'notes/project.md' or 'research/')",
|
||||
),
|
||||
snapshot_id: str = typer.Option(
|
||||
...,
|
||||
"--snapshot",
|
||||
"-s",
|
||||
help="ID of the snapshot to restore from",
|
||||
),
|
||||
force: bool = typer.Option(
|
||||
False,
|
||||
"--force",
|
||||
"-f",
|
||||
help="Skip confirmation prompt",
|
||||
),
|
||||
) -> None:
|
||||
"""Restore a file or folder from a snapshot.
|
||||
|
||||
This command restores files from a previous snapshot to the current bucket.
|
||||
The restored files will overwrite any existing files at the same path.
|
||||
|
||||
Examples:
|
||||
bm cloud restore notes/project.md --snapshot abc123
|
||||
bm cloud restore research/ --snapshot abc123
|
||||
bm cloud restore notes/project.md --snapshot abc123 --force
|
||||
"""
|
||||
|
||||
async def _restore():
|
||||
try:
|
||||
config_manager = ConfigManager()
|
||||
config = config_manager.config
|
||||
host_url = config.cloud_host.rstrip("/")
|
||||
|
||||
# Normalize path - remove leading slash if present
|
||||
normalized_path = path.lstrip("/")
|
||||
|
||||
if not force:
|
||||
# Show what will be restored
|
||||
console.print(f"[blue]Preparing to restore from snapshot {snapshot_id}[/blue]")
|
||||
console.print(f" Path: {normalized_path}")
|
||||
|
||||
# Try to browse the snapshot to show what files will be affected
|
||||
try:
|
||||
browse_url = f"{host_url}/api/bucket-snapshots/{snapshot_id}/browse"
|
||||
if normalized_path:
|
||||
browse_url += f"?prefix={normalized_path}"
|
||||
|
||||
response = await make_api_request(
|
||||
method="GET",
|
||||
url=browse_url,
|
||||
)
|
||||
browse_response = BucketSnapshotBrowseResponse.model_validate(response.json())
|
||||
|
||||
if browse_response.files:
|
||||
if len(browse_response.files) <= 10:
|
||||
console.print("\n Files to restore:")
|
||||
for file_info in browse_response.files:
|
||||
console.print(f" - {file_info.key}")
|
||||
else:
|
||||
console.print(
|
||||
f"\n {len(browse_response.files)} files will be restored"
|
||||
)
|
||||
console.print(" First 5 files:")
|
||||
for file_info in browse_response.files[:5]:
|
||||
console.print(f" - {file_info.key}")
|
||||
console.print(f" ... and {len(browse_response.files) - 5} more")
|
||||
else:
|
||||
console.print(
|
||||
f"\n[yellow]No files found matching '{normalized_path}' "
|
||||
f"in snapshot[/yellow]"
|
||||
)
|
||||
raise typer.Exit(0)
|
||||
|
||||
except CloudAPIError as browse_error:
|
||||
if browse_error.status_code == 404:
|
||||
console.print(f"[red]Snapshot not found: {snapshot_id}[/red]")
|
||||
raise typer.Exit(1)
|
||||
# If browse fails for other reasons, proceed with confirmation anyway
|
||||
pass
|
||||
|
||||
console.print(
|
||||
"\n[yellow]Warning: Restored files will overwrite existing files![/yellow]"
|
||||
)
|
||||
confirmed = typer.confirm("\nProceed with restore?")
|
||||
if not confirmed:
|
||||
console.print("[yellow]Restore cancelled[/yellow]")
|
||||
raise typer.Exit(0)
|
||||
|
||||
console.print(f"[blue]Restoring from snapshot {snapshot_id}...[/blue]")
|
||||
|
||||
response = await make_api_request(
|
||||
method="POST",
|
||||
url=f"{host_url}/api/bucket-snapshots/{snapshot_id}/restore",
|
||||
json_data={"path": normalized_path},
|
||||
)
|
||||
|
||||
data = response.json()
|
||||
restored_files = data.get("restored", [])
|
||||
returned_snapshot_id = data.get("snapshot_id", snapshot_id)
|
||||
|
||||
if restored_files:
|
||||
console.print(f"[green]Successfully restored {len(restored_files)} file(s)[/green]")
|
||||
if len(restored_files) <= 10:
|
||||
for file_path in restored_files:
|
||||
console.print(f" - {file_path}")
|
||||
else:
|
||||
console.print(" First 5 restored files:")
|
||||
for file_path in restored_files[:5]:
|
||||
console.print(f" - {file_path}")
|
||||
console.print(f" ... and {len(restored_files) - 5} more")
|
||||
console.print(f"\n[dim]Snapshot ID: {returned_snapshot_id}[/dim]")
|
||||
else:
|
||||
console.print("[yellow]No files were restored[/yellow]")
|
||||
console.print(f"[dim]No files matching '{normalized_path}' found in snapshot[/dim]")
|
||||
|
||||
except typer.Exit:
|
||||
# Re-raise typer.Exit without modification - it's used for clean exits
|
||||
raise
|
||||
except SubscriptionRequiredError as e:
|
||||
console.print("\n[red]Subscription Required[/red]\n")
|
||||
console.print(f"[yellow]{e.args[0]}[/yellow]\n")
|
||||
console.print(f"Subscribe at: [blue underline]{e.subscribe_url}[/blue underline]\n")
|
||||
raise typer.Exit(1)
|
||||
except CloudAPIError as e:
|
||||
if e.status_code == 404:
|
||||
console.print(f"[red]Snapshot not found: {snapshot_id}[/red]")
|
||||
else:
|
||||
console.print(f"[red]Failed to restore: {e}[/red]")
|
||||
raise typer.Exit(1)
|
||||
except Exception as e:
|
||||
console.print(f"[red]Unexpected error: {e}[/red]")
|
||||
raise typer.Exit(1)
|
||||
|
||||
asyncio.run(_restore())
|
||||
@@ -0,0 +1,55 @@
|
||||
"""Pydantic schemas for Basic Memory Cloud API responses.
|
||||
|
||||
These schemas mirror the API response models from basic-memory-cloud
|
||||
for type-safe parsing of API responses in CLI commands.
|
||||
"""
|
||||
|
||||
from datetime import datetime
|
||||
from uuid import UUID
|
||||
|
||||
from pydantic import BaseModel
|
||||
|
||||
|
||||
class BucketSnapshotFileInfo(BaseModel):
|
||||
"""File info from snapshot browse response."""
|
||||
|
||||
key: str
|
||||
size: int
|
||||
last_modified: datetime
|
||||
etag: str | None = None
|
||||
|
||||
|
||||
class BucketSnapshotBrowseResponse(BaseModel):
|
||||
"""Response from browsing snapshot contents."""
|
||||
|
||||
files: list[BucketSnapshotFileInfo]
|
||||
prefix: str
|
||||
snapshot_version: str
|
||||
|
||||
|
||||
class BucketSnapshotResponse(BaseModel):
|
||||
"""Response model for bucket snapshot data."""
|
||||
|
||||
id: UUID
|
||||
bucket_name: str
|
||||
snapshot_version: str
|
||||
name: str
|
||||
description: str | None
|
||||
auto: bool
|
||||
created_at: datetime
|
||||
created_by: UUID | None = None
|
||||
|
||||
|
||||
class BucketSnapshotListResponse(BaseModel):
|
||||
"""Response from listing bucket snapshots."""
|
||||
|
||||
snapshots: list[BucketSnapshotResponse]
|
||||
total: int
|
||||
|
||||
|
||||
class BucketSnapshotRestoreResponse(BaseModel):
|
||||
"""Response from restore operation."""
|
||||
|
||||
restored: list[str]
|
||||
snapshot_version: str
|
||||
snapshot_id: UUID
|
||||
@@ -0,0 +1,370 @@
|
||||
"""Snapshot CLI commands for Basic Memory Cloud.
|
||||
|
||||
SPEC-29 Phase 3: CLI commands for managing Tigris bucket snapshots.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
from datetime import datetime
|
||||
from typing import Optional
|
||||
|
||||
import typer
|
||||
from rich.console import Console
|
||||
from rich.table import Table
|
||||
|
||||
from basic_memory.cli.commands.cloud.api_client import (
|
||||
CloudAPIError,
|
||||
SubscriptionRequiredError,
|
||||
make_api_request,
|
||||
)
|
||||
from basic_memory.cli.commands.cloud.schemas import BucketSnapshotBrowseResponse
|
||||
from basic_memory.config import ConfigManager
|
||||
|
||||
console = Console()
|
||||
snapshot_app = typer.Typer(help="Manage bucket snapshots")
|
||||
|
||||
|
||||
def _format_timestamp(iso_timestamp: str) -> str:
|
||||
"""Format ISO timestamp to a human-readable format."""
|
||||
try:
|
||||
dt = datetime.fromisoformat(iso_timestamp.replace("Z", "+00:00"))
|
||||
return dt.strftime("%Y-%m-%d %H:%M:%S")
|
||||
except (ValueError, AttributeError):
|
||||
return iso_timestamp
|
||||
|
||||
|
||||
@snapshot_app.command("create")
|
||||
def create(
|
||||
description: str = typer.Argument(
|
||||
...,
|
||||
help="Description for the snapshot",
|
||||
),
|
||||
) -> None:
|
||||
"""Create a new bucket snapshot.
|
||||
|
||||
Examples:
|
||||
bm cloud snapshot create "before major refactor"
|
||||
bm cloud snapshot create "daily backup"
|
||||
"""
|
||||
|
||||
async def _create():
|
||||
try:
|
||||
config_manager = ConfigManager()
|
||||
config = config_manager.config
|
||||
host_url = config.cloud_host.rstrip("/")
|
||||
|
||||
console.print("[blue]Creating snapshot...[/blue]")
|
||||
|
||||
response = await make_api_request(
|
||||
method="POST",
|
||||
url=f"{host_url}/api/bucket-snapshots",
|
||||
json_data={"description": description},
|
||||
)
|
||||
|
||||
data = response.json()
|
||||
snapshot_id = data.get("id", "unknown")
|
||||
snapshot_version = data.get("snapshot_version", "unknown")
|
||||
created_at = _format_timestamp(data.get("created_at", ""))
|
||||
|
||||
console.print("[green]Snapshot created successfully[/green]")
|
||||
console.print(f" ID: {snapshot_id}")
|
||||
console.print(f" Version: {snapshot_version}")
|
||||
console.print(f" Created: {created_at}")
|
||||
console.print(f" Description: {description}")
|
||||
|
||||
except SubscriptionRequiredError as e:
|
||||
console.print("\n[red]Subscription Required[/red]\n")
|
||||
console.print(f"[yellow]{e.args[0]}[/yellow]\n")
|
||||
console.print(f"Subscribe at: [blue underline]{e.subscribe_url}[/blue underline]\n")
|
||||
raise typer.Exit(1)
|
||||
except CloudAPIError as e:
|
||||
console.print(f"[red]Failed to create snapshot: {e}[/red]")
|
||||
raise typer.Exit(1)
|
||||
except Exception as e:
|
||||
console.print(f"[red]Unexpected error: {e}[/red]")
|
||||
raise typer.Exit(1)
|
||||
|
||||
asyncio.run(_create())
|
||||
|
||||
|
||||
@snapshot_app.command("list")
|
||||
def list_snapshots(
|
||||
limit: int = typer.Option(
|
||||
10,
|
||||
"--limit",
|
||||
"-l",
|
||||
help="Maximum number of snapshots to display",
|
||||
),
|
||||
) -> None:
|
||||
"""List all bucket snapshots.
|
||||
|
||||
Examples:
|
||||
bm cloud snapshot list
|
||||
bm cloud snapshot list --limit 20
|
||||
"""
|
||||
|
||||
async def _list():
|
||||
try:
|
||||
config_manager = ConfigManager()
|
||||
config = config_manager.config
|
||||
host_url = config.cloud_host.rstrip("/")
|
||||
|
||||
console.print("[blue]Fetching snapshots...[/blue]")
|
||||
|
||||
response = await make_api_request(
|
||||
method="GET",
|
||||
url=f"{host_url}/api/bucket-snapshots",
|
||||
)
|
||||
|
||||
data = response.json()
|
||||
snapshots = data.get("snapshots", [])
|
||||
total = data.get("total", len(snapshots))
|
||||
|
||||
if not snapshots:
|
||||
console.print("[yellow]No snapshots found[/yellow]")
|
||||
console.print(
|
||||
'\n[dim]Create a snapshot with: bm cloud snapshot create "description"[/dim]'
|
||||
)
|
||||
return
|
||||
|
||||
# Create a table for displaying snapshots
|
||||
table = Table(title=f"Bucket Snapshots ({total} total)")
|
||||
table.add_column("ID", style="cyan", no_wrap=True)
|
||||
table.add_column("Description", style="white")
|
||||
table.add_column("Auto", style="dim")
|
||||
table.add_column("Created", style="green")
|
||||
|
||||
for snapshot in snapshots[:limit]:
|
||||
snapshot_id = snapshot.get("id", "unknown")
|
||||
desc = snapshot.get("description") or snapshot.get("name", "-")
|
||||
auto = "yes" if snapshot.get("auto", False) else "no"
|
||||
created_at = _format_timestamp(snapshot.get("created_at", ""))
|
||||
|
||||
table.add_row(snapshot_id, desc, auto, created_at)
|
||||
|
||||
console.print(table)
|
||||
|
||||
if total > limit:
|
||||
console.print(
|
||||
f"\n[dim]Showing {limit} of {total} snapshots. Use --limit to see more.[/dim]"
|
||||
)
|
||||
|
||||
except SubscriptionRequiredError as e:
|
||||
console.print("\n[red]Subscription Required[/red]\n")
|
||||
console.print(f"[yellow]{e.args[0]}[/yellow]\n")
|
||||
console.print(f"Subscribe at: [blue underline]{e.subscribe_url}[/blue underline]\n")
|
||||
raise typer.Exit(1)
|
||||
except CloudAPIError as e:
|
||||
console.print(f"[red]Failed to list snapshots: {e}[/red]")
|
||||
raise typer.Exit(1)
|
||||
except Exception as e:
|
||||
console.print(f"[red]Unexpected error: {e}[/red]")
|
||||
raise typer.Exit(1)
|
||||
|
||||
asyncio.run(_list())
|
||||
|
||||
|
||||
@snapshot_app.command("delete")
|
||||
def delete(
|
||||
snapshot_id: str = typer.Argument(
|
||||
...,
|
||||
help="The ID of the snapshot to delete",
|
||||
),
|
||||
force: bool = typer.Option(
|
||||
False,
|
||||
"--force",
|
||||
"-f",
|
||||
help="Skip confirmation prompt",
|
||||
),
|
||||
) -> None:
|
||||
"""Delete a bucket snapshot.
|
||||
|
||||
Examples:
|
||||
bm cloud snapshot delete abc123
|
||||
bm cloud snapshot delete abc123 --force
|
||||
"""
|
||||
|
||||
async def _delete():
|
||||
try:
|
||||
config_manager = ConfigManager()
|
||||
config = config_manager.config
|
||||
host_url = config.cloud_host.rstrip("/")
|
||||
|
||||
if not force:
|
||||
# Fetch snapshot details first to show what will be deleted
|
||||
console.print("[blue]Fetching snapshot details...[/blue]")
|
||||
try:
|
||||
response = await make_api_request(
|
||||
method="GET",
|
||||
url=f"{host_url}/api/bucket-snapshots/{snapshot_id}",
|
||||
)
|
||||
data = response.json()
|
||||
desc = data.get("description") or data.get("name", "unnamed")
|
||||
created_at = _format_timestamp(data.get("created_at", ""))
|
||||
console.print("\nSnapshot to delete:")
|
||||
console.print(f" ID: {snapshot_id}")
|
||||
console.print(f" Description: {desc}")
|
||||
console.print(f" Created: {created_at}")
|
||||
except CloudAPIError:
|
||||
# If we can't fetch details, proceed with confirmation anyway
|
||||
pass
|
||||
|
||||
confirmed = typer.confirm("\nAre you sure you want to delete this snapshot?")
|
||||
if not confirmed:
|
||||
console.print("[yellow]Deletion cancelled[/yellow]")
|
||||
raise typer.Exit(0)
|
||||
|
||||
console.print("[blue]Deleting snapshot...[/blue]")
|
||||
|
||||
await make_api_request(
|
||||
method="DELETE",
|
||||
url=f"{host_url}/api/bucket-snapshots/{snapshot_id}",
|
||||
)
|
||||
|
||||
console.print(f"[green]Snapshot {snapshot_id} deleted successfully[/green]")
|
||||
|
||||
except typer.Exit:
|
||||
# Re-raise typer.Exit without modification - it's used for clean exits
|
||||
raise
|
||||
except SubscriptionRequiredError as e:
|
||||
console.print("\n[red]Subscription Required[/red]\n")
|
||||
console.print(f"[yellow]{e.args[0]}[/yellow]\n")
|
||||
console.print(f"Subscribe at: [blue underline]{e.subscribe_url}[/blue underline]\n")
|
||||
raise typer.Exit(1)
|
||||
except CloudAPIError as e:
|
||||
if e.status_code == 404:
|
||||
console.print(f"[red]Snapshot not found: {snapshot_id}[/red]")
|
||||
else:
|
||||
console.print(f"[red]Failed to delete snapshot: {e}[/red]")
|
||||
raise typer.Exit(1)
|
||||
except Exception as e:
|
||||
console.print(f"[red]Unexpected error: {e}[/red]")
|
||||
raise typer.Exit(1)
|
||||
|
||||
asyncio.run(_delete())
|
||||
|
||||
|
||||
@snapshot_app.command("show")
|
||||
def show(
|
||||
snapshot_id: str = typer.Argument(
|
||||
...,
|
||||
help="The ID of the snapshot to show",
|
||||
),
|
||||
) -> None:
|
||||
"""Show details of a specific snapshot.
|
||||
|
||||
Examples:
|
||||
bm cloud snapshot show abc123
|
||||
"""
|
||||
|
||||
async def _show():
|
||||
try:
|
||||
config_manager = ConfigManager()
|
||||
config = config_manager.config
|
||||
host_url = config.cloud_host.rstrip("/")
|
||||
|
||||
response = await make_api_request(
|
||||
method="GET",
|
||||
url=f"{host_url}/api/bucket-snapshots/{snapshot_id}",
|
||||
)
|
||||
|
||||
data = response.json()
|
||||
|
||||
console.print("[bold blue]Snapshot Details[/bold blue]")
|
||||
console.print(f" ID: {data.get('id', 'unknown')}")
|
||||
console.print(f" Bucket: {data.get('bucket_name', 'unknown')}")
|
||||
console.print(f" Version: {data.get('snapshot_version', 'unknown')}")
|
||||
console.print(f" Name: {data.get('name', '-')}")
|
||||
console.print(f" Description: {data.get('description') or '-'}")
|
||||
console.print(f" Auto: {'yes' if data.get('auto', False) else 'no'}")
|
||||
console.print(f" Created: {_format_timestamp(data.get('created_at', ''))}")
|
||||
|
||||
except SubscriptionRequiredError as e:
|
||||
console.print("\n[red]Subscription Required[/red]\n")
|
||||
console.print(f"[yellow]{e.args[0]}[/yellow]\n")
|
||||
console.print(f"Subscribe at: [blue underline]{e.subscribe_url}[/blue underline]\n")
|
||||
raise typer.Exit(1)
|
||||
except CloudAPIError as e:
|
||||
if e.status_code == 404:
|
||||
console.print(f"[red]Snapshot not found: {snapshot_id}[/red]")
|
||||
else:
|
||||
console.print(f"[red]Failed to get snapshot details: {e}[/red]")
|
||||
raise typer.Exit(1)
|
||||
except Exception as e:
|
||||
console.print(f"[red]Unexpected error: {e}[/red]")
|
||||
raise typer.Exit(1)
|
||||
|
||||
asyncio.run(_show())
|
||||
|
||||
|
||||
@snapshot_app.command("browse")
|
||||
def browse(
|
||||
snapshot_id: str = typer.Argument(
|
||||
...,
|
||||
help="The ID of the snapshot to browse",
|
||||
),
|
||||
prefix: Optional[str] = typer.Option(
|
||||
None,
|
||||
"--prefix",
|
||||
"-p",
|
||||
help="Filter files by path prefix (e.g., 'notes/')",
|
||||
),
|
||||
) -> None:
|
||||
"""Browse contents of a snapshot.
|
||||
|
||||
Examples:
|
||||
bm cloud snapshot browse abc123
|
||||
bm cloud snapshot browse abc123 --prefix notes/
|
||||
"""
|
||||
|
||||
async def _browse():
|
||||
try:
|
||||
config_manager = ConfigManager()
|
||||
config = config_manager.config
|
||||
host_url = config.cloud_host.rstrip("/")
|
||||
|
||||
url = f"{host_url}/api/bucket-snapshots/{snapshot_id}/browse"
|
||||
if prefix:
|
||||
url += f"?prefix={prefix}"
|
||||
|
||||
response = await make_api_request(
|
||||
method="GET",
|
||||
url=url,
|
||||
)
|
||||
|
||||
browse_response = BucketSnapshotBrowseResponse.model_validate(response.json())
|
||||
|
||||
if not browse_response.files:
|
||||
if prefix:
|
||||
console.print(f"[yellow]No files found with prefix '{prefix}'[/yellow]")
|
||||
else:
|
||||
console.print("[yellow]No files found in snapshot[/yellow]")
|
||||
return
|
||||
|
||||
console.print(
|
||||
f"[bold blue]Snapshot Contents ({len(browse_response.files)} files)[/bold blue]"
|
||||
)
|
||||
for file_info in browse_response.files:
|
||||
size_kb = file_info.size // 1024
|
||||
console.print(f" {file_info.key} ({size_kb} KB)")
|
||||
|
||||
console.print(
|
||||
f"\n[dim]Use 'bm cloud restore <path> --snapshot {snapshot_id}' "
|
||||
f"to restore files[/dim]"
|
||||
)
|
||||
|
||||
except SubscriptionRequiredError as e:
|
||||
console.print("\n[red]Subscription Required[/red]\n")
|
||||
console.print(f"[yellow]{e.args[0]}[/yellow]\n")
|
||||
console.print(f"Subscribe at: [blue underline]{e.subscribe_url}[/blue underline]\n")
|
||||
raise typer.Exit(1)
|
||||
except CloudAPIError as e:
|
||||
if e.status_code == 404:
|
||||
console.print(f"[red]Snapshot not found: {snapshot_id}[/red]")
|
||||
else:
|
||||
console.print(f"[red]Failed to browse snapshot: {e}[/red]")
|
||||
raise typer.Exit(1)
|
||||
except Exception as e:
|
||||
console.print(f"[red]Unexpected error: {e}[/red]")
|
||||
raise typer.Exit(1)
|
||||
|
||||
asyncio.run(_browse())
|
||||
@@ -14,7 +14,6 @@ from basic_memory.mcp.async_client import get_client
|
||||
from basic_memory.mcp.tools.utils import call_post, call_get
|
||||
from basic_memory.mcp.project_context import get_active_project
|
||||
from basic_memory.schemas import ProjectInfoResponse
|
||||
from basic_memory.telemetry import shutdown_telemetry
|
||||
|
||||
console = Console()
|
||||
|
||||
@@ -24,8 +23,8 @@ T = TypeVar("T")
|
||||
def run_with_cleanup(coro: Coroutine[Any, Any, T]) -> T:
|
||||
"""Run an async coroutine with proper database cleanup.
|
||||
|
||||
This helper ensures database connections and telemetry threads are cleaned up
|
||||
before the event loop closes, preventing process hangs in CLI commands.
|
||||
This helper ensures database connections are cleaned up before the
|
||||
event loop closes, preventing process hangs in CLI commands.
|
||||
|
||||
Args:
|
||||
coro: The coroutine to run
|
||||
@@ -39,9 +38,6 @@ def run_with_cleanup(coro: Coroutine[Any, Any, T]) -> T:
|
||||
return await coro
|
||||
finally:
|
||||
await db.shutdown_db()
|
||||
# Shutdown telemetry to stop the OpenPanel background thread
|
||||
# This prevents hangs on Python 3.14+ during thread shutdown
|
||||
shutdown_telemetry()
|
||||
|
||||
return asyncio.run(_with_cleanup())
|
||||
|
||||
|
||||
@@ -1,81 +0,0 @@
|
||||
"""Telemetry commands for basic-memory CLI."""
|
||||
|
||||
import typer
|
||||
from rich.console import Console
|
||||
from rich.panel import Panel
|
||||
|
||||
from basic_memory.cli.app import app
|
||||
from basic_memory.config import ConfigManager
|
||||
|
||||
console = Console()
|
||||
|
||||
# Create telemetry subcommand group
|
||||
telemetry_app = typer.Typer(help="Manage anonymous telemetry settings")
|
||||
app.add_typer(telemetry_app, name="telemetry")
|
||||
|
||||
|
||||
@telemetry_app.command("enable")
|
||||
def enable() -> None:
|
||||
"""Enable anonymous telemetry.
|
||||
|
||||
Telemetry helps improve Basic Memory by collecting anonymous usage data.
|
||||
No personal data, note content, or file paths are ever collected.
|
||||
"""
|
||||
config_manager = ConfigManager()
|
||||
config = config_manager.config
|
||||
config.telemetry_enabled = True
|
||||
config_manager.save_config(config)
|
||||
console.print("[green]Telemetry enabled[/green]")
|
||||
console.print("[dim]Thank you for helping improve Basic Memory![/dim]")
|
||||
|
||||
|
||||
@telemetry_app.command("disable")
|
||||
def disable() -> None:
|
||||
"""Disable anonymous telemetry.
|
||||
|
||||
You can re-enable telemetry anytime with: bm telemetry enable
|
||||
"""
|
||||
config_manager = ConfigManager()
|
||||
config = config_manager.config
|
||||
config.telemetry_enabled = False
|
||||
config_manager.save_config(config)
|
||||
console.print("[yellow]Telemetry disabled[/yellow]")
|
||||
|
||||
|
||||
@telemetry_app.command("status")
|
||||
def status() -> None:
|
||||
"""Show current telemetry status and what's collected."""
|
||||
from basic_memory.telemetry import get_install_id, TELEMETRY_DOCS_URL
|
||||
|
||||
config = ConfigManager().config
|
||||
|
||||
status_text = (
|
||||
"[green]enabled[/green]" if config.telemetry_enabled else "[yellow]disabled[/yellow]"
|
||||
)
|
||||
|
||||
console.print(f"\nTelemetry: {status_text}")
|
||||
console.print(f"Install ID: [dim]{get_install_id()}[/dim]")
|
||||
console.print()
|
||||
|
||||
what_we_collect = """
|
||||
[bold]What we collect:[/bold]
|
||||
- App version, Python version, OS, architecture
|
||||
- Feature usage (which MCP tools and CLI commands)
|
||||
- Sync statistics (entity count, duration)
|
||||
- Error types (sanitized, no file paths)
|
||||
|
||||
[bold]What we NEVER collect:[/bold]
|
||||
- Note content, file names, or paths
|
||||
- Personal information
|
||||
- IP addresses
|
||||
"""
|
||||
|
||||
console.print(
|
||||
Panel(
|
||||
what_we_collect.strip(),
|
||||
title="Telemetry Details",
|
||||
border_style="blue",
|
||||
expand=False,
|
||||
)
|
||||
)
|
||||
console.print(f"[dim]Details: {TELEMETRY_DOCS_URL}[/dim]")
|
||||
@@ -13,7 +13,6 @@ from basic_memory.cli.commands import ( # noqa: F401 # pragma: no cover
|
||||
mcp,
|
||||
project,
|
||||
status,
|
||||
telemetry,
|
||||
tool,
|
||||
)
|
||||
|
||||
|
||||
@@ -221,17 +221,6 @@ class BasicMemoryConfig(BaseSettings):
|
||||
description="Cloud project sync configuration mapping project names to their local paths and sync state",
|
||||
)
|
||||
|
||||
# Telemetry configuration (Homebrew-style opt-out)
|
||||
telemetry_enabled: bool = Field(
|
||||
default=True,
|
||||
description="Send anonymous usage statistics to help improve Basic Memory. Disable with: bm telemetry disable",
|
||||
)
|
||||
|
||||
telemetry_notice_shown: bool = Field(
|
||||
default=False,
|
||||
description="Whether the one-time telemetry notice has been shown to the user",
|
||||
)
|
||||
|
||||
@property
|
||||
def is_test_env(self) -> bool:
|
||||
"""Check if running in a test environment.
|
||||
@@ -241,7 +230,7 @@ class BasicMemoryConfig(BaseSettings):
|
||||
- BASIC_MEMORY_ENV environment variable is "test"
|
||||
- PYTEST_CURRENT_TEST environment variable is set (pytest is running)
|
||||
|
||||
Used to disable features like telemetry and file watchers during tests.
|
||||
Used to disable features like file watchers during tests.
|
||||
"""
|
||||
return (
|
||||
self.env == "test"
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
"""Utilities for converting between markdown and entity models."""
|
||||
|
||||
import uuid
|
||||
from pathlib import Path
|
||||
from typing import Any, Optional
|
||||
|
||||
@@ -40,6 +41,12 @@ def entity_model_from_markdown(
|
||||
# Create or update entity
|
||||
model = entity or Entity()
|
||||
|
||||
# Ensure external_id is set for new entities
|
||||
# SQLAlchemy's Python-side default may not always evaluate,
|
||||
# so we explicitly set it here for reliability (fixes #512)
|
||||
if not model.external_id:
|
||||
model.external_id = str(uuid.uuid4())
|
||||
|
||||
# Update basic fields
|
||||
model.title = markdown.frontmatter.title
|
||||
model.entity_type = markdown.frontmatter.type
|
||||
|
||||
@@ -10,7 +10,6 @@ from loguru import logger
|
||||
from basic_memory import db
|
||||
from basic_memory.mcp.container import McpContainer, set_container
|
||||
from basic_memory.services.initialization import initialize_app
|
||||
from basic_memory.telemetry import show_notice_if_needed, track_app_started
|
||||
|
||||
|
||||
@asynccontextmanager
|
||||
@@ -19,7 +18,6 @@ async def lifespan(app: FastMCP):
|
||||
|
||||
Handles:
|
||||
- Database initialization and migrations
|
||||
- Telemetry notice and tracking
|
||||
- File sync via SyncCoordinator (if enabled and not in cloud mode)
|
||||
- Proper cleanup on shutdown
|
||||
"""
|
||||
@@ -30,10 +28,6 @@ async def lifespan(app: FastMCP):
|
||||
|
||||
logger.debug(f"Starting Basic Memory MCP server (mode={container.mode.name})")
|
||||
|
||||
# Show telemetry notice (first run only) and track startup
|
||||
show_notice_if_needed()
|
||||
track_app_started("mcp")
|
||||
|
||||
# Track if we created the engine (vs test fixtures providing it)
|
||||
# This prevents disposing an engine provided by test fixtures when
|
||||
# multiple Client connections are made in the same test
|
||||
|
||||
@@ -8,7 +8,6 @@ from fastmcp import Context
|
||||
from basic_memory.mcp.async_client import get_client
|
||||
from basic_memory.mcp.project_context import get_active_project
|
||||
from basic_memory.mcp.server import mcp
|
||||
from basic_memory.telemetry import track_mcp_tool
|
||||
from basic_memory.schemas.base import TimeFrame
|
||||
from basic_memory.schemas.memory import (
|
||||
GraphContext,
|
||||
@@ -87,7 +86,6 @@ async def build_context(
|
||||
Raises:
|
||||
ToolError: If project doesn't exist or depth parameter is invalid
|
||||
"""
|
||||
track_mcp_tool("build_context")
|
||||
logger.info(f"Building context from {url} in project {project}")
|
||||
|
||||
# Convert string depth to integer if needed
|
||||
|
||||
@@ -13,7 +13,6 @@ from basic_memory.mcp.async_client import get_client
|
||||
from basic_memory.mcp.project_context import get_active_project
|
||||
from basic_memory.mcp.server import mcp
|
||||
from basic_memory.mcp.tools.utils import call_put, call_post, resolve_entity_id
|
||||
from basic_memory.telemetry import track_mcp_tool
|
||||
|
||||
|
||||
@mcp.tool(
|
||||
@@ -95,7 +94,6 @@ async def canvas(
|
||||
Raises:
|
||||
ToolError: If project doesn't exist or folder path is invalid
|
||||
"""
|
||||
track_mcp_tool("canvas")
|
||||
async with get_client() as client:
|
||||
active_project = await get_active_project(client, project, context)
|
||||
|
||||
|
||||
@@ -15,7 +15,6 @@ from basic_memory.mcp.tools.search import search_notes
|
||||
from basic_memory.mcp.tools.read_note import read_note
|
||||
from basic_memory.schemas.search import SearchResponse
|
||||
from basic_memory.config import ConfigManager
|
||||
from basic_memory.telemetry import track_mcp_tool
|
||||
|
||||
|
||||
def _format_search_results_for_chatgpt(results: SearchResponse) -> List[Dict[str, Any]]:
|
||||
@@ -89,7 +88,6 @@ async def search(
|
||||
List with one dict: `{ "type": "text", "text": "{...JSON...}" }`
|
||||
where the JSON body contains `results`, `total_count`, and echo of `query`.
|
||||
"""
|
||||
track_mcp_tool("search")
|
||||
logger.info(f"ChatGPT search request: query='{query}'")
|
||||
|
||||
try:
|
||||
@@ -153,7 +151,6 @@ async def fetch(
|
||||
List with one dict: `{ "type": "text", "text": "{...JSON...}" }`
|
||||
where the JSON body includes `id`, `title`, `text`, `url`, and metadata.
|
||||
"""
|
||||
track_mcp_tool("fetch")
|
||||
logger.info(f"ChatGPT fetch request: id='{id}'")
|
||||
|
||||
try:
|
||||
|
||||
@@ -8,7 +8,6 @@ from mcp.server.fastmcp.exceptions import ToolError
|
||||
from basic_memory.mcp.project_context import get_active_project
|
||||
from basic_memory.mcp.server import mcp
|
||||
from basic_memory.mcp.async_client import get_client
|
||||
from basic_memory.telemetry import track_mcp_tool
|
||||
|
||||
|
||||
def _format_delete_error_response(project: str, error_message: str, identifier: str) -> str:
|
||||
@@ -202,7 +201,6 @@ async def delete_note(
|
||||
with suggestions for finding the correct identifier, including search
|
||||
commands and alternative formats to try.
|
||||
"""
|
||||
track_mcp_tool("delete_note")
|
||||
async with get_client() as client:
|
||||
active_project = await get_active_project(client, project, context)
|
||||
|
||||
|
||||
@@ -8,7 +8,6 @@ from fastmcp import Context
|
||||
from basic_memory.mcp.async_client import get_client
|
||||
from basic_memory.mcp.project_context import get_active_project, add_project_metadata
|
||||
from basic_memory.mcp.server import mcp
|
||||
from basic_memory.telemetry import track_mcp_tool
|
||||
|
||||
|
||||
def _format_error_response(
|
||||
@@ -213,7 +212,6 @@ async def edit_note(
|
||||
search_notes() first to find the correct identifier. The tool provides detailed
|
||||
error messages with suggestions if operations fail.
|
||||
"""
|
||||
track_mcp_tool("edit_note")
|
||||
async with get_client() as client:
|
||||
active_project = await get_active_project(client, project, context)
|
||||
|
||||
|
||||
@@ -8,7 +8,6 @@ from fastmcp import Context
|
||||
from basic_memory.mcp.async_client import get_client
|
||||
from basic_memory.mcp.project_context import get_active_project
|
||||
from basic_memory.mcp.server import mcp
|
||||
from basic_memory.telemetry import track_mcp_tool
|
||||
|
||||
|
||||
@mcp.tool(
|
||||
@@ -63,7 +62,6 @@ async def list_directory(
|
||||
Raises:
|
||||
ToolError: If project doesn't exist or directory path is invalid
|
||||
"""
|
||||
track_mcp_tool("list_directory")
|
||||
async with get_client() as client:
|
||||
active_project = await get_active_project(client, project, context)
|
||||
|
||||
|
||||
@@ -9,7 +9,6 @@ from fastmcp import Context
|
||||
from basic_memory.mcp.async_client import get_client
|
||||
from basic_memory.mcp.server import mcp
|
||||
from basic_memory.mcp.project_context import get_active_project
|
||||
from basic_memory.telemetry import track_mcp_tool
|
||||
from basic_memory.utils import validate_project_path
|
||||
|
||||
|
||||
@@ -400,7 +399,6 @@ async def move_note(
|
||||
- Re-indexes the entity for search
|
||||
- Maintains all observations and relations
|
||||
"""
|
||||
track_mcp_tool("move_note")
|
||||
async with get_client() as client:
|
||||
logger.debug(f"Moving note: {identifier} to {destination_path} in project: {project}")
|
||||
|
||||
|
||||
@@ -10,7 +10,6 @@ from fastmcp import Context
|
||||
from basic_memory.mcp.async_client import get_client
|
||||
from basic_memory.mcp.server import mcp
|
||||
from basic_memory.schemas.project_info import ProjectInfoRequest
|
||||
from basic_memory.telemetry import track_mcp_tool
|
||||
from basic_memory.utils import generate_permalink
|
||||
|
||||
|
||||
@@ -36,7 +35,6 @@ async def list_memory_projects(context: Context | None = None) -> str:
|
||||
Example:
|
||||
list_memory_projects()
|
||||
"""
|
||||
track_mcp_tool("list_memory_projects")
|
||||
async with get_client() as client:
|
||||
if context: # pragma: no cover
|
||||
await context.info("Listing all available projects")
|
||||
@@ -92,7 +90,6 @@ async def create_memory_project(
|
||||
create_memory_project("my-research", "~/Documents/research")
|
||||
create_memory_project("work-notes", "/home/user/work", set_default=True)
|
||||
"""
|
||||
track_mcp_tool("create_memory_project")
|
||||
async with get_client() as client:
|
||||
# Check if server is constrained to a specific project
|
||||
constrained_project = os.environ.get("BASIC_MEMORY_MCP_PROJECT")
|
||||
@@ -151,7 +148,6 @@ async def delete_project(project_name: str, context: Context | None = None) -> s
|
||||
This action cannot be undone. The project will need to be re-added
|
||||
to access its content through Basic Memory again.
|
||||
"""
|
||||
track_mcp_tool("delete_project")
|
||||
async with get_client() as client:
|
||||
# Check if server is constrained to a specific project
|
||||
constrained_project = os.environ.get("BASIC_MEMORY_MCP_PROJECT")
|
||||
|
||||
@@ -20,7 +20,6 @@ from basic_memory.mcp.server import mcp
|
||||
from basic_memory.mcp.async_client import get_client
|
||||
from basic_memory.mcp.tools.utils import call_get, resolve_entity_id
|
||||
from basic_memory.schemas.memory import memory_url_path
|
||||
from basic_memory.telemetry import track_mcp_tool
|
||||
from basic_memory.utils import validate_project_path
|
||||
|
||||
|
||||
@@ -201,7 +200,6 @@ async def read_content(
|
||||
HTTPError: If project doesn't exist or is inaccessible
|
||||
SecurityError: If path attempts path traversal
|
||||
"""
|
||||
track_mcp_tool("read_content")
|
||||
logger.info("Reading file", path=path, project=project)
|
||||
|
||||
async with get_client() as client:
|
||||
|
||||
@@ -10,7 +10,6 @@ from basic_memory.mcp.async_client import get_client
|
||||
from basic_memory.mcp.project_context import get_active_project
|
||||
from basic_memory.mcp.server import mcp
|
||||
from basic_memory.mcp.tools.search import search_notes
|
||||
from basic_memory.telemetry import track_mcp_tool
|
||||
from basic_memory.schemas.memory import memory_url_path
|
||||
from basic_memory.utils import validate_project_path
|
||||
|
||||
@@ -77,7 +76,6 @@ async def read_note(
|
||||
If the exact note isn't found, this tool provides helpful suggestions
|
||||
including related notes, search commands, and note creation templates.
|
||||
"""
|
||||
track_mcp_tool("read_note")
|
||||
async with get_client() as client:
|
||||
# Get and validate the project
|
||||
active_project = await get_active_project(client, project, context)
|
||||
|
||||
@@ -10,7 +10,6 @@ from basic_memory.mcp.async_client import get_client
|
||||
from basic_memory.mcp.project_context import get_active_project, resolve_project_parameter
|
||||
from basic_memory.mcp.server import mcp
|
||||
from basic_memory.mcp.tools.utils import call_get
|
||||
from basic_memory.telemetry import track_mcp_tool
|
||||
from basic_memory.schemas.base import TimeFrame
|
||||
from basic_memory.schemas.memory import (
|
||||
GraphContext,
|
||||
@@ -100,7 +99,6 @@ async def recent_activity(
|
||||
- For focused queries, consider using build_context with a specific URI
|
||||
- Max timeframe is 1 year in the past
|
||||
"""
|
||||
track_mcp_tool("recent_activity")
|
||||
async with get_client() as client:
|
||||
# Build common parameters for API calls
|
||||
params = {
|
||||
|
||||
@@ -9,7 +9,6 @@ from fastmcp import Context
|
||||
from basic_memory.mcp.async_client import get_client
|
||||
from basic_memory.mcp.project_context import get_active_project
|
||||
from basic_memory.mcp.server import mcp
|
||||
from basic_memory.telemetry import track_mcp_tool
|
||||
from basic_memory.schemas.search import SearchItemType, SearchQuery, SearchResponse
|
||||
|
||||
|
||||
@@ -330,7 +329,6 @@ async def search_notes(
|
||||
# Explicit project specification
|
||||
results = await search_notes("project planning", project="my-project")
|
||||
"""
|
||||
track_mcp_tool("search_notes")
|
||||
# Avoid mutable-default-argument footguns. Treat None as "no filter".
|
||||
types = types or []
|
||||
entity_types = entity_types or []
|
||||
|
||||
@@ -8,7 +8,6 @@ from fastmcp import Context
|
||||
|
||||
from basic_memory.mcp.server import mcp
|
||||
from basic_memory.mcp.tools.read_note import read_note
|
||||
from basic_memory.telemetry import track_mcp_tool
|
||||
|
||||
|
||||
@mcp.tool(
|
||||
@@ -55,7 +54,6 @@ async def view_note(
|
||||
HTTPError: If project doesn't exist or is inaccessible
|
||||
SecurityError: If identifier attempts path traversal
|
||||
"""
|
||||
track_mcp_tool("view_note")
|
||||
logger.info(f"Viewing note: {identifier} in project: {project}")
|
||||
|
||||
# Call the existing read_note logic
|
||||
|
||||
@@ -7,7 +7,6 @@ from loguru import logger
|
||||
from basic_memory.mcp.async_client import get_client
|
||||
from basic_memory.mcp.project_context import get_active_project, add_project_metadata
|
||||
from basic_memory.mcp.server import mcp
|
||||
from basic_memory.telemetry import track_mcp_tool
|
||||
from fastmcp import Context
|
||||
from basic_memory.schemas.base import Entity
|
||||
from basic_memory.utils import parse_tags, validate_project_path
|
||||
@@ -115,7 +114,6 @@ async def write_note(
|
||||
HTTPError: If project doesn't exist or is inaccessible
|
||||
SecurityError: If folder path attempts path traversal
|
||||
"""
|
||||
track_mcp_tool("write_note")
|
||||
async with get_client() as client:
|
||||
logger.info(
|
||||
f"MCP tool call tool=write_note project={project} folder={folder}, title={title}, tags={tags}"
|
||||
|
||||
@@ -88,7 +88,7 @@ class ProjectRepository(Repository[Project]):
|
||||
|
||||
async def get_default_project(self) -> Optional[Project]:
|
||||
"""Get the default project (the one marked as is_default=True)."""
|
||||
query = self.select().where(Project.is_default.is_not(None))
|
||||
query = self.select().where(Project.is_default.is_(True))
|
||||
return await self.find_one(query)
|
||||
|
||||
async def get_active_projects(self) -> Sequence[Project]:
|
||||
|
||||
@@ -1,320 +0,0 @@
|
||||
"""Anonymous telemetry for Basic Memory (Homebrew-style opt-out).
|
||||
|
||||
This module implements privacy-respecting usage analytics following the Homebrew model:
|
||||
- Telemetry is ON by default
|
||||
- Users can easily opt out: `bm telemetry disable`
|
||||
- First run shows a one-time notice (not a prompt)
|
||||
- Only anonymous data is collected (random UUID, no personal info)
|
||||
|
||||
What we collect:
|
||||
- App version, Python version, OS, architecture
|
||||
- Feature usage (which MCP tools and CLI commands are used)
|
||||
- Error types (sanitized, no file paths or personal data)
|
||||
|
||||
What we NEVER collect:
|
||||
- Note content, file names, or paths
|
||||
- Personal information
|
||||
- IP addresses (OpenPanel doesn't store these)
|
||||
|
||||
Documentation: https://basicmemory.com/telemetry
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import platform
|
||||
import re
|
||||
import uuid
|
||||
from pathlib import Path
|
||||
from typing import TYPE_CHECKING, Any
|
||||
|
||||
from loguru import logger
|
||||
|
||||
from basic_memory import __version__
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from openpanel import OpenPanel
|
||||
|
||||
# --- Configuration ---
|
||||
|
||||
# OpenPanel credentials (write-only, safe to embed in client code)
|
||||
# These can only send events to our dashboard, not read any data
|
||||
OPENPANEL_CLIENT_ID = "2e7b036d-c6e5-40aa-91eb-5c70a8ef21a3"
|
||||
OPENPANEL_CLIENT_SECRET = "sec_92f7f8328bd0368ff4c2"
|
||||
|
||||
TELEMETRY_DOCS_URL = "https://basicmemory.com/telemetry"
|
||||
|
||||
TELEMETRY_NOTICE = f"""
|
||||
Basic Memory collects anonymous usage statistics to help improve the software.
|
||||
This includes: version, OS, feature usage, and errors. No personal data or note content.
|
||||
|
||||
To opt out: bm telemetry disable
|
||||
Details: {TELEMETRY_DOCS_URL}
|
||||
"""
|
||||
|
||||
# --- Module State ---
|
||||
|
||||
_client: OpenPanel | None = None
|
||||
_initialized: bool = False
|
||||
_telemetry_enabled: bool | None = None # Cached to avoid repeated config reads
|
||||
|
||||
|
||||
# --- Telemetry State ---
|
||||
|
||||
|
||||
def _is_telemetry_enabled() -> bool:
|
||||
"""Check if telemetry is enabled (cached).
|
||||
|
||||
Returns False if:
|
||||
- User disabled via `bm telemetry disable`
|
||||
- DO_NOT_TRACK environment variable is set
|
||||
- Running in test environment
|
||||
"""
|
||||
global _telemetry_enabled
|
||||
|
||||
if _telemetry_enabled is None:
|
||||
from basic_memory.config import ConfigManager
|
||||
|
||||
config = ConfigManager().config
|
||||
_telemetry_enabled = config.telemetry_enabled and not config.is_test_env
|
||||
|
||||
return _telemetry_enabled
|
||||
|
||||
|
||||
# --- Installation ID ---
|
||||
|
||||
|
||||
def get_install_id() -> str:
|
||||
"""Get or create anonymous installation ID.
|
||||
|
||||
Creates a random UUID on first run and stores it locally.
|
||||
User can delete ~/.basic-memory/.install_id to reset.
|
||||
"""
|
||||
id_file = Path.home() / ".basic-memory" / ".install_id"
|
||||
|
||||
if id_file.exists():
|
||||
return id_file.read_text().strip()
|
||||
|
||||
install_id = str(uuid.uuid4())
|
||||
id_file.parent.mkdir(parents=True, exist_ok=True)
|
||||
id_file.write_text(install_id)
|
||||
return install_id
|
||||
|
||||
|
||||
# --- Client Management ---
|
||||
|
||||
|
||||
def _get_client() -> OpenPanel | None:
|
||||
"""Get or create the OpenPanel client (singleton).
|
||||
|
||||
Lazily initializes the client with global properties.
|
||||
Returns None if telemetry is disabled (avoids creating background thread).
|
||||
"""
|
||||
global _client, _initialized
|
||||
|
||||
# Trigger: telemetry disabled via config, env var, or test mode
|
||||
# Why: OpenPanel creates a background thread even when disabled=True,
|
||||
# which can cause hangs on Python 3.14 during thread shutdown
|
||||
# Outcome: return None early, no OpenPanel client or thread created
|
||||
if not _is_telemetry_enabled():
|
||||
return None
|
||||
|
||||
if _client is None:
|
||||
# Defer import to avoid creating background thread when telemetry disabled
|
||||
from openpanel import OpenPanel
|
||||
|
||||
_client = OpenPanel(
|
||||
client_id=OPENPANEL_CLIENT_ID,
|
||||
client_secret=OPENPANEL_CLIENT_SECRET,
|
||||
)
|
||||
|
||||
if not _initialized:
|
||||
install_id = get_install_id()
|
||||
# Set profile ID for OpenPanel (required for API to accept events)
|
||||
_client.identify(install_id)
|
||||
# Set global properties that go with every event
|
||||
_client.set_global_properties(
|
||||
{
|
||||
"app_version": __version__,
|
||||
"python_version": platform.python_version(),
|
||||
"os": platform.system().lower(),
|
||||
"arch": platform.machine(),
|
||||
"install_id": install_id,
|
||||
"source": "foss",
|
||||
}
|
||||
)
|
||||
_initialized = True
|
||||
|
||||
return _client
|
||||
|
||||
|
||||
def reset_client() -> None:
|
||||
"""Reset the telemetry client (for testing or after config changes)."""
|
||||
global _client, _initialized, _telemetry_enabled
|
||||
_client = None
|
||||
_initialized = False
|
||||
_telemetry_enabled = None
|
||||
|
||||
|
||||
def shutdown_telemetry() -> None:
|
||||
"""Shutdown the telemetry client, stopping its background thread.
|
||||
|
||||
Call this on application exit to ensure clean shutdown.
|
||||
The OpenPanel client creates a background thread with an event loop
|
||||
that needs to be stopped to avoid hangs on Python 3.14+.
|
||||
"""
|
||||
import gc
|
||||
import io
|
||||
import sys
|
||||
|
||||
global _client
|
||||
|
||||
if _client is not None:
|
||||
try:
|
||||
# Suppress "Task was destroyed but it is pending!" warnings
|
||||
# These occur when we stop the event loop with pending HTTP requests,
|
||||
# which is expected during shutdown. The message is printed directly
|
||||
# to stderr by asyncio.Task.__del__(), so we redirect stderr temporarily.
|
||||
# We also force garbage collection to ensure the warning happens
|
||||
# while stderr is still redirected.
|
||||
stderr_backup = sys.stderr
|
||||
sys.stderr = io.StringIO()
|
||||
try:
|
||||
# OpenPanel._cleanup stops the event loop and joins the thread
|
||||
_client._cleanup()
|
||||
_client = None
|
||||
# Force garbage collection to trigger Task.__del__ while stderr is redirected
|
||||
gc.collect()
|
||||
finally:
|
||||
sys.stderr = stderr_backup
|
||||
except Exception as e:
|
||||
logger.opt(exception=False).debug(f"Telemetry shutdown failed: {e}")
|
||||
finally:
|
||||
_client = None
|
||||
|
||||
|
||||
# --- Event Tracking ---
|
||||
|
||||
|
||||
def track(event: str, properties: dict[str, Any] | None = None) -> None:
|
||||
"""Track an event. Fire-and-forget, never raises.
|
||||
|
||||
Args:
|
||||
event: Event name (e.g., "app_started", "mcp_tool_called")
|
||||
properties: Optional event properties
|
||||
"""
|
||||
# Constraint: telemetry must never break the application
|
||||
# Even if OpenPanel API is down or config is corrupt, user's command must succeed
|
||||
try:
|
||||
client = _get_client()
|
||||
if client is not None:
|
||||
client.track(event, properties or {})
|
||||
except Exception as e:
|
||||
logger.opt(exception=False).debug(f"Telemetry failed: {e}")
|
||||
|
||||
|
||||
# --- First-Run Notice ---
|
||||
|
||||
|
||||
def show_notice_if_needed() -> None:
|
||||
"""Show one-time telemetry notice (Homebrew style).
|
||||
|
||||
Only shows if:
|
||||
- Telemetry is enabled
|
||||
- Notice hasn't been shown before
|
||||
|
||||
After showing, marks the notice as shown in config.
|
||||
"""
|
||||
from basic_memory.config import ConfigManager
|
||||
|
||||
config_manager = ConfigManager()
|
||||
config = config_manager.config
|
||||
|
||||
if config.telemetry_enabled and not config.telemetry_notice_shown:
|
||||
from rich.console import Console
|
||||
from rich.panel import Panel
|
||||
|
||||
# Print to stderr so it doesn't interfere with command output
|
||||
console = Console(stderr=True)
|
||||
console.print(
|
||||
Panel(
|
||||
TELEMETRY_NOTICE.strip(),
|
||||
title="[dim]Telemetry Notice[/dim]",
|
||||
border_style="dim",
|
||||
expand=False,
|
||||
)
|
||||
)
|
||||
|
||||
# Mark as shown so we don't show again
|
||||
config.telemetry_notice_shown = True
|
||||
config_manager.save_config(config)
|
||||
|
||||
|
||||
# --- Convenience Functions ---
|
||||
|
||||
|
||||
def track_app_started(mode: str) -> None:
|
||||
"""Track app startup.
|
||||
|
||||
Args:
|
||||
mode: "cli" or "mcp"
|
||||
"""
|
||||
track("app_started", {"mode": mode})
|
||||
|
||||
|
||||
def track_mcp_tool(tool_name: str) -> None:
|
||||
"""Track MCP tool usage.
|
||||
|
||||
Args:
|
||||
tool_name: Name of the tool (e.g., "write_note", "search_notes")
|
||||
"""
|
||||
track("mcp_tool_called", {"tool": tool_name})
|
||||
|
||||
|
||||
def track_cli_command(command: str) -> None:
|
||||
"""Track CLI command usage.
|
||||
|
||||
Args:
|
||||
command: Command name (e.g., "sync", "import claude")
|
||||
"""
|
||||
track("cli_command", {"command": command})
|
||||
|
||||
|
||||
def track_sync_completed(entity_count: int, duration_ms: int) -> None:
|
||||
"""Track sync completion.
|
||||
|
||||
Args:
|
||||
entity_count: Number of entities synced
|
||||
duration_ms: Duration in milliseconds
|
||||
"""
|
||||
track("sync_completed", {"entity_count": entity_count, "duration_ms": duration_ms})
|
||||
|
||||
|
||||
def track_import_completed(source: str, count: int) -> None:
|
||||
"""Track import completion.
|
||||
|
||||
Args:
|
||||
source: Import source (e.g., "claude", "chatgpt")
|
||||
count: Number of items imported
|
||||
"""
|
||||
track("import_completed", {"source": source, "count": count})
|
||||
|
||||
|
||||
def track_error(error_type: str, message: str) -> None:
|
||||
"""Track an error (sanitized).
|
||||
|
||||
Args:
|
||||
error_type: Exception class name
|
||||
message: Error message (will be sanitized to remove file paths)
|
||||
"""
|
||||
if not message:
|
||||
track("error", {"type": error_type, "message": ""})
|
||||
return
|
||||
|
||||
# Sanitize file paths to prevent leaking user directory structure
|
||||
# Unix paths: /Users/name/file.py, /home/user/notes/doc.md
|
||||
sanitized = re.sub(r"/[\w/.+-]+\.\w+", "[FILE]", message)
|
||||
# Windows paths: C:\Users\name\file.py, D:\projects\doc.md
|
||||
sanitized = re.sub(r"[A-Z]:\\[\w\\.+-]+\.\w+", "[FILE]", sanitized, flags=re.IGNORECASE)
|
||||
|
||||
# Truncate to avoid sending too much data
|
||||
track("error", {"type": error_type, "message": sanitized[:200]})
|
||||
@@ -0,0 +1,409 @@
|
||||
"""Tests for cloud restore CLI commands.
|
||||
|
||||
SPEC-29 Phase 3: Tests for restore command.
|
||||
"""
|
||||
|
||||
from unittest.mock import Mock, patch
|
||||
|
||||
import httpx
|
||||
from typer.testing import CliRunner
|
||||
|
||||
from basic_memory.cli.app import app
|
||||
from basic_memory.cli.commands.cloud.api_client import (
|
||||
CloudAPIError,
|
||||
SubscriptionRequiredError,
|
||||
)
|
||||
|
||||
|
||||
class TestRestoreCommand:
|
||||
"""Tests for 'bm cloud restore' command."""
|
||||
|
||||
def test_restore_file_success_with_force(self):
|
||||
"""Test successful file restoration with --force flag."""
|
||||
runner = CliRunner()
|
||||
|
||||
mock_response = Mock(spec=httpx.Response)
|
||||
mock_response.status_code = 200
|
||||
mock_response.json.return_value = {
|
||||
"restored": ["notes/project.md"],
|
||||
"snapshot_id": "snap_123",
|
||||
}
|
||||
|
||||
async def mock_make_api_request(*args, **kwargs):
|
||||
return mock_response
|
||||
|
||||
with patch(
|
||||
"basic_memory.cli.commands.cloud.restore.make_api_request",
|
||||
side_effect=mock_make_api_request,
|
||||
):
|
||||
mock_config = Mock()
|
||||
mock_config.cloud_host = "https://cloud.example.com"
|
||||
mock_config_manager = Mock()
|
||||
mock_config_manager.config = mock_config
|
||||
|
||||
with patch(
|
||||
"basic_memory.cli.commands.cloud.restore.ConfigManager",
|
||||
return_value=mock_config_manager,
|
||||
):
|
||||
result = runner.invoke(
|
||||
app,
|
||||
[
|
||||
"cloud",
|
||||
"restore",
|
||||
"notes/project.md",
|
||||
"--snapshot",
|
||||
"snap_123",
|
||||
"--force",
|
||||
],
|
||||
)
|
||||
|
||||
assert result.exit_code == 0
|
||||
assert "Successfully restored" in result.stdout
|
||||
assert "notes/project.md" in result.stdout
|
||||
|
||||
def test_restore_folder_success(self):
|
||||
"""Test successful folder restoration."""
|
||||
runner = CliRunner()
|
||||
|
||||
mock_restore_response = Mock(spec=httpx.Response)
|
||||
mock_restore_response.status_code = 200
|
||||
mock_restore_response.json.return_value = {
|
||||
"restored": [
|
||||
"research/paper1.md",
|
||||
"research/paper2.md",
|
||||
"research/notes.md",
|
||||
],
|
||||
"snapshot_id": "snap_123",
|
||||
}
|
||||
|
||||
async def mock_make_api_request(*args, **kwargs):
|
||||
return mock_restore_response
|
||||
|
||||
with patch(
|
||||
"basic_memory.cli.commands.cloud.restore.make_api_request",
|
||||
side_effect=mock_make_api_request,
|
||||
):
|
||||
mock_config = Mock()
|
||||
mock_config.cloud_host = "https://cloud.example.com"
|
||||
mock_config_manager = Mock()
|
||||
mock_config_manager.config = mock_config
|
||||
|
||||
with patch(
|
||||
"basic_memory.cli.commands.cloud.restore.ConfigManager",
|
||||
return_value=mock_config_manager,
|
||||
):
|
||||
result = runner.invoke(
|
||||
app,
|
||||
["cloud", "restore", "research/", "--snapshot", "snap_123", "--force"],
|
||||
)
|
||||
|
||||
assert result.exit_code == 0
|
||||
assert "Successfully restored 3 file(s)" in result.stdout
|
||||
|
||||
def test_restore_many_files_truncated_output(self):
|
||||
"""Test restore output is truncated for many files."""
|
||||
runner = CliRunner()
|
||||
|
||||
mock_response = Mock(spec=httpx.Response)
|
||||
mock_response.status_code = 200
|
||||
mock_response.json.return_value = {
|
||||
"restored": [f"notes/file{i}.md" for i in range(20)],
|
||||
"snapshot_id": "snap_123",
|
||||
}
|
||||
|
||||
async def mock_make_api_request(*args, **kwargs):
|
||||
return mock_response
|
||||
|
||||
with patch(
|
||||
"basic_memory.cli.commands.cloud.restore.make_api_request",
|
||||
side_effect=mock_make_api_request,
|
||||
):
|
||||
mock_config = Mock()
|
||||
mock_config.cloud_host = "https://cloud.example.com"
|
||||
mock_config_manager = Mock()
|
||||
mock_config_manager.config = mock_config
|
||||
|
||||
with patch(
|
||||
"basic_memory.cli.commands.cloud.restore.ConfigManager",
|
||||
return_value=mock_config_manager,
|
||||
):
|
||||
result = runner.invoke(
|
||||
app,
|
||||
["cloud", "restore", "notes/", "--snapshot", "snap_123", "--force"],
|
||||
)
|
||||
|
||||
assert result.exit_code == 0
|
||||
assert "Successfully restored 20 file(s)" in result.stdout
|
||||
assert "and 15 more" in result.stdout
|
||||
|
||||
def test_restore_no_files_found(self):
|
||||
"""Test restore when no files match the path."""
|
||||
runner = CliRunner()
|
||||
|
||||
mock_response = Mock(spec=httpx.Response)
|
||||
mock_response.status_code = 200
|
||||
mock_response.json.return_value = {
|
||||
"restored": [],
|
||||
"snapshot_id": "snap_123",
|
||||
}
|
||||
|
||||
async def mock_make_api_request(*args, **kwargs):
|
||||
return mock_response
|
||||
|
||||
with patch(
|
||||
"basic_memory.cli.commands.cloud.restore.make_api_request",
|
||||
side_effect=mock_make_api_request,
|
||||
):
|
||||
mock_config = Mock()
|
||||
mock_config.cloud_host = "https://cloud.example.com"
|
||||
mock_config_manager = Mock()
|
||||
mock_config_manager.config = mock_config
|
||||
|
||||
with patch(
|
||||
"basic_memory.cli.commands.cloud.restore.ConfigManager",
|
||||
return_value=mock_config_manager,
|
||||
):
|
||||
result = runner.invoke(
|
||||
app,
|
||||
[
|
||||
"cloud",
|
||||
"restore",
|
||||
"nonexistent/",
|
||||
"--snapshot",
|
||||
"snap_123",
|
||||
"--force",
|
||||
],
|
||||
)
|
||||
|
||||
assert result.exit_code == 0
|
||||
assert "No files were restored" in result.stdout
|
||||
|
||||
def test_restore_snapshot_not_found(self):
|
||||
"""Test restore from non-existent snapshot."""
|
||||
runner = CliRunner()
|
||||
|
||||
async def mock_make_api_request(*args, **kwargs):
|
||||
raise CloudAPIError("Not found", status_code=404)
|
||||
|
||||
with patch(
|
||||
"basic_memory.cli.commands.cloud.restore.make_api_request",
|
||||
side_effect=mock_make_api_request,
|
||||
):
|
||||
mock_config = Mock()
|
||||
mock_config.cloud_host = "https://cloud.example.com"
|
||||
mock_config_manager = Mock()
|
||||
mock_config_manager.config = mock_config
|
||||
|
||||
with patch(
|
||||
"basic_memory.cli.commands.cloud.restore.ConfigManager",
|
||||
return_value=mock_config_manager,
|
||||
):
|
||||
result = runner.invoke(
|
||||
app,
|
||||
[
|
||||
"cloud",
|
||||
"restore",
|
||||
"notes/project.md",
|
||||
"--snapshot",
|
||||
"snap_nonexistent",
|
||||
"--force",
|
||||
],
|
||||
)
|
||||
|
||||
assert result.exit_code == 1
|
||||
assert "Snapshot not found" in result.stdout
|
||||
|
||||
def test_restore_subscription_required(self):
|
||||
"""Test restore requires subscription."""
|
||||
runner = CliRunner()
|
||||
|
||||
async def mock_make_api_request(*args, **kwargs):
|
||||
raise SubscriptionRequiredError(
|
||||
message="Active subscription required",
|
||||
subscribe_url="https://basicmemory.com/subscribe",
|
||||
)
|
||||
|
||||
with patch(
|
||||
"basic_memory.cli.commands.cloud.restore.make_api_request",
|
||||
side_effect=mock_make_api_request,
|
||||
):
|
||||
mock_config = Mock()
|
||||
mock_config.cloud_host = "https://cloud.example.com"
|
||||
mock_config_manager = Mock()
|
||||
mock_config_manager.config = mock_config
|
||||
|
||||
with patch(
|
||||
"basic_memory.cli.commands.cloud.restore.ConfigManager",
|
||||
return_value=mock_config_manager,
|
||||
):
|
||||
result = runner.invoke(
|
||||
app,
|
||||
[
|
||||
"cloud",
|
||||
"restore",
|
||||
"notes/project.md",
|
||||
"--snapshot",
|
||||
"snap_123",
|
||||
"--force",
|
||||
],
|
||||
)
|
||||
|
||||
assert result.exit_code == 1
|
||||
assert "Subscription Required" in result.stdout
|
||||
assert "https://basicmemory.com/subscribe" in result.stdout
|
||||
|
||||
def test_restore_cancelled_by_user(self):
|
||||
"""Test restore cancelled by user confirmation."""
|
||||
runner = CliRunner()
|
||||
|
||||
# First call is browse, second would be restore (should not happen)
|
||||
mock_browse_response = Mock(spec=httpx.Response)
|
||||
mock_browse_response.status_code = 200
|
||||
mock_browse_response.json.return_value = {
|
||||
"files": [
|
||||
{"key": "notes/project.md", "size": 1024, "last_modified": "2025-01-18T12:00:00Z"}
|
||||
],
|
||||
"prefix": "notes/project.md",
|
||||
"snapshot_version": "12345",
|
||||
}
|
||||
|
||||
call_count = 0
|
||||
|
||||
async def mock_make_api_request(method, url, *args, **kwargs):
|
||||
nonlocal call_count
|
||||
call_count += 1
|
||||
if "browse" in url:
|
||||
return mock_browse_response
|
||||
# Track unexpected calls - the test will verify later
|
||||
return mock_browse_response
|
||||
|
||||
with patch(
|
||||
"basic_memory.cli.commands.cloud.restore.make_api_request",
|
||||
side_effect=mock_make_api_request,
|
||||
):
|
||||
mock_config = Mock()
|
||||
mock_config.cloud_host = "https://cloud.example.com"
|
||||
mock_config_manager = Mock()
|
||||
mock_config_manager.config = mock_config
|
||||
|
||||
with patch(
|
||||
"basic_memory.cli.commands.cloud.restore.ConfigManager",
|
||||
return_value=mock_config_manager,
|
||||
):
|
||||
# Simulate user saying "n" to confirmation
|
||||
result = runner.invoke(
|
||||
app,
|
||||
["cloud", "restore", "notes/project.md", "--snapshot", "snap_123"],
|
||||
input="n\n",
|
||||
)
|
||||
|
||||
assert result.exit_code == 0
|
||||
assert "cancelled" in result.stdout
|
||||
# Only one call should happen (browse), not the restore POST
|
||||
assert call_count == 1
|
||||
|
||||
def test_restore_with_leading_slash_normalized(self):
|
||||
"""Test that leading slashes are stripped from path."""
|
||||
runner = CliRunner()
|
||||
|
||||
mock_response = Mock(spec=httpx.Response)
|
||||
mock_response.status_code = 200
|
||||
mock_response.json.return_value = {
|
||||
"restored": ["notes/project.md"],
|
||||
"snapshot_id": "snap_123",
|
||||
}
|
||||
|
||||
captured_json_data = []
|
||||
|
||||
async def mock_make_api_request(*args, **kwargs):
|
||||
if "json_data" in kwargs:
|
||||
captured_json_data.append(kwargs["json_data"])
|
||||
return mock_response
|
||||
|
||||
with patch(
|
||||
"basic_memory.cli.commands.cloud.restore.make_api_request",
|
||||
side_effect=mock_make_api_request,
|
||||
):
|
||||
mock_config = Mock()
|
||||
mock_config.cloud_host = "https://cloud.example.com"
|
||||
mock_config_manager = Mock()
|
||||
mock_config_manager.config = mock_config
|
||||
|
||||
with patch(
|
||||
"basic_memory.cli.commands.cloud.restore.ConfigManager",
|
||||
return_value=mock_config_manager,
|
||||
):
|
||||
result = runner.invoke(
|
||||
app,
|
||||
[
|
||||
"cloud",
|
||||
"restore",
|
||||
"/notes/project.md", # Leading slash
|
||||
"--snapshot",
|
||||
"snap_123",
|
||||
"--force",
|
||||
],
|
||||
)
|
||||
|
||||
assert result.exit_code == 0
|
||||
# Verify the path was normalized (no leading slash)
|
||||
assert any(data.get("path") == "notes/project.md" for data in captured_json_data)
|
||||
|
||||
def test_restore_api_error(self):
|
||||
"""Test handling generic API errors during restore."""
|
||||
runner = CliRunner()
|
||||
|
||||
async def mock_make_api_request(*args, **kwargs):
|
||||
raise CloudAPIError("Server error", status_code=500)
|
||||
|
||||
with patch(
|
||||
"basic_memory.cli.commands.cloud.restore.make_api_request",
|
||||
side_effect=mock_make_api_request,
|
||||
):
|
||||
mock_config = Mock()
|
||||
mock_config.cloud_host = "https://cloud.example.com"
|
||||
mock_config_manager = Mock()
|
||||
mock_config_manager.config = mock_config
|
||||
|
||||
with patch(
|
||||
"basic_memory.cli.commands.cloud.restore.ConfigManager",
|
||||
return_value=mock_config_manager,
|
||||
):
|
||||
result = runner.invoke(
|
||||
app,
|
||||
[
|
||||
"cloud",
|
||||
"restore",
|
||||
"notes/project.md",
|
||||
"--snapshot",
|
||||
"snap_123",
|
||||
"--force",
|
||||
],
|
||||
)
|
||||
|
||||
assert result.exit_code == 1
|
||||
assert "Failed to restore" in result.stdout
|
||||
|
||||
|
||||
class TestRestoreCommandHelp:
|
||||
"""Tests for restore command help and usage."""
|
||||
|
||||
def test_restore_requires_snapshot_option(self):
|
||||
"""Test that --snapshot option is required."""
|
||||
runner = CliRunner()
|
||||
|
||||
result = runner.invoke(app, ["cloud", "restore", "notes/project.md"])
|
||||
|
||||
# Should fail due to missing required option (exit code 2 for usage error)
|
||||
assert result.exit_code == 2
|
||||
# Typer writes the error message to the output
|
||||
assert "Missing option" in result.output or "--snapshot" in result.output
|
||||
|
||||
def test_restore_requires_path_argument(self):
|
||||
"""Test that path argument is required."""
|
||||
runner = CliRunner()
|
||||
|
||||
result = runner.invoke(app, ["cloud", "restore", "--snapshot", "snap_123"])
|
||||
|
||||
# Should fail due to missing required argument
|
||||
assert result.exit_code != 0
|
||||
@@ -0,0 +1,529 @@
|
||||
"""Tests for cloud snapshot CLI commands.
|
||||
|
||||
SPEC-29 Phase 3: Tests for snapshot create, list, delete, show, browse commands.
|
||||
"""
|
||||
|
||||
from unittest.mock import Mock, patch
|
||||
|
||||
import httpx
|
||||
from typer.testing import CliRunner
|
||||
|
||||
from basic_memory.cli.app import app
|
||||
from basic_memory.cli.commands.cloud.api_client import (
|
||||
CloudAPIError,
|
||||
SubscriptionRequiredError,
|
||||
)
|
||||
|
||||
|
||||
class TestSnapshotCreateCommand:
|
||||
"""Tests for 'bm cloud snapshot create' command."""
|
||||
|
||||
def test_create_snapshot_success(self):
|
||||
"""Test successful snapshot creation."""
|
||||
runner = CliRunner()
|
||||
|
||||
mock_response = Mock(spec=httpx.Response)
|
||||
mock_response.status_code = 200
|
||||
mock_response.json.return_value = {
|
||||
"id": "snap_123",
|
||||
"bucket_name": "tenant-abc",
|
||||
"snapshot_version": "1703430000000",
|
||||
"name": "manual-snapshot",
|
||||
"description": "before major refactor",
|
||||
"auto": False,
|
||||
"created_at": "2024-12-24T12:00:00Z",
|
||||
}
|
||||
|
||||
async def mock_make_api_request(*args, **kwargs):
|
||||
return mock_response
|
||||
|
||||
with patch(
|
||||
"basic_memory.cli.commands.cloud.snapshot.make_api_request",
|
||||
side_effect=mock_make_api_request,
|
||||
):
|
||||
mock_config = Mock()
|
||||
mock_config.cloud_host = "https://cloud.example.com"
|
||||
mock_config_manager = Mock()
|
||||
mock_config_manager.config = mock_config
|
||||
|
||||
with patch(
|
||||
"basic_memory.cli.commands.cloud.snapshot.ConfigManager",
|
||||
return_value=mock_config_manager,
|
||||
):
|
||||
result = runner.invoke(
|
||||
app, ["cloud", "snapshot", "create", "before major refactor"]
|
||||
)
|
||||
|
||||
assert result.exit_code == 0
|
||||
assert "Snapshot created successfully" in result.stdout
|
||||
assert "snap_123" in result.stdout
|
||||
assert "before major refactor" in result.stdout
|
||||
|
||||
def test_create_snapshot_subscription_required(self):
|
||||
"""Test snapshot creation requires subscription."""
|
||||
runner = CliRunner()
|
||||
|
||||
async def mock_make_api_request(*args, **kwargs):
|
||||
raise SubscriptionRequiredError(
|
||||
message="Active subscription required",
|
||||
subscribe_url="https://basicmemory.com/subscribe",
|
||||
)
|
||||
|
||||
with patch(
|
||||
"basic_memory.cli.commands.cloud.snapshot.make_api_request",
|
||||
side_effect=mock_make_api_request,
|
||||
):
|
||||
mock_config = Mock()
|
||||
mock_config.cloud_host = "https://cloud.example.com"
|
||||
mock_config_manager = Mock()
|
||||
mock_config_manager.config = mock_config
|
||||
|
||||
with patch(
|
||||
"basic_memory.cli.commands.cloud.snapshot.ConfigManager",
|
||||
return_value=mock_config_manager,
|
||||
):
|
||||
result = runner.invoke(app, ["cloud", "snapshot", "create", "test snapshot"])
|
||||
|
||||
assert result.exit_code == 1
|
||||
assert "Subscription Required" in result.stdout
|
||||
assert "https://basicmemory.com/subscribe" in result.stdout
|
||||
|
||||
def test_create_snapshot_api_error(self):
|
||||
"""Test handling API errors during snapshot creation."""
|
||||
runner = CliRunner()
|
||||
|
||||
async def mock_make_api_request(*args, **kwargs):
|
||||
raise CloudAPIError("Server error", status_code=500)
|
||||
|
||||
with patch(
|
||||
"basic_memory.cli.commands.cloud.snapshot.make_api_request",
|
||||
side_effect=mock_make_api_request,
|
||||
):
|
||||
mock_config = Mock()
|
||||
mock_config.cloud_host = "https://cloud.example.com"
|
||||
mock_config_manager = Mock()
|
||||
mock_config_manager.config = mock_config
|
||||
|
||||
with patch(
|
||||
"basic_memory.cli.commands.cloud.snapshot.ConfigManager",
|
||||
return_value=mock_config_manager,
|
||||
):
|
||||
result = runner.invoke(app, ["cloud", "snapshot", "create", "test snapshot"])
|
||||
|
||||
assert result.exit_code == 1
|
||||
assert "Failed to create snapshot" in result.stdout
|
||||
|
||||
|
||||
class TestSnapshotListCommand:
|
||||
"""Tests for 'bm cloud snapshot list' command."""
|
||||
|
||||
def test_list_snapshots_success(self):
|
||||
"""Test successful snapshot listing."""
|
||||
runner = CliRunner()
|
||||
|
||||
mock_response = Mock(spec=httpx.Response)
|
||||
mock_response.status_code = 200
|
||||
mock_response.json.return_value = {
|
||||
"snapshots": [
|
||||
{
|
||||
"id": "snap_123",
|
||||
"name": "snapshot-1",
|
||||
"description": "first snapshot",
|
||||
"auto": False,
|
||||
"created_at": "2024-12-24T12:00:00Z",
|
||||
},
|
||||
{
|
||||
"id": "snap_456",
|
||||
"name": "daily-auto",
|
||||
"description": "daily backup",
|
||||
"auto": True,
|
||||
"created_at": "2024-12-23T03:00:00Z",
|
||||
},
|
||||
],
|
||||
"total": 2,
|
||||
}
|
||||
|
||||
async def mock_make_api_request(*args, **kwargs):
|
||||
return mock_response
|
||||
|
||||
with patch(
|
||||
"basic_memory.cli.commands.cloud.snapshot.make_api_request",
|
||||
side_effect=mock_make_api_request,
|
||||
):
|
||||
mock_config = Mock()
|
||||
mock_config.cloud_host = "https://cloud.example.com"
|
||||
mock_config_manager = Mock()
|
||||
mock_config_manager.config = mock_config
|
||||
|
||||
with patch(
|
||||
"basic_memory.cli.commands.cloud.snapshot.ConfigManager",
|
||||
return_value=mock_config_manager,
|
||||
):
|
||||
result = runner.invoke(app, ["cloud", "snapshot", "list"])
|
||||
|
||||
assert result.exit_code == 0
|
||||
assert "snap_123" in result.stdout
|
||||
assert "snap_456" in result.stdout
|
||||
assert "first snapshot" in result.stdout
|
||||
assert "daily backup" in result.stdout
|
||||
|
||||
def test_list_snapshots_empty(self):
|
||||
"""Test listing when no snapshots exist."""
|
||||
runner = CliRunner()
|
||||
|
||||
mock_response = Mock(spec=httpx.Response)
|
||||
mock_response.status_code = 200
|
||||
mock_response.json.return_value = {"snapshots": [], "total": 0}
|
||||
|
||||
async def mock_make_api_request(*args, **kwargs):
|
||||
return mock_response
|
||||
|
||||
with patch(
|
||||
"basic_memory.cli.commands.cloud.snapshot.make_api_request",
|
||||
side_effect=mock_make_api_request,
|
||||
):
|
||||
mock_config = Mock()
|
||||
mock_config.cloud_host = "https://cloud.example.com"
|
||||
mock_config_manager = Mock()
|
||||
mock_config_manager.config = mock_config
|
||||
|
||||
with patch(
|
||||
"basic_memory.cli.commands.cloud.snapshot.ConfigManager",
|
||||
return_value=mock_config_manager,
|
||||
):
|
||||
result = runner.invoke(app, ["cloud", "snapshot", "list"])
|
||||
|
||||
assert result.exit_code == 0
|
||||
assert "No snapshots found" in result.stdout
|
||||
|
||||
def test_list_snapshots_with_limit(self):
|
||||
"""Test listing snapshots with custom limit."""
|
||||
runner = CliRunner()
|
||||
|
||||
mock_response = Mock(spec=httpx.Response)
|
||||
mock_response.status_code = 200
|
||||
mock_response.json.return_value = {
|
||||
"snapshots": [
|
||||
{
|
||||
"id": f"snap_{i}",
|
||||
"name": f"snap-{i}",
|
||||
"auto": False,
|
||||
"created_at": "2024-12-24T12:00:00Z",
|
||||
}
|
||||
for i in range(20)
|
||||
],
|
||||
"total": 20,
|
||||
}
|
||||
|
||||
async def mock_make_api_request(*args, **kwargs):
|
||||
return mock_response
|
||||
|
||||
with patch(
|
||||
"basic_memory.cli.commands.cloud.snapshot.make_api_request",
|
||||
side_effect=mock_make_api_request,
|
||||
):
|
||||
mock_config = Mock()
|
||||
mock_config.cloud_host = "https://cloud.example.com"
|
||||
mock_config_manager = Mock()
|
||||
mock_config_manager.config = mock_config
|
||||
|
||||
with patch(
|
||||
"basic_memory.cli.commands.cloud.snapshot.ConfigManager",
|
||||
return_value=mock_config_manager,
|
||||
):
|
||||
result = runner.invoke(app, ["cloud", "snapshot", "list", "--limit", "5"])
|
||||
|
||||
assert result.exit_code == 0
|
||||
# Should show message about more snapshots available
|
||||
assert "Showing 5 of 20" in result.stdout
|
||||
|
||||
|
||||
class TestSnapshotDeleteCommand:
|
||||
"""Tests for 'bm cloud snapshot delete' command."""
|
||||
|
||||
def test_delete_snapshot_success_with_force(self):
|
||||
"""Test successful snapshot deletion with --force flag."""
|
||||
runner = CliRunner()
|
||||
|
||||
mock_response = Mock(spec=httpx.Response)
|
||||
mock_response.status_code = 200
|
||||
mock_response.json.return_value = {}
|
||||
|
||||
async def mock_make_api_request(*args, **kwargs):
|
||||
return mock_response
|
||||
|
||||
with patch(
|
||||
"basic_memory.cli.commands.cloud.snapshot.make_api_request",
|
||||
side_effect=mock_make_api_request,
|
||||
):
|
||||
mock_config = Mock()
|
||||
mock_config.cloud_host = "https://cloud.example.com"
|
||||
mock_config_manager = Mock()
|
||||
mock_config_manager.config = mock_config
|
||||
|
||||
with patch(
|
||||
"basic_memory.cli.commands.cloud.snapshot.ConfigManager",
|
||||
return_value=mock_config_manager,
|
||||
):
|
||||
result = runner.invoke(app, ["cloud", "snapshot", "delete", "snap_123", "--force"])
|
||||
|
||||
assert result.exit_code == 0
|
||||
assert "deleted successfully" in result.stdout
|
||||
|
||||
def test_delete_snapshot_not_found(self):
|
||||
"""Test deletion of non-existent snapshot."""
|
||||
runner = CliRunner()
|
||||
|
||||
async def mock_make_api_request(*args, **kwargs):
|
||||
raise CloudAPIError("Not found", status_code=404)
|
||||
|
||||
with patch(
|
||||
"basic_memory.cli.commands.cloud.snapshot.make_api_request",
|
||||
side_effect=mock_make_api_request,
|
||||
):
|
||||
mock_config = Mock()
|
||||
mock_config.cloud_host = "https://cloud.example.com"
|
||||
mock_config_manager = Mock()
|
||||
mock_config_manager.config = mock_config
|
||||
|
||||
with patch(
|
||||
"basic_memory.cli.commands.cloud.snapshot.ConfigManager",
|
||||
return_value=mock_config_manager,
|
||||
):
|
||||
result = runner.invoke(
|
||||
app, ["cloud", "snapshot", "delete", "snap_nonexistent", "--force"]
|
||||
)
|
||||
|
||||
assert result.exit_code == 1
|
||||
assert "Snapshot not found" in result.stdout
|
||||
|
||||
def test_delete_snapshot_cancelled(self):
|
||||
"""Test snapshot deletion cancelled by user."""
|
||||
runner = CliRunner()
|
||||
|
||||
# Mock successful GET for snapshot details
|
||||
mock_get_response = Mock(spec=httpx.Response)
|
||||
mock_get_response.status_code = 200
|
||||
mock_get_response.json.return_value = {
|
||||
"id": "snap_123",
|
||||
"description": "test snapshot",
|
||||
"created_at": "2024-12-24T12:00:00Z",
|
||||
}
|
||||
|
||||
call_count = 0
|
||||
|
||||
async def mock_make_api_request(*args, **kwargs):
|
||||
nonlocal call_count
|
||||
call_count += 1
|
||||
method = kwargs.get("method", args[0] if args else None)
|
||||
if method == "GET":
|
||||
return mock_get_response
|
||||
# Track unexpected calls
|
||||
return mock_get_response
|
||||
|
||||
with patch(
|
||||
"basic_memory.cli.commands.cloud.snapshot.make_api_request",
|
||||
side_effect=mock_make_api_request,
|
||||
):
|
||||
mock_config = Mock()
|
||||
mock_config.cloud_host = "https://cloud.example.com"
|
||||
mock_config_manager = Mock()
|
||||
mock_config_manager.config = mock_config
|
||||
|
||||
with patch(
|
||||
"basic_memory.cli.commands.cloud.snapshot.ConfigManager",
|
||||
return_value=mock_config_manager,
|
||||
):
|
||||
# Simulate user saying "n" to confirmation
|
||||
result = runner.invoke(
|
||||
app, ["cloud", "snapshot", "delete", "snap_123"], input="n\n"
|
||||
)
|
||||
|
||||
assert result.exit_code == 0
|
||||
assert "cancelled" in result.stdout
|
||||
# Only one call should happen (GET for details), not the DELETE
|
||||
assert call_count == 1
|
||||
|
||||
|
||||
class TestSnapshotShowCommand:
|
||||
"""Tests for 'bm cloud snapshot show' command."""
|
||||
|
||||
def test_show_snapshot_success(self):
|
||||
"""Test showing snapshot details."""
|
||||
runner = CliRunner()
|
||||
|
||||
mock_response = Mock(spec=httpx.Response)
|
||||
mock_response.status_code = 200
|
||||
mock_response.json.return_value = {
|
||||
"id": "snap_123",
|
||||
"bucket_name": "tenant-abc",
|
||||
"snapshot_version": "1703430000000",
|
||||
"name": "test-snapshot",
|
||||
"description": "test description",
|
||||
"auto": False,
|
||||
"created_at": "2024-12-24T12:00:00Z",
|
||||
}
|
||||
|
||||
async def mock_make_api_request(*args, **kwargs):
|
||||
return mock_response
|
||||
|
||||
with patch(
|
||||
"basic_memory.cli.commands.cloud.snapshot.make_api_request",
|
||||
side_effect=mock_make_api_request,
|
||||
):
|
||||
mock_config = Mock()
|
||||
mock_config.cloud_host = "https://cloud.example.com"
|
||||
mock_config_manager = Mock()
|
||||
mock_config_manager.config = mock_config
|
||||
|
||||
with patch(
|
||||
"basic_memory.cli.commands.cloud.snapshot.ConfigManager",
|
||||
return_value=mock_config_manager,
|
||||
):
|
||||
result = runner.invoke(app, ["cloud", "snapshot", "show", "snap_123"])
|
||||
|
||||
assert result.exit_code == 0
|
||||
assert "snap_123" in result.stdout
|
||||
assert "tenant-abc" in result.stdout
|
||||
assert "test description" in result.stdout
|
||||
|
||||
def test_show_snapshot_not_found(self):
|
||||
"""Test showing non-existent snapshot."""
|
||||
runner = CliRunner()
|
||||
|
||||
async def mock_make_api_request(*args, **kwargs):
|
||||
raise CloudAPIError("Not found", status_code=404)
|
||||
|
||||
with patch(
|
||||
"basic_memory.cli.commands.cloud.snapshot.make_api_request",
|
||||
side_effect=mock_make_api_request,
|
||||
):
|
||||
mock_config = Mock()
|
||||
mock_config.cloud_host = "https://cloud.example.com"
|
||||
mock_config_manager = Mock()
|
||||
mock_config_manager.config = mock_config
|
||||
|
||||
with patch(
|
||||
"basic_memory.cli.commands.cloud.snapshot.ConfigManager",
|
||||
return_value=mock_config_manager,
|
||||
):
|
||||
result = runner.invoke(app, ["cloud", "snapshot", "show", "snap_nonexistent"])
|
||||
|
||||
assert result.exit_code == 1
|
||||
assert "Snapshot not found" in result.stdout
|
||||
|
||||
|
||||
class TestSnapshotBrowseCommand:
|
||||
"""Tests for 'bm cloud snapshot browse' command."""
|
||||
|
||||
def test_browse_snapshot_success(self):
|
||||
"""Test browsing snapshot contents."""
|
||||
runner = CliRunner()
|
||||
|
||||
mock_response = Mock(spec=httpx.Response)
|
||||
mock_response.status_code = 200
|
||||
mock_response.json.return_value = {
|
||||
"files": [
|
||||
{"key": "notes/project.md", "size": 1024, "last_modified": "2025-01-18T12:00:00Z"},
|
||||
{"key": "notes/ideas.md", "size": 2048, "last_modified": "2025-01-18T12:00:00Z"},
|
||||
{"key": "research/paper.md", "size": 4096, "last_modified": "2025-01-18T12:00:00Z"},
|
||||
],
|
||||
"prefix": "",
|
||||
"snapshot_version": "12345",
|
||||
}
|
||||
|
||||
async def mock_make_api_request(*args, **kwargs):
|
||||
return mock_response
|
||||
|
||||
with patch(
|
||||
"basic_memory.cli.commands.cloud.snapshot.make_api_request",
|
||||
side_effect=mock_make_api_request,
|
||||
):
|
||||
mock_config = Mock()
|
||||
mock_config.cloud_host = "https://cloud.example.com"
|
||||
mock_config_manager = Mock()
|
||||
mock_config_manager.config = mock_config
|
||||
|
||||
with patch(
|
||||
"basic_memory.cli.commands.cloud.snapshot.ConfigManager",
|
||||
return_value=mock_config_manager,
|
||||
):
|
||||
result = runner.invoke(app, ["cloud", "snapshot", "browse", "snap_123"])
|
||||
|
||||
assert result.exit_code == 0
|
||||
assert "notes/project.md" in result.stdout
|
||||
assert "notes/ideas.md" in result.stdout
|
||||
assert "research/paper.md" in result.stdout
|
||||
|
||||
def test_browse_snapshot_with_prefix(self):
|
||||
"""Test browsing snapshot with prefix filter."""
|
||||
runner = CliRunner()
|
||||
|
||||
mock_response = Mock(spec=httpx.Response)
|
||||
mock_response.status_code = 200
|
||||
mock_response.json.return_value = {
|
||||
"files": [
|
||||
{"key": "notes/project.md", "size": 1024, "last_modified": "2025-01-18T12:00:00Z"},
|
||||
{"key": "notes/ideas.md", "size": 2048, "last_modified": "2025-01-18T12:00:00Z"},
|
||||
],
|
||||
"prefix": "notes/",
|
||||
"snapshot_version": "12345",
|
||||
}
|
||||
|
||||
async def mock_make_api_request(*args, **kwargs):
|
||||
# Verify prefix is in the URL
|
||||
url = args[1] if len(args) > 1 else kwargs.get("url", "")
|
||||
assert "prefix=notes/" in url
|
||||
return mock_response
|
||||
|
||||
with patch(
|
||||
"basic_memory.cli.commands.cloud.snapshot.make_api_request",
|
||||
side_effect=mock_make_api_request,
|
||||
):
|
||||
mock_config = Mock()
|
||||
mock_config.cloud_host = "https://cloud.example.com"
|
||||
mock_config_manager = Mock()
|
||||
mock_config_manager.config = mock_config
|
||||
|
||||
with patch(
|
||||
"basic_memory.cli.commands.cloud.snapshot.ConfigManager",
|
||||
return_value=mock_config_manager,
|
||||
):
|
||||
result = runner.invoke(
|
||||
app, ["cloud", "snapshot", "browse", "snap_123", "--prefix", "notes/"]
|
||||
)
|
||||
|
||||
assert result.exit_code == 0
|
||||
|
||||
def test_browse_snapshot_empty(self):
|
||||
"""Test browsing snapshot with no files."""
|
||||
runner = CliRunner()
|
||||
|
||||
mock_response = Mock(spec=httpx.Response)
|
||||
mock_response.status_code = 200
|
||||
mock_response.json.return_value = {
|
||||
"files": [],
|
||||
"prefix": "",
|
||||
"snapshot_version": "12345",
|
||||
}
|
||||
|
||||
async def mock_make_api_request(*args, **kwargs):
|
||||
return mock_response
|
||||
|
||||
with patch(
|
||||
"basic_memory.cli.commands.cloud.snapshot.make_api_request",
|
||||
side_effect=mock_make_api_request,
|
||||
):
|
||||
mock_config = Mock()
|
||||
mock_config.cloud_host = "https://cloud.example.com"
|
||||
mock_config_manager = Mock()
|
||||
mock_config_manager.config = mock_config
|
||||
|
||||
with patch(
|
||||
"basic_memory.cli.commands.cloud.snapshot.ConfigManager",
|
||||
return_value=mock_config_manager,
|
||||
):
|
||||
result = runner.invoke(app, ["cloud", "snapshot", "browse", "snap_123"])
|
||||
|
||||
assert result.exit_code == 0
|
||||
assert "No files found" in result.stdout
|
||||
@@ -0,0 +1,163 @@
|
||||
"""Tests for markdown/utils.py - entity model conversion utilities."""
|
||||
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from basic_memory.markdown.schemas import EntityMarkdown, EntityFrontmatter, Observation
|
||||
from basic_memory.markdown.utils import entity_model_from_markdown
|
||||
from basic_memory.models import Entity
|
||||
|
||||
|
||||
class TestEntityModelFromMarkdown:
|
||||
"""Tests for entity_model_from_markdown function."""
|
||||
|
||||
def _create_markdown(
|
||||
self,
|
||||
title: str = "Test Entity",
|
||||
entity_type: str = "note",
|
||||
permalink: str = "test/test-entity",
|
||||
created: datetime | None = None,
|
||||
modified: datetime | None = None,
|
||||
observations: list[Observation] | None = None,
|
||||
) -> EntityMarkdown:
|
||||
"""Helper to create test EntityMarkdown objects."""
|
||||
now = datetime.now(timezone.utc)
|
||||
return EntityMarkdown(
|
||||
frontmatter=EntityFrontmatter(
|
||||
title=title,
|
||||
type=entity_type,
|
||||
permalink=permalink,
|
||||
),
|
||||
content=f"# {title}\n\nTest content.",
|
||||
observations=observations or [],
|
||||
relations=[],
|
||||
created=created or now,
|
||||
modified=modified or now,
|
||||
)
|
||||
|
||||
def test_new_entity_has_external_id(self):
|
||||
"""Test that a new entity always gets an external_id set.
|
||||
|
||||
This is a regression test for GitHub issue #512 where SQLite failed
|
||||
with NOT NULL constraint on external_id because SQLAlchemy's Python-side
|
||||
default wasn't always evaluated.
|
||||
"""
|
||||
markdown = self._create_markdown()
|
||||
file_path = Path("test/test-entity.md")
|
||||
|
||||
entity = entity_model_from_markdown(file_path, markdown)
|
||||
|
||||
# external_id must be set (non-None, non-empty)
|
||||
assert entity.external_id is not None
|
||||
assert entity.external_id != ""
|
||||
# Should be a valid UUID format (36 chars with hyphens)
|
||||
assert len(entity.external_id) == 36
|
||||
assert entity.external_id.count("-") == 4
|
||||
|
||||
def test_existing_entity_preserves_external_id(self):
|
||||
"""Test that an existing entity's external_id is preserved."""
|
||||
markdown = self._create_markdown()
|
||||
file_path = Path("test/test-entity.md")
|
||||
|
||||
# Create existing entity with known external_id
|
||||
existing_external_id = "12345678-1234-1234-1234-123456789012"
|
||||
existing_entity = Entity()
|
||||
existing_entity.external_id = existing_external_id
|
||||
|
||||
entity = entity_model_from_markdown(file_path, markdown, entity=existing_entity)
|
||||
|
||||
# Should preserve the existing external_id
|
||||
assert entity.external_id == existing_external_id
|
||||
|
||||
def test_entity_with_empty_external_id_gets_new_one(self):
|
||||
"""Test that an entity with empty string external_id gets a new UUID."""
|
||||
markdown = self._create_markdown()
|
||||
file_path = Path("test/test-entity.md")
|
||||
|
||||
# Create existing entity with empty external_id
|
||||
existing_entity = Entity()
|
||||
existing_entity.external_id = ""
|
||||
|
||||
entity = entity_model_from_markdown(file_path, markdown, entity=existing_entity)
|
||||
|
||||
# Should have a new external_id
|
||||
assert entity.external_id is not None
|
||||
assert entity.external_id != ""
|
||||
assert len(entity.external_id) == 36
|
||||
|
||||
def test_entity_with_none_external_id_gets_new_one(self):
|
||||
"""Test that an entity with None external_id gets a new UUID."""
|
||||
markdown = self._create_markdown()
|
||||
file_path = Path("test/test-entity.md")
|
||||
|
||||
# Create existing entity with None external_id
|
||||
existing_entity = Entity()
|
||||
# Explicitly set to None to test this case
|
||||
object.__setattr__(existing_entity, "external_id", None)
|
||||
|
||||
entity = entity_model_from_markdown(file_path, markdown, entity=existing_entity)
|
||||
|
||||
# Should have a new external_id
|
||||
assert entity.external_id is not None
|
||||
assert entity.external_id != ""
|
||||
assert len(entity.external_id) == 36
|
||||
|
||||
def test_multiple_calls_generate_unique_ids(self):
|
||||
"""Test that multiple new entities get unique external_ids."""
|
||||
markdown1 = self._create_markdown(title="Entity 1", permalink="test/entity-1")
|
||||
markdown2 = self._create_markdown(title="Entity 2", permalink="test/entity-2")
|
||||
|
||||
entity1 = entity_model_from_markdown(Path("test/entity-1.md"), markdown1)
|
||||
entity2 = entity_model_from_markdown(Path("test/entity-2.md"), markdown2)
|
||||
|
||||
# Both should have external_ids
|
||||
assert entity1.external_id is not None
|
||||
assert entity2.external_id is not None
|
||||
|
||||
# They should be unique
|
||||
assert entity1.external_id != entity2.external_id
|
||||
|
||||
def test_entity_model_fields_populated_with_external_id(self):
|
||||
"""Test that entity fields are populated correctly, including external_id.
|
||||
|
||||
This is a basic sanity check that entity_model_from_markdown sets
|
||||
the key fields we care about for the #512 fix.
|
||||
"""
|
||||
markdown = self._create_markdown(
|
||||
title="My Test Entity",
|
||||
entity_type="component",
|
||||
permalink="components/my-test",
|
||||
)
|
||||
file_path = Path("components/my-test.md")
|
||||
|
||||
entity = entity_model_from_markdown(file_path, markdown, project_id=1)
|
||||
|
||||
# The key assertion: external_id must always be set
|
||||
assert entity.external_id is not None
|
||||
assert len(entity.external_id) == 36 # UUID format
|
||||
|
||||
# Verify file_path is set correctly (uses posix format)
|
||||
assert entity.file_path == "components/my-test.md"
|
||||
|
||||
# Timestamps should be set from markdown
|
||||
assert entity.created_at is not None
|
||||
assert entity.updated_at is not None
|
||||
|
||||
def test_missing_dates_raises_error(self):
|
||||
"""Test that missing created/modified dates raise ValueError."""
|
||||
markdown = EntityMarkdown(
|
||||
frontmatter=EntityFrontmatter(
|
||||
title="Test",
|
||||
type="note",
|
||||
),
|
||||
content="# Test",
|
||||
observations=[],
|
||||
relations=[],
|
||||
created=None,
|
||||
modified=None,
|
||||
)
|
||||
|
||||
with pytest.raises(ValueError, match="Both created and modified dates are required"):
|
||||
entity_model_from_markdown(Path("test.md"), markdown)
|
||||
@@ -136,6 +136,39 @@ async def test_get_default_project(project_repository: ProjectRepository, test_p
|
||||
assert default_project.is_default is True
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_default_project_with_false_values(project_repository: ProjectRepository):
|
||||
"""Test that get_default_project ignores projects with is_default=False.
|
||||
|
||||
Regression test for bug where is_not(None) matched both True and False,
|
||||
causing MultipleResultsFound when multiple projects had different boolean values.
|
||||
"""
|
||||
# Create projects with explicit is_default values
|
||||
project_true = await project_repository.create({
|
||||
"name": "Default Project",
|
||||
"path": "/default/path",
|
||||
"is_default": True,
|
||||
})
|
||||
|
||||
await project_repository.create({
|
||||
"name": "Not Default Project",
|
||||
"path": "/not-default/path",
|
||||
"is_default": False,
|
||||
})
|
||||
|
||||
await project_repository.create({
|
||||
"name": "Null Default Project",
|
||||
"path": "/null/path",
|
||||
"is_default": None,
|
||||
})
|
||||
|
||||
# Should return only the project with is_default=True
|
||||
default = await project_repository.get_default_project()
|
||||
assert default is not None
|
||||
assert default.id == project_true.id
|
||||
assert default.name == "Default Project"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_active_projects(project_repository: ProjectRepository):
|
||||
"""Test getting all active projects."""
|
||||
|
||||
@@ -1,290 +0,0 @@
|
||||
"""Tests for telemetry module (minimal mocking).
|
||||
|
||||
We avoid standard-library mocks and instead use small stub objects + pytest monkeypatch.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
from basic_memory.config import BasicMemoryConfig
|
||||
|
||||
|
||||
class _StubOpenPanel:
|
||||
def __init__(self, *, client_id: str, client_secret: str, disabled: bool = False):
|
||||
self.client_id = client_id
|
||||
self.client_secret = client_secret
|
||||
self.disabled = disabled
|
||||
self.global_properties: dict | None = None
|
||||
self.events: list[tuple[str, dict]] = []
|
||||
self.raise_on_track: Exception | None = None
|
||||
self.profile_id: str | None = None
|
||||
|
||||
def identify(self, profile_id: str) -> None:
|
||||
self.profile_id = profile_id
|
||||
|
||||
def set_global_properties(self, props: dict) -> None:
|
||||
self.global_properties = props
|
||||
|
||||
def track(self, event: str, properties: dict) -> None:
|
||||
if self.raise_on_track:
|
||||
raise self.raise_on_track
|
||||
self.events.append((event, properties))
|
||||
|
||||
|
||||
class _StubConsole:
|
||||
def __init__(self, *args, **kwargs):
|
||||
self.print_calls: list[tuple[tuple, dict]] = []
|
||||
|
||||
def print(self, *args, **kwargs):
|
||||
self.print_calls.append((args, kwargs))
|
||||
|
||||
|
||||
class TestGetInstallId:
|
||||
def test_creates_install_id_on_first_call(self, tmp_path, monkeypatch):
|
||||
monkeypatch.setattr(Path, "home", lambda: tmp_path)
|
||||
from basic_memory.telemetry import get_install_id
|
||||
|
||||
install_id = get_install_id()
|
||||
assert len(install_id) == 36
|
||||
assert install_id.count("-") == 4
|
||||
|
||||
id_file = tmp_path / ".basic-memory" / ".install_id"
|
||||
assert id_file.exists()
|
||||
assert id_file.read_text().strip() == install_id
|
||||
|
||||
def test_returns_existing_install_id(self, tmp_path, monkeypatch):
|
||||
monkeypatch.setattr(Path, "home", lambda: tmp_path)
|
||||
id_file = tmp_path / ".basic-memory" / ".install_id"
|
||||
id_file.parent.mkdir(parents=True, exist_ok=True)
|
||||
id_file.write_text("test-uuid-12345")
|
||||
|
||||
from basic_memory.telemetry import get_install_id
|
||||
|
||||
assert get_install_id() == "test-uuid-12345"
|
||||
|
||||
|
||||
class TestTelemetryConfig:
|
||||
def test_telemetry_enabled_defaults_to_true(self, config_home, monkeypatch):
|
||||
import basic_memory.config
|
||||
|
||||
basic_memory.config._CONFIG_CACHE = None
|
||||
assert BasicMemoryConfig().telemetry_enabled is True
|
||||
|
||||
def test_telemetry_notice_shown_defaults_to_false(self, config_home, monkeypatch):
|
||||
import basic_memory.config
|
||||
|
||||
basic_memory.config._CONFIG_CACHE = None
|
||||
assert BasicMemoryConfig().telemetry_notice_shown is False
|
||||
|
||||
def test_telemetry_enabled_via_env_var(self, config_home, monkeypatch):
|
||||
import basic_memory.config
|
||||
|
||||
basic_memory.config._CONFIG_CACHE = None
|
||||
monkeypatch.setenv("BASIC_MEMORY_TELEMETRY_ENABLED", "false")
|
||||
assert BasicMemoryConfig().telemetry_enabled is False
|
||||
|
||||
|
||||
class TestTrack:
|
||||
def test_track_does_not_raise_on_error(self, config_home, monkeypatch):
|
||||
import basic_memory.telemetry as telemetry
|
||||
import basic_memory.config
|
||||
|
||||
basic_memory.config._CONFIG_CACHE = None
|
||||
telemetry.reset_client()
|
||||
# Force telemetry to be enabled for this test
|
||||
monkeypatch.setattr(telemetry, "_is_telemetry_enabled", lambda: True)
|
||||
|
||||
# Replace OpenPanel with a stub that raises on track
|
||||
stub_client = _StubOpenPanel(client_id="id", client_secret="sec", disabled=False)
|
||||
stub_client.raise_on_track = Exception("Network error")
|
||||
|
||||
def openpanel_factory(*, client_id, client_secret):
|
||||
stub_client.client_id = client_id
|
||||
stub_client.client_secret = client_secret
|
||||
return stub_client
|
||||
|
||||
# Mock the import inside _get_client()
|
||||
monkeypatch.setattr("openpanel.OpenPanel", openpanel_factory)
|
||||
|
||||
# Should not raise
|
||||
telemetry.track("test_event", {"key": "value"})
|
||||
|
||||
def test_track_respects_disabled_config(self, config_home, monkeypatch):
|
||||
import basic_memory.telemetry as telemetry
|
||||
import basic_memory.config
|
||||
|
||||
basic_memory.config._CONFIG_CACHE = None
|
||||
telemetry.reset_client()
|
||||
|
||||
monkeypatch.setenv("BASIC_MEMORY_TELEMETRY_ENABLED", "false")
|
||||
|
||||
created: list[_StubOpenPanel] = []
|
||||
|
||||
def openpanel_factory(*, client_id, client_secret):
|
||||
client = _StubOpenPanel(
|
||||
client_id=client_id, client_secret=client_secret, disabled=False
|
||||
)
|
||||
created.append(client)
|
||||
return client
|
||||
|
||||
monkeypatch.setattr("openpanel.OpenPanel", openpanel_factory)
|
||||
|
||||
telemetry.track("test_event")
|
||||
# With disabled config, OpenPanel client is never created (early return)
|
||||
assert len(created) == 0
|
||||
|
||||
|
||||
class TestShowNoticeIfNeeded:
|
||||
def test_shows_notice_when_enabled_and_not_shown(self, config_manager, monkeypatch):
|
||||
import basic_memory.telemetry as telemetry
|
||||
|
||||
telemetry.reset_client()
|
||||
|
||||
# Ensure config state: enabled + not yet shown
|
||||
cfg = config_manager.load_config()
|
||||
cfg.telemetry_enabled = True
|
||||
cfg.telemetry_notice_shown = False
|
||||
config_manager.save_config(cfg)
|
||||
|
||||
instances: list[_StubConsole] = []
|
||||
|
||||
def console_factory(*_args, **_kwargs):
|
||||
c = _StubConsole()
|
||||
instances.append(c)
|
||||
return c
|
||||
|
||||
monkeypatch.setattr("rich.console.Console", console_factory)
|
||||
|
||||
telemetry.show_notice_if_needed()
|
||||
|
||||
assert len(instances) == 1
|
||||
assert len(instances[0].print_calls) == 1
|
||||
|
||||
cfg2 = config_manager.load_config()
|
||||
assert cfg2.telemetry_notice_shown is True
|
||||
|
||||
def test_does_not_show_notice_when_disabled(self, config_manager, monkeypatch):
|
||||
import basic_memory.telemetry as telemetry
|
||||
|
||||
telemetry.reset_client()
|
||||
|
||||
cfg = config_manager.load_config()
|
||||
cfg.telemetry_enabled = False
|
||||
cfg.telemetry_notice_shown = False
|
||||
config_manager.save_config(cfg)
|
||||
|
||||
def console_factory(*_args, **_kwargs):
|
||||
raise AssertionError("Console should not be constructed when telemetry is disabled")
|
||||
|
||||
monkeypatch.setattr("rich.console.Console", console_factory)
|
||||
|
||||
telemetry.show_notice_if_needed()
|
||||
|
||||
|
||||
class TestConvenienceFunctions:
|
||||
def test_track_app_started(self, config_home, monkeypatch):
|
||||
import basic_memory.telemetry as telemetry
|
||||
import basic_memory.config
|
||||
|
||||
basic_memory.config._CONFIG_CACHE = None
|
||||
telemetry.reset_client()
|
||||
# Force telemetry to be enabled for this test (pytest sets PYTEST_CURRENT_TEST)
|
||||
monkeypatch.setattr(telemetry, "_is_telemetry_enabled", lambda: True)
|
||||
|
||||
created: list[_StubOpenPanel] = []
|
||||
|
||||
def openpanel_factory(*, client_id, client_secret):
|
||||
client = _StubOpenPanel(
|
||||
client_id=client_id, client_secret=client_secret, disabled=False
|
||||
)
|
||||
created.append(client)
|
||||
return client
|
||||
|
||||
monkeypatch.setattr("openpanel.OpenPanel", openpanel_factory)
|
||||
|
||||
telemetry.track_app_started("cli")
|
||||
assert created
|
||||
assert created[0].events[-1] == ("app_started", {"mode": "cli"})
|
||||
|
||||
def test_track_mcp_tool(self, config_home, monkeypatch):
|
||||
import basic_memory.telemetry as telemetry
|
||||
import basic_memory.config
|
||||
|
||||
basic_memory.config._CONFIG_CACHE = None
|
||||
telemetry.reset_client()
|
||||
# Force telemetry to be enabled for this test
|
||||
monkeypatch.setattr(telemetry, "_is_telemetry_enabled", lambda: True)
|
||||
|
||||
created: list[_StubOpenPanel] = []
|
||||
|
||||
def openpanel_factory(*, client_id, client_secret):
|
||||
client = _StubOpenPanel(
|
||||
client_id=client_id, client_secret=client_secret, disabled=False
|
||||
)
|
||||
created.append(client)
|
||||
return client
|
||||
|
||||
monkeypatch.setattr("openpanel.OpenPanel", openpanel_factory)
|
||||
|
||||
telemetry.track_mcp_tool("write_note")
|
||||
assert created
|
||||
assert created[0].events[-1] == ("mcp_tool_called", {"tool": "write_note"})
|
||||
|
||||
def test_track_error_truncates_message(self, config_home, monkeypatch):
|
||||
import basic_memory.telemetry as telemetry
|
||||
import basic_memory.config
|
||||
|
||||
basic_memory.config._CONFIG_CACHE = None
|
||||
telemetry.reset_client()
|
||||
# Force telemetry to be enabled for this test
|
||||
monkeypatch.setattr(telemetry, "_is_telemetry_enabled", lambda: True)
|
||||
|
||||
created: list[_StubOpenPanel] = []
|
||||
|
||||
def openpanel_factory(*, client_id, client_secret):
|
||||
client = _StubOpenPanel(
|
||||
client_id=client_id, client_secret=client_secret, disabled=False
|
||||
)
|
||||
created.append(client)
|
||||
return client
|
||||
|
||||
monkeypatch.setattr("openpanel.OpenPanel", openpanel_factory)
|
||||
|
||||
telemetry.track_error("ValueError", "x" * 500)
|
||||
_, props = created[0].events[-1]
|
||||
assert len(props["message"]) == 200
|
||||
|
||||
def test_track_error_sanitizes_file_paths(self, config_home, monkeypatch):
|
||||
import basic_memory.telemetry as telemetry
|
||||
import basic_memory.config
|
||||
|
||||
basic_memory.config._CONFIG_CACHE = None
|
||||
telemetry.reset_client()
|
||||
# Force telemetry to be enabled for this test
|
||||
monkeypatch.setattr(telemetry, "_is_telemetry_enabled", lambda: True)
|
||||
|
||||
created: list[_StubOpenPanel] = []
|
||||
|
||||
def openpanel_factory(*, client_id, client_secret):
|
||||
client = _StubOpenPanel(
|
||||
client_id=client_id, client_secret=client_secret, disabled=False
|
||||
)
|
||||
created.append(client)
|
||||
return client
|
||||
|
||||
monkeypatch.setattr("openpanel.OpenPanel", openpanel_factory)
|
||||
|
||||
telemetry.track_error("FileNotFoundError", "No such file: /Users/john/notes/secret.md")
|
||||
_, props = created[0].events[-1]
|
||||
assert "/Users/john" not in props["message"]
|
||||
assert "[FILE]" in props["message"]
|
||||
|
||||
telemetry.reset_client()
|
||||
created.clear()
|
||||
|
||||
monkeypatch.setattr("openpanel.OpenPanel", openpanel_factory)
|
||||
telemetry.track_error("FileNotFoundError", "Cannot open C:\\Users\\john\\docs\\private.txt")
|
||||
_, props = created[0].events[-1]
|
||||
assert "C:\\Users\\john" not in props["message"]
|
||||
assert "[FILE]" in props["message"]
|
||||
@@ -156,7 +156,6 @@ dependencies = [
|
||||
{ name = "mdformat-frontmatter" },
|
||||
{ name = "mdformat-gfm" },
|
||||
{ name = "nest-asyncio" },
|
||||
{ name = "openpanel" },
|
||||
{ name = "pillow" },
|
||||
{ name = "psycopg" },
|
||||
{ name = "pybars3" },
|
||||
@@ -212,7 +211,6 @@ requires-dist = [
|
||||
{ name = "mdformat-frontmatter", specifier = ">=2.0.8" },
|
||||
{ name = "mdformat-gfm", specifier = ">=0.3.7" },
|
||||
{ name = "nest-asyncio", specifier = ">=1.6.0" },
|
||||
{ name = "openpanel", specifier = ">=0.0.1" },
|
||||
{ name = "pillow", specifier = ">=11.1.0" },
|
||||
{ name = "psycopg", specifier = "==3.3.1" },
|
||||
{ name = "pybars3", specifier = ">=0.9.7" },
|
||||
@@ -1359,18 +1357,6 @@ wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/27/dd/b3fd642260cb17532f66cc1e8250f3507d1e580483e209dc1e9d13bd980d/openapi_spec_validator-0.7.2-py3-none-any.whl", hash = "sha256:4bbdc0894ec85f1d1bea1d6d9c8b2c3c8d7ccaa13577ef40da9c006c9fd0eb60", size = 39713, upload-time = "2025-06-07T14:48:54.077Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "openpanel"
|
||||
version = "0.0.1"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "requests" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/42/d2/1ca167988225113a2162fcc528ef309715f348e1ee2bcaa8405222fea08c/openpanel-0.0.1.tar.gz", hash = "sha256:96a27848d670218c03a75528b95b8e3efbd4898665d57b3ee9c81c4b3c06f922", size = 3721, upload-time = "2024-10-16T14:19:41.923Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/f9/73/44ad513438c56d3e9c8dbdbea647a2f79e8be03a80967eeaa873f29a0527/openpanel-0.0.1-py3-none-any.whl", hash = "sha256:c4d5a31694d4307975bbf70bbb2fa9fae920df0fb4b8ff97fa7b78ee8fe667b2", size = 15865, upload-time = "2024-10-22T19:27:53.139Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "packaging"
|
||||
version = "25.0"
|
||||
|
||||
Reference in New Issue
Block a user