diff --git a/src/basic_memory/config.py b/src/basic_memory/config.py index b902d289..01078db2 100644 --- a/src/basic_memory/config.py +++ b/src/basic_memory/config.py @@ -94,7 +94,9 @@ class BasicMemoryConfig(BaseSettings): """Ensure configuration is valid after initialization.""" # Ensure main project exists if "main" not in self.projects: # pragma: no cover - self.projects["main"] = str(Path(os.getenv("BASIC_MEMORY_HOME", Path.home() / "basic-memory"))) + self.projects["main"] = str( + Path(os.getenv("BASIC_MEMORY_HOME", Path.home() / "basic-memory")) + ) # Ensure default project is valid if self.default_project not in self.projects: # pragma: no cover diff --git a/src/basic_memory/mcp/tools/move_note.py b/src/basic_memory/mcp/tools/move_note.py index bf67e1ef..3fdfbb7c 100644 --- a/src/basic_memory/mcp/tools/move_note.py +++ b/src/basic_memory/mcp/tools/move_note.py @@ -17,12 +17,12 @@ async def _detect_cross_project_move_attempt( identifier: str, destination_path: str, current_project: str ) -> Optional[str]: """Detect potential cross-project move attempts and return guidance. - + Args: identifier: The note identifier being moved destination_path: The destination path current_project: The current active project - + Returns: Error message with guidance if cross-project move is detected, None otherwise """ @@ -31,36 +31,40 @@ async def _detect_cross_project_move_attempt( response = await call_get(client, "/projects/projects") project_list = ProjectList.model_validate(response.json()) project_names = [p.name.lower() for p in project_list.projects] - + # Check if destination path contains any project names dest_lower = destination_path.lower() path_parts = dest_lower.split("/") - + # Look for project names in the destination path for part in path_parts: if part in project_names and part != current_project.lower(): # Found a different project name in the path - matching_project = next(p.name for p in project_list.projects if p.name.lower() == part) + matching_project = next( + p.name for p in project_list.projects if p.name.lower() == part + ) return _format_cross_project_error_response( identifier, destination_path, current_project, matching_project ) - + # Check if the destination path looks like it might be trying to reference another project # (e.g., contains common project-like patterns) if any(keyword in dest_lower for keyword in ["project", "workspace", "repo"]): # This might be a cross-project attempt, but we can't be sure # Return a general guidance message - available_projects = [p.name for p in project_list.projects if p.name != current_project] + available_projects = [ + p.name for p in project_list.projects if p.name != current_project + ] if available_projects: return _format_potential_cross_project_guidance( identifier, destination_path, current_project, available_projects ) - + except Exception as e: # If we can't detect, don't interfere with normal error handling logger.debug(f"Could not check for cross-project move: {e}") return None - + return None @@ -116,7 +120,7 @@ def _format_potential_cross_project_guidance( other_projects = ", ".join(available_projects[:3]) # Show first 3 projects if len(available_projects) > 3: other_projects += f" (and {len(available_projects) - 3} others)" - + return dedent(f""" # Move Failed - Check Project Context diff --git a/src/basic_memory/services/entity_service.py b/src/basic_memory/services/entity_service.py index 3c0da241..ca2234df 100644 --- a/src/basic_memory/services/entity_service.py +++ b/src/basic_memory/services/entity_service.py @@ -694,7 +694,9 @@ class EntityService(BaseService[EntityModel]): updates["permalink"] = new_permalink if old_permalink is None: - logger.info(f"Generated permalink for entity with null permalink: {new_permalink}") + logger.info( + f"Generated permalink for entity with null permalink: {new_permalink}" + ) else: logger.info(f"Updated permalink: {old_permalink} -> {new_permalink}") diff --git a/test-int/mcp/test_move_note_integration.py b/test-int/mcp/test_move_note_integration.py index e906a7ff..8c0895e9 100644 --- a/test-int/mcp/test_move_note_integration.py +++ b/test-int/mcp/test_move_note_integration.py @@ -519,7 +519,7 @@ async def test_move_note_cross_project_detection(mcp_server, app): "create_memory_project", { "project_name": "test-project-b", - "project_path": "/tmp/test-project-b", + "project_path": "/tmp/test-project-b", "set_default": False, }, ) @@ -598,7 +598,7 @@ async def test_move_note_potential_cross_project_guidance(mcp_server, app): assert "switch_project" in error_message -@pytest.mark.asyncio +@pytest.mark.asyncio async def test_move_note_normal_moves_still_work(mcp_server, app): """Test that normal within-project moves still work after cross-project detection.""" diff --git a/tests/repository/test_entity_repository_upsert.py b/tests/repository/test_entity_repository_upsert.py index e66e1158..04b9375e 100644 --- a/tests/repository/test_entity_repository_upsert.py +++ b/tests/repository/test_entity_repository_upsert.py @@ -4,7 +4,6 @@ import pytest from datetime import datetime, timezone from basic_memory.models.knowledge import Entity -from basic_memory.models.project import Project from basic_memory.repository.entity_repository import EntityRepository from basic_memory.repository.project_repository import ProjectRepository @@ -246,14 +245,14 @@ async def test_upsert_entity_gap_in_suffixes(entity_repository: EntityRepository # Should get the next available suffix - our implementation finds gaps # so it should be "test/gap-2" (filling the gap) - assert result.permalink == "test/gap-2" + assert result.permalink == "test/gap-2" assert result.title == "Gap Filler" @pytest.mark.asyncio async def test_upsert_entity_project_scoping_isolation(session_maker): """Test that upsert_entity properly scopes entities by project_id. - + This test ensures that the fix for issue #167 works correctly by verifying: 1. Entities with same permalinks/file_paths can exist in different projects 2. Upsert operations properly scope queries by project_id @@ -261,7 +260,7 @@ async def test_upsert_entity_project_scoping_isolation(session_maker): """ # Create two separate projects project_repository = ProjectRepository(session_maker) - + project1_data = { "name": "project-1", "description": "First test project", @@ -270,20 +269,20 @@ async def test_upsert_entity_project_scoping_isolation(session_maker): "is_default": False, } project1 = await project_repository.create(project1_data) - + project2_data = { - "name": "project-2", + "name": "project-2", "description": "Second test project", "path": "/tmp/project2", "is_active": True, "is_default": False, } project2 = await project_repository.create(project2_data) - + # Create entity repositories for each project repo1 = EntityRepository(session_maker, project_id=project1.id) repo2 = EntityRepository(session_maker, project_id=project2.id) - + # Create entities with identical permalinks and file_paths in different projects entity1 = Entity( project_id=project1.id, @@ -295,22 +294,22 @@ async def test_upsert_entity_project_scoping_isolation(session_maker): created_at=datetime.now(timezone.utc), updated_at=datetime.now(timezone.utc), ) - + entity2 = Entity( project_id=project2.id, title="Shared Entity", - entity_type="note", + entity_type="note", permalink="docs/shared-name", # Same permalink file_path="docs/shared-name.md", # Same file_path content_type="text/markdown", created_at=datetime.now(timezone.utc), updated_at=datetime.now(timezone.utc), ) - + # These should succeed without "multiple rows" errors result1 = await repo1.upsert_entity(entity1) result2 = await repo2.upsert_entity(entity2) - + # Verify both entities were created successfully assert result1.id is not None assert result2.id is not None @@ -319,7 +318,7 @@ async def test_upsert_entity_project_scoping_isolation(session_maker): assert result2.project_id == project2.id assert result1.permalink == "docs/shared-name" assert result2.permalink == "docs/shared-name" - + # Test updating entities in different projects (should also work without conflicts) entity1_update = Entity( project_id=project1.id, @@ -331,7 +330,7 @@ async def test_upsert_entity_project_scoping_isolation(session_maker): created_at=datetime.now(timezone.utc), updated_at=datetime.now(timezone.utc), ) - + entity2_update = Entity( project_id=project2.id, title="Also Updated Shared Entity", @@ -342,21 +341,21 @@ async def test_upsert_entity_project_scoping_isolation(session_maker): created_at=datetime.now(timezone.utc), updated_at=datetime.now(timezone.utc), ) - + # Updates should work without conflicts updated1 = await repo1.upsert_entity(entity1_update) updated2 = await repo2.upsert_entity(entity2_update) - + # Should update existing entities (same IDs) assert updated1.id == result1.id assert updated2.id == result2.id assert updated1.title == "Updated Shared Entity" assert updated2.title == "Also Updated Shared Entity" - + # Verify cross-project queries don't interfere found_in_project1 = await repo1.get_by_permalink("docs/shared-name") found_in_project2 = await repo2.get_by_permalink("docs/shared-name") - + assert found_in_project1 is not None assert found_in_project2 is not None assert found_in_project1.id == updated1.id @@ -368,14 +367,14 @@ async def test_upsert_entity_project_scoping_isolation(session_maker): @pytest.mark.asyncio async def test_upsert_entity_permalink_conflict_within_project_only(session_maker): """Test that permalink conflicts only occur within the same project. - + This ensures that the project scoping fix allows entities with identical - permalinks to exist across different projects without triggering + permalinks to exist across different projects without triggering permalink conflict resolution. """ # Create two separate projects project_repository = ProjectRepository(session_maker) - + project1_data = { "name": "conflict-project-1", "description": "First conflict test project", @@ -384,20 +383,20 @@ async def test_upsert_entity_permalink_conflict_within_project_only(session_make "is_default": False, } project1 = await project_repository.create(project1_data) - + project2_data = { "name": "conflict-project-2", - "description": "Second conflict test project", + "description": "Second conflict test project", "path": "/tmp/conflict2", "is_active": True, "is_default": False, } project2 = await project_repository.create(project2_data) - + # Create entity repositories for each project repo1 = EntityRepository(session_maker, project_id=project1.id) repo2 = EntityRepository(session_maker, project_id=project2.id) - + # Create first entity in project1 entity1 = Entity( project_id=project1.id, @@ -409,10 +408,10 @@ async def test_upsert_entity_permalink_conflict_within_project_only(session_make created_at=datetime.now(timezone.utc), updated_at=datetime.now(timezone.utc), ) - + result1 = await repo1.upsert_entity(entity1) assert result1.permalink == "test/conflict-permalink" - + # Create entity with same permalink in project2 (should NOT get suffix) entity2 = Entity( project_id=project2.id, @@ -424,11 +423,11 @@ async def test_upsert_entity_permalink_conflict_within_project_only(session_make created_at=datetime.now(timezone.utc), updated_at=datetime.now(timezone.utc), ) - + result2 = await repo2.upsert_entity(entity2) # Should keep original permalink (no suffix) since it's in a different project assert result2.permalink == "test/conflict-permalink" - + # Now create entity with same permalink in project1 (should get suffix) entity3 = Entity( project_id=project1.id, @@ -440,11 +439,11 @@ async def test_upsert_entity_permalink_conflict_within_project_only(session_make created_at=datetime.now(timezone.utc), updated_at=datetime.now(timezone.utc), ) - + result3 = await repo1.upsert_entity(entity3) # Should get suffix since it conflicts within the same project assert result3.permalink == "test/conflict-permalink-1" - + # Verify all entities exist correctly assert result1.id != result2.id != result3.id assert result1.project_id == project1.id diff --git a/tests/services/test_entity_service.py b/tests/services/test_entity_service.py index fbad95fa..0b7707f3 100644 --- a/tests/services/test_entity_service.py +++ b/tests/services/test_entity_service.py @@ -1737,16 +1737,15 @@ async def test_move_entity_with_null_permalink_generates_permalink( entity_repository: EntityRepository, ): """Test that moving entity with null permalink generates a new permalink automatically. - - This tests the fix for issue #155 where entities with null permalinks from the database - migration would fail validation when being moved. The fix ensures that entities with - null permalinks get a generated permalink during move operations, regardless of the + + This tests the fix for issue #155 where entities with null permalinks from the database + migration would fail validation when being moved. The fix ensures that entities with + null permalinks get a generated permalink during move operations, regardless of the update_permalinks_on_move setting. """ # Create entity through direct database insertion to simulate migrated entity with null permalink - from basic_memory.models.knowledge import Entity as EntityModel from datetime import datetime, timezone - + # Create an entity with null permalink directly in database (simulating migrated data) entity_data = { "title": "Test Entity", @@ -1757,19 +1756,19 @@ async def test_move_entity_with_null_permalink_generates_permalink( "created_at": datetime.now(timezone.utc), "updated_at": datetime.now(timezone.utc), } - + # Create the entity directly in database created_entity = await entity_repository.create(entity_data) assert created_entity.permalink is None - + # Create the physical file file_path = project_config.home / created_entity.file_path file_path.parent.mkdir(parents=True, exist_ok=True) file_path.write_text("# Test Entity\n\nContent here.") - + # Configure move without permalink updates (the default setting that previously triggered the bug) app_config = BasicMemoryConfig(update_permalinks_on_move=False) - + # Move entity - this should now succeed and generate a permalink moved_entity = await entity_service.move_entity( identifier=created_entity.title, # Use title since permalink is None @@ -1777,18 +1776,19 @@ async def test_move_entity_with_null_permalink_generates_permalink( project_config=project_config, app_config=app_config, ) - + # Verify the move succeeded and a permalink was generated assert moved_entity is not None assert moved_entity.file_path == "moved/test-entity.md" assert moved_entity.permalink is not None assert moved_entity.permalink != "" - + # Verify the moved entity can be used to create an EntityResponse without validation errors from basic_memory.schemas.response import EntityResponse + response = EntityResponse.model_validate(moved_entity) assert response.permalink == moved_entity.permalink - + # Verify the physical file was moved old_path = project_config.home / "test/null-permalink-entity.md" new_path = project_config.home / "moved/test-entity.md" diff --git a/tests/test_config.py b/tests/test_config.py index c85c70e0..6c03fbc0 100644 --- a/tests/test_config.py +++ b/tests/test_config.py @@ -1,10 +1,5 @@ """Test configuration management.""" -import os -from pathlib import Path - -import pytest - from basic_memory.config import BasicMemoryConfig