Compare commits

...

5 Commits

Author SHA1 Message Date
Drew Cain 1799c94953 fix: Docker container Python symlink broken at runtime (#510)
Signed-off-by: Drew Cain <groksrc@gmail.com>
Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-14 11:59:35 -06:00
Drew Cain 07996181b3 chore: add doc update reminder to release commands
Remind developers to update docs.basicmemory.com and basicmachines.co
after running just release or just beta.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-14 10:21:23 -06:00
phernandez a1c37c1dba chore: update version to 0.17.5 for v0.17.5 release 2026-01-11 16:53:55 -06:00
phernandez aff53cca93 docs: add changelog entry for v0.17.5
- Python 3.14 compatibility fix for CLI commands hanging on exit
- Skip nest_asyncio on Python 3.14+
- Update pyright to 1.1.408 for Python 3.14 support
- Fix SQLAlchemy rowcount typing

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
Signed-off-by: phernandez <paul@basicmachines.co>
2026-01-11 16:53:38 -06:00
Paul Hernandez 863e0a4e24 fix: prevent CLI commands from hanging on exit (Python 3.14) (#505)
Signed-off-by: phernandez <paul@basicmachines.co>
Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-11 16:47:35 -06:00
75 changed files with 1882 additions and 1801 deletions
+3 -3
View File
@@ -19,7 +19,7 @@ jobs:
fail-fast: false
matrix:
os: [ubuntu-latest, windows-latest]
python-version: [ "3.12", "3.13" ]
python-version: [ "3.12", "3.13", "3.14" ]
runs-on: ${{ matrix.os }}
steps:
@@ -75,7 +75,7 @@ jobs:
strategy:
fail-fast: false
matrix:
python-version: [ "3.12", "3.13" ]
python-version: [ "3.12", "3.13", "3.14" ]
runs-on: ubuntu-latest
# Note: No services section needed - testcontainers handles Postgres in Docker
@@ -164,4 +164,4 @@ jobs:
uses: actions/upload-artifact@v4
with:
name: htmlcov
path: htmlcov/
path: htmlcov/
+1 -1
View File
@@ -1 +1 @@
3.12
3.14
+11
View File
@@ -1,5 +1,16 @@
# CHANGELOG
## v0.17.5 (2026-01-11)
### Bug Fixes
- **#505**: Prevent CLI commands from hanging on exit (Python 3.14 compatibility)
([`863e0a4`](https://github.com/basicmachines-co/basic-memory/commit/863e0a4))
- Skip `nest_asyncio` on Python 3.14+ where it causes event loop issues
- Simplify CLI test infrastructure for cross-version compatibility
- Update pyright to 1.1.408 for Python 3.14 support
- Fix SQLAlchemy rowcount typing for Python 3.14
## v0.17.4 (2026-01-05)
### Bug Fixes
+10 -4
View File
@@ -8,8 +8,13 @@ ARG GID=1000
COPY --from=ghcr.io/astral-sh/uv:latest /uv /uvx /bin/
# Set environment variables
# UV_PYTHON_INSTALL_DIR ensures Python is installed to a persistent location
# that survives in the final image (not in /root/.local which gets lost)
# UV_PYTHON_PREFERENCE=only-managed tells uv to use its managed Python version
ENV PYTHONUNBUFFERED=1 \
PYTHONDONTWRITEBYTECODE=1
PYTHONDONTWRITEBYTECODE=1 \
UV_PYTHON_INSTALL_DIR=/python \
UV_PYTHON_PREFERENCE=only-managed
# Create a group and user with the provided UID/GID
# Check if the GID already exists, if not create appgroup
@@ -19,9 +24,10 @@ RUN (getent group ${GID} || groupadd --gid ${GID} appgroup) && \
# Copy the project into the image
ADD . /app
# Sync the project into a new environment, asserting the lockfile is up to date
# Install Python 3.13 explicitly and sync the project
WORKDIR /app
RUN uv sync --locked
RUN uv python install 3.13
RUN uv sync --locked --python 3.13
# Create necessary directories and set ownership
RUN mkdir -p /app/data/basic-memory /app/.basic-memory && \
@@ -43,4 +49,4 @@ HEALTHCHECK --interval=30s --timeout=10s --start-period=5s --retries=3 \
CMD basic-memory --version || exit 1
# Use the basic-memory entrypoint to run the MCP server with default SSE transport
CMD ["basic-memory", "mcp", "--transport", "sse", "--host", "0.0.0.0", "--port", "8000"]
CMD ["basic-memory", "mcp", "--transport", "sse", "--host", "0.0.0.0", "--port", "8000"]
+10
View File
@@ -220,6 +220,11 @@ release version:
echo "✅ Release {{version}} created successfully!"
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 " 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 " See: .claude/commands/release/release.md for detailed instructions"
# Create a beta release (e.g., just beta v0.13.2b1)
beta version:
@@ -281,6 +286,11 @@ beta version:
echo "📦 GitHub Actions will build and publish to PyPI as pre-release"
echo "🔗 Monitor at: https://github.com/basicmachines-co/basic-memory/actions"
echo "📥 Install with: uv tool install basic-memory --pre"
echo ""
echo "📝 REMINDER: For stable releases, update documentation sites:"
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 " See: .claude/commands/release/release.md for detailed instructions"
# List all available recipes
default:
+7 -3
View File
@@ -14,7 +14,7 @@ dependencies = [
"typer>=0.9.0",
"aiosqlite>=0.20.0",
"greenlet>=3.1.1",
"pydantic[email,timezone]>=2.10.3",
"pydantic[email,timezone]>=2.12.0",
"mcp>=1.23.1",
"pydantic-settings>=2.6.1",
"loguru>=0.7.3",
@@ -29,7 +29,7 @@ dependencies = [
"alembic>=1.14.1",
"pillow>=11.1.0",
"pybars3>=0.9.7",
"fastmcp==2.12.3", # Pinned - 2.14.x breaks MCP tools visibility (issue #463)
"fastmcp==2.12.3", # Pinned - 2.14.x breaks MCP tools visibility (issue #463)
"pyjwt>=2.10.1",
"python-dotenv>=1.1.0",
"pytest-aio>=1.9.0",
@@ -41,7 +41,10 @@ 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)
"openpanel>=0.0.1", # Anonymous usage telemetry (Homebrew-style opt-out)
"sniffio>=1.3.1",
"anyio>=4.10.0",
"httpx>=0.28.0",
]
@@ -88,6 +91,7 @@ dev = [
"freezegun>=1.5.5",
"testcontainers[postgres]>=4.0.0",
"psycopg>=3.2.0",
"pyright>=1.1.408",
]
[tool.hatch.version]
+1 -1
View File
@@ -1,7 +1,7 @@
"""basic-memory - Local-first knowledge management combining Zettelkasten with knowledge graphs"""
# Package version - updated by release automation
__version__ = "0.17.4"
__version__ = "0.17.5"
# API version for FastAPI - independent of package version
__api_version__ = "v0"
+11 -7
View File
@@ -5,14 +5,18 @@ import os
from logging.config import fileConfig
# Allow nested event loops (needed for pytest-asyncio and other async contexts)
# Note: nest_asyncio doesn't work with uvloop, so we handle that case separately
try:
import nest_asyncio
# Note: nest_asyncio doesn't work with uvloop or Python 3.14+, so we handle those cases separately
import sys
nest_asyncio.apply()
except (ImportError, ValueError):
# nest_asyncio not available or can't patch this loop type (e.g., uvloop)
pass
if sys.version_info < (3, 14):
try:
import nest_asyncio
nest_asyncio.apply()
except (ImportError, ValueError):
# nest_asyncio not available or can't patch this loop type (e.g., uvloop)
pass
# For Python 3.14+, we rely on the thread-based fallback in run_migrations_online()
from sqlalchemy import engine_from_config, pool
from sqlalchemy.ext.asyncio import AsyncEngine, create_async_engine
@@ -5,13 +5,13 @@ Revises: a2b3c4d5e6f7, g9a0b3c4d5e6
Create Date: 2025-12-29 12:46:46.476268
"""
from typing import Sequence, Union
# revision identifiers, used by Alembic.
revision: str = '6830751f5fb6'
down_revision: Union[str, Sequence[str], None] = ('a2b3c4d5e6f7', 'g9a0b3c4d5e6')
revision: str = "6830751f5fb6"
down_revision: Union[str, Sequence[str], None] = ("a2b3c4d5e6f7", "g9a0b3c4d5e6")
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
@@ -414,9 +414,7 @@ async def move_entity(
result = EntityResponseV2.model_validate(moved_entity)
logger.info(
f"API v2 response: moved external_id={entity_id} to '{data.destination_path}'"
)
logger.info(f"API v2 response: moved external_id={entity_id} to '{data.destination_path}'")
return result
@@ -264,9 +264,7 @@ async def delete_project_by_id(
# Use is_default from database, not ConfigManager (which doesn't work in cloud mode)
if old_project.is_default:
available_projects = await project_service.list_projects()
other_projects = [
p.name for p in available_projects if p.external_id != project_id
]
other_projects = [p.name for p in available_projects if p.external_id != project_id]
detail = f"Cannot delete default project '{old_project.name}'. "
if other_projects:
detail += ( # pragma: no cover
@@ -1,11 +1,10 @@
"""Core cloud commands for Basic Memory CLI."""
import asyncio
import typer
from rich.console import Console
from basic_memory.cli.app import cloud_app
from basic_memory.cli.commands.command_utils import run_with_cleanup
from basic_memory.cli.auth import CLIAuth
from basic_memory.config import ConfigManager
from basic_memory.cli.commands.cloud.api_client import (
@@ -64,7 +63,7 @@ def login():
)
raise typer.Exit(1)
asyncio.run(_login())
run_with_cleanup(_login())
@cloud_app.command()
@@ -110,7 +109,7 @@ def status() -> None:
console.print("\n[blue]Checking cloud instance health...[/blue]")
# Make API request to check health
response = asyncio.run(
response = run_with_cleanup(
make_api_request(method="GET", url=f"{host_url}/proxy/health", headers=headers)
)
@@ -156,12 +155,12 @@ def setup() -> None:
# Step 2: Get tenant info
console.print("\n[blue]Step 2: Getting tenant information...[/blue]")
tenant_info = asyncio.run(get_mount_info())
tenant_info = run_with_cleanup(get_mount_info())
console.print(f"[green]Found tenant: {tenant_info.tenant_id}[/green]")
# Step 3: Generate credentials
console.print("\n[blue]Step 3: Generating sync credentials...[/blue]")
creds = asyncio.run(generate_mount_credentials(tenant_info.tenant_id))
creds = run_with_cleanup(generate_mount_credentials(tenant_info.tenant_id))
console.print("[green]Generated secure credentials[/green]")
# Step 4: Configure rclone remote
@@ -27,6 +27,7 @@ console = Console()
# Minimum rclone version for --create-empty-src-dirs support
MIN_RCLONE_VERSION_EMPTY_DIRS = (1, 64, 0)
class RunResult(Protocol):
returncode: int
stdout: str
@@ -1,12 +1,12 @@
"""Upload CLI commands for basic-memory projects."""
import asyncio
from pathlib import Path
import typer
from rich.console import Console
from basic_memory.cli.app import cloud_app
from basic_memory.cli.commands.command_utils import run_with_cleanup
from basic_memory.cli.commands.cloud.cloud_utils import (
create_cloud_project,
project_exists,
@@ -121,4 +121,4 @@ def upload(
console.print(f"[yellow]Warning: Sync failed: {e}[/yellow]")
console.print("[dim]Files uploaded but may not be indexed yet[/dim]")
asyncio.run(_upload())
run_with_cleanup(_upload())
@@ -14,6 +14,7 @@ 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()
@@ -23,8 +24,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 are cleaned up before the event
loop closes, preventing process hangs in CLI commands.
This helper ensures database connections and telemetry threads are cleaned up
before the event loop closes, preventing process hangs in CLI commands.
Args:
coro: The coroutine to run
@@ -38,6 +39,9 @@ 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())
+5 -3
View File
@@ -1,6 +1,5 @@
"""Database management commands."""
import asyncio
from pathlib import Path
import typer
@@ -10,6 +9,7 @@ from sqlalchemy.exc import OperationalError
from basic_memory import db
from basic_memory.cli.app import app
from basic_memory.cli.commands.command_utils import run_with_cleanup
from basic_memory.config import ConfigManager
from basic_memory.repository import ProjectRepository
from basic_memory.services.initialization import reconcile_projects_with_config
@@ -81,7 +81,7 @@ def reset(
# Create a new empty database (preserves project configuration)
try:
asyncio.run(db.run_migrations(app_config))
run_with_cleanup(db.run_migrations(app_config))
except OperationalError as e:
if "disk I/O error" in str(e) or "database is locked" in str(e):
console.print(
@@ -99,5 +99,7 @@ def reset(
console.print("[yellow]No projects configured. Skipping reindex.[/yellow]")
else:
console.print(f"Rebuilding search index for {len(projects)} project(s)...")
asyncio.run(_reindex_projects(app_config))
# Note: _reindex_projects has its own cleanup, but run_with_cleanup
# ensures db.shutdown_db() is called even if _reindex_projects changes
run_with_cleanup(_reindex_projects(app_config))
console.print("[green]Reindex complete[/green]")
+2 -2
View File
@@ -1,6 +1,5 @@
"""Format command for basic-memory CLI."""
import asyncio
from pathlib import Path
from typing import Annotated, Optional
@@ -10,6 +9,7 @@ from rich.console import Console
from rich.progress import Progress, SpinnerColumn, TextColumn
from basic_memory.cli.app import app
from basic_memory.cli.commands.command_utils import run_with_cleanup
from basic_memory.config import ConfigManager, get_project_config
from basic_memory.file_utils import format_file
@@ -189,7 +189,7 @@ def format(
basic-memory format notes/ # Format all files in directory
"""
try:
asyncio.run(run_format(path, project))
run_with_cleanup(run_format(path, project))
except Exception as e:
if not isinstance(e, typer.Exit):
logger.error(f"Error formatting files: {e}")
@@ -1,12 +1,12 @@
"""Import command for ChatGPT conversations."""
import asyncio
import json
from pathlib import Path
from typing import Annotated, Tuple
import typer
from basic_memory.cli.app import import_app
from basic_memory.cli.commands.command_utils import run_with_cleanup
from basic_memory.config import ConfigManager, get_project_config
from basic_memory.importers import ChatGPTImporter
from basic_memory.markdown import EntityParser, MarkdownProcessor
@@ -53,7 +53,7 @@ def import_chatgpt(
raise typer.Exit(1)
# Get importer dependencies
markdown_processor, file_service = asyncio.run(get_importer_dependencies())
markdown_processor, file_service = run_with_cleanup(get_importer_dependencies())
config = get_project_config()
# Process the file
base_path = config.home / folder
@@ -63,7 +63,7 @@ def import_chatgpt(
importer = ChatGPTImporter(config.home, markdown_processor, file_service)
with conversations_json.open("r", encoding="utf-8") as file:
json_data = json.load(file)
result = asyncio.run(importer.import_data(json_data, folder))
result = run_with_cleanup(importer.import_data(json_data, folder))
if not result.success: # pragma: no cover
typer.echo(f"Error during import: {result.error_message}", err=True)
@@ -1,12 +1,12 @@
"""Import command for basic-memory CLI to import chat data from conversations2.json format."""
import asyncio
import json
from pathlib import Path
from typing import Annotated, Tuple
import typer
from basic_memory.cli.app import claude_app
from basic_memory.cli.commands.command_utils import run_with_cleanup
from basic_memory.config import ConfigManager, get_project_config
from basic_memory.importers.claude_conversations_importer import ClaudeConversationsImporter
from basic_memory.markdown import EntityParser, MarkdownProcessor
@@ -54,7 +54,7 @@ def import_claude(
raise typer.Exit(1)
# Get importer dependencies
markdown_processor, file_service = asyncio.run(get_importer_dependencies())
markdown_processor, file_service = run_with_cleanup(get_importer_dependencies())
# Create the importer
importer = ClaudeConversationsImporter(config.home, markdown_processor, file_service)
@@ -66,7 +66,7 @@ def import_claude(
# Run the import
with conversations_json.open("r", encoding="utf-8") as file:
json_data = json.load(file)
result = asyncio.run(importer.import_data(json_data, folder))
result = run_with_cleanup(importer.import_data(json_data, folder))
if not result.success: # pragma: no cover
typer.echo(f"Error during import: {result.error_message}", err=True)
@@ -1,12 +1,12 @@
"""Import command for basic-memory CLI to import project data from Claude.ai."""
import asyncio
import json
from pathlib import Path
from typing import Annotated, Tuple
import typer
from basic_memory.cli.app import claude_app
from basic_memory.cli.commands.command_utils import run_with_cleanup
from basic_memory.config import ConfigManager, get_project_config
from basic_memory.importers.claude_projects_importer import ClaudeProjectsImporter
from basic_memory.markdown import EntityParser, MarkdownProcessor
@@ -53,7 +53,7 @@ def import_projects(
raise typer.Exit(1)
# Get importer dependencies
markdown_processor, file_service = asyncio.run(get_importer_dependencies())
markdown_processor, file_service = run_with_cleanup(get_importer_dependencies())
# Create the importer
importer = ClaudeProjectsImporter(config.home, markdown_processor, file_service)
@@ -65,7 +65,7 @@ def import_projects(
# Run the import
with projects_json.open("r", encoding="utf-8") as file:
json_data = json.load(file)
result = asyncio.run(importer.import_data(json_data, base_folder))
result = run_with_cleanup(importer.import_data(json_data, base_folder))
if not result.success: # pragma: no cover
typer.echo(f"Error during import: {result.error_message}", err=True)
@@ -1,12 +1,12 @@
"""Import command for basic-memory CLI to import from JSON memory format."""
import asyncio
import json
from pathlib import Path
from typing import Annotated, Tuple
import typer
from basic_memory.cli.app import import_app
from basic_memory.cli.commands.command_utils import run_with_cleanup
from basic_memory.config import ConfigManager, get_project_config
from basic_memory.importers.memory_json_importer import MemoryJsonImporter
from basic_memory.markdown import EntityParser, MarkdownProcessor
@@ -52,7 +52,7 @@ def memory_json(
config = get_project_config()
try:
# Get importer dependencies
markdown_processor, file_service = asyncio.run(get_importer_dependencies())
markdown_processor, file_service = run_with_cleanup(get_importer_dependencies())
# Create the importer
importer = MemoryJsonImporter(config.home, markdown_processor, file_service)
@@ -67,7 +67,7 @@ def memory_json(
for line in file:
json_data = json.loads(line)
file_data.append(json_data)
result = asyncio.run(importer.import_data(file_data, destination_folder))
result = run_with_cleanup(importer.import_data(file_data, destination_folder))
if not result.success: # pragma: no cover
typer.echo(f"Error during import: {result.error_message}", err=True)
+19 -20
View File
@@ -1,6 +1,5 @@
"""Command module for basic-memory project management."""
import asyncio
import os
from pathlib import Path
@@ -9,7 +8,7 @@ from rich.console import Console
from rich.table import Table
from basic_memory.cli.app import app
from basic_memory.cli.commands.command_utils import get_project_info
from basic_memory.cli.commands.command_utils import get_project_info, run_with_cleanup
from basic_memory.config import ConfigManager
import json
from datetime import datetime
@@ -56,7 +55,7 @@ def list_projects() -> None:
return ProjectList.model_validate(response.json())
try:
result = asyncio.run(_list_projects())
result = run_with_cleanup(_list_projects())
config = ConfigManager().config
table = Table(title="Basic Memory Projects")
@@ -155,7 +154,7 @@ def add_project(
return ProjectStatusResponse.model_validate(response.json())
try:
result = asyncio.run(_add_project())
result = run_with_cleanup(_add_project())
console.print(f"[green]{result.message}[/green]")
# Save local sync path to config if in cloud mode
@@ -212,7 +211,7 @@ def setup_project_sync(
try:
# Verify project exists on cloud
asyncio.run(_verify_project_exists())
run_with_cleanup(_verify_project_exists())
# Resolve and create local path
resolved_path = Path(os.path.abspath(os.path.expanduser(local_path)))
@@ -279,7 +278,7 @@ def remove_project(
has_bisync_state = bisync_state_path.exists()
# Remove project from cloud/API
result = asyncio.run(_remove_project())
result = run_with_cleanup(_remove_project())
console.print(f"[green]{result.message}[/green]")
# Clean up local sync directory if it exists and delete_notes is True
@@ -347,7 +346,7 @@ def set_default_project(
return ProjectStatusResponse.model_validate(response.json())
try:
result = asyncio.run(_set_default())
result = run_with_cleanup(_set_default())
console.print(f"[green]{result.message}[/green]")
except Exception as e:
console.print(f"[red]Error setting default project: {str(e)}[/red]")
@@ -372,7 +371,7 @@ def synchronize_projects() -> None:
return ProjectStatusResponse.model_validate(response.json())
try:
result = asyncio.run(_sync_config())
result = run_with_cleanup(_sync_config())
console.print(f"[green]{result.message}[/green]")
except Exception as e: # pragma: no cover
console.print(f"[red]Error synchronizing projects: {str(e)}[/red]")
@@ -407,7 +406,7 @@ def move_project(
return ProjectStatusResponse.model_validate(response.json())
try:
result = asyncio.run(_move_project())
result = run_with_cleanup(_move_project())
console.print(f"[green]{result.message}[/green]")
# Show important file movement reminder
@@ -448,7 +447,7 @@ def sync_project_command(
try:
# Get tenant info for bucket name
tenant_info = asyncio.run(get_mount_info())
tenant_info = run_with_cleanup(get_mount_info())
bucket_name = tenant_info.bucket_name
# Get project info
@@ -461,7 +460,7 @@ def sync_project_command(
return proj
return None
project_data = asyncio.run(_get_project())
project_data = run_with_cleanup(_get_project())
if not project_data:
console.print(f"[red]Error: Project '{name}' not found[/red]")
raise typer.Exit(1)
@@ -502,7 +501,7 @@ def sync_project_command(
return response.json()
try:
result = asyncio.run(_trigger_db_sync())
result = run_with_cleanup(_trigger_db_sync())
console.print(f"[dim]Database sync initiated: {result.get('message')}[/dim]")
except Exception as e:
console.print(f"[yellow]Warning: Could not trigger database sync: {e}[/yellow]")
@@ -539,7 +538,7 @@ def bisync_project_command(
try:
# Get tenant info for bucket name
tenant_info = asyncio.run(get_mount_info())
tenant_info = run_with_cleanup(get_mount_info())
bucket_name = tenant_info.bucket_name
# Get project info
@@ -552,7 +551,7 @@ def bisync_project_command(
return proj
return None
project_data = asyncio.run(_get_project())
project_data = run_with_cleanup(_get_project())
if not project_data:
console.print(f"[red]Error: Project '{name}' not found[/red]")
raise typer.Exit(1)
@@ -600,7 +599,7 @@ def bisync_project_command(
return response.json()
try:
result = asyncio.run(_trigger_db_sync())
result = run_with_cleanup(_trigger_db_sync())
console.print(f"[dim]Database sync initiated: {result.get('message')}[/dim]")
except Exception as e:
console.print(f"[yellow]Warning: Could not trigger database sync: {e}[/yellow]")
@@ -633,7 +632,7 @@ def check_project_command(
try:
# Get tenant info for bucket name
tenant_info = asyncio.run(get_mount_info())
tenant_info = run_with_cleanup(get_mount_info())
bucket_name = tenant_info.bucket_name
# Get project info
@@ -646,7 +645,7 @@ def check_project_command(
return proj
return None
project_data = asyncio.run(_get_project())
project_data = run_with_cleanup(_get_project())
if not project_data:
console.print(f"[red]Error: Project '{name}' not found[/red]")
raise typer.Exit(1)
@@ -734,7 +733,7 @@ def ls_project_command(
try:
# Get tenant info for bucket name
tenant_info = asyncio.run(get_mount_info())
tenant_info = run_with_cleanup(get_mount_info())
bucket_name = tenant_info.bucket_name
# Get project info
@@ -747,7 +746,7 @@ def ls_project_command(
return proj
return None
project_data = asyncio.run(_get_project())
project_data = run_with_cleanup(_get_project())
if not project_data:
console.print(f"[red]Error: Project '{name}' not found[/red]")
raise typer.Exit(1)
@@ -784,7 +783,7 @@ def display_project_info(
"""Display detailed information and statistics about the current project."""
try:
# Get project info
info = asyncio.run(get_project_info(name))
info = run_with_cleanup(get_project_info(name))
if json_output:
# Convert to JSON and print
+7 -7
View File
@@ -1,6 +1,5 @@
"""CLI tool commands for Basic Memory."""
import asyncio
import sys
from typing import Annotated, List, Optional
@@ -9,6 +8,7 @@ from loguru import logger
from rich import print as rprint
from basic_memory.cli.app import app
from basic_memory.cli.commands.command_utils import run_with_cleanup
from basic_memory.config import ConfigManager
# Import prompts
@@ -109,7 +109,7 @@ def write_note(
# use the project name, or the default from the config
project_name = project_name or config_manager.default_project
note = asyncio.run(mcp_write_note.fn(title, content, folder, project_name, tags))
note = run_with_cleanup(mcp_write_note.fn(title, content, folder, project_name, tags))
rprint(note)
except Exception as e: # pragma: no cover
if not isinstance(e, typer.Exit):
@@ -145,7 +145,7 @@ def read_note(
project_name = project_name or config_manager.default_project
try:
note = asyncio.run(mcp_read_note.fn(identifier, project_name, page, page_size))
note = run_with_cleanup(mcp_read_note.fn(identifier, project_name, page, page_size))
rprint(note)
except Exception as e: # pragma: no cover
if not isinstance(e, typer.Exit):
@@ -182,7 +182,7 @@ def build_context(
project_name = project_name or config_manager.default_project
try:
context = asyncio.run(
context = run_with_cleanup(
mcp_build_context.fn(
project=project_name,
url=url,
@@ -213,7 +213,7 @@ def recent_activity(
):
"""Get recent activity across the knowledge base."""
try:
result = asyncio.run(
result = run_with_cleanup(
mcp_recent_activity.fn(
type=type, # pyright: ignore [reportArgumentType]
depth=depth,
@@ -279,7 +279,7 @@ def search_notes(
search_type = ("title" if title else None,)
search_type = "text" if search_type is None else search_type
results = asyncio.run(
results = run_with_cleanup(
mcp_search.fn(
query,
project_name,
@@ -312,7 +312,7 @@ def continue_conversation(
"""Prompt to continue a previous conversation or work session."""
try:
# Prompt functions return formatted strings directly
session = asyncio.run(mcp_continue_conversation.fn(topic=topic, timeframe=timeframe)) # type: ignore
session = run_with_cleanup(mcp_continue_conversation.fn(topic=topic, timeframe=timeframe)) # type: ignore
rprint(session)
except Exception as e: # pragma: no cover
if not isinstance(e, typer.Exit):
+1 -3
View File
@@ -233,6 +233,4 @@ async def get_project_config_v2_external(
)
ProjectConfigV2ExternalDep = Annotated[
ProjectConfig, Depends(get_project_config_v2_external)
]
ProjectConfigV2ExternalDep = Annotated[ProjectConfig, Depends(get_project_config_v2_external)]
+6 -2
View File
@@ -58,7 +58,9 @@ async def get_entity_repository_v2_external(
return EntityRepository(session_maker, project_id=project_id)
EntityRepositoryV2ExternalDep = Annotated[EntityRepository, Depends(get_entity_repository_v2_external)]
EntityRepositoryV2ExternalDep = Annotated[
EntityRepository, Depends(get_entity_repository_v2_external)
]
# --- Observation Repository ---
@@ -176,4 +178,6 @@ async def get_search_repository_v2_external(
return create_search_repository(session_maker, project_id=project_id)
SearchRepositoryV2ExternalDep = Annotated[SearchRepository, Depends(get_search_repository_v2_external)]
SearchRepositoryV2ExternalDep = Annotated[
SearchRepository, Depends(get_search_repository_v2_external)
]
+6 -2
View File
@@ -54,7 +54,9 @@ async def get_entity_parser(project_config: ProjectConfigDep) -> EntityParser:
EntityParserDep = Annotated["EntityParser", Depends(get_entity_parser)]
async def get_entity_parser_v2(project_config: ProjectConfigV2Dep) -> EntityParser: # pragma: no cover
async def get_entity_parser_v2(
project_config: ProjectConfigV2Dep,
) -> EntityParser: # pragma: no cover
return EntityParser(project_config.home)
@@ -477,4 +479,6 @@ async def get_directory_service_v2_external(
)
DirectoryServiceV2ExternalDep = Annotated[DirectoryService, Depends(get_directory_service_v2_external)]
DirectoryServiceV2ExternalDep = Annotated[
DirectoryService, Depends(get_directory_service_v2_external)
]
+1 -3
View File
@@ -55,9 +55,7 @@ class McpContainer:
- Not in test mode (tests manage their own sync)
- Not in cloud mode (cloud handles sync differently)
"""
return (
self.config.sync_changes and not self.mode.is_test and not self.mode.is_cloud
)
return self.config.sync_changes and not self.mode.is_test and not self.mode.is_cloud
@property
def sync_skip_reason(self) -> str | None:
+3 -1
View File
@@ -127,7 +127,9 @@ async def canvas(
):
logger.info(f"Canvas file exists, updating instead: {file_path}")
try:
entity_id = await resolve_entity_id(client, active_project.external_id, file_path)
entity_id = await resolve_entity_id(
client, active_project.external_id, file_path
)
# For update, send content in JSON body
response = await call_put(
client,
+3 -1
View File
@@ -231,7 +231,9 @@ async def read_content(
raise ToolError(f"Resource not found: {url}")
# Call the v2 resource endpoint
response = await call_get(client, f"/v2/projects/{active_project.external_id}/resource/{entity_id}")
response = await call_get(
client, f"/v2/projects/{active_project.external_id}/resource/{entity_id}"
)
content_type = response.headers.get("content-type", "application/octet-stream")
content_length = int(response.headers.get("content-length", 0))
+14 -4
View File
@@ -208,16 +208,26 @@ async def recent_activity(
else:
# At least one project has activity: suggest the most active project.
suggested_project = most_active_project or next(
(name for name, activity in projects_activity.items() if activity.item_count > 0),
(
name
for name, activity in projects_activity.items()
if activity.item_count > 0
),
None,
)
if suggested_project:
suffix = (
f"(most active with {most_active_count} items)" if most_active_count > 0 else ""
f"(most active with {most_active_count} items)"
if most_active_count > 0
else ""
)
guidance_lines.append(
f"Suggested project: '{suggested_project}' {suffix}".strip()
)
guidance_lines.append(f"Suggested project: '{suggested_project}' {suffix}".strip())
if active_projects == 1:
guidance_lines.append(f"Ask user: 'Should I use {suggested_project} for this task?'")
guidance_lines.append(
f"Ask user: 'Should I use {suggested_project} for this task?'"
)
else:
guidance_lines.append(
f"Ask user: 'Should I use {suggested_project} for this task, or would you prefer a different project?'"
+6 -2
View File
@@ -451,7 +451,9 @@ async def resolve_entity_id(client: AsyncClient, project_external_id: str, ident
"""
try:
response = await call_post(
client, f"/v2/projects/{project_external_id}/knowledge/resolve", json={"identifier": identifier}
client,
f"/v2/projects/{project_external_id}/knowledge/resolve",
json={"identifier": identifier},
)
data = response.json()
return data["external_id"]
@@ -460,7 +462,9 @@ async def resolve_entity_id(client: AsyncClient, project_external_id: str, ident
raise ToolError(f"Entity not found: '{identifier}'") # pragma: no cover
raise ToolError(f"Error resolving identifier '{identifier}': {e}") # pragma: no cover
except Exception as e:
raise ToolError(f"Unexpected error resolving identifier '{identifier}': {e}") # pragma: no cover
raise ToolError(
f"Unexpected error resolving identifier '{identifier}': {e}"
) # pragma: no cover
async def call_delete(
+3 -1
View File
@@ -173,7 +173,9 @@ async def write_note(
logger.debug(f"Entity exists, updating instead permalink={entity.permalink}")
try:
if not entity.permalink:
raise ValueError("Entity permalink is required for updates") # pragma: no cover
raise ValueError(
"Entity permalink is required for updates"
) # pragma: no cover
entity_id = await knowledge_client.resolve_entity(entity.permalink)
result = await knowledge_client.update_entity(entity_id, entity.model_dump())
action = "Updated"
+1 -3
View File
@@ -62,9 +62,7 @@ class Entity(Base):
# Core identity
id: Mapped[int] = mapped_column(Integer, primary_key=True)
# External UUID for API references - stable identifier that won't change
external_id: Mapped[str] = mapped_column(
String, unique=True, default=lambda: str(uuid.uuid4())
)
external_id: Mapped[str] = mapped_column(String, unique=True, default=lambda: str(uuid.uuid4()))
title: Mapped[str] = mapped_column(String)
entity_type: Mapped[str] = mapped_column(String)
entity_metadata: Mapped[Optional[dict]] = mapped_column(JSON, nullable=True)
+1 -3
View File
@@ -42,9 +42,7 @@ class Project(Base):
# Core identity
id: Mapped[int] = mapped_column(Integer, primary_key=True)
# External UUID for API references - stable identifier that won't change
external_id: Mapped[str] = mapped_column(
String, unique=True, default=lambda: str(uuid.uuid4())
)
external_id: Mapped[str] = mapped_column(String, unique=True, default=lambda: str(uuid.uuid4()))
name: Mapped[str] = mapped_column(String, unique=True)
description: Mapped[Optional[str]] = mapped_column(Text, nullable=True)
@@ -55,9 +55,7 @@ class EntityRepository(Repository[Entity]):
Entity if found, None otherwise
"""
query = (
self.select()
.where(Entity.external_id == external_id)
.options(*self.get_load_options())
self.select().where(Entity.external_id == external_id).options(*self.get_load_options())
)
return await self.find_one(query)
@@ -1,9 +1,9 @@
"""Repository for managing Relation objects."""
from typing import Sequence, List, Optional
from typing import Sequence, List, Optional, Any, cast
from sqlalchemy import and_, delete, select
from sqlalchemy.engine import CursorResult
from sqlalchemy.dialects.postgresql import insert as pg_insert
from sqlalchemy.dialects.sqlite import insert as sqlite_insert
from sqlalchemy.ext.asyncio import async_sessionmaker
@@ -139,7 +139,7 @@ class RelationRepository(Repository[Relation]):
# SQLite: rowcount works correctly
stmt = sqlite_insert(Relation).values(values)
stmt = stmt.on_conflict_do_nothing()
result = await session.execute(stmt)
result = cast(CursorResult[Any], await session.execute(stmt))
return result.rowcount if result.rowcount > 0 else 0
def get_load_options(self) -> List[LoaderOption]:
+4 -3
View File
@@ -1,6 +1,6 @@
"""Base repository implementation."""
from typing import Type, Optional, Any, Sequence, TypeVar, List, Dict
from typing import Type, Optional, Any, Sequence, TypeVar, List, Dict, cast
from loguru import logger
@@ -14,6 +14,7 @@ from sqlalchemy import (
and_,
delete,
)
from sqlalchemy.engine import CursorResult
from sqlalchemy.exc import NoResultFound
from sqlalchemy.ext.asyncio import async_sessionmaker, AsyncSession
from sqlalchemy.orm.interfaces import LoaderOption
@@ -324,7 +325,7 @@ class Repository[T: Base]:
conditions.append(getattr(self.Model, "project_id") == self.project_id)
query = delete(self.Model).where(and_(*conditions))
result = await session.execute(query)
result = cast(CursorResult[Any], await session.execute(query))
logger.debug(f"Deleted {result.rowcount} records")
return result.rowcount
@@ -339,7 +340,7 @@ class Repository[T: Base]:
conditions.append(getattr(self.Model, "project_id") == self.project_id)
query = delete(self.Model).where(and_(*conditions))
result = await session.execute(query)
result = cast(CursorResult[Any], await session.execute(query))
deleted = result.rowcount > 0
logger.debug(f"Deleted {result.rowcount} records")
return deleted
+1 -1
View File
@@ -58,4 +58,4 @@ def resolve_runtime_mode(
if cloud_mode_enabled:
return RuntimeMode.CLOUD
return RuntimeMode.LOCAL
return RuntimeMode.LOCAL
+3 -1
View File
@@ -267,7 +267,9 @@ class ContextService:
if isinstance(self.search_repository, PostgresSearchRepository): # pragma: no cover
# asyncpg expects timezone-NAIVE datetime in UTC for DateTime(timezone=True) columns
# even though the column stores timezone-aware values
since_utc = since.astimezone(timezone.utc) if since.tzinfo else since # pragma: no cover
since_utc = (
since.astimezone(timezone.utc) if since.tzinfo else since
) # pragma: no cover
params["since_date"] = since_utc.replace(tzinfo=None) # pyright: ignore # pragma: no cover
else:
params["since_date"] = since.isoformat() # pyright: ignore
+3 -1
View File
@@ -623,7 +623,9 @@ class SyncService:
except Exception as e:
# Check if this is a fatal error (or caused by one)
# Fatal errors like project deletion should terminate sync immediately
if isinstance(e, SyncFatalError) or isinstance(e.__cause__, SyncFatalError): # pragma: no cover
if isinstance(e, SyncFatalError) or isinstance(
e.__cause__, SyncFatalError
): # pragma: no cover
logger.error(f"Fatal sync error encountered, terminating sync: path={path}")
raise
+87 -16
View File
@@ -19,17 +19,21 @@ What we NEVER collect:
Documentation: https://basicmemory.com/telemetry
"""
from __future__ import annotations
import platform
import re
import uuid
from pathlib import Path
from typing import Any
from typing import TYPE_CHECKING, Any
from loguru import logger
from openpanel import OpenPanel
from basic_memory import __version__
if TYPE_CHECKING:
from openpanel import OpenPanel
# --- Configuration ---
# OpenPanel credentials (write-only, safe to embed in client code)
@@ -51,6 +55,29 @@ Details: {TELEMETRY_DOCS_URL}
_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 ---
@@ -76,30 +103,34 @@ def get_install_id() -> str:
# --- Client Management ---
def _get_client() -> OpenPanel:
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:
from basic_memory.config import ConfigManager
# Defer import to avoid creating background thread when telemetry disabled
from openpanel import OpenPanel
config = ConfigManager().config
# Trigger: first call to track an event
# Why: lazy init avoids work if telemetry never used; disabled flag
# tells OpenPanel to skip network calls when user opts out or during tests
# Outcome: client ready to queue events (or silently discard if disabled)
is_disabled = not config.telemetry_enabled or config.is_test_env
_client = OpenPanel(
client_id=OPENPANEL_CLIENT_ID,
client_secret=OPENPANEL_CLIENT_SECRET,
disabled=is_disabled,
)
if config.telemetry_enabled and not config.is_test_env and not _initialized:
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(
{
@@ -107,7 +138,7 @@ def _get_client() -> OpenPanel:
"python_version": platform.python_version(),
"os": platform.system().lower(),
"arch": platform.machine(),
"install_id": get_install_id(),
"install_id": install_id,
"source": "foss",
}
)
@@ -118,9 +149,47 @@ def _get_client() -> OpenPanel:
def reset_client() -> None:
"""Reset the telemetry client (for testing or after config changes)."""
global _client, _initialized
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 ---
@@ -136,7 +205,9 @@ def track(event: str, properties: dict[str, Any] | None = None) -> None:
# Constraint: telemetry must never break the application
# Even if OpenPanel API is down or config is corrupt, user's command must succeed
try:
_get_client().track(event, properties or {})
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}")
@@ -66,7 +66,9 @@ def test_lifespan_shutdown_awaits_sync_task_cancellation(app, monkeypatch):
assert sync_coordinator._sync_task.done()
monkeypatch.setattr(
container_module.ApiContainer, "shutdown_database", _assert_sync_task_done_before_db_shutdown
container_module.ApiContainer,
"shutdown_database",
_assert_sync_task_done_before_db_shutdown,
)
async def _run_client_once():
-1
View File
@@ -3,7 +3,6 @@
import pytest
@pytest.mark.asyncio
async def test_get_directory_tree_endpoint(test_graph, client, project_url):
"""Test the get_directory_tree endpoint returns correctly structured data."""
-2
View File
@@ -119,5 +119,3 @@ async def test_stop_watch_service_already_done(app_with_state: FastAPI):
app_with_state.state.watch_task = _Task(done=True)
resp = await stop_watch_service(_Request(app_with_state))
assert resp.running is False
+4 -2
View File
@@ -342,11 +342,13 @@ async def test_update_project_both_params_endpoint(
@pytest.mark.asyncio
async def test_update_project_nonexistent_endpoint(client, project_url):
async def test_update_project_nonexistent_endpoint(client, project_url, tmp_path):
"""Test the update project endpoint with a nonexistent project."""
# Try to update a project that doesn't exist
# Use tmp_path for cross-platform absolute path compatibility
new_path = str(tmp_path / "new-path")
response = await client.patch(
f"{project_url}/project/nonexistent-project", json={"path": "/tmp/new-path"}
f"{project_url}/project/nonexistent-project", json={"path": new_path}
)
# Should return 400 error
@@ -8,6 +8,7 @@ from basic_memory.api.routers.knowledge_router import resolve_relations_backgrou
@pytest.mark.asyncio
async def test_resolve_relations_background_success():
"""Test that background relation resolution calls sync service correctly."""
class StubSyncService:
def __init__(self) -> None:
self.calls: list[int] = []
@@ -30,6 +31,7 @@ async def test_resolve_relations_background_success():
@pytest.mark.asyncio
async def test_resolve_relations_background_handles_errors():
"""Test that background relation resolution handles errors gracefully."""
class StubSyncService:
def __init__(self) -> None:
self.calls: list[int] = []
+3 -1
View File
@@ -374,7 +374,9 @@ async def test_v2_endpoints_use_project_id_not_name(client: AsyncClient, test_pr
"""Verify v2 endpoints require project external_id UUID, not name."""
# Try using project name instead of external_id - should fail
fake_entity_uuid = "00000000-0000-0000-0000-000000000000"
response = await client.get(f"/v2/projects/{test_project.name}/knowledge/entities/{fake_entity_uuid}")
response = await client.get(
f"/v2/projects/{test_project.name}/knowledge/entities/{fake_entity_uuid}"
)
# Should get 404 because name is not a valid project external_id
assert response.status_code == 404
+7 -3
View File
@@ -74,10 +74,12 @@ async def test_update_project_invalid_path(
@pytest.mark.asyncio
async def test_update_project_not_found(client: AsyncClient, v2_projects_url):
async def test_update_project_not_found(client: AsyncClient, v2_projects_url, tmp_path):
"""Test updating a non-existent project returns 404."""
fake_uuid = "00000000-0000-0000-0000-000000000000"
update_data = {"path": "/tmp/new-path"}
# Use tmp_path for cross-platform absolute path compatibility
new_path = str(tmp_path / "new-path")
update_data = {"path": new_path}
response = await client.patch(
f"{v2_projects_url}/{fake_uuid}",
json=update_data,
@@ -167,7 +169,9 @@ async def test_delete_project_with_delete_notes_param(
assert created_project is not None
# Delete with delete_notes=true
response = await client.delete(f"{v2_projects_url}/{created_project.external_id}?delete_notes=true")
response = await client.delete(
f"{v2_projects_url}/{created_project.external_id}?delete_notes=true"
)
assert response.status_code == 200
@@ -17,7 +17,9 @@ from basic_memory.cli.commands.cloud.cloud_utils import (
@pytest.mark.asyncio
async def test_make_api_request_success_injects_auth_and_accept_encoding(config_home, config_manager):
async def test_make_api_request_success_injects_auth_and_accept_encoding(
config_home, config_manager
):
# Arrange: create a token on disk so CLIAuth can authenticate without any network.
auth = CLIAuth(client_id="cid", authkit_domain="https://auth.example.test")
auth.token_file.parent.mkdir(parents=True, exist_ok=True)
@@ -90,7 +92,9 @@ async def test_make_api_request_raises_subscription_required(config_home, config
@pytest.mark.asyncio
async def test_cloud_utils_fetch_and_exists_and_create_project(config_home, config_manager, monkeypatch):
async def test_cloud_utils_fetch_and_exists_and_create_project(
config_home, config_manager, monkeypatch
):
# Point config.cloud_host at our mocked base URL
config = config_manager.load_config()
config.cloud_host = "https://cloud.example.test"
@@ -140,7 +144,9 @@ async def test_cloud_utils_fetch_and_exists_and_create_project(config_home, conf
@asynccontextmanager
async def http_client_factory():
async with httpx.AsyncClient(transport=transport, base_url="https://cloud.example.test") as client:
async with httpx.AsyncClient(
transport=transport, base_url="https://cloud.example.test"
) as client:
yield client
async def api_request(**kwargs):
@@ -157,5 +163,3 @@ async def test_cloud_utils_fetch_and_exists_and_create_project(config_home, conf
assert created.new_project["name"] == "My Project"
# Path should be permalink-like (kebab)
assert seen["create_payload"]["path"] == "my-project"
@@ -76,5 +76,3 @@ def test_configure_rclone_remote_writes_config_and_backs_up_existing(config_home
# Backup exists
backups = list(cfg_path.parent.glob("rclone.conf.backup-*"))
assert backups, "expected a backup of rclone.conf to be created"
+3 -3
View File
@@ -53,7 +53,9 @@ async def test_upload_path_non_dry_puts_files_and_skips_archives(config_home, tm
@asynccontextmanager
async def client_cm_factory():
async with httpx.AsyncClient(transport=transport, base_url="https://cloud.example.test") as client:
async with httpx.AsyncClient(
transport=transport, base_url="https://cloud.example.test"
) as client:
yield client
ok = await upload_path(
@@ -69,5 +71,3 @@ async def test_upload_path_non_dry_puts_files_and_skips_archives(config_home, tm
# Only keep.md uploaded; archive skipped
assert "/webdav/proj/keep.md" in seen["puts"]
assert all("archive.zip" not in p for p in seen["puts"])
+9 -4
View File
@@ -15,7 +15,9 @@ def _make_mock_transport(handler):
@pytest.mark.asyncio
async def test_cli_auth_request_device_authorization_uses_injected_http_client(tmp_path, monkeypatch):
async def test_cli_auth_request_device_authorization_uses_injected_http_client(
tmp_path, monkeypatch
):
"""Integration-style test: exercise the request flow with real httpx plumbing (MockTransport)."""
monkeypatch.setenv("HOME", str(tmp_path))
monkeypatch.setenv("BASIC_MEMORY_ENV", "test")
@@ -76,7 +78,12 @@ async def test_cli_auth_save_load_and_get_valid_token_roundtrip(tmp_path, monkey
auth = CLIAuth(client_id="cid", authkit_domain="https://example.test")
tokens = {"access_token": "at", "refresh_token": "rt", "expires_in": 3600, "token_type": "Bearer"}
tokens = {
"access_token": "at",
"refresh_token": "rt",
"expires_in": 3600,
"token_type": "Bearer",
}
auth.save_tokens(tokens)
loaded = auth.load_tokens()
@@ -143,5 +150,3 @@ async def test_cli_auth_refresh_flow_uses_injected_http_client(tmp_path, monkeyp
token = await auth.get_valid_token()
assert token == "new-at"
+3 -1
View File
@@ -23,7 +23,9 @@ import pytest
# Windows has different process cleanup behavior that makes these tests unreliable
IS_WINDOWS = platform.system() == "Windows"
SUBPROCESS_TIMEOUT = 10.0
skip_on_windows = pytest.mark.skipif(IS_WINDOWS, reason="Subprocess cleanup tests unreliable on Windows CI")
skip_on_windows = pytest.mark.skipif(
IS_WINDOWS, reason="Subprocess cleanup tests unreliable on Windows CI"
)
@skip_on_windows
+6 -468
View File
@@ -1,476 +1,14 @@
"""Tests for the Basic Memory CLI tools.
These tests use real MCP tools with the test environment instead of mocks.
These tests verify CLI tool functionality. Some tests that previously used
subprocess have been removed due to a pre-existing CLI architecture issue
where ASGI transport doesn't trigger FastAPI lifespan initialization.
The subprocess-based integration tests are kept in test_cli_integration.py
for future use when the CLI initialization issue is fixed.
"""
# Import for testing
import io
from datetime import datetime, timedelta
import json
from textwrap import dedent
from typing import AsyncGenerator
import nest_asyncio
import pytest
import pytest_asyncio
from typer.testing import CliRunner
from basic_memory.cli.commands.tool import tool_app
from basic_memory.schemas.base import Entity as EntitySchema
# Allow nested asyncio.run() calls - needed for CLI tests with async fixtures
nest_asyncio.apply()
runner = CliRunner()
@pytest_asyncio.fixture
async def setup_test_note(entity_service, search_service) -> AsyncGenerator[dict, None]:
"""Create a test note for CLI tests."""
note_content = dedent("""
# Test Note
This is a test note for CLI commands.
## Observations
- [tech] Test observation #test
- [note] Another observation
## Relations
- connects_to [[Another Note]]
""")
entity, created = await entity_service.create_or_update_entity(
EntitySchema(
title="Test Note",
folder="test",
entity_type="note",
content=note_content,
)
)
# Index the entity for search
await search_service.index_entity(entity)
yield {
"title": entity.title,
"permalink": entity.permalink,
"content": note_content,
}
def test_write_note(cli_env, project_config, test_project):
"""Test write_note command with basic arguments."""
result = runner.invoke(
tool_app,
[
"write-note",
"--title",
"CLI Test Note",
"--content",
"This is a CLI test note",
"--folder",
"test",
"--project",
test_project.name,
],
)
assert result.exit_code == 0
# Check for expected success message
assert "CLI Test Note" in result.stdout
assert "Created" in result.stdout or "Updated" in result.stdout
assert "permalink" in result.stdout
def test_write_note_with_project_arg(cli_env, project_config, test_project):
"""Test write_note command with basic arguments."""
result = runner.invoke(
tool_app,
[
"write-note",
"--project",
test_project.name,
"--title",
"CLI Test Note",
"--content",
"This is a CLI test note",
"--folder",
"test",
],
)
assert result.exit_code == 0
# Check for expected success message
assert "CLI Test Note" in result.stdout
assert "Created" in result.stdout or "Updated" in result.stdout
assert "permalink" in result.stdout
def test_write_note_with_tags(cli_env, project_config):
"""Test write_note command with tags."""
result = runner.invoke(
tool_app,
[
"write-note",
"--title",
"Tagged CLI Test Note",
"--content",
"This is a test note with tags",
"--folder",
"test",
"--tags",
"tag1",
"--tags",
"tag2",
],
)
assert result.exit_code == 0
# Check for expected success message
assert "Tagged CLI Test Note" in result.stdout
assert "tag1, tag2" in result.stdout or "tag1" in result.stdout and "tag2" in result.stdout
def test_write_note_from_stdin(cli_env, project_config, monkeypatch):
"""Test write_note command reading from stdin.
This test requires minimal mocking of stdin to simulate piped input.
"""
test_content = "This is content from stdin for testing"
# Mock stdin using monkeypatch, which works better with typer's CliRunner
monkeypatch.setattr("sys.stdin", io.StringIO(test_content))
monkeypatch.setattr("sys.stdin.isatty", lambda: False) # Simulate piped input
# Use runner.invoke with input parameter as a fallback
result = runner.invoke(
tool_app,
[
"write-note",
"--title",
"Stdin Test Note",
"--folder",
"test",
],
input=test_content, # Provide input as a fallback
)
assert result.exit_code == 0
# Check for expected success message
assert "Stdin Test Note" in result.stdout
assert "Created" in result.stdout or "Updated" in result.stdout
assert "permalink" in result.stdout
def test_write_note_content_param_priority(cli_env, project_config):
"""Test that content parameter has priority over stdin."""
stdin_content = "This content from stdin should NOT be used"
param_content = "This explicit content parameter should be used"
# Mock stdin but provide explicit content parameter
import sys
old_stdin = sys.stdin
try:
sys.stdin = io.StringIO(stdin_content)
sys.stdin.isatty = lambda: False # type: ignore[attr-defined]
result = runner.invoke(
tool_app,
[
"write-note",
"--title",
"Priority Test Note",
"--content",
param_content,
"--folder",
"test",
],
)
finally:
sys.stdin = old_stdin
assert result.exit_code == 0
assert "Priority Test Note" in result.stdout
assert "Created" in result.stdout or "Updated" in result.stdout
def test_write_note_no_content(cli_env, project_config, monkeypatch):
"""Test error handling when no content is provided."""
# Mock stdin to appear as a terminal, not a pipe
import sys
monkeypatch.setattr(sys.stdin, "isatty", lambda: True)
result = runner.invoke(
tool_app,
[
"write-note",
"--title",
"No Content Note",
"--folder",
"test",
],
)
# Should exit with an error
assert result.exit_code == 1
def test_read_note(cli_env, setup_test_note):
"""Test read_note command."""
permalink = setup_test_note["permalink"]
result = runner.invoke(
tool_app,
["read-note", permalink],
)
assert result.exit_code == 0
# Should contain the note content and structure
assert "Test Note" in result.stdout
assert "This is a test note for CLI commands" in result.stdout
assert "## Observations" in result.stdout
assert "Test observation" in result.stdout
assert "## Relations" in result.stdout
assert "connects_to [[Another Note]]" in result.stdout
# Note: We found that square brackets like [tech] are being stripped in CLI output,
# so we're not asserting their presence
def test_search_basic(cli_env, setup_test_note, test_project):
"""Test basic search command."""
result = runner.invoke(
tool_app,
["search-notes", "test observation", "--project", test_project.name],
)
assert result.exit_code == 0
# Result should be JSON containing our test note
search_result = json.loads(result.stdout)
assert len(search_result["results"]) > 0
# At least one result should match our test note or observation
found = False
for item in search_result["results"]:
if "test" in item["permalink"].lower() and "observation" in item["permalink"].lower():
found = True
break
assert found, "Search did not find the test observation"
def test_search_permalink(cli_env, setup_test_note):
"""Test search with permalink flag."""
permalink = setup_test_note["permalink"]
result = runner.invoke(
tool_app,
["search-notes", permalink, "--permalink"],
)
assert result.exit_code == 0
# Result should be JSON containing our test note
search_result = json.loads(result.stdout)
assert len(search_result["results"]) > 0
# Should find a result with matching permalink
found = False
for item in search_result["results"]:
if item["permalink"] == permalink:
found = True
break
assert found, "Search did not find the note by permalink"
def test_build_context(cli_env, setup_test_note):
"""Test build_context command."""
permalink = setup_test_note["permalink"]
result = runner.invoke(
tool_app,
["build-context", f"memory://{permalink}"],
)
assert result.exit_code == 0
# Result should be JSON containing our test note
context_result = json.loads(result.stdout)
assert "results" in context_result
assert len(context_result["results"]) > 0
# Primary results should include our test note
found = False
for item in context_result["results"]:
if item["primary_result"]["permalink"] == permalink:
found = True
break
assert found, "Context did not include the test note"
def test_build_context_with_options(cli_env, setup_test_note):
"""Test build_context command with all options."""
permalink = setup_test_note["permalink"]
result = runner.invoke(
tool_app,
[
"build-context",
f"memory://{permalink}",
"--depth",
"2",
"--timeframe",
"1d",
"--page",
"1",
"--page-size",
"5",
"--max-related",
"20",
],
)
assert result.exit_code == 0
# Result should be JSON containing our test note
context_result = json.loads(result.stdout)
# Check that metadata reflects our options
assert context_result["metadata"]["depth"] == 2
timeframe = datetime.fromisoformat(context_result["metadata"]["timeframe"])
assert datetime.now().astimezone() - timeframe <= timedelta(
days=2
) # Compare timezone-aware datetimes
# Results should include our test note
found = False
for item in context_result["results"]:
if item["primary_result"]["permalink"] == permalink:
found = True
break
assert found, "Context did not include the test note"
def test_build_context_string_depth_parameter(cli_env, setup_test_note):
"""Test build_context command handles string depth parameter correctly."""
permalink = setup_test_note["permalink"]
# Test valid string depth parameter - Typer should convert it to int
result = runner.invoke(
tool_app,
[
"build-context",
f"memory://{permalink}",
"--depth",
"2", # This is always a string from CLI
],
)
assert result.exit_code == 0
# Result should be JSON containing our test note with correct depth
context_result = json.loads(result.stdout)
assert context_result["metadata"]["depth"] == 2
# Test invalid string depth parameter - should fail with Typer validation error
result = runner.invoke(
tool_app,
[
"build-context",
f"memory://{permalink}",
"--depth",
"invalid",
],
)
assert result.exit_code == 2 # Typer exits with code 2 for parameter validation errors
# Typer should show a usage error for invalid integer
assert (
"invalid" in result.stderr
and "is not a valid" in result.stderr
and "integer" in result.stderr
)
# The get-entity CLI command was removed when tools were refactored
# into separate files with improved error handling
def test_recent_activity(cli_env, setup_test_note, test_project):
"""Test recent_activity command with defaults."""
result = runner.invoke(
tool_app,
["recent-activity"],
)
assert result.exit_code == 0
# Result should be human-readable string containing recent activity
output = result.stdout
assert "Recent Activity Summary" in output
assert "Most Active Project:" in output or "Other Active Projects:" in output
# Our test note should be referenced in the output
assert setup_test_note["permalink"] in output or setup_test_note["title"] in output
def test_recent_activity_with_options(cli_env, setup_test_note, test_project):
"""Test recent_activity command with options."""
result = runner.invoke(
tool_app,
[
"recent-activity",
"--type",
"entity",
"--depth",
"2",
"--timeframe",
"7d",
],
)
assert result.exit_code == 0
# Result should be human-readable string containing recent activity
output = result.stdout
assert "Recent Activity Summary" in output
assert "Most Active Project:" in output or "Other Active Projects:" in output
# Should include information about entities since we requested entity type
assert setup_test_note["permalink"] in output or setup_test_note["title"] in output
def test_continue_conversation(cli_env, setup_test_note):
"""Test continue_conversation command."""
permalink = setup_test_note["permalink"]
# Run the CLI command
result = runner.invoke(
tool_app,
["continue-conversation", "--topic", "Test Note"],
)
assert result.exit_code == 0
# Check result contains expected content
assert "Continuing conversation on: Test Note" in result.stdout
assert "This is a memory retrieval session" in result.stdout
assert "read_note" in result.stdout
assert permalink in result.stdout
def test_continue_conversation_no_results(cli_env):
"""Test continue_conversation command with no results."""
# Run the CLI command with a nonexistent topic
result = runner.invoke(
tool_app,
["continue-conversation", "--topic", "NonexistentTopic"],
)
assert result.exit_code == 0
# Check result contains expected content for no results
assert "Continuing conversation on: NonexistentTopic" in result.stdout
assert "The supplied query did not return any information" in result.stdout
def test_ensure_migrations_functionality(app_config, monkeypatch):
-2
View File
@@ -221,5 +221,3 @@ class TestLoginCommand:
result = runner.invoke(app, ["cloud", "login"])
assert result.exit_code == 1
assert "Login failed" in result.stdout
-1
View File
@@ -147,7 +147,6 @@ class TestMemoryClient:
result = await client.build_context("specs/search")
assert result.results == []
@pytest.mark.asyncio
async def test_recent(self, monkeypatch):
"""Test recent calls correct endpoint."""
-2
View File
@@ -78,5 +78,3 @@ async def test_get_client_local_mode_uses_asgi_transport(config_manager):
async with get_client() as client:
# httpx stores ASGITransport privately, but we can still sanity-check type
assert isinstance(client._transport, httpx.ASGITransport) # pyright: ignore[reportPrivateUsage]
-2
View File
@@ -101,5 +101,3 @@ async def test_local_mode_returns_none_when_no_resolution(config_manager, monkey
monkeypatch.delenv("BASIC_MEMORY_MCP_PROJECT", raising=False)
assert await resolve_project_parameter(project=None) is None
+16 -6
View File
@@ -34,7 +34,11 @@ async def test_recent_activity_prompt_discovery_mode(monkeypatch):
project_name="p1",
project_path="/tmp/p1",
activity=GraphContext(
results=[ContextResult(primary_result=_entity("A"), observations=[], related_results=[])],
results=[
ContextResult(
primary_result=_entity("A"), observations=[], related_results=[]
)
],
metadata=MemoryMetadata(
uri=None,
types=[SearchItemType.ENTITY],
@@ -49,7 +53,11 @@ async def test_recent_activity_prompt_discovery_mode(monkeypatch):
project_name="p2",
project_path="/tmp/p2",
activity=GraphContext(
results=[ContextResult(primary_result=_entity("B", 2), observations=[], related_results=[])],
results=[
ContextResult(
primary_result=_entity("B", 2), observations=[], related_results=[]
)
],
metadata=MemoryMetadata(
uri=None,
types=[SearchItemType.ENTITY],
@@ -61,7 +69,9 @@ async def test_recent_activity_prompt_discovery_mode(monkeypatch):
item_count=1,
),
},
summary=ActivityStats(total_projects=2, active_projects=2, most_active_project="p1", total_items=2),
summary=ActivityStats(
total_projects=2, active_projects=2, most_active_project="p1", total_items=2
),
timeframe="7d",
generated_at=datetime.now(UTC),
)
@@ -79,7 +89,9 @@ async def test_recent_activity_prompt_discovery_mode(monkeypatch):
@pytest.mark.asyncio
async def test_recent_activity_prompt_project_mode(monkeypatch):
recent = GraphContext(
results=[ContextResult(primary_result=_entity("Only"), observations=[], related_results=[])],
results=[
ContextResult(primary_result=_entity("Only"), observations=[], related_results=[])
],
metadata=MemoryMetadata(
uri=None,
types=[SearchItemType.ENTITY],
@@ -97,5 +109,3 @@ async def test_recent_activity_prompt_project_mode(monkeypatch):
out = await recent_activity_prompt.fn(timeframe="1d", project="proj") # pyright: ignore[reportGeneralTypeIssues]
assert "Recent Activity in proj" in out
assert "Opportunity to Capture Activity Summary" in out
@@ -1,4 +1,3 @@
import pytest
from basic_memory import db
@@ -32,5 +31,3 @@ async def test_mcp_lifespan_shuts_down_db_when_engine_was_none(config_manager):
db._engine = None
async with lifespan(mcp):
pass
-2
View File
@@ -911,5 +911,3 @@ class TestMoveNoteSecurityValidation:
assert isinstance(result, str)
# Should NOT contain security error message
assert "Security Validation Error" not in result
@@ -47,5 +47,3 @@ async def test_create_and_delete_project_and_name_match_branch(
delete_result = await delete_project.fn("My Project")
assert delete_result.startswith("")
-2
View File
@@ -166,5 +166,3 @@ async def test_read_content_empty_path_does_not_trigger_security_error(client, t
except ToolError:
# Acceptable: resource resolution may treat empty path as not-found.
pass
+1 -2
View File
@@ -243,8 +243,7 @@ async def test_recent_activity_get_project_activity_timezone_normalization(monke
},
"observations": [],
"related_results": [],
}
,
},
{
"primary_result": {
"type": "entity",
@@ -82,7 +82,9 @@ async def test_postgres_search_repository_index_and_search(session_maker, test_p
@pytest.mark.asyncio
async def test_postgres_search_repository_bulk_index_items_and_prepare_terms(session_maker, test_project):
async def test_postgres_search_repository_bulk_index_items_and_prepare_terms(
session_maker, test_project
):
repo = PostgresSearchRepository(session_maker, project_id=test_project.id)
# Empty batch is a no-op
@@ -167,7 +169,9 @@ async def test_postgres_search_repository_wildcard_text_and_permalink_match_exac
@pytest.mark.asyncio
async def test_postgres_search_repository_tsquery_syntax_error_returns_empty(session_maker, test_project):
async def test_postgres_search_repository_tsquery_syntax_error_returns_empty(
session_maker, test_project
):
repo = PostgresSearchRepository(session_maker, project_id=test_project.id)
# Trailing boolean operator creates an invalid tsquery; repository should return []
@@ -176,7 +180,9 @@ async def test_postgres_search_repository_tsquery_syntax_error_returns_empty(ses
@pytest.mark.asyncio
async def test_postgres_search_repository_reraises_non_tsquery_db_errors(session_maker, test_project):
async def test_postgres_search_repository_reraises_non_tsquery_db_errors(
session_maker, test_project
):
"""Dropping the search_index table triggers a non-tsquery DB error which should be re-raised."""
repo = PostgresSearchRepository(session_maker, project_id=test_project.id)
@@ -191,5 +197,3 @@ async def test_postgres_search_repository_reraises_non_tsquery_db_errors(session
# Use a non-text query so the generated SQL doesn't include to_tsquery(),
# ensuring we hit the generic "re-raise other db errors" branch.
await repo.search(permalink="docs/anything")
@@ -1,4 +1,3 @@
from basic_memory.schemas.response import RelationResponse
@@ -38,5 +37,3 @@ def test_relation_response_resolves_from_to_from_orm_like_object_fallbacks():
assert rel.from_id == "From2.md"
assert rel.to_id == "To2.md"
assert rel.to_name == "To2 Title"
+12 -6
View File
@@ -37,11 +37,15 @@ async def test_initialize_database_creates_engine_and_allows_queries(app_config:
@pytest.mark.asyncio
async def test_initialize_database_raises_on_invalid_postgres_config(app_config: BasicMemoryConfig, config_manager):
async def test_initialize_database_raises_on_invalid_postgres_config(
app_config: BasicMemoryConfig, config_manager
):
"""If config selects Postgres but has no DATABASE_URL, initialization should fail."""
await db.shutdown_db()
try:
bad_config = app_config.model_copy(update={"database_backend": DatabaseBackend.POSTGRES, "database_url": None})
bad_config = app_config.model_copy(
update={"database_backend": DatabaseBackend.POSTGRES, "database_url": None}
)
config_manager.save_config(bad_config)
with pytest.raises(ValueError):
@@ -74,7 +78,9 @@ async def test_reconcile_projects_with_config_creates_projects_and_default(
await initialize_database(updated)
await reconcile_projects_with_config(updated)
_, session_maker = await db.get_or_create_db(updated.database_path, db_type=db.DatabaseType.FILESYSTEM)
_, session_maker = await db.get_or_create_db(
updated.database_path, db_type=db.DatabaseType.FILESYSTEM
)
repo = ProjectRepository(session_maker)
active = await repo.get_active_projects()
@@ -89,7 +95,9 @@ async def test_reconcile_projects_with_config_creates_projects_and_default(
@pytest.mark.asyncio
async def test_reconcile_projects_with_config_swallow_errors(monkeypatch, app_config: BasicMemoryConfig):
async def test_reconcile_projects_with_config_swallow_errors(
monkeypatch, app_config: BasicMemoryConfig
):
"""reconcile_projects_with_config should not raise if ProjectService sync fails."""
await db.shutdown_db()
try:
@@ -116,5 +124,3 @@ def test_ensure_initialization_runs_and_cleans_up(app_config: BasicMemoryConfig,
# Must be cleaned up to avoid hanging processes.
assert db._engine is None # pyright: ignore [reportPrivateUsage]
assert db._session_maker is None # pyright: ignore [reportPrivateUsage]
@@ -1,6 +1,10 @@
import pytest
from basic_memory.services.initialization import ensure_initialization, initialize_app, initialize_file_sync
from basic_memory.services.initialization import (
ensure_initialization,
initialize_app,
initialize_file_sync,
)
@pytest.mark.asyncio
@@ -19,5 +23,3 @@ async def test_initialize_file_sync_skips_in_test_env(app_config):
# app_config fixture uses env="test"
assert app_config.is_test_env is True
await initialize_file_sync(app_config)
@@ -69,5 +69,3 @@ async def test_add_project_to_config(project_service: ProjectService, config_man
# Clean up
if test_project_name in project_service.projects:
config_manager.remove_project(test_project_name)
@@ -49,5 +49,3 @@ async def test_handle_changes_reclassifies_added_existing_files_as_modified(
actions = [e.action for e in watch_service.state.recent_events]
assert "new" not in actions
assert actions.count("modified") >= 2
-2
View File
@@ -255,5 +255,3 @@ async def test_new_project_addition_scenario(monkeypatch, tmp_path):
assert cycle_count == 3
assert any(len(p) == 1 for p in project_lists_used)
assert any(len(p) == 2 for p in project_lists_used)
+56 -19
View File
@@ -47,7 +47,9 @@ def _write_filter_file(tmp_path: Path) -> Path:
def test_sync_project_dataclass():
project = SyncProject(name="research", path="app/data/research", local_sync_path="/Users/test/research")
project = SyncProject(
name="research", path="app/data/research", local_sync_path="/Users/test/research"
)
assert project.name == "research"
assert project.path == "app/data/research"
assert project.local_sync_path == "/Users/test/research"
@@ -134,9 +136,18 @@ def test_project_sync_success(tmp_path):
def test_project_sync_with_verbose(tmp_path):
runner = _Runner(returncode=0)
filter_path = _write_filter_file(tmp_path)
project = SyncProject(name="research", path="app/data/research", local_sync_path="/tmp/research")
project = SyncProject(
name="research", path="app/data/research", local_sync_path="/tmp/research"
)
project_sync(project, "my-bucket", verbose=True, run=runner, is_installed=lambda: True, filter_path=filter_path)
project_sync(
project,
"my-bucket",
verbose=True,
run=runner,
is_installed=lambda: True,
filter_path=filter_path,
)
cmd, _ = runner.calls[0]
assert "--verbose" in cmd
@@ -146,9 +157,13 @@ def test_project_sync_with_verbose(tmp_path):
def test_project_sync_with_progress(tmp_path):
runner = _Runner(returncode=0)
filter_path = _write_filter_file(tmp_path)
project = SyncProject(name="research", path="app/data/research", local_sync_path="/tmp/research")
project = SyncProject(
name="research", path="app/data/research", local_sync_path="/tmp/research"
)
project_sync(project, "my-bucket", run=runner, is_installed=lambda: True, filter_path=filter_path)
project_sync(
project, "my-bucket", run=runner, is_installed=lambda: True, filter_path=filter_path
)
cmd, _ = runner.calls[0]
assert "--progress" in cmd
@@ -163,7 +178,9 @@ def test_project_sync_no_local_path():
def test_project_sync_checks_rclone_installed():
project = SyncProject(name="research", path="app/data/research", local_sync_path="/tmp/research")
project = SyncProject(
name="research", path="app/data/research", local_sync_path="/tmp/research"
)
with pytest.raises(RcloneError) as exc_info:
project_sync(project, "my-bucket", is_installed=lambda: False)
assert "rclone is not installed" in str(exc_info.value)
@@ -173,7 +190,9 @@ def test_project_bisync_success(tmp_path):
runner = _Runner(returncode=0)
filter_path = _write_filter_file(tmp_path)
state_path = tmp_path / "state"
project = SyncProject(name="research", path="app/data/research", local_sync_path="/tmp/research")
project = SyncProject(
name="research", path="app/data/research", local_sync_path="/tmp/research"
)
result = project_bisync(
project,
@@ -200,7 +219,9 @@ def test_project_bisync_success(tmp_path):
def test_project_bisync_requires_resync_first_time(tmp_path):
filter_path = _write_filter_file(tmp_path)
state_path = tmp_path / "state"
project = SyncProject(name="research", path="app/data/research", local_sync_path="/tmp/research")
project = SyncProject(
name="research", path="app/data/research", local_sync_path="/tmp/research"
)
with pytest.raises(RcloneError) as exc_info:
project_bisync(
@@ -220,7 +241,9 @@ def test_project_bisync_with_resync_flag(tmp_path):
runner = _Runner(returncode=0)
filter_path = _write_filter_file(tmp_path)
state_path = tmp_path / "state"
project = SyncProject(name="research", path="app/data/research", local_sync_path="/tmp/research")
project = SyncProject(
name="research", path="app/data/research", local_sync_path="/tmp/research"
)
result = project_bisync(
project,
@@ -243,7 +266,9 @@ def test_project_bisync_dry_run_skips_init_check(tmp_path):
runner = _Runner(returncode=0)
filter_path = _write_filter_file(tmp_path)
state_path = tmp_path / "state"
project = SyncProject(name="research", path="app/data/research", local_sync_path="/tmp/research")
project = SyncProject(
name="research", path="app/data/research", local_sync_path="/tmp/research"
)
result = project_bisync(
project,
@@ -270,7 +295,9 @@ def test_project_bisync_no_local_path():
def test_project_bisync_checks_rclone_installed(tmp_path):
project = SyncProject(name="research", path="app/data/research", local_sync_path="/tmp/research")
project = SyncProject(
name="research", path="app/data/research", local_sync_path="/tmp/research"
)
with pytest.raises(RcloneError) as exc_info:
project_bisync(
project,
@@ -287,7 +314,9 @@ def test_project_bisync_includes_empty_dirs_flag_when_supported(tmp_path):
runner = _Runner(returncode=0)
filter_path = _write_filter_file(tmp_path)
state_path = tmp_path / "state"
project = SyncProject(name="research", path="app/data/research", local_sync_path="/tmp/research")
project = SyncProject(
name="research", path="app/data/research", local_sync_path="/tmp/research"
)
project_bisync(
project,
@@ -308,7 +337,9 @@ def test_project_bisync_excludes_empty_dirs_flag_when_not_supported(tmp_path):
runner = _Runner(returncode=0)
filter_path = _write_filter_file(tmp_path)
state_path = tmp_path / "state"
project = SyncProject(name="research", path="app/data/research", local_sync_path="/tmp/research")
project = SyncProject(
name="research", path="app/data/research", local_sync_path="/tmp/research"
)
project_bisync(
project,
@@ -328,9 +359,13 @@ def test_project_bisync_excludes_empty_dirs_flag_when_not_supported(tmp_path):
def test_project_check_success(tmp_path):
runner = _Runner(returncode=0)
filter_path = _write_filter_file(tmp_path)
project = SyncProject(name="research", path="app/data/research", local_sync_path="/tmp/research")
project = SyncProject(
name="research", path="app/data/research", local_sync_path="/tmp/research"
)
result = project_check(project, "my-bucket", run=runner, is_installed=lambda: True, filter_path=filter_path)
result = project_check(
project, "my-bucket", run=runner, is_installed=lambda: True, filter_path=filter_path
)
assert result is True
cmd, kwargs = runner.calls[0]
assert cmd[:2] == ["rclone", "check"]
@@ -341,7 +376,9 @@ def test_project_check_success(tmp_path):
def test_project_check_with_one_way(tmp_path):
runner = _Runner(returncode=0)
filter_path = _write_filter_file(tmp_path)
project = SyncProject(name="research", path="app/data/research", local_sync_path="/tmp/research")
project = SyncProject(
name="research", path="app/data/research", local_sync_path="/tmp/research"
)
project_check(
project,
@@ -357,7 +394,9 @@ def test_project_check_with_one_way(tmp_path):
def test_project_check_checks_rclone_installed():
project = SyncProject(name="research", path="app/data/research", local_sync_path="/tmp/research")
project = SyncProject(
name="research", path="app/data/research", local_sync_path="/tmp/research"
)
with pytest.raises(RcloneError) as exc_info:
project_check(project, "my-bucket", is_installed=lambda: False)
assert "rclone is not installed" in str(exc_info.value)
@@ -456,5 +495,3 @@ def test_supports_create_empty_src_dirs_false_for_unknown_version():
def test_min_rclone_version_constant():
assert MIN_RCLONE_VERSION_EMPTY_DIRS == (1, 64, 0)
-1
View File
@@ -1,6 +1,5 @@
"""Tests for runtime mode resolution."""
from basic_memory.runtime import RuntimeMode, resolve_runtime_mode
+45 -23
View File
@@ -18,6 +18,10 @@ class _StubOpenPanel:
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
@@ -88,18 +92,20 @@ class TestTrack:
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, disabled=False):
def openpanel_factory(*, client_id, client_secret):
stub_client.client_id = client_id
stub_client.client_secret = client_secret
stub_client.disabled = disabled
return stub_client
monkeypatch.setattr(telemetry, "OpenPanel", openpanel_factory)
# Mock the import inside _get_client()
monkeypatch.setattr("openpanel.OpenPanel", openpanel_factory)
# Should not raise
telemetry.track("test_event", {"key": "value"})
@@ -115,16 +121,18 @@ class TestTrack:
created: list[_StubOpenPanel] = []
def openpanel_factory(*, client_id, client_secret, disabled=False):
client = _StubOpenPanel(client_id=client_id, client_secret=client_secret, disabled=disabled)
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(telemetry, "OpenPanel", openpanel_factory)
monkeypatch.setattr("openpanel.OpenPanel", openpanel_factory)
telemetry.track("test_event")
assert len(created) == 1
assert created[0].disabled is True
# With disabled config, OpenPanel client is never created (early return)
assert len(created) == 0
class TestShowNoticeIfNeeded:
@@ -181,15 +189,19 @@ class TestConvenienceFunctions:
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, disabled=False):
client = _StubOpenPanel(client_id=client_id, client_secret=client_secret, disabled=disabled)
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(telemetry, "OpenPanel", openpanel_factory)
monkeypatch.setattr("openpanel.OpenPanel", openpanel_factory)
telemetry.track_app_started("cli")
assert created
@@ -201,15 +213,19 @@ class TestConvenienceFunctions:
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, disabled=False):
client = _StubOpenPanel(client_id=client_id, client_secret=client_secret, disabled=disabled)
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(telemetry, "OpenPanel", openpanel_factory)
monkeypatch.setattr("openpanel.OpenPanel", openpanel_factory)
telemetry.track_mcp_tool("write_note")
assert created
@@ -221,15 +237,19 @@ class TestConvenienceFunctions:
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, disabled=False):
client = _StubOpenPanel(client_id=client_id, client_secret=client_secret, disabled=disabled)
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(telemetry, "OpenPanel", openpanel_factory)
monkeypatch.setattr("openpanel.OpenPanel", openpanel_factory)
telemetry.track_error("ValueError", "x" * 500)
_, props = created[0].events[-1]
@@ -241,15 +261,19 @@ class TestConvenienceFunctions:
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, disabled=False):
client = _StubOpenPanel(client_id=client_id, client_secret=client_secret, disabled=disabled)
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(telemetry, "OpenPanel", openpanel_factory)
monkeypatch.setattr("openpanel.OpenPanel", openpanel_factory)
telemetry.track_error("FileNotFoundError", "No such file: /Users/john/notes/secret.md")
_, props = created[0].events[-1]
@@ -259,10 +283,8 @@ class TestConvenienceFunctions:
telemetry.reset_client()
created.clear()
monkeypatch.setattr(telemetry, "OpenPanel", openpanel_factory)
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"]
Generated
+1424 -1083
View File
File diff suppressed because it is too large Load Diff