mirror of
https://github.com/basicmachines-co/basic-memory
synced 2026-06-21 13:47:35 +00:00
feat: Add delete_notes parameter to remove project endpoint (#391)
Signed-off-by: phernandez <paul@basicmachines.co> Co-authored-by: Claude <noreply@anthropic.com>
This commit is contained in:
@@ -1,7 +1,7 @@
|
||||
"""Router for project management."""
|
||||
|
||||
import os
|
||||
from fastapi import APIRouter, HTTPException, Path, Body, BackgroundTasks, Response
|
||||
from fastapi import APIRouter, HTTPException, Path, Body, BackgroundTasks, Response, Query
|
||||
from typing import Optional
|
||||
from loguru import logger
|
||||
|
||||
@@ -247,11 +247,15 @@ async def add_project(
|
||||
async def remove_project(
|
||||
project_service: ProjectServiceDep,
|
||||
name: str = Path(..., description="Name of the project to remove"),
|
||||
delete_notes: bool = Query(
|
||||
False, description="If True, delete project directory from filesystem"
|
||||
),
|
||||
) -> ProjectStatusResponse:
|
||||
"""Remove a project from configuration and database.
|
||||
|
||||
Args:
|
||||
name: The name of the project to remove
|
||||
delete_notes: If True, delete the project directory from the filesystem
|
||||
|
||||
Returns:
|
||||
Response confirming the project was removed
|
||||
@@ -276,7 +280,7 @@ async def remove_project(
|
||||
detail += "This is the only project in your configuration."
|
||||
raise HTTPException(status_code=400, detail=detail)
|
||||
|
||||
await project_service.remove_project(name)
|
||||
await project_service.remove_project(name, delete_notes=delete_notes)
|
||||
|
||||
return ProjectStatusResponse(
|
||||
message=f"Project '{name}' removed successfully",
|
||||
|
||||
@@ -119,13 +119,18 @@ def add_project(
|
||||
@project_app.command("remove")
|
||||
def remove_project(
|
||||
name: str = typer.Argument(..., help="Name of the project to remove"),
|
||||
delete_notes: bool = typer.Option(
|
||||
False, "--delete-notes", help="Delete project files from disk"
|
||||
),
|
||||
) -> None:
|
||||
"""Remove a project."""
|
||||
|
||||
async def _remove_project():
|
||||
async with get_client() as client:
|
||||
project_permalink = generate_permalink(name)
|
||||
response = await call_delete(client, f"/projects/{project_permalink}")
|
||||
response = await call_delete(
|
||||
client, f"/projects/{project_permalink}?delete_notes={delete_notes}"
|
||||
)
|
||||
return ProjectStatusResponse.model_validate(response.json())
|
||||
|
||||
try:
|
||||
@@ -135,8 +140,9 @@ def remove_project(
|
||||
console.print(f"[red]Error removing project: {str(e)}[/red]")
|
||||
raise typer.Exit(1)
|
||||
|
||||
# Show this message regardless of method used
|
||||
console.print("[yellow]Note: The project files have not been deleted from disk.[/yellow]")
|
||||
# Show this message only if files were not deleted
|
||||
if not delete_notes:
|
||||
console.print("[yellow]Note: The project files have not been deleted from disk.[/yellow]")
|
||||
|
||||
|
||||
@project_app.command("default")
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
"""Project management service for Basic Memory."""
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import os
|
||||
import shutil
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
from typing import Dict, Optional, Sequence
|
||||
@@ -219,11 +221,12 @@ class ProjectService:
|
||||
|
||||
logger.info(f"Project '{name}' added at {resolved_path}")
|
||||
|
||||
async def remove_project(self, name: str) -> None:
|
||||
async def remove_project(self, name: str, delete_notes: bool = False) -> None:
|
||||
"""Remove a project from configuration and database.
|
||||
|
||||
Args:
|
||||
name: The name of the project to remove
|
||||
delete_notes: If True, delete the project directory from filesystem
|
||||
|
||||
Raises:
|
||||
ValueError: If the project doesn't exist or is the default project
|
||||
@@ -231,16 +234,33 @@ class ProjectService:
|
||||
if not self.repository: # pragma: no cover
|
||||
raise ValueError("Repository is required for remove_project")
|
||||
|
||||
# Get project path before removing from config
|
||||
project = await self.get_project(name)
|
||||
project_path = project.path if project else None
|
||||
|
||||
# First remove from config (this will validate the project exists and is not default)
|
||||
self.config_manager.remove_project(name)
|
||||
|
||||
# Then remove from database using robust lookup
|
||||
project = await self.get_project(name)
|
||||
if project:
|
||||
await self.repository.delete(project.id)
|
||||
|
||||
logger.info(f"Project '{name}' removed from configuration and database")
|
||||
|
||||
# Optionally delete the project directory
|
||||
if delete_notes and project_path:
|
||||
try:
|
||||
path_obj = Path(project_path)
|
||||
if path_obj.exists() and path_obj.is_dir():
|
||||
await asyncio.to_thread(shutil.rmtree, project_path)
|
||||
logger.info(f"Deleted project directory: {project_path}")
|
||||
else:
|
||||
logger.warning(
|
||||
f"Project directory not found or not a directory: {project_path}"
|
||||
)
|
||||
except Exception as e:
|
||||
logger.warning(f"Failed to delete project directory {project_path}: {e}")
|
||||
|
||||
async def set_default_project(self, name: str) -> None:
|
||||
"""Set the default project in configuration and database.
|
||||
|
||||
|
||||
@@ -635,3 +635,86 @@ async def test_create_project_fails_different_path(test_config, client, project_
|
||||
await project_service.remove_project(test_project_name)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_remove_project_with_delete_notes_false(test_config, client, project_service):
|
||||
"""Test that removing a project with delete_notes=False leaves directory intact."""
|
||||
# Create a test project with actual directory
|
||||
test_project_name = "test-remove-keep-files"
|
||||
with tempfile.TemporaryDirectory() as temp_dir:
|
||||
test_path = Path(temp_dir) / "test-project"
|
||||
test_path.mkdir()
|
||||
test_file = test_path / "test.md"
|
||||
test_file.write_text("# Test Note")
|
||||
|
||||
await project_service.add_project(test_project_name, str(test_path))
|
||||
|
||||
# Remove the project without deleting files (default)
|
||||
response = await client.delete(f"/projects/{test_project_name}")
|
||||
|
||||
# Verify response
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert data["status"] == "success"
|
||||
|
||||
# Verify project is removed from config/db
|
||||
removed_project = await project_service.get_project(test_project_name)
|
||||
assert removed_project is None
|
||||
|
||||
# Verify directory still exists
|
||||
assert test_path.exists()
|
||||
assert test_file.exists()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_remove_project_with_delete_notes_true(test_config, client, project_service):
|
||||
"""Test that removing a project with delete_notes=True deletes the directory."""
|
||||
# Create a test project with actual directory
|
||||
test_project_name = "test-remove-delete-files"
|
||||
with tempfile.TemporaryDirectory() as temp_dir:
|
||||
test_path = Path(temp_dir) / "test-project"
|
||||
test_path.mkdir()
|
||||
test_file = test_path / "test.md"
|
||||
test_file.write_text("# Test Note")
|
||||
|
||||
await project_service.add_project(test_project_name, str(test_path))
|
||||
|
||||
# Remove the project with delete_notes=True
|
||||
response = await client.delete(f"/projects/{test_project_name}?delete_notes=true")
|
||||
|
||||
# Verify response
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert data["status"] == "success"
|
||||
|
||||
# Verify project is removed from config/db
|
||||
removed_project = await project_service.get_project(test_project_name)
|
||||
assert removed_project is None
|
||||
|
||||
# Verify directory is deleted
|
||||
assert not test_path.exists()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_remove_project_delete_notes_nonexistent_directory(
|
||||
test_config, client, project_service
|
||||
):
|
||||
"""Test that removing a project with delete_notes=True handles missing directory gracefully."""
|
||||
# Create a project pointing to a non-existent path
|
||||
test_project_name = "test-remove-missing-dir"
|
||||
test_path = "/tmp/this-directory-does-not-exist-12345"
|
||||
|
||||
await project_service.add_project(test_project_name, test_path)
|
||||
|
||||
# Remove the project with delete_notes=True (should not fail even if dir doesn't exist)
|
||||
response = await client.delete(f"/projects/{test_project_name}?delete_notes=true")
|
||||
|
||||
# Should succeed
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert data["status"] == "success"
|
||||
|
||||
# Verify project is removed
|
||||
removed_project = await project_service.get_project(test_project_name)
|
||||
assert removed_project is None
|
||||
|
||||
@@ -1250,3 +1250,108 @@ async def test_synchronize_projects_removes_db_only_projects(project_service: Pr
|
||||
db_project = await project_service.repository.get_by_name(test_project_name)
|
||||
if db_project:
|
||||
await project_service.repository.delete(db_project.id)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_remove_project_with_delete_notes_false(project_service: ProjectService):
|
||||
"""Test that remove_project with delete_notes=False keeps directory intact."""
|
||||
test_project_name = f"test-remove-keep-{os.urandom(4).hex()}"
|
||||
with tempfile.TemporaryDirectory() as temp_dir:
|
||||
test_root = Path(temp_dir)
|
||||
test_project_path = test_root / "test-project"
|
||||
test_project_path.mkdir()
|
||||
test_file = test_project_path / "test.md"
|
||||
test_file.write_text("# Test Note")
|
||||
|
||||
try:
|
||||
# Add project
|
||||
await project_service.add_project(test_project_name, str(test_project_path))
|
||||
|
||||
# Verify project exists
|
||||
assert test_project_name in project_service.projects
|
||||
assert test_project_path.exists()
|
||||
assert test_file.exists()
|
||||
|
||||
# Remove project without deleting notes (default behavior)
|
||||
await project_service.remove_project(test_project_name, delete_notes=False)
|
||||
|
||||
# Verify project is removed from config/db
|
||||
assert test_project_name not in project_service.projects
|
||||
db_project = await project_service.repository.get_by_name(test_project_name)
|
||||
assert db_project is None
|
||||
|
||||
# Verify directory and files still exist
|
||||
assert test_project_path.exists()
|
||||
assert test_file.exists()
|
||||
|
||||
finally:
|
||||
# Cleanup happens automatically with temp_dir context manager
|
||||
pass
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_remove_project_with_delete_notes_true(project_service: ProjectService):
|
||||
"""Test that remove_project with delete_notes=True deletes directory."""
|
||||
test_project_name = f"test-remove-delete-{os.urandom(4).hex()}"
|
||||
with tempfile.TemporaryDirectory() as temp_dir:
|
||||
test_root = Path(temp_dir)
|
||||
test_project_path = test_root / "test-project"
|
||||
test_project_path.mkdir()
|
||||
test_file = test_project_path / "test.md"
|
||||
test_file.write_text("# Test Note")
|
||||
|
||||
try:
|
||||
# Add project
|
||||
await project_service.add_project(test_project_name, str(test_project_path))
|
||||
|
||||
# Verify project exists
|
||||
assert test_project_name in project_service.projects
|
||||
assert test_project_path.exists()
|
||||
assert test_file.exists()
|
||||
|
||||
# Remove project with delete_notes=True
|
||||
await project_service.remove_project(test_project_name, delete_notes=True)
|
||||
|
||||
# Verify project is removed from config/db
|
||||
assert test_project_name not in project_service.projects
|
||||
db_project = await project_service.repository.get_by_name(test_project_name)
|
||||
assert db_project is None
|
||||
|
||||
# Verify directory and files are deleted
|
||||
assert not test_project_path.exists()
|
||||
|
||||
finally:
|
||||
# Cleanup happens automatically with temp_dir context manager
|
||||
pass
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_remove_project_delete_notes_missing_directory(project_service: ProjectService):
|
||||
"""Test that remove_project with delete_notes=True handles missing directory gracefully."""
|
||||
test_project_name = f"test-remove-missing-{os.urandom(4).hex()}"
|
||||
test_project_path = f"/tmp/nonexistent-directory-{os.urandom(8).hex()}"
|
||||
|
||||
try:
|
||||
# Add project pointing to non-existent path
|
||||
await project_service.add_project(test_project_name, test_project_path)
|
||||
|
||||
# Verify project exists in config/db
|
||||
assert test_project_name in project_service.projects
|
||||
db_project = await project_service.repository.get_by_name(test_project_name)
|
||||
assert db_project is not None
|
||||
|
||||
# Remove project with delete_notes=True (should not fail even if dir doesn't exist)
|
||||
await project_service.remove_project(test_project_name, delete_notes=True)
|
||||
|
||||
# Verify project is removed from config/db
|
||||
assert test_project_name not in project_service.projects
|
||||
db_project = await project_service.repository.get_by_name(test_project_name)
|
||||
assert db_project is None
|
||||
|
||||
finally:
|
||||
# Ensure cleanup
|
||||
if test_project_name in project_service.projects:
|
||||
try:
|
||||
project_service.config_manager.remove_project(test_project_name)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
Reference in New Issue
Block a user