ruff checks

Signed-off-by: Drew Cain <groksrc@gmail.com>
This commit is contained in:
Drew Cain
2025-08-01 21:50:18 -05:00
parent fb1350b294
commit 43fa5762a8
12 changed files with 212 additions and 202 deletions
@@ -51,7 +51,7 @@ async def update_project(
# Validate that path is absolute if provided
if path and not os.path.isabs(path):
raise HTTPException(status_code=400, detail="Path must be absolute")
# Get original project info for the response
old_project_info = ProjectItem(
name=name,
+19 -15
View File
@@ -157,29 +157,33 @@ def move_project(
"""Move a project to a new location."""
# Resolve to absolute path
resolved_path = os.path.abspath(os.path.expanduser(new_path))
try:
data = {"path": resolved_path}
project_name = generate_permalink(name)
current_project = session.get_current_project()
response = asyncio.run(call_patch(client, f"/{current_project}/project/{project_name}", json=data))
response = asyncio.run(
call_patch(client, f"/{current_project}/project/{project_name}", json=data)
)
result = ProjectStatusResponse.model_validate(response.json())
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
))
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)
@@ -209,24 +209,24 @@ class ChatGPTImporter(Importer[ChatImportResult]):
# Use iterative approach with stack to avoid recursion depth issues
stack = [root_id]
while stack:
node_id = stack.pop()
if not node_id:
continue
node = mapping.get(node_id)
if not node:
continue
# Process current node if it has a message and hasn't been seen
if node["id"] not in seen and node.get("message"):
seen.add(node["id"])
messages.append(node["message"])
# Add children to stack in reverse order to maintain conversation flow
children = node.get("children", [])
for child_id in reversed(children):
stack.append(child_id)
return messages
@@ -86,11 +86,11 @@ class ProjectRepository(Repository[Project]):
async def update_path(self, project_id: int, new_path: str) -> Optional[Project]:
"""Update project path.
Args:
project_id: ID of the project to update
new_path: New filesystem path for the project
Returns:
The updated project if found, None otherwise
"""
+11 -11
View File
@@ -47,35 +47,35 @@ class EntityService(BaseService[EntityModel]):
async def detect_file_path_conflicts(self, file_path: str) -> List[Entity]:
"""Detect potential file path conflicts for a given file path.
This checks for entities with similar file paths that might cause conflicts:
- Case sensitivity differences (Finance/file.md vs finance/file.md)
- Character encoding differences
- Hyphen vs space differences
- Unicode normalization differences
Args:
file_path: The file path to check for conflicts
Returns:
List of entities that might conflict with the given file path
"""
from basic_memory.utils import detect_potential_file_conflicts
conflicts = []
# Get all existing file paths
all_entities = await self.repository.find_all()
existing_paths = [entity.file_path for entity in all_entities]
# Use the enhanced conflict detection utility
conflicting_paths = detect_potential_file_conflicts(file_path, existing_paths)
# Find the entities corresponding to conflicting paths
for entity in all_entities:
if entity.file_path in conflicting_paths:
conflicts.append(entity)
return conflicts
async def resolve_permalink(
@@ -88,11 +88,11 @@ class EntityService(BaseService[EntityModel]):
2. If markdown has permalink but it's used by another file -> make unique
3. For existing files, keep current permalink from db
4. Generate new unique permalink from file path
Enhanced to detect and handle character-related conflicts.
"""
file_path_str = str(file_path)
# Check for potential file path conflicts before resolving permalink
conflicts = await self.detect_file_path_conflicts(file_path_str)
if conflicts:
@@ -100,7 +100,7 @@ class EntityService(BaseService[EntityModel]):
f"Detected potential file path conflicts for '{file_path_str}': "
f"{[entity.file_path for entity in conflicts]}"
)
# If markdown has explicit permalink, try to validate it
if markdown and markdown.frontmatter.permalink:
desired_permalink = markdown.frontmatter.permalink
+7 -7
View File
@@ -311,33 +311,33 @@ class ProjectService:
async def move_project(self, name: str, new_path: str) -> None:
"""Move a project to a new location.
Args:
name: The name of the project to move
new_path: The new absolute path for the project
Raises:
ValueError: If the project doesn't exist or repository isn't initialized
"""
if not self.repository:
raise ValueError("Repository is required for move_project")
# Resolve to absolute path
resolved_path = os.path.abspath(os.path.expanduser(new_path))
# Validate project exists in config
if name not in self.config_manager.projects:
raise ValueError(f"Project '{name}' not found in configuration")
# Create the new directory if it doesn't exist
Path(resolved_path).mkdir(parents=True, exist_ok=True)
# Update in configuration
config = self.config_manager.load_config()
old_path = config.projects[name]
config.projects[name] = resolved_path
self.config_manager.save_config(config)
# Update in database
project = await self.repository.get_by_name(name)
if project:
+1 -1
View File
@@ -462,7 +462,7 @@ class SyncService:
f"entity_id={entity.id} trying to move from '{old_path}' to '{new_path}', "
f"but entity_id={existing_at_destination.id} already occupies '{new_path}'"
)
# Check if this is a file swap (the destination entity is being moved to our old path)
# This would indicate a simultaneous move operation
old_path_after_swap = await self.entity_repository.get_by_file_path(old_path)
+19 -19
View File
@@ -183,74 +183,74 @@ def setup_logging(
def normalize_file_path_for_comparison(file_path: str) -> str:
"""Normalize a file path for conflict detection.
This function normalizes file paths to help detect potential conflicts:
- Converts to lowercase for case-insensitive comparison
- Normalizes Unicode characters
- Handles path separators consistently
Args:
file_path: The file path to normalize
Returns:
Normalized file path for comparison purposes
"""
import unicodedata
# Convert to lowercase for case-insensitive comparison
normalized = file_path.lower()
# Normalize Unicode characters (NFD normalization)
normalized = unicodedata.normalize('NFD', normalized)
normalized = unicodedata.normalize("NFD", normalized)
# Replace path separators with forward slashes
normalized = normalized.replace('\\', '/')
normalized = normalized.replace("\\", "/")
# Remove multiple slashes
normalized = re.sub(r'/+', '/', normalized)
normalized = re.sub(r"/+", "/", normalized)
return normalized
def detect_potential_file_conflicts(file_path: str, existing_paths: List[str]) -> List[str]:
"""Detect potential conflicts between a file path and existing paths.
This function checks for various types of conflicts:
- Case sensitivity differences
- Unicode normalization differences
- Path separator differences
- Permalink generation conflicts
Args:
file_path: The file path to check
existing_paths: List of existing file paths to check against
Returns:
List of existing paths that might conflict with the given file path
"""
conflicts = []
# Normalize the input file path
normalized_input = normalize_file_path_for_comparison(file_path)
input_permalink = generate_permalink(file_path)
for existing_path in existing_paths:
# Skip identical paths
if existing_path == file_path:
continue
# Check for case-insensitive path conflicts
normalized_existing = normalize_file_path_for_comparison(existing_path)
if normalized_input == normalized_existing:
conflicts.append(existing_path)
continue
# Check for permalink conflicts
existing_permalink = generate_permalink(existing_path)
if input_permalink == existing_permalink:
conflicts.append(existing_path)
continue
return conflicts
+52 -52
View File
@@ -162,51 +162,52 @@ async def test_set_default_project_endpoint(test_config, client, project_service
@pytest.mark.asyncio
async def test_update_project_path_endpoint(test_config, client, project_service, project_url, tmp_path):
async def test_update_project_path_endpoint(
test_config, client, project_service, project_url, tmp_path
):
"""Test the update project endpoint for changing project path."""
# Create a test project to update
test_project_name = "test-update-project"
old_path = str(tmp_path / "old-location")
new_path = str(tmp_path / "new-location")
await project_service.add_project(test_project_name, old_path)
try:
# Verify initial state
project = await project_service.get_project(test_project_name)
assert project is not None
assert project.path == old_path
# Update the project path
response = await client.patch(
f"{project_url}/project/{test_project_name}",
json={"path": new_path}
f"{project_url}/project/{test_project_name}", json={"path": new_path}
)
# Verify response
assert response.status_code == 200
data = response.json()
# Check response structure
assert "message" in data
assert "status" in data
assert data["status"] == "success"
assert "old_project" in data
assert "new_project" in data
# Check old project data
assert data["old_project"]["name"] == test_project_name
assert data["old_project"]["path"] == old_path
# Check new project data
assert data["new_project"]["name"] == test_project_name
assert data["new_project"]["path"] == new_path
# Verify project was actually updated in database
updated_project = await project_service.get_project(test_project_name)
assert updated_project is not None
assert updated_project.path == new_path
finally:
# Clean up
try:
@@ -221,26 +222,25 @@ async def test_update_project_is_active_endpoint(test_config, client, project_se
# Create a test project to update
test_project_name = "test-update-active-project"
test_path = "/tmp/test-update-active"
await project_service.add_project(test_project_name, test_path)
try:
# Update the project is_active status
response = await client.patch(
f"{project_url}/project/{test_project_name}",
json={"is_active": False}
f"{project_url}/project/{test_project_name}", json={"is_active": False}
)
# Verify response
assert response.status_code == 200
data = response.json()
# Check response structure
assert "message" in data
assert "status" in data
assert data["status"] == "success"
assert f"Project '{test_project_name}' updated successfully" == data["message"]
finally:
# Clean up
try:
@@ -250,34 +250,36 @@ async def test_update_project_is_active_endpoint(test_config, client, project_se
@pytest.mark.asyncio
async def test_update_project_both_params_endpoint(test_config, client, project_service, project_url, tmp_path):
async def test_update_project_both_params_endpoint(
test_config, client, project_service, project_url, tmp_path
):
"""Test the update project endpoint with both path and is_active parameters."""
# Create a test project to update
test_project_name = "test-update-both-project"
old_path = str(tmp_path / "old-location")
new_path = str(tmp_path / "new-location")
await project_service.add_project(test_project_name, old_path)
try:
# Update both path and is_active (path should take precedence)
response = await client.patch(
f"{project_url}/project/{test_project_name}",
json={"path": new_path, "is_active": False}
json={"path": new_path, "is_active": False},
)
# Verify response
assert response.status_code == 200
data = response.json()
# Check that path update was performed (takes precedence)
assert data["new_project"]["path"] == new_path
# Verify project was actually updated in database
updated_project = await project_service.get_project(test_project_name)
assert updated_project is not None
assert updated_project.path == new_path
finally:
# Clean up
try:
@@ -291,10 +293,9 @@ async def test_update_project_nonexistent_endpoint(client, project_url):
"""Test the update project endpoint with a nonexistent project."""
# Try to update a project that doesn't exist
response = await client.patch(
f"{project_url}/project/nonexistent-project",
json={"path": "/tmp/new-path"}
f"{project_url}/project/nonexistent-project", json={"path": "/tmp/new-path"}
)
# Should return 400 error
assert response.status_code == 400
data = response.json()
@@ -303,27 +304,28 @@ async def test_update_project_nonexistent_endpoint(client, project_url):
@pytest.mark.asyncio
async def test_update_project_relative_path_error_endpoint(test_config, client, project_service, project_url):
async def test_update_project_relative_path_error_endpoint(
test_config, client, project_service, project_url
):
"""Test the update project endpoint with relative path (should fail)."""
# Create a test project to update
test_project_name = "test-update-relative-project"
test_path = "/tmp/test-update-relative"
await project_service.add_project(test_project_name, test_path)
try:
# Try to update with relative path
response = await client.patch(
f"{project_url}/project/{test_project_name}",
json={"path": "./relative-path"}
f"{project_url}/project/{test_project_name}", json={"path": "./relative-path"}
)
# Should return 400 error
assert response.status_code == 400
data = response.json()
assert "detail" in data
assert "Path must be absolute" in data["detail"]
finally:
# Clean up
try:
@@ -338,25 +340,22 @@ async def test_update_project_no_params_endpoint(test_config, client, project_se
# Create a test project to update
test_project_name = "test-update-no-params-project"
test_path = "/tmp/test-update-no-params"
await project_service.add_project(test_project_name, test_path)
proj_info = await project_service.get_project(test_project_name)
assert proj_info.name == test_project_name
assert proj_info.path == test_path
try:
# Try to update with no parameters
response = await client.patch(
f"{project_url}/project/{test_project_name}",
json={}
)
response = await client.patch(f"{project_url}/project/{test_project_name}", json={})
# Should return 200 (no-op)
assert response.status_code == 200
proj_info = await project_service.get_project(test_project_name)
assert proj_info.name == test_project_name
assert proj_info.path == test_path
finally:
# Clean up
try:
@@ -366,26 +365,27 @@ async def test_update_project_no_params_endpoint(test_config, client, project_se
@pytest.mark.asyncio
async def test_update_project_empty_path_endpoint(test_config, client, project_service, project_url):
async def test_update_project_empty_path_endpoint(
test_config, client, project_service, project_url
):
"""Test the update project endpoint with empty path parameter."""
# Create a test project to update
test_project_name = "test-update-empty-path-project"
test_path = "/tmp/test-update-empty-path"
await project_service.add_project(test_project_name, test_path)
try:
# Try to update with empty/null path - should be treated as no path update
response = await client.patch(
f"{project_url}/project/{test_project_name}",
json={"path": None, "is_active": True}
f"{project_url}/project/{test_project_name}", json={"path": None, "is_active": True}
)
# Should succeed and perform is_active update
assert response.status_code == 200
data = response.json()
assert data["status"] == "success"
finally:
# Clean up
try:
+3 -3
View File
@@ -273,16 +273,16 @@ async def test_delete_nonexistent_project(project_repository: ProjectRepository)
async def test_update_path(project_repository: ProjectRepository, sample_project: Project):
"""Test updating a project's path."""
new_path = "/new/project/path"
# Update the project path
updated_project = await project_repository.update_path(sample_project.id, new_path)
# Verify returned object
assert updated_project is not None
assert updated_project.id == sample_project.id
assert updated_project.path == new_path
assert updated_project.name == sample_project.name # Other fields unchanged
# Verify in database
found = await project_repository.find_by_id(sample_project.id)
assert found is not None
+24 -26
View File
@@ -541,36 +541,36 @@ async def test_move_project(project_service: ProjectService, tmp_path):
test_project_name = f"test-move-project-{os.urandom(4).hex()}"
old_path = str(tmp_path / "old-location")
new_path = str(tmp_path / "new-location")
# Create old directory
os.makedirs(old_path, exist_ok=True)
try:
# Add project with initial path
await project_service.add_project(test_project_name, old_path)
# Verify initial state
assert test_project_name in project_service.projects
assert project_service.projects[test_project_name] == old_path
project = await project_service.repository.get_by_name(test_project_name)
assert project is not None
assert project.path == old_path
# Move project to new location
await project_service.move_project(test_project_name, new_path)
# Verify config was updated
assert project_service.projects[test_project_name] == new_path
# Verify database was updated
updated_project = await project_service.repository.get_by_name(test_project_name)
assert updated_project is not None
assert updated_project.path == new_path
# Verify new directory was created
assert os.path.exists(new_path)
finally:
# Clean up
if test_project_name in project_service.projects:
@@ -581,7 +581,7 @@ async def test_move_project(project_service: ProjectService, tmp_path):
async def test_move_project_nonexistent(project_service: ProjectService, tmp_path):
"""Test moving a project that doesn't exist."""
new_path = str(tmp_path / "new-location")
with pytest.raises(ValueError, match="not found in configuration"):
await project_service.move_project("nonexistent-project", new_path)
@@ -592,28 +592,28 @@ async def test_move_project_db_mismatch(project_service: ProjectService, tmp_pat
test_project_name = f"test-move-mismatch-{os.urandom(4).hex()}"
old_path = str(tmp_path / "old-location")
new_path = str(tmp_path / "new-location")
# Create directories
os.makedirs(old_path, exist_ok=True)
config_manager = project_service.config_manager
try:
# Add project to config only (not to database)
config_manager.add_project(test_project_name, old_path)
# Verify it's in config but not in database
assert test_project_name in project_service.projects
db_project = await project_service.repository.get_by_name(test_project_name)
assert db_project is None
# Try to move project - should fail and restore config
with pytest.raises(ValueError, match="not found in database"):
await project_service.move_project(test_project_name, new_path)
# Verify config was restored to original path
assert project_service.projects[test_project_name] == old_path
finally:
# Clean up
if test_project_name in project_service.projects:
@@ -625,28 +625,28 @@ async def test_move_project_expands_path(project_service: ProjectService, tmp_pa
"""Test that move_project expands ~ and relative paths."""
test_project_name = f"test-move-expand-{os.urandom(4).hex()}"
old_path = str(tmp_path / "old-location")
# Create old directory
os.makedirs(old_path, exist_ok=True)
try:
# Add project with initial path
await project_service.add_project(test_project_name, old_path)
# Use a relative path for the move
relative_new_path = "./new-location"
expected_absolute_path = os.path.abspath(relative_new_path)
# Move project using relative path
await project_service.move_project(test_project_name, relative_new_path)
# Verify the path was expanded to absolute
assert project_service.projects[test_project_name] == expected_absolute_path
updated_project = await project_service.repository.get_by_name(test_project_name)
assert updated_project is not None
assert updated_project.path == expected_absolute_path
finally:
# Clean up
if test_project_name in project_service.projects:
@@ -713,5 +713,3 @@ async def test_synchronize_projects_handles_case_sensitivity_bug(
db_project = await project_service.repository.get_by_name(name)
if db_project:
await project_service.repository.delete(db_project.id)
+68 -60
View File
@@ -1,6 +1,5 @@
"""Test character-related sync conflicts and permalink generation."""
import asyncio
from pathlib import Path
from textwrap import dedent
@@ -8,14 +7,12 @@ import pytest
from sqlalchemy.exc import IntegrityError
from basic_memory.config import ProjectConfig
from basic_memory.models import Entity
from basic_memory.repository import EntityRepository
from basic_memory.services import EntityService
from basic_memory.sync.sync_service import SyncService
from basic_memory.utils import (
generate_permalink,
generate_permalink,
normalize_file_path_for_comparison,
detect_potential_file_conflicts
detect_potential_file_conflicts,
)
@@ -31,29 +28,37 @@ class TestUtilityFunctions:
def test_normalize_file_path_for_comparison(self):
"""Test file path normalization for conflict detection."""
# Case sensitivity normalization
assert normalize_file_path_for_comparison("Finance/Investment.md") == "finance/investment.md"
assert normalize_file_path_for_comparison("FINANCE/INVESTMENT.MD") == "finance/investment.md"
assert (
normalize_file_path_for_comparison("Finance/Investment.md") == "finance/investment.md"
)
assert (
normalize_file_path_for_comparison("FINANCE/INVESTMENT.MD") == "finance/investment.md"
)
# Path separator normalization
assert normalize_file_path_for_comparison("Finance\\Investment.md") == "finance/investment.md"
assert (
normalize_file_path_for_comparison("Finance\\Investment.md") == "finance/investment.md"
)
# Multiple slash handling
assert normalize_file_path_for_comparison("Finance//Investment.md") == "finance/investment.md"
assert (
normalize_file_path_for_comparison("Finance//Investment.md") == "finance/investment.md"
)
def test_detect_potential_file_conflicts(self):
"""Test the enhanced conflict detection function."""
existing_paths = [
"Finance/Investment.md",
"finance/Investment.md",
"finance/Investment.md",
"docs/my-feature.md",
"docs/my feature.md"
"docs/my feature.md",
]
# Case sensitivity conflict
conflicts = detect_potential_file_conflicts("FINANCE/INVESTMENT.md", existing_paths)
assert "Finance/Investment.md" in conflicts
assert "finance/Investment.md" in conflicts
# Permalink conflict (space vs hyphen)
conflicts = detect_potential_file_conflicts("docs/my_feature.md", existing_paths)
assert "docs/my-feature.md" in conflicts
@@ -68,10 +73,10 @@ class TestPermalinkGeneration:
# File with existing hyphens
assert generate_permalink("docs/my-feature.md") == "docs/my-feature"
assert generate_permalink("docs/basic-memory bug.md") == "docs/basic-memory-bug"
# File with spaces that become hyphens
assert generate_permalink("docs/my feature.md") == "docs/my-feature"
# Mixed scenarios
assert generate_permalink("docs/my-old feature.md") == "docs/my-old-feature"
@@ -79,7 +84,7 @@ class TestPermalinkGeneration:
"""Test that forward slashes are handled properly."""
# Normal directory structure
assert generate_permalink("Finance/Investment.md") == "finance/investment"
# Path with spaces in directory names
assert generate_permalink("My Finance/Investment.md") == "my-finance/investment"
@@ -93,11 +98,14 @@ class TestPermalinkGeneration:
def test_unicode_character_handling(self):
"""Test that international characters are handled properly."""
# Italian characters as mentioned in user feedback
assert generate_permalink("Finance/Punti Chiave di Peter Lynch.md") == "finance/punti-chiave-di-peter-lynch"
assert (
generate_permalink("Finance/Punti Chiave di Peter Lynch.md")
== "finance/punti-chiave-di-peter-lynch"
)
# Chinese characters (should be preserved)
assert generate_permalink("中文/测试文档.md") == "中文/测试文档"
# Mixed international characters
assert generate_permalink("docs/Café München.md") == "docs/cafe-munchen"
@@ -105,7 +113,7 @@ class TestPermalinkGeneration:
"""Test handling of special punctuation characters."""
# Apostrophes should be removed
assert generate_permalink("Peter's Guide.md") == "peters-guide"
# Other punctuation should become hyphens
assert generate_permalink("Q&A Session.md") == "q-a-session"
@@ -122,7 +130,7 @@ class TestSyncConflictHandling:
):
"""Test that file path conflicts are detected during move operations."""
project_dir = project_config.home
# Create two files
content1 = dedent("""
---
@@ -131,7 +139,7 @@ class TestSyncConflictHandling:
# Document One
This is the first document.
""")
content2 = dedent("""
---
type: knowledge
@@ -139,44 +147,44 @@ class TestSyncConflictHandling:
# Document Two
This is the second document.
""")
await create_test_file(project_dir / "doc1.md", content1)
await create_test_file(project_dir / "doc2.md", content2)
# Initial sync
await sync_service.sync(project_config.home)
# Verify both entities exist
entities = await entity_repository.find_all()
assert len(entities) == 2
# Now simulate a move where doc1.md tries to move to doc2.md's location
# This should be handled gracefully, not throw an IntegrityError
# First, get the entities
entity1 = await entity_repository.get_by_file_path("doc1.md")
entity2 = await entity_repository.get_by_file_path("doc2.md")
assert entity1 is not None
assert entity2 is not None
# Simulate the conflict scenario
with pytest.raises(Exception) as exc_info:
# This should detect the conflict and handle it gracefully
await sync_service.handle_move("doc1.md", "doc2.md")
# The exception should be a meaningful error, not an IntegrityError
assert not isinstance(exc_info.value, IntegrityError)
async def test_hyphen_filename_conflict(
self,
sync_service: SyncService,
sync_service: SyncService,
project_config: ProjectConfig,
entity_repository: EntityRepository,
):
"""Test conflict when filename with hyphens conflicts with generated permalink."""
project_dir = project_config.home
# Create file with spaces (will generate permalink with hyphens)
content1 = dedent("""
---
@@ -185,7 +193,7 @@ class TestSyncConflictHandling:
# Basic Memory Bug
This file has spaces in the name.
""")
# Create file with hyphens (already has hyphens in filename)
content2 = dedent("""
---
@@ -194,17 +202,17 @@ class TestSyncConflictHandling:
# Basic Memory Bug Report
This file has hyphens in the name.
""")
await create_test_file(project_dir / "basic memory bug.md", content1)
await create_test_file(project_dir / "basic-memory-bug.md", content2)
# Sync should handle this without conflict
await sync_service.sync(project_config.home)
# Verify both entities were created with unique permalinks
entities = await entity_repository.find_all()
assert len(entities) == 2
# Check that permalinks are unique
permalinks = [entity.permalink for entity in entities if entity.permalink]
assert len(set(permalinks)) == len(permalinks), "Permalinks should be unique"
@@ -212,16 +220,16 @@ class TestSyncConflictHandling:
async def test_case_sensitivity_conflict(
self,
sync_service: SyncService,
project_config: ProjectConfig,
project_config: ProjectConfig,
entity_repository: EntityRepository,
):
"""Test conflict handling when case differences cause issues."""
project_dir = project_config.home
# Create directory structure that might cause case conflicts
(project_dir / "Finance").mkdir(parents=True, exist_ok=True)
(project_dir / "finance").mkdir(parents=True, exist_ok=True)
content1 = dedent("""
---
type: knowledge
@@ -229,7 +237,7 @@ class TestSyncConflictHandling:
# Investment Guide
Upper case directory.
""")
content2 = dedent("""
---
type: knowledge
@@ -237,17 +245,17 @@ class TestSyncConflictHandling:
# Investment Tips
Lower case directory.
""")
await create_test_file(project_dir / "Finance" / "investment.md", content1)
await create_test_file(project_dir / "finance" / "investment.md", content2)
# Sync should handle case differences properly
await sync_service.sync(project_config.home)
# Verify entities were created
entities = await entity_repository.find_all()
assert len(entities) >= 2 # Allow for potential other test files
# Check that file paths are preserved correctly
file_paths = [entity.file_path for entity in entities]
assert "Finance/investment.md" in file_paths
@@ -261,44 +269,44 @@ class TestSyncConflictHandling:
):
"""Test that move conflicts are resolved with proper error handling."""
project_dir = project_config.home
# Create three files in a scenario that could cause move conflicts
await create_test_file(project_dir / "file-a.md", "# File A")
await create_test_file(project_dir / "file-b.md", "# File B")
await create_test_file(project_dir / "temp.md", "# Temp File")
# Initial sync
await sync_service.sync(project_config.home)
# Simulate a complex move scenario where files swap locations
# This is the kind of scenario that caused the original bug
# Get the entities
entity_a = await entity_repository.get_by_file_path("file-a.md")
entity_b = await entity_repository.get_by_file_path("file-b.md")
entity_b = await entity_repository.get_by_file_path("file-b.md")
entity_temp = await entity_repository.get_by_file_path("temp.md")
assert all([entity_a, entity_b, entity_temp])
# Try to move file-a to file-b's location (should detect conflict)
try:
await sync_service.handle_move("file-a.md", "file-b.md")
# If this doesn't raise an exception, the conflict was resolved
# Verify the state is consistent
updated_entities = await entity_repository.find_all()
file_paths = [entity.file_path for entity in updated_entities]
# Should not have duplicate file paths
assert len(file_paths) == len(set(file_paths)), "File paths should be unique"
except Exception as e:
# If an exception is raised, it should be a meaningful error
assert "conflict" in str(e).lower() or "already exists" in str(e).lower()
assert not isinstance(e, IntegrityError), "Should not be a raw IntegrityError"
@pytest.mark.asyncio
@pytest.mark.asyncio
class TestEnhancedErrorMessages:
"""Test that error messages provide helpful guidance for character conflicts."""
@@ -316,6 +324,6 @@ class TestEnhancedErrorMessages:
sync_service: SyncService,
project_config: ProjectConfig,
):
"""Test that case sensitivity conflicts generate helpful error messages."""
"""Test that case sensitivity conflicts generate helpful error messages."""
# This test will be implemented after we enhance the error handling
pass
pass