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:
Paul Hernandez
2025-10-21 14:09:20 -05:00
committed by GitHub
parent bb8da31472
commit c7e6eab02f
5 changed files with 225 additions and 7 deletions
@@ -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",
+9 -3
View File
@@ -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")
+22 -2
View File
@@ -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.
+83
View File
@@ -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
+105
View File
@@ -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