mirror of
https://github.com/basicmachines-co/basic-memory
synced 2026-06-21 13:47:35 +00:00
Compare commits
7 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 994c8b8e7e | |||
| a78e8c3ac5 | |||
| 53900c5baa | |||
| 02c6de3387 | |||
| 9ccf4b6b56 | |||
| ba74ca7e18 | |||
| 5258f45730 |
+45
-1
@@ -1,5 +1,49 @@
|
||||
# CHANGELOG
|
||||
|
||||
## v0.15.2 (2025-10-14)
|
||||
|
||||
### Features
|
||||
|
||||
- **#356**: Add WebDAV upload command for cloud projects
|
||||
([`5258f45`](https://github.com/basicmachines-co/basic-memory/commit/5258f457))
|
||||
- New `bm cloud upload` command for uploading local files/directories to cloud projects
|
||||
- WebDAV-based file transfer with automatic directory creation
|
||||
- Support for `.gitignore` and `.bmignore` pattern filtering
|
||||
- Automatic project creation with `--create-project` flag
|
||||
- Optional post-upload sync with `--sync` flag (enabled by default)
|
||||
- Human-readable file size reporting (bytes, KB, MB)
|
||||
- Comprehensive test coverage (28 unit tests)
|
||||
|
||||
### Migration Guide
|
||||
|
||||
No manual migration required. Upgrade with:
|
||||
|
||||
```bash
|
||||
# Update via uv
|
||||
uv tool upgrade basic-memory
|
||||
|
||||
# Or install fresh
|
||||
uv tool install basic-memory
|
||||
```
|
||||
|
||||
**What's New:**
|
||||
- Upload local files to cloud projects with `bm cloud upload`
|
||||
- Streamlined cloud project creation and management
|
||||
- Better file filtering with gitignore integration
|
||||
|
||||
### Installation
|
||||
|
||||
```bash
|
||||
# Latest stable release
|
||||
uv tool install basic-memory
|
||||
|
||||
# Update existing installation
|
||||
uv tool upgrade basic-memory
|
||||
|
||||
# Docker
|
||||
docker pull ghcr.io/basicmachines-co/basic-memory:v0.15.2
|
||||
```
|
||||
|
||||
## v0.15.1 (2025-10-13)
|
||||
|
||||
### Performance Improvements
|
||||
@@ -1728,4 +1772,4 @@ Co-authored-by: phernandez <phernandez@basicmachines.co>
|
||||
### Chores
|
||||
|
||||
- Remove basic-foundation src ref in pyproject.toml
|
||||
([`29fce8b`](https://github.com/basicmachines-co/basic-memory/commit/29fce8b0b922d54d7799bf2534107ee6cfb961b8))
|
||||
([`29fce8b`](https://github.com/basicmachines-co/basic-memory/commit/29fce8b0b922d54d7799bf2534107ee6cfb961b8))
|
||||
|
||||
@@ -7,6 +7,7 @@ The Basic Memory Cloud CLI provides seamless integration between local and cloud
|
||||
The cloud CLI enables you to:
|
||||
- **Toggle cloud mode** with `bm cloud login` / `bm cloud logout`
|
||||
- **Use regular commands in cloud mode**: `bm project`, `bm sync`, `bm tool` all work with cloud
|
||||
- **Upload local files** directly to cloud projects via `bm cloud upload`
|
||||
- **Bidirectional sync** with rclone bisync (recommended for most users)
|
||||
- **Direct file access** via rclone mount (alternative workflow)
|
||||
- **Integrity verification** with `bm cloud check`
|
||||
@@ -160,6 +161,69 @@ bm project list
|
||||
|
||||
This Dropbox-like workflow means you don't need to manually coordinate projects between local and cloud.
|
||||
|
||||
### Uploading Local Files
|
||||
|
||||
You can directly upload local files or directories to cloud projects using `bm cloud upload`. This is useful for:
|
||||
- Migrating existing local projects to the cloud
|
||||
- Quickly uploading specific files or directories
|
||||
- One-time bulk uploads without setting up sync
|
||||
|
||||
**Basic Usage:**
|
||||
|
||||
```bash
|
||||
# Upload a directory to existing project
|
||||
bm cloud upload ~/my-notes --project research
|
||||
|
||||
# Upload a single file
|
||||
bm cloud upload important-doc.md --project research
|
||||
```
|
||||
|
||||
**Create Project On-the-Fly:**
|
||||
|
||||
If the target project doesn't exist yet, use `--create-project`:
|
||||
|
||||
```bash
|
||||
# Upload and create project in one step
|
||||
bm cloud upload ~/local-project --project new-research --create-project
|
||||
```
|
||||
|
||||
**Skip Automatic Sync:**
|
||||
|
||||
By default, the command syncs the project after upload to index the files. To skip this:
|
||||
|
||||
```bash
|
||||
# Upload without triggering sync
|
||||
bm cloud upload ~/bulk-data --project archives --no-sync
|
||||
```
|
||||
|
||||
**File Filtering:**
|
||||
|
||||
The upload command respects `.bmignore` and `.gitignore` patterns, automatically excluding:
|
||||
- Hidden files (`.git`, `.DS_Store`)
|
||||
- Build artifacts (`node_modules`, `__pycache__`)
|
||||
- Database files (`*.db`, `*.db-wal`)
|
||||
- Environment files (`.env`)
|
||||
|
||||
To customize what gets uploaded, edit `~/.basic-memory/.bmignore`.
|
||||
|
||||
**Complete Example:**
|
||||
|
||||
```bash
|
||||
# 1. Login to cloud
|
||||
bm cloud login
|
||||
|
||||
# 2. Upload local project (creates project if needed)
|
||||
bm cloud upload ~/Documents/research-notes --project research --create-project
|
||||
|
||||
# 3. Verify upload
|
||||
bm project list
|
||||
```
|
||||
|
||||
**Notes:**
|
||||
- Files are uploaded directly via WebDAV (no sync setup required)
|
||||
- Uploads are immediate and don't require bisync or mount
|
||||
- Use this for migration or one-time uploads; use `bm sync` for ongoing synchronization
|
||||
|
||||
## File Synchronization
|
||||
|
||||
### The `bm sync` Command (Cloud Mode Aware)
|
||||
@@ -628,6 +692,15 @@ bm cloud check # Full integrity check
|
||||
bm cloud check --one-way # Faster one-way check
|
||||
```
|
||||
|
||||
### File Upload
|
||||
|
||||
```bash
|
||||
# Upload files/directories to cloud projects
|
||||
bm cloud upload <path> --project <name> # Upload to existing project
|
||||
bm cloud upload <path> -p <name> --create-project # Upload and create project
|
||||
bm cloud upload <path> -p <name> --no-sync # Upload without syncing
|
||||
```
|
||||
|
||||
### Direct File Access (Mount)
|
||||
|
||||
```bash
|
||||
|
||||
+1
-1
@@ -34,7 +34,7 @@ dependencies = [
|
||||
"pyjwt>=2.10.1",
|
||||
"python-dotenv>=1.1.0",
|
||||
"pytest-aio>=1.9.0",
|
||||
"aiofiles>=24.1.0", # Async file I/O
|
||||
"aiofiles>=24.1.0", # Async file I/O
|
||||
]
|
||||
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
"""basic-memory - Local-first knowledge management combining Zettelkasten with knowledge graphs"""
|
||||
|
||||
# Package version - updated by release automation
|
||||
__version__ = "0.15.1"
|
||||
__version__ = "0.15.2"
|
||||
|
||||
# API version for FastAPI - independent of package version
|
||||
__api_version__ = "v0"
|
||||
|
||||
@@ -2,4 +2,5 @@
|
||||
|
||||
# Import all commands to register them with typer
|
||||
from basic_memory.cli.commands.cloud.core_commands import * # noqa: F401,F403
|
||||
from basic_memory.cli.commands.cloud.api_client import get_authenticated_headers # noqa: F401
|
||||
from basic_memory.cli.commands.cloud.api_client import get_authenticated_headers, get_cloud_config # noqa: F401
|
||||
from basic_memory.cli.commands.cloud.upload_command import * # noqa: F401,F403
|
||||
|
||||
@@ -12,6 +12,10 @@ from rich.console import Console
|
||||
from rich.table import Table
|
||||
|
||||
from basic_memory.cli.commands.cloud.api_client import CloudAPIError, make_api_request
|
||||
from basic_memory.cli.commands.cloud.cloud_utils import (
|
||||
create_cloud_project,
|
||||
fetch_cloud_projects,
|
||||
)
|
||||
from basic_memory.cli.commands.cloud.rclone_config import (
|
||||
add_tenant_to_rclone_config,
|
||||
)
|
||||
@@ -21,11 +25,7 @@ from basic_memory.ignore_utils import get_bmignore_path, create_default_bmignore
|
||||
from basic_memory.schemas.cloud import (
|
||||
TenantMountInfo,
|
||||
MountCredentials,
|
||||
CloudProjectList,
|
||||
CloudProjectCreateRequest,
|
||||
CloudProjectCreateResponse,
|
||||
)
|
||||
from basic_memory.utils import generate_permalink
|
||||
|
||||
console = Console()
|
||||
|
||||
@@ -110,24 +110,6 @@ async def generate_mount_credentials(tenant_id: str) -> MountCredentials:
|
||||
raise BisyncError(f"Failed to generate credentials: {e}") from e
|
||||
|
||||
|
||||
async def fetch_cloud_projects() -> CloudProjectList:
|
||||
"""Fetch list of projects from cloud API.
|
||||
|
||||
Returns:
|
||||
CloudProjectList with projects from cloud
|
||||
"""
|
||||
try:
|
||||
config_manager = ConfigManager()
|
||||
config = config_manager.config
|
||||
host_url = config.cloud_host.rstrip("/")
|
||||
|
||||
response = await make_api_request(method="GET", url=f"{host_url}/proxy/projects/projects")
|
||||
|
||||
return CloudProjectList.model_validate(response.json())
|
||||
except Exception as e:
|
||||
raise BisyncError(f"Failed to fetch cloud projects: {e}") from e
|
||||
|
||||
|
||||
def scan_local_directories(sync_dir: Path) -> list[str]:
|
||||
"""Scan local sync directory for project folders.
|
||||
|
||||
@@ -148,41 +130,6 @@ def scan_local_directories(sync_dir: Path) -> list[str]:
|
||||
return directories
|
||||
|
||||
|
||||
async def create_cloud_project(project_name: str) -> CloudProjectCreateResponse:
|
||||
"""Create a new project on cloud.
|
||||
|
||||
Args:
|
||||
project_name: Name of project to create
|
||||
|
||||
Returns:
|
||||
CloudProjectCreateResponse with project details from API
|
||||
"""
|
||||
try:
|
||||
config_manager = ConfigManager()
|
||||
config = config_manager.config
|
||||
host_url = config.cloud_host.rstrip("/")
|
||||
|
||||
# Use generate_permalink to ensure consistent naming
|
||||
project_path = generate_permalink(project_name)
|
||||
|
||||
project_data = CloudProjectCreateRequest(
|
||||
name=project_name,
|
||||
path=project_path,
|
||||
set_default=False,
|
||||
)
|
||||
|
||||
response = await make_api_request(
|
||||
method="POST",
|
||||
url=f"{host_url}/proxy/projects/projects",
|
||||
headers={"Content-Type": "application/json"},
|
||||
json_data=project_data.model_dump(),
|
||||
)
|
||||
|
||||
return CloudProjectCreateResponse.model_validate(response.json())
|
||||
except Exception as e:
|
||||
raise BisyncError(f"Failed to create cloud project '{project_name}': {e}") from e
|
||||
|
||||
|
||||
def get_bisync_state_path(tenant_id: str) -> Path:
|
||||
"""Get path to bisync state directory."""
|
||||
return Path.home() / ".basic-memory" / "bisync-state" / tenant_id
|
||||
|
||||
@@ -0,0 +1,100 @@
|
||||
"""Shared utilities for cloud operations."""
|
||||
|
||||
from basic_memory.cli.commands.cloud.api_client import make_api_request
|
||||
from basic_memory.config import ConfigManager
|
||||
from basic_memory.schemas.cloud import (
|
||||
CloudProjectList,
|
||||
CloudProjectCreateRequest,
|
||||
CloudProjectCreateResponse,
|
||||
)
|
||||
from basic_memory.utils import generate_permalink
|
||||
|
||||
|
||||
class CloudUtilsError(Exception):
|
||||
"""Exception raised for cloud utility errors."""
|
||||
|
||||
pass
|
||||
|
||||
|
||||
async def fetch_cloud_projects() -> CloudProjectList:
|
||||
"""Fetch list of projects from cloud API.
|
||||
|
||||
Returns:
|
||||
CloudProjectList with projects from cloud
|
||||
"""
|
||||
try:
|
||||
config_manager = ConfigManager()
|
||||
config = config_manager.config
|
||||
host_url = config.cloud_host.rstrip("/")
|
||||
|
||||
response = await make_api_request(method="GET", url=f"{host_url}/proxy/projects/projects")
|
||||
|
||||
return CloudProjectList.model_validate(response.json())
|
||||
except Exception as e:
|
||||
raise CloudUtilsError(f"Failed to fetch cloud projects: {e}") from e
|
||||
|
||||
|
||||
async def create_cloud_project(project_name: str) -> CloudProjectCreateResponse:
|
||||
"""Create a new project on cloud.
|
||||
|
||||
Args:
|
||||
project_name: Name of project to create
|
||||
|
||||
Returns:
|
||||
CloudProjectCreateResponse with project details from API
|
||||
"""
|
||||
try:
|
||||
config_manager = ConfigManager()
|
||||
config = config_manager.config
|
||||
host_url = config.cloud_host.rstrip("/")
|
||||
|
||||
# Use generate_permalink to ensure consistent naming
|
||||
project_path = generate_permalink(project_name)
|
||||
|
||||
project_data = CloudProjectCreateRequest(
|
||||
name=project_name,
|
||||
path=project_path,
|
||||
set_default=False,
|
||||
)
|
||||
|
||||
response = await make_api_request(
|
||||
method="POST",
|
||||
url=f"{host_url}/proxy/projects/projects",
|
||||
headers={"Content-Type": "application/json"},
|
||||
json_data=project_data.model_dump(),
|
||||
)
|
||||
|
||||
return CloudProjectCreateResponse.model_validate(response.json())
|
||||
except Exception as e:
|
||||
raise CloudUtilsError(f"Failed to create cloud project '{project_name}': {e}") from e
|
||||
|
||||
|
||||
async def sync_project(project_name: str) -> None:
|
||||
"""Trigger sync for a specific project on cloud.
|
||||
|
||||
Args:
|
||||
project_name: Name of project to sync
|
||||
"""
|
||||
try:
|
||||
from basic_memory.cli.commands.command_utils import run_sync
|
||||
|
||||
await run_sync(project=project_name)
|
||||
except Exception as e:
|
||||
raise CloudUtilsError(f"Failed to sync project '{project_name}': {e}") from e
|
||||
|
||||
|
||||
async def project_exists(project_name: str) -> bool:
|
||||
"""Check if a project exists on cloud.
|
||||
|
||||
Args:
|
||||
project_name: Name of project to check
|
||||
|
||||
Returns:
|
||||
True if project exists, False otherwise
|
||||
"""
|
||||
try:
|
||||
projects = await fetch_cloud_projects()
|
||||
project_names = {p.name for p in projects.projects}
|
||||
return project_name in project_names
|
||||
except Exception:
|
||||
return False
|
||||
@@ -0,0 +1,128 @@
|
||||
"""WebDAV upload functionality for basic-memory projects."""
|
||||
|
||||
import os
|
||||
from pathlib import Path
|
||||
|
||||
import aiofiles
|
||||
import httpx
|
||||
|
||||
from basic_memory.ignore_utils import load_gitignore_patterns, should_ignore_path
|
||||
from basic_memory.mcp.async_client import get_client
|
||||
from basic_memory.mcp.tools.utils import call_put
|
||||
|
||||
|
||||
async def upload_path(local_path: Path, project_name: str) -> bool:
|
||||
"""
|
||||
Upload a file or directory to cloud project via WebDAV.
|
||||
|
||||
Args:
|
||||
local_path: Path to local file or directory
|
||||
project_name: Name of cloud project (destination)
|
||||
|
||||
Returns:
|
||||
True if upload succeeded, False otherwise
|
||||
"""
|
||||
try:
|
||||
# Resolve path
|
||||
local_path = local_path.resolve()
|
||||
|
||||
# Check if path exists
|
||||
if not local_path.exists():
|
||||
print(f"Error: Path does not exist: {local_path}")
|
||||
return False
|
||||
|
||||
# Get files to upload
|
||||
if local_path.is_file():
|
||||
files_to_upload = [(local_path, local_path.name)]
|
||||
else:
|
||||
files_to_upload = _get_files_to_upload(local_path)
|
||||
|
||||
if not files_to_upload:
|
||||
print("No files found to upload")
|
||||
return True
|
||||
|
||||
print(f"Found {len(files_to_upload)} file(s) to upload")
|
||||
|
||||
# Upload files using httpx
|
||||
total_bytes = 0
|
||||
|
||||
async with get_client() as client:
|
||||
for i, (file_path, relative_path) in enumerate(files_to_upload, 1):
|
||||
# Build remote path: /webdav/{project_name}/{relative_path}
|
||||
remote_path = f"/webdav/{project_name}/{relative_path}"
|
||||
print(f"Uploading {relative_path} ({i}/{len(files_to_upload)})")
|
||||
|
||||
# Read file content asynchronously
|
||||
async with aiofiles.open(file_path, "rb") as f:
|
||||
content = await f.read()
|
||||
|
||||
# Upload via HTTP PUT to WebDAV endpoint
|
||||
response = await call_put(client, remote_path, content=content)
|
||||
response.raise_for_status()
|
||||
|
||||
total_bytes += file_path.stat().st_size
|
||||
|
||||
# Format size based on magnitude
|
||||
if total_bytes < 1024:
|
||||
size_str = f"{total_bytes} bytes"
|
||||
elif total_bytes < 1024 * 1024:
|
||||
size_str = f"{total_bytes / 1024:.1f} KB"
|
||||
else:
|
||||
size_str = f"{total_bytes / (1024 * 1024):.1f} MB"
|
||||
|
||||
print(f"✓ Upload complete: {len(files_to_upload)} file(s) ({size_str})")
|
||||
return True
|
||||
|
||||
except httpx.HTTPStatusError as e:
|
||||
print(f"Upload failed: HTTP {e.response.status_code} - {e.response.text}")
|
||||
return False
|
||||
except Exception as e:
|
||||
print(f"Upload failed: {e}")
|
||||
return False
|
||||
|
||||
|
||||
def _get_files_to_upload(directory: Path) -> list[tuple[Path, str]]:
|
||||
"""
|
||||
Get list of files to upload from directory.
|
||||
|
||||
Uses .bmignore and .gitignore patterns for filtering.
|
||||
|
||||
Args:
|
||||
directory: Directory to scan
|
||||
|
||||
Returns:
|
||||
List of (absolute_path, relative_path) tuples
|
||||
"""
|
||||
files = []
|
||||
|
||||
# Load ignore patterns from .bmignore and .gitignore
|
||||
ignore_patterns = load_gitignore_patterns(directory)
|
||||
|
||||
# Walk through directory
|
||||
for root, dirs, filenames in os.walk(directory):
|
||||
root_path = Path(root)
|
||||
|
||||
# Filter directories based on ignore patterns
|
||||
filtered_dirs = []
|
||||
for d in dirs:
|
||||
dir_path = root_path / d
|
||||
if not should_ignore_path(dir_path, directory, ignore_patterns):
|
||||
filtered_dirs.append(d)
|
||||
dirs[:] = filtered_dirs
|
||||
|
||||
# Process files
|
||||
for filename in filenames:
|
||||
file_path = root_path / filename
|
||||
|
||||
# Check if file should be ignored
|
||||
if should_ignore_path(file_path, directory, ignore_patterns):
|
||||
continue
|
||||
|
||||
# Calculate relative path for remote
|
||||
rel_path = file_path.relative_to(directory)
|
||||
# Use forward slashes for WebDAV paths
|
||||
remote_path = str(rel_path).replace("\\", "/")
|
||||
|
||||
files.append((file_path, remote_path))
|
||||
|
||||
return files
|
||||
@@ -0,0 +1,93 @@
|
||||
"""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.cloud.cloud_utils import (
|
||||
create_cloud_project,
|
||||
project_exists,
|
||||
sync_project,
|
||||
)
|
||||
from basic_memory.cli.commands.cloud.upload import upload_path
|
||||
|
||||
console = Console()
|
||||
|
||||
|
||||
@cloud_app.command("upload")
|
||||
def upload(
|
||||
path: Path = typer.Argument(
|
||||
...,
|
||||
help="Path to local file or directory to upload",
|
||||
exists=True,
|
||||
readable=True,
|
||||
resolve_path=True,
|
||||
),
|
||||
project: str = typer.Option(
|
||||
...,
|
||||
"--project",
|
||||
"-p",
|
||||
help="Cloud project name (destination)",
|
||||
),
|
||||
create_project: bool = typer.Option(
|
||||
False,
|
||||
"--create-project",
|
||||
"-c",
|
||||
help="Create project if it doesn't exist",
|
||||
),
|
||||
sync: bool = typer.Option(
|
||||
True,
|
||||
"--sync/--no-sync",
|
||||
help="Sync project after upload (default: true)",
|
||||
),
|
||||
) -> None:
|
||||
"""Upload local files or directories to cloud project via WebDAV.
|
||||
|
||||
Examples:
|
||||
bm cloud upload ~/my-notes --project research
|
||||
bm cloud upload notes.md --project research --create-project
|
||||
bm cloud upload ~/docs --project work --no-sync
|
||||
"""
|
||||
|
||||
async def _upload():
|
||||
# Check if project exists
|
||||
if not await project_exists(project):
|
||||
if create_project:
|
||||
console.print(f"[blue]Creating cloud project '{project}'...[/blue]")
|
||||
try:
|
||||
await create_cloud_project(project)
|
||||
console.print(f"[green]✓ Created project '{project}'[/green]")
|
||||
except Exception as e:
|
||||
console.print(f"[red]Failed to create project: {e}[/red]")
|
||||
raise typer.Exit(1)
|
||||
else:
|
||||
console.print(
|
||||
f"[red]Project '{project}' does not exist.[/red]\n"
|
||||
f"[yellow]Options:[/yellow]\n"
|
||||
f" 1. Create it first: bm project add {project}\n"
|
||||
f" 2. Use --create-project flag to create automatically"
|
||||
)
|
||||
raise typer.Exit(1)
|
||||
|
||||
# Perform upload
|
||||
console.print(f"[blue]Uploading {path} to project '{project}'...[/blue]")
|
||||
success = await upload_path(path, project)
|
||||
if not success:
|
||||
console.print("[red]Upload failed[/red]")
|
||||
raise typer.Exit(1)
|
||||
|
||||
console.print(f"[green]✅ Successfully uploaded to '{project}'[/green]")
|
||||
|
||||
# Sync project if requested
|
||||
if sync:
|
||||
console.print(f"[blue]Syncing project '{project}'...[/blue]")
|
||||
try:
|
||||
await sync_project(project)
|
||||
except Exception as e:
|
||||
console.print(f"[yellow]Warning: Sync failed: {e}[/yellow]")
|
||||
console.print("[dim]Files uploaded but may not be indexed yet[/dim]")
|
||||
|
||||
asyncio.run(_upload())
|
||||
@@ -31,8 +31,6 @@ console = Console()
|
||||
project_app = typer.Typer(help="Manage multiple Basic Memory projects")
|
||||
app.add_typer(project_app, name="project")
|
||||
|
||||
config = ConfigManager().config
|
||||
|
||||
|
||||
def format_path(path: str) -> str:
|
||||
"""Format a path for display, using ~ for home directory."""
|
||||
@@ -69,40 +67,34 @@ def list_projects() -> None:
|
||||
raise typer.Exit(1)
|
||||
|
||||
|
||||
if config.cloud_mode_enabled:
|
||||
@project_app.command("add")
|
||||
def add_project(
|
||||
name: str = typer.Argument(..., help="Name of the project"),
|
||||
path: str = typer.Argument(
|
||||
None, help="Path to the project directory (required for local mode)"
|
||||
),
|
||||
set_default: bool = typer.Option(False, "--default", help="Set as default project"),
|
||||
) -> None:
|
||||
"""Add a new project.
|
||||
|
||||
@project_app.command("add")
|
||||
def add_project_cloud(
|
||||
name: str = typer.Argument(..., help="Name of the project"),
|
||||
set_default: bool = typer.Option(False, "--default", help="Set as default project"),
|
||||
) -> None:
|
||||
"""Add a new project to Basic Memory Cloud"""
|
||||
For cloud mode: only name is required
|
||||
For local mode: both name and path are required
|
||||
"""
|
||||
config = ConfigManager().config
|
||||
|
||||
if config.cloud_mode_enabled:
|
||||
# Cloud mode: path not needed (auto-generated from name)
|
||||
async def _add_project():
|
||||
async with get_client() as client:
|
||||
data = {"name": name, "path": generate_permalink(name), "set_default": set_default}
|
||||
response = await call_post(client, "/projects/projects", json=data)
|
||||
return ProjectStatusResponse.model_validate(response.json())
|
||||
|
||||
try:
|
||||
result = asyncio.run(_add_project())
|
||||
console.print(f"[green]{result.message}[/green]")
|
||||
except Exception as e:
|
||||
console.print(f"[red]Error adding project: {str(e)}[/red]")
|
||||
else:
|
||||
# Local mode: path is required
|
||||
if path is None:
|
||||
console.print("[red]Error: path argument is required in local mode[/red]")
|
||||
raise typer.Exit(1)
|
||||
|
||||
# Display usage hint
|
||||
console.print("\nTo use this project:")
|
||||
console.print(f" basic-memory --project={name} <command>")
|
||||
else:
|
||||
|
||||
@project_app.command("add")
|
||||
def add_project(
|
||||
name: str = typer.Argument(..., help="Name of the project"),
|
||||
path: str = typer.Argument(..., help="Path to the project directory"),
|
||||
set_default: bool = typer.Option(False, "--default", help="Set as default project"),
|
||||
) -> None:
|
||||
"""Add a new project."""
|
||||
# Resolve to absolute path
|
||||
resolved_path = Path(os.path.abspath(os.path.expanduser(path))).as_posix()
|
||||
|
||||
@@ -112,16 +104,16 @@ else:
|
||||
response = await call_post(client, "/projects/projects", json=data)
|
||||
return ProjectStatusResponse.model_validate(response.json())
|
||||
|
||||
try:
|
||||
result = asyncio.run(_add_project())
|
||||
console.print(f"[green]{result.message}[/green]")
|
||||
except Exception as e:
|
||||
console.print(f"[red]Error adding project: {str(e)}[/red]")
|
||||
raise typer.Exit(1)
|
||||
try:
|
||||
result = asyncio.run(_add_project())
|
||||
console.print(f"[green]{result.message}[/green]")
|
||||
except Exception as e:
|
||||
console.print(f"[red]Error adding project: {str(e)}[/red]")
|
||||
raise typer.Exit(1)
|
||||
|
||||
# Display usage hint
|
||||
console.print("\nTo use this project:")
|
||||
console.print(f" basic-memory --project={name} <command>")
|
||||
# Display usage hint
|
||||
console.print("\nTo use this project:")
|
||||
console.print(f" basic-memory --project={name} <command>")
|
||||
|
||||
|
||||
@project_app.command("remove")
|
||||
@@ -147,84 +139,107 @@ def remove_project(
|
||||
console.print("[yellow]Note: The project files have not been deleted from disk.[/yellow]")
|
||||
|
||||
|
||||
if not config.cloud_mode_enabled:
|
||||
@project_app.command("default")
|
||||
def set_default_project(
|
||||
name: str = typer.Argument(..., help="Name of the project to set as CLI default"),
|
||||
) -> None:
|
||||
"""Set the default project when 'config.default_project_mode' is set.
|
||||
|
||||
@project_app.command("default")
|
||||
def set_default_project(
|
||||
name: str = typer.Argument(..., help="Name of the project to set as CLI default"),
|
||||
) -> None:
|
||||
"""Set the default project when 'config.default_project_mode' is set."""
|
||||
Note: This command is only available in local mode.
|
||||
"""
|
||||
config = ConfigManager().config
|
||||
|
||||
async def _set_default():
|
||||
async with get_client() as client:
|
||||
project_permalink = generate_permalink(name)
|
||||
response = await call_put(client, f"/projects/{project_permalink}/default")
|
||||
return ProjectStatusResponse.model_validate(response.json())
|
||||
if config.cloud_mode_enabled:
|
||||
console.print("[red]Error: 'default' command is not available in cloud mode[/red]")
|
||||
raise typer.Exit(1)
|
||||
|
||||
try:
|
||||
result = asyncio.run(_set_default())
|
||||
console.print(f"[green]{result.message}[/green]")
|
||||
except Exception as e:
|
||||
console.print(f"[red]Error setting default project: {str(e)}[/red]")
|
||||
raise typer.Exit(1)
|
||||
async def _set_default():
|
||||
async with get_client() as client:
|
||||
project_permalink = generate_permalink(name)
|
||||
response = await call_put(client, f"/projects/{project_permalink}/default")
|
||||
return ProjectStatusResponse.model_validate(response.json())
|
||||
|
||||
@project_app.command("sync-config")
|
||||
def synchronize_projects() -> None:
|
||||
"""Synchronize project config between configuration file and database."""
|
||||
try:
|
||||
result = asyncio.run(_set_default())
|
||||
console.print(f"[green]{result.message}[/green]")
|
||||
except Exception as e:
|
||||
console.print(f"[red]Error setting default project: {str(e)}[/red]")
|
||||
raise typer.Exit(1)
|
||||
|
||||
async def _sync_config():
|
||||
async with get_client() as client:
|
||||
response = await call_post(client, "/projects/config/sync")
|
||||
return ProjectStatusResponse.model_validate(response.json())
|
||||
|
||||
try:
|
||||
result = asyncio.run(_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]")
|
||||
raise typer.Exit(1)
|
||||
@project_app.command("sync-config")
|
||||
def synchronize_projects() -> None:
|
||||
"""Synchronize project config between configuration file and database.
|
||||
|
||||
@project_app.command("move")
|
||||
def move_project(
|
||||
name: str = typer.Argument(..., help="Name of the project to move"),
|
||||
new_path: str = typer.Argument(..., help="New absolute path for the project"),
|
||||
) -> None:
|
||||
"""Move a project to a new location."""
|
||||
# Resolve to absolute path
|
||||
resolved_path = Path(os.path.abspath(os.path.expanduser(new_path))).as_posix()
|
||||
Note: This command is only available in local mode.
|
||||
"""
|
||||
config = ConfigManager().config
|
||||
|
||||
async def _move_project():
|
||||
async with get_client() as client:
|
||||
data = {"path": resolved_path}
|
||||
project_permalink = generate_permalink(name)
|
||||
if config.cloud_mode_enabled:
|
||||
console.print("[red]Error: 'sync-config' command is not available in cloud mode[/red]")
|
||||
raise typer.Exit(1)
|
||||
|
||||
# TODO fix route to use ProjectPathDep
|
||||
response = await call_patch(
|
||||
client, f"/{name}/project/{project_permalink}", json=data
|
||||
)
|
||||
return ProjectStatusResponse.model_validate(response.json())
|
||||
async def _sync_config():
|
||||
async with get_client() as client:
|
||||
response = await call_post(client, "/projects/config/sync")
|
||||
return ProjectStatusResponse.model_validate(response.json())
|
||||
|
||||
try:
|
||||
result = asyncio.run(_move_project())
|
||||
console.print(f"[green]{result.message}[/green]")
|
||||
try:
|
||||
result = asyncio.run(_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]")
|
||||
raise typer.Exit(1)
|
||||
|
||||
# Show important file movement reminder
|
||||
console.print() # Empty line for spacing
|
||||
console.print(
|
||||
Panel(
|
||||
"[bold red]IMPORTANT:[/bold red] Project configuration updated successfully.\n\n"
|
||||
"[yellow]You must manually move your project files from the old location to:[/yellow]\n"
|
||||
f"[cyan]{resolved_path}[/cyan]\n\n"
|
||||
"[dim]Basic Memory has only updated the configuration - your files remain in their original location.[/dim]",
|
||||
title="⚠️ Manual File Movement Required",
|
||||
border_style="yellow",
|
||||
expand=False,
|
||||
)
|
||||
|
||||
@project_app.command("move")
|
||||
def move_project(
|
||||
name: str = typer.Argument(..., help="Name of the project to move"),
|
||||
new_path: str = typer.Argument(..., help="New absolute path for the project"),
|
||||
) -> None:
|
||||
"""Move a project to a new location.
|
||||
|
||||
Note: This command is only available in local mode.
|
||||
"""
|
||||
config = ConfigManager().config
|
||||
|
||||
if config.cloud_mode_enabled:
|
||||
console.print("[red]Error: 'move' command is not available in cloud mode[/red]")
|
||||
raise typer.Exit(1)
|
||||
|
||||
# Resolve to absolute path
|
||||
resolved_path = Path(os.path.abspath(os.path.expanduser(new_path))).as_posix()
|
||||
|
||||
async def _move_project():
|
||||
async with get_client() as client:
|
||||
data = {"path": resolved_path}
|
||||
project_permalink = generate_permalink(name)
|
||||
|
||||
# TODO fix route to use ProjectPathDep
|
||||
response = await call_patch(client, f"/{name}/project/{project_permalink}", json=data)
|
||||
return ProjectStatusResponse.model_validate(response.json())
|
||||
|
||||
try:
|
||||
result = asyncio.run(_move_project())
|
||||
console.print(f"[green]{result.message}[/green]")
|
||||
|
||||
# Show important file movement reminder
|
||||
console.print() # Empty line for spacing
|
||||
console.print(
|
||||
Panel(
|
||||
"[bold red]IMPORTANT:[/bold red] Project configuration updated successfully.\n\n"
|
||||
"[yellow]You must manually move your project files from the old location to:[/yellow]\n"
|
||||
f"[cyan]{resolved_path}[/cyan]\n\n"
|
||||
"[dim]Basic Memory has only updated the configuration - your files remain in their original location.[/dim]",
|
||||
title="⚠️ Manual File Movement Required",
|
||||
border_style="yellow",
|
||||
expand=False,
|
||||
)
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
console.print(f"[red]Error moving project: {str(e)}[/red]")
|
||||
raise typer.Exit(1)
|
||||
except Exception as e:
|
||||
console.print(f"[red]Error moving project: {str(e)}[/red]")
|
||||
raise typer.Exit(1)
|
||||
|
||||
|
||||
@project_app.command("info")
|
||||
|
||||
@@ -41,6 +41,10 @@ class CloudProjectCreateRequest(BaseModel):
|
||||
class CloudProjectCreateResponse(BaseModel):
|
||||
"""Response from creating a cloud project."""
|
||||
|
||||
name: str = Field(..., description="Created project name")
|
||||
path: str = Field(..., description="Created project path")
|
||||
message: str = Field(default="", description="Success message")
|
||||
message: str = Field(..., description="Status message about the project creation")
|
||||
status: str = Field(..., description="Status of the creation (success or error)")
|
||||
default: bool = Field(..., description="True if the project was set as the default")
|
||||
old_project: dict | None = Field(None, description="Information about the previous project")
|
||||
new_project: dict | None = Field(
|
||||
None, description="Information about the newly created project"
|
||||
)
|
||||
|
||||
@@ -404,11 +404,9 @@ class TestCloudProjectAutoRegistration:
|
||||
@pytest.mark.asyncio
|
||||
async def test_extracts_directory_names_from_cloud_paths(self):
|
||||
"""Test extraction of directory names from cloud project paths."""
|
||||
from basic_memory.cli.commands.cloud.bisync_commands import fetch_cloud_projects
|
||||
from basic_memory.cli.commands.cloud.cloud_utils import fetch_cloud_projects
|
||||
|
||||
with patch(
|
||||
"basic_memory.cli.commands.cloud.bisync_commands.make_api_request"
|
||||
) as mock_request:
|
||||
with patch("basic_memory.cli.commands.cloud.cloud_utils.make_api_request") as mock_request:
|
||||
mock_response = Mock()
|
||||
mock_response.json.return_value = {
|
||||
"projects": [
|
||||
@@ -435,20 +433,20 @@ class TestCloudProjectAutoRegistration:
|
||||
@pytest.mark.asyncio
|
||||
async def test_create_cloud_project_generates_permalink(self):
|
||||
"""Test that create_cloud_project generates correct permalink."""
|
||||
from basic_memory.cli.commands.cloud.bisync_commands import create_cloud_project
|
||||
from basic_memory.cli.commands.cloud.cloud_utils import create_cloud_project
|
||||
|
||||
with patch(
|
||||
"basic_memory.cli.commands.cloud.bisync_commands.make_api_request"
|
||||
) as mock_request:
|
||||
with patch("basic_memory.cli.commands.cloud.cloud_utils.make_api_request") as mock_request:
|
||||
with patch(
|
||||
"basic_memory.cli.commands.cloud.bisync_commands.generate_permalink"
|
||||
"basic_memory.cli.commands.cloud.cloud_utils.generate_permalink"
|
||||
) as mock_permalink:
|
||||
mock_permalink.return_value = "my-new-project"
|
||||
mock_response = Mock()
|
||||
mock_response.json.return_value = {
|
||||
"name": "My New Project",
|
||||
"path": "my-new-project",
|
||||
"message": "Created",
|
||||
"message": "Project 'My New Project' added successfully",
|
||||
"status": "success",
|
||||
"default": False,
|
||||
"old_project": None,
|
||||
"new_project": {"name": "My New Project", "path": "my-new-project"},
|
||||
}
|
||||
mock_request.return_value = mock_response
|
||||
|
||||
|
||||
@@ -0,0 +1,326 @@
|
||||
"""Tests for cloud_utils module."""
|
||||
|
||||
from unittest.mock import AsyncMock, Mock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
from basic_memory.cli.commands.cloud.cloud_utils import (
|
||||
CloudUtilsError,
|
||||
create_cloud_project,
|
||||
fetch_cloud_projects,
|
||||
project_exists,
|
||||
sync_project,
|
||||
)
|
||||
|
||||
|
||||
class TestFetchCloudProjects:
|
||||
"""Tests for fetch_cloud_projects()."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_fetches_projects_successfully(self):
|
||||
"""Test successful fetch of cloud projects."""
|
||||
with patch("basic_memory.cli.commands.cloud.cloud_utils.make_api_request") as mock_request:
|
||||
with patch("basic_memory.cli.commands.cloud.cloud_utils.ConfigManager") as mock_config:
|
||||
# Setup config
|
||||
mock_config.return_value.config.cloud_host = "https://example.com"
|
||||
|
||||
# Mock API response
|
||||
mock_response = Mock()
|
||||
mock_response.json.return_value = {
|
||||
"projects": [
|
||||
{"name": "Project 1", "path": "/app/data/project-1"},
|
||||
{"name": "Project 2", "path": "/app/data/project-2"},
|
||||
]
|
||||
}
|
||||
mock_request.return_value = mock_response
|
||||
|
||||
result = await fetch_cloud_projects()
|
||||
|
||||
# Verify result
|
||||
assert len(result.projects) == 2
|
||||
assert result.projects[0].name == "Project 1"
|
||||
assert result.projects[1].name == "Project 2"
|
||||
|
||||
# Verify API was called correctly
|
||||
mock_request.assert_called_once_with(
|
||||
method="GET", url="https://example.com/proxy/projects/projects"
|
||||
)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_strips_trailing_slash_from_host(self):
|
||||
"""Test that trailing slash is stripped from cloud_host."""
|
||||
with patch("basic_memory.cli.commands.cloud.cloud_utils.make_api_request") as mock_request:
|
||||
with patch("basic_memory.cli.commands.cloud.cloud_utils.ConfigManager") as mock_config:
|
||||
# Setup config with trailing slash
|
||||
mock_config.return_value.config.cloud_host = "https://example.com/"
|
||||
|
||||
mock_response = Mock()
|
||||
mock_response.json.return_value = {"projects": []}
|
||||
mock_request.return_value = mock_response
|
||||
|
||||
await fetch_cloud_projects()
|
||||
|
||||
# Verify trailing slash was removed
|
||||
call_args = mock_request.call_args
|
||||
assert call_args[1]["url"] == "https://example.com/proxy/projects/projects"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_raises_error_on_api_failure(self):
|
||||
"""Test that CloudUtilsError is raised on API failure."""
|
||||
with patch("basic_memory.cli.commands.cloud.cloud_utils.make_api_request") as mock_request:
|
||||
with patch("basic_memory.cli.commands.cloud.cloud_utils.ConfigManager") as mock_config:
|
||||
mock_config.return_value.config.cloud_host = "https://example.com"
|
||||
mock_request.side_effect = Exception("API Error")
|
||||
|
||||
with pytest.raises(CloudUtilsError) as exc_info:
|
||||
await fetch_cloud_projects()
|
||||
|
||||
assert "Failed to fetch cloud projects" in str(exc_info.value)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_handles_empty_project_list(self):
|
||||
"""Test handling of empty project list."""
|
||||
with patch("basic_memory.cli.commands.cloud.cloud_utils.make_api_request") as mock_request:
|
||||
with patch("basic_memory.cli.commands.cloud.cloud_utils.ConfigManager") as mock_config:
|
||||
mock_config.return_value.config.cloud_host = "https://example.com"
|
||||
|
||||
mock_response = Mock()
|
||||
mock_response.json.return_value = {"projects": []}
|
||||
mock_request.return_value = mock_response
|
||||
|
||||
result = await fetch_cloud_projects()
|
||||
|
||||
assert len(result.projects) == 0
|
||||
|
||||
|
||||
class TestCreateCloudProject:
|
||||
"""Tests for create_cloud_project()."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_creates_project_successfully(self):
|
||||
"""Test successful project creation."""
|
||||
with patch("basic_memory.cli.commands.cloud.cloud_utils.make_api_request") as mock_request:
|
||||
with patch("basic_memory.cli.commands.cloud.cloud_utils.ConfigManager") as mock_config:
|
||||
with patch(
|
||||
"basic_memory.cli.commands.cloud.cloud_utils.generate_permalink"
|
||||
) as mock_permalink:
|
||||
# Setup mocks
|
||||
mock_config.return_value.config.cloud_host = "https://example.com"
|
||||
mock_permalink.return_value = "my-project"
|
||||
|
||||
mock_response = Mock()
|
||||
mock_response.json.return_value = {
|
||||
"message": "Project 'My Project' added successfully",
|
||||
"status": "success",
|
||||
"default": False,
|
||||
"old_project": None,
|
||||
"new_project": {"name": "My Project", "path": "my-project"},
|
||||
}
|
||||
mock_request.return_value = mock_response
|
||||
|
||||
result = await create_cloud_project("My Project")
|
||||
|
||||
# Verify result
|
||||
assert result.message == "Project 'My Project' added successfully"
|
||||
assert result.status == "success"
|
||||
assert result.default is False
|
||||
|
||||
# Verify permalink was generated
|
||||
mock_permalink.assert_called_once_with("My Project")
|
||||
|
||||
# Verify API request
|
||||
call_args = mock_request.call_args
|
||||
assert call_args[1]["method"] == "POST"
|
||||
assert call_args[1]["url"] == "https://example.com/proxy/projects/projects"
|
||||
assert call_args[1]["headers"]["Content-Type"] == "application/json"
|
||||
|
||||
json_data = call_args[1]["json_data"]
|
||||
assert json_data["name"] == "My Project"
|
||||
assert json_data["path"] == "my-project"
|
||||
assert json_data["set_default"] is False
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_generates_permalink_from_name(self):
|
||||
"""Test that permalink is generated from project name."""
|
||||
with patch("basic_memory.cli.commands.cloud.cloud_utils.make_api_request") as mock_request:
|
||||
with patch("basic_memory.cli.commands.cloud.cloud_utils.ConfigManager") as mock_config:
|
||||
with patch(
|
||||
"basic_memory.cli.commands.cloud.cloud_utils.generate_permalink"
|
||||
) as mock_permalink:
|
||||
mock_config.return_value.config.cloud_host = "https://example.com"
|
||||
mock_permalink.return_value = "test-project-123"
|
||||
|
||||
mock_response = Mock()
|
||||
mock_response.json.return_value = {
|
||||
"message": "Project 'Test Project 123' added successfully",
|
||||
"status": "success",
|
||||
"default": False,
|
||||
"old_project": None,
|
||||
"new_project": {"name": "Test Project 123", "path": "test-project-123"},
|
||||
}
|
||||
mock_request.return_value = mock_response
|
||||
|
||||
await create_cloud_project("Test Project 123")
|
||||
|
||||
# Verify generate_permalink was called with project name
|
||||
mock_permalink.assert_called_once_with("Test Project 123")
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_raises_error_on_api_failure(self):
|
||||
"""Test that CloudUtilsError is raised on API failure."""
|
||||
with patch("basic_memory.cli.commands.cloud.cloud_utils.make_api_request") as mock_request:
|
||||
with patch("basic_memory.cli.commands.cloud.cloud_utils.ConfigManager") as mock_config:
|
||||
with patch(
|
||||
"basic_memory.cli.commands.cloud.cloud_utils.generate_permalink"
|
||||
) as mock_permalink:
|
||||
mock_config.return_value.config.cloud_host = "https://example.com"
|
||||
mock_permalink.return_value = "project"
|
||||
mock_request.side_effect = Exception("API Error")
|
||||
|
||||
with pytest.raises(CloudUtilsError) as exc_info:
|
||||
await create_cloud_project("Test Project")
|
||||
|
||||
assert "Failed to create cloud project 'Test Project'" in str(exc_info.value)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_strips_trailing_slash_from_host(self):
|
||||
"""Test that trailing slash is stripped from cloud_host."""
|
||||
with patch("basic_memory.cli.commands.cloud.cloud_utils.make_api_request") as mock_request:
|
||||
with patch("basic_memory.cli.commands.cloud.cloud_utils.ConfigManager") as mock_config:
|
||||
with patch(
|
||||
"basic_memory.cli.commands.cloud.cloud_utils.generate_permalink"
|
||||
) as mock_permalink:
|
||||
mock_config.return_value.config.cloud_host = "https://example.com/"
|
||||
mock_permalink.return_value = "project"
|
||||
|
||||
mock_response = Mock()
|
||||
mock_response.json.return_value = {
|
||||
"message": "Project 'Project' added successfully",
|
||||
"status": "success",
|
||||
"default": False,
|
||||
"old_project": None,
|
||||
"new_project": {"name": "Project", "path": "project"},
|
||||
}
|
||||
mock_request.return_value = mock_response
|
||||
|
||||
await create_cloud_project("Project")
|
||||
|
||||
# Verify trailing slash was removed
|
||||
call_args = mock_request.call_args
|
||||
assert call_args[1]["url"] == "https://example.com/proxy/projects/projects"
|
||||
|
||||
|
||||
class TestSyncProject:
|
||||
"""Tests for sync_project()."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_syncs_project_successfully(self):
|
||||
"""Test successful project sync."""
|
||||
# Patch at the point where it's imported (inside the function)
|
||||
with patch(
|
||||
"basic_memory.cli.commands.command_utils.run_sync", new_callable=AsyncMock
|
||||
) as mock_sync:
|
||||
await sync_project("test-project")
|
||||
|
||||
# Verify run_sync was called with project name
|
||||
mock_sync.assert_called_once_with(project="test-project")
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_raises_error_on_sync_failure(self):
|
||||
"""Test that CloudUtilsError is raised on sync failure."""
|
||||
# Patch at the point where it's imported (inside the function)
|
||||
with patch(
|
||||
"basic_memory.cli.commands.command_utils.run_sync", new_callable=AsyncMock
|
||||
) as mock_sync:
|
||||
mock_sync.side_effect = Exception("Sync failed")
|
||||
|
||||
with pytest.raises(CloudUtilsError) as exc_info:
|
||||
await sync_project("test-project")
|
||||
|
||||
assert "Failed to sync project 'test-project'" in str(exc_info.value)
|
||||
|
||||
|
||||
class TestProjectExists:
|
||||
"""Tests for project_exists()."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_returns_true_when_project_exists(self):
|
||||
"""Test that True is returned when project exists."""
|
||||
from basic_memory.schemas.cloud import CloudProject, CloudProjectList
|
||||
|
||||
with patch(
|
||||
"basic_memory.cli.commands.cloud.cloud_utils.fetch_cloud_projects"
|
||||
) as mock_fetch:
|
||||
# Create actual CloudProject objects
|
||||
projects = CloudProjectList(
|
||||
projects=[
|
||||
CloudProject(name="project-1", path="/app/data/project-1"),
|
||||
CloudProject(name="test-project", path="/app/data/test-project"),
|
||||
CloudProject(name="project-2", path="/app/data/project-2"),
|
||||
]
|
||||
)
|
||||
mock_fetch.return_value = projects
|
||||
|
||||
result = await project_exists("test-project")
|
||||
|
||||
assert result is True
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_returns_false_when_project_not_found(self):
|
||||
"""Test that False is returned when project doesn't exist."""
|
||||
with patch(
|
||||
"basic_memory.cli.commands.cloud.cloud_utils.fetch_cloud_projects"
|
||||
) as mock_fetch:
|
||||
# Mock project list without matching project
|
||||
mock_projects = Mock()
|
||||
mock_projects.projects = [
|
||||
Mock(name="project-1"),
|
||||
Mock(name="project-2"),
|
||||
]
|
||||
mock_fetch.return_value = mock_projects
|
||||
|
||||
result = await project_exists("nonexistent-project")
|
||||
|
||||
assert result is False
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_returns_false_on_api_error(self):
|
||||
"""Test that False is returned on API error."""
|
||||
with patch(
|
||||
"basic_memory.cli.commands.cloud.cloud_utils.fetch_cloud_projects"
|
||||
) as mock_fetch:
|
||||
mock_fetch.side_effect = Exception("API Error")
|
||||
|
||||
result = await project_exists("test-project")
|
||||
|
||||
# Should return False instead of raising exception
|
||||
assert result is False
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_handles_empty_project_list(self):
|
||||
"""Test handling of empty project list."""
|
||||
with patch(
|
||||
"basic_memory.cli.commands.cloud.cloud_utils.fetch_cloud_projects"
|
||||
) as mock_fetch:
|
||||
mock_projects = Mock()
|
||||
mock_projects.projects = []
|
||||
mock_fetch.return_value = mock_projects
|
||||
|
||||
result = await project_exists("any-project")
|
||||
|
||||
assert result is False
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_case_sensitive_matching(self):
|
||||
"""Test that project name matching is case-sensitive."""
|
||||
with patch(
|
||||
"basic_memory.cli.commands.cloud.cloud_utils.fetch_cloud_projects"
|
||||
) as mock_fetch:
|
||||
mock_projects = Mock()
|
||||
mock_projects.projects = [Mock(name="Test-Project")]
|
||||
mock_fetch.return_value = mock_projects
|
||||
|
||||
# Different case should not match
|
||||
result = await project_exists("test-project")
|
||||
|
||||
assert result is False
|
||||
@@ -0,0 +1,328 @@
|
||||
"""Tests for upload module."""
|
||||
|
||||
from unittest.mock import AsyncMock, Mock, patch
|
||||
|
||||
import httpx
|
||||
import pytest
|
||||
|
||||
from basic_memory.cli.commands.cloud.upload import _get_files_to_upload, upload_path
|
||||
|
||||
|
||||
class TestGetFilesToUpload:
|
||||
"""Tests for _get_files_to_upload()."""
|
||||
|
||||
def test_collects_files_from_directory(self, tmp_path):
|
||||
"""Test collecting files from a directory."""
|
||||
# Create test directory structure
|
||||
(tmp_path / "file1.txt").write_text("content1")
|
||||
(tmp_path / "file2.md").write_text("content2")
|
||||
(tmp_path / "subdir").mkdir()
|
||||
(tmp_path / "subdir" / "file3.py").write_text("content3")
|
||||
|
||||
# Call with real ignore utils (no mocking)
|
||||
result = _get_files_to_upload(tmp_path)
|
||||
|
||||
# Should find all 3 files
|
||||
assert len(result) == 3
|
||||
|
||||
# Extract just the relative paths for easier assertion
|
||||
relative_paths = [rel_path for _, rel_path in result]
|
||||
assert "file1.txt" in relative_paths
|
||||
assert "file2.md" in relative_paths
|
||||
assert "subdir/file3.py" in relative_paths
|
||||
|
||||
def test_respects_gitignore_patterns(self, tmp_path):
|
||||
"""Test that gitignore patterns are respected."""
|
||||
# Create test files
|
||||
(tmp_path / "keep.txt").write_text("keep")
|
||||
(tmp_path / "ignore.pyc").write_text("ignore")
|
||||
|
||||
# Create .gitignore file
|
||||
gitignore_file = tmp_path / ".gitignore"
|
||||
gitignore_file.write_text("*.pyc\n")
|
||||
|
||||
result = _get_files_to_upload(tmp_path)
|
||||
|
||||
# Should only find keep.txt (not .pyc or .gitignore itself)
|
||||
relative_paths = [rel_path for _, rel_path in result]
|
||||
assert "keep.txt" in relative_paths
|
||||
assert "ignore.pyc" not in relative_paths
|
||||
|
||||
def test_handles_empty_directory(self, tmp_path):
|
||||
"""Test handling of empty directory."""
|
||||
empty_dir = tmp_path / "empty"
|
||||
empty_dir.mkdir()
|
||||
|
||||
result = _get_files_to_upload(empty_dir)
|
||||
|
||||
assert result == []
|
||||
|
||||
def test_converts_windows_paths_to_forward_slashes(self, tmp_path):
|
||||
"""Test that Windows backslashes are converted to forward slashes."""
|
||||
# Create nested structure
|
||||
(tmp_path / "dir1").mkdir()
|
||||
(tmp_path / "dir1" / "dir2").mkdir()
|
||||
(tmp_path / "dir1" / "dir2" / "file.txt").write_text("content")
|
||||
|
||||
result = _get_files_to_upload(tmp_path)
|
||||
|
||||
# Remote path should use forward slashes
|
||||
_, remote_path = result[0]
|
||||
assert "\\" not in remote_path # No backslashes
|
||||
assert "dir1/dir2/file.txt" == remote_path
|
||||
|
||||
|
||||
class TestUploadPath:
|
||||
"""Tests for upload_path()."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_uploads_single_file(self, tmp_path):
|
||||
"""Test uploading a single file."""
|
||||
test_file = tmp_path / "test.txt"
|
||||
test_file.write_text("test content")
|
||||
|
||||
# Mock the client and HTTP response
|
||||
mock_client = AsyncMock()
|
||||
mock_response = Mock()
|
||||
mock_response.raise_for_status = Mock()
|
||||
|
||||
with patch("basic_memory.cli.commands.cloud.upload.get_client") as mock_get_client:
|
||||
with patch("basic_memory.cli.commands.cloud.upload.call_put") as mock_put:
|
||||
with patch("aiofiles.open", create=True) as mock_aiofiles_open:
|
||||
# Setup mocks
|
||||
mock_get_client.return_value.__aenter__.return_value = mock_client
|
||||
mock_get_client.return_value.__aexit__.return_value = None
|
||||
mock_put.return_value = mock_response
|
||||
|
||||
# Mock file reading
|
||||
mock_file = AsyncMock()
|
||||
mock_file.read.return_value = b"test content"
|
||||
mock_aiofiles_open.return_value.__aenter__.return_value = mock_file
|
||||
|
||||
result = await upload_path(test_file, "test-project")
|
||||
|
||||
# Verify success
|
||||
assert result is True
|
||||
|
||||
# Verify PUT was called with correct path
|
||||
mock_put.assert_called_once()
|
||||
call_args = mock_put.call_args
|
||||
assert call_args[0][0] == mock_client
|
||||
assert call_args[0][1] == "/webdav/test-project/test.txt"
|
||||
assert call_args[1]["content"] == b"test content"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_uploads_directory(self, tmp_path):
|
||||
"""Test uploading a directory with multiple files."""
|
||||
# Create test files
|
||||
(tmp_path / "file1.txt").write_text("content1")
|
||||
(tmp_path / "file2.txt").write_text("content2")
|
||||
|
||||
mock_client = AsyncMock()
|
||||
mock_response = Mock()
|
||||
mock_response.raise_for_status = Mock()
|
||||
|
||||
with patch("basic_memory.cli.commands.cloud.upload.get_client") as mock_get_client:
|
||||
with patch("basic_memory.cli.commands.cloud.upload.call_put") as mock_put:
|
||||
with patch(
|
||||
"basic_memory.cli.commands.cloud.upload._get_files_to_upload"
|
||||
) as mock_get_files:
|
||||
with patch("aiofiles.open", create=True) as mock_aiofiles_open:
|
||||
# Setup mocks
|
||||
mock_get_client.return_value.__aenter__.return_value = mock_client
|
||||
mock_get_client.return_value.__aexit__.return_value = None
|
||||
mock_put.return_value = mock_response
|
||||
|
||||
# Mock file listing
|
||||
mock_get_files.return_value = [
|
||||
(tmp_path / "file1.txt", "file1.txt"),
|
||||
(tmp_path / "file2.txt", "file2.txt"),
|
||||
]
|
||||
|
||||
# Mock file reading
|
||||
mock_file = AsyncMock()
|
||||
mock_file.read.side_effect = [b"content1", b"content2"]
|
||||
mock_aiofiles_open.return_value.__aenter__.return_value = mock_file
|
||||
|
||||
result = await upload_path(tmp_path, "test-project")
|
||||
|
||||
# Verify success
|
||||
assert result is True
|
||||
|
||||
# Verify PUT was called twice
|
||||
assert mock_put.call_count == 2
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_handles_nonexistent_path(self, tmp_path):
|
||||
"""Test handling of nonexistent path."""
|
||||
nonexistent = tmp_path / "does-not-exist"
|
||||
|
||||
result = await upload_path(nonexistent, "test-project")
|
||||
|
||||
# Should return False
|
||||
assert result is False
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_handles_http_error(self, tmp_path):
|
||||
"""Test handling of HTTP errors during upload."""
|
||||
test_file = tmp_path / "test.txt"
|
||||
test_file.write_text("test content")
|
||||
|
||||
mock_client = AsyncMock()
|
||||
mock_response = Mock()
|
||||
mock_response.status_code = 403
|
||||
mock_response.text = "Forbidden"
|
||||
mock_response.raise_for_status.side_effect = httpx.HTTPStatusError(
|
||||
"Forbidden", request=Mock(), response=mock_response
|
||||
)
|
||||
|
||||
with patch("basic_memory.cli.commands.cloud.upload.get_client") as mock_get_client:
|
||||
with patch("basic_memory.cli.commands.cloud.upload.call_put") as mock_put:
|
||||
with patch("aiofiles.open", create=True) as mock_aiofiles_open:
|
||||
# Setup mocks
|
||||
mock_get_client.return_value.__aenter__.return_value = mock_client
|
||||
mock_get_client.return_value.__aexit__.return_value = None
|
||||
mock_put.return_value = mock_response
|
||||
|
||||
# Mock file reading
|
||||
mock_file = AsyncMock()
|
||||
mock_file.read.return_value = b"test content"
|
||||
mock_aiofiles_open.return_value.__aenter__.return_value = mock_file
|
||||
|
||||
result = await upload_path(test_file, "test-project")
|
||||
|
||||
# Should return False on error
|
||||
assert result is False
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_handles_empty_directory(self, tmp_path):
|
||||
"""Test uploading an empty directory."""
|
||||
empty_dir = tmp_path / "empty"
|
||||
empty_dir.mkdir()
|
||||
|
||||
with patch("basic_memory.cli.commands.cloud.upload._get_files_to_upload") as mock_get_files:
|
||||
mock_get_files.return_value = []
|
||||
|
||||
result = await upload_path(empty_dir, "test-project")
|
||||
|
||||
# Should return True (no-op success)
|
||||
assert result is True
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_formats_file_size_bytes(self, tmp_path, capsys):
|
||||
"""Test file size formatting for small files (bytes)."""
|
||||
test_file = tmp_path / "small.txt"
|
||||
test_file.write_text("hi") # 2 bytes
|
||||
|
||||
mock_client = AsyncMock()
|
||||
mock_response = Mock()
|
||||
mock_response.raise_for_status = Mock()
|
||||
|
||||
with patch("basic_memory.cli.commands.cloud.upload.get_client") as mock_get_client:
|
||||
with patch("basic_memory.cli.commands.cloud.upload.call_put") as mock_put:
|
||||
with patch("aiofiles.open", create=True) as mock_aiofiles_open:
|
||||
mock_get_client.return_value.__aenter__.return_value = mock_client
|
||||
mock_get_client.return_value.__aexit__.return_value = None
|
||||
mock_put.return_value = mock_response
|
||||
|
||||
mock_file = AsyncMock()
|
||||
mock_file.read.return_value = b"hi"
|
||||
mock_aiofiles_open.return_value.__aenter__.return_value = mock_file
|
||||
|
||||
await upload_path(test_file, "test-project")
|
||||
|
||||
# Check output contains "bytes"
|
||||
captured = capsys.readouterr()
|
||||
assert "bytes" in captured.out
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_formats_file_size_kilobytes(self, tmp_path, capsys):
|
||||
"""Test file size formatting for medium files (KB)."""
|
||||
test_file = tmp_path / "medium.txt"
|
||||
# Create file with 2KB of content
|
||||
test_file.write_text("x" * 2048)
|
||||
|
||||
mock_client = AsyncMock()
|
||||
mock_response = Mock()
|
||||
mock_response.raise_for_status = Mock()
|
||||
|
||||
with patch("basic_memory.cli.commands.cloud.upload.get_client") as mock_get_client:
|
||||
with patch("basic_memory.cli.commands.cloud.upload.call_put") as mock_put:
|
||||
with patch("aiofiles.open", create=True) as mock_aiofiles_open:
|
||||
mock_get_client.return_value.__aenter__.return_value = mock_client
|
||||
mock_get_client.return_value.__aexit__.return_value = None
|
||||
mock_put.return_value = mock_response
|
||||
|
||||
mock_file = AsyncMock()
|
||||
mock_file.read.return_value = b"x" * 2048
|
||||
mock_aiofiles_open.return_value.__aenter__.return_value = mock_file
|
||||
|
||||
await upload_path(test_file, "test-project")
|
||||
|
||||
# Check output contains "KB"
|
||||
captured = capsys.readouterr()
|
||||
assert "KB" in captured.out
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_formats_file_size_megabytes(self, tmp_path, capsys):
|
||||
"""Test file size formatting for large files (MB)."""
|
||||
test_file = tmp_path / "large.txt"
|
||||
# Create file with 2MB of content
|
||||
test_file.write_text("x" * (2 * 1024 * 1024))
|
||||
|
||||
mock_client = AsyncMock()
|
||||
mock_response = Mock()
|
||||
mock_response.raise_for_status = Mock()
|
||||
|
||||
with patch("basic_memory.cli.commands.cloud.upload.get_client") as mock_get_client:
|
||||
with patch("basic_memory.cli.commands.cloud.upload.call_put") as mock_put:
|
||||
with patch("aiofiles.open", create=True) as mock_aiofiles_open:
|
||||
mock_get_client.return_value.__aenter__.return_value = mock_client
|
||||
mock_get_client.return_value.__aexit__.return_value = None
|
||||
mock_put.return_value = mock_response
|
||||
|
||||
mock_file = AsyncMock()
|
||||
mock_file.read.return_value = b"x" * (2 * 1024 * 1024)
|
||||
mock_aiofiles_open.return_value.__aenter__.return_value = mock_file
|
||||
|
||||
await upload_path(test_file, "test-project")
|
||||
|
||||
# Check output contains "MB"
|
||||
captured = capsys.readouterr()
|
||||
assert "MB" in captured.out
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_builds_correct_webdav_path(self, tmp_path):
|
||||
"""Test that WebDAV path is correctly constructed."""
|
||||
# Create nested structure
|
||||
(tmp_path / "subdir").mkdir()
|
||||
test_file = tmp_path / "subdir" / "file.txt"
|
||||
test_file.write_text("content")
|
||||
|
||||
mock_client = AsyncMock()
|
||||
mock_response = Mock()
|
||||
mock_response.raise_for_status = Mock()
|
||||
|
||||
with patch("basic_memory.cli.commands.cloud.upload.get_client") as mock_get_client:
|
||||
with patch("basic_memory.cli.commands.cloud.upload.call_put") as mock_put:
|
||||
with patch(
|
||||
"basic_memory.cli.commands.cloud.upload._get_files_to_upload"
|
||||
) as mock_get_files:
|
||||
with patch("aiofiles.open", create=True) as mock_aiofiles_open:
|
||||
mock_get_client.return_value.__aenter__.return_value = mock_client
|
||||
mock_get_client.return_value.__aexit__.return_value = None
|
||||
mock_put.return_value = mock_response
|
||||
|
||||
# Mock file listing with relative path
|
||||
mock_get_files.return_value = [(test_file, "subdir/file.txt")]
|
||||
|
||||
mock_file = AsyncMock()
|
||||
mock_file.read.return_value = b"content"
|
||||
mock_aiofiles_open.return_value.__aenter__.return_value = mock_file
|
||||
|
||||
await upload_path(tmp_path, "my-project")
|
||||
|
||||
# Verify WebDAV path format: /webdav/{project_name}/{relative_path}
|
||||
mock_put.assert_called_once()
|
||||
call_args = mock_put.call_args
|
||||
assert call_args[0][1] == "/webdav/my-project/subdir/file.txt"
|
||||
Reference in New Issue
Block a user