mirror of
https://github.com/basicmachines-co/basic-memory
synced 2026-06-21 13:47:35 +00:00
Compare commits
5 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 1799c94953 | |||
| 07996181b3 | |||
| a1c37c1dba | |||
| aff53cca93 | |||
| 863e0a4e24 |
@@ -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
@@ -1 +1 @@
|
||||
3.12
|
||||
3.14
|
||||
|
||||
@@ -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
@@ -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"]
|
||||
|
||||
@@ -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
@@ -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,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"
|
||||
|
||||
@@ -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())
|
||||
|
||||
|
||||
@@ -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]")
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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)]
|
||||
|
||||
@@ -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)
|
||||
]
|
||||
|
||||
@@ -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)
|
||||
]
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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))
|
||||
|
||||
|
||||
@@ -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?'"
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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]:
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -58,4 +58,4 @@ def resolve_runtime_mode(
|
||||
if cloud_mode_enabled:
|
||||
return RuntimeMode.CLOUD
|
||||
|
||||
return RuntimeMode.LOCAL
|
||||
return RuntimeMode.LOCAL
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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():
|
||||
|
||||
@@ -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."""
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
|
||||
@@ -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] = []
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"
|
||||
|
||||
|
||||
|
||||
@@ -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"])
|
||||
|
||||
|
||||
|
||||
@@ -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"
|
||||
|
||||
|
||||
|
||||
@@ -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
@@ -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):
|
||||
|
||||
@@ -221,5 +221,3 @@ class TestLoginCommand:
|
||||
result = runner.invoke(app, ["cloud", "login"])
|
||||
assert result.exit_code == 1
|
||||
assert "Login failed" in result.stdout
|
||||
|
||||
|
||||
|
||||
@@ -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."""
|
||||
|
||||
@@ -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]
|
||||
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
|
||||
@@ -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("✓")
|
||||
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
|
||||
@@ -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"
|
||||
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
|
||||
@@ -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,6 +1,5 @@
|
||||
"""Tests for runtime mode resolution."""
|
||||
|
||||
|
||||
from basic_memory.runtime import RuntimeMode, resolve_runtime_mode
|
||||
|
||||
|
||||
|
||||
+45
-23
@@ -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"]
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user