diff --git a/src/basic_memory/cli/commands/project.py b/src/basic_memory/cli/commands/project.py index 7f754283a..aff279e89 100644 --- a/src/basic_memory/cli/commands/project.py +++ b/src/basic_memory/cli/commands/project.py @@ -74,7 +74,7 @@ def add_project( ) -> None: """Add a new project.""" # Resolve to absolute path - resolved_path = os.path.abspath(os.path.expanduser(path)) + resolved_path = Path(os.path.abspath(os.path.expanduser(path))).as_posix() try: data = {"name": name, "path": resolved_path, "set_default": set_default} @@ -156,7 +156,7 @@ def move_project( ) -> None: """Move a project to a new location.""" # Resolve to absolute path - resolved_path = os.path.abspath(os.path.expanduser(new_path)) + resolved_path = Path(os.path.abspath(os.path.expanduser(new_path))).as_posix() try: data = {"path": resolved_path} diff --git a/src/basic_memory/config.py b/src/basic_memory/config.py index 4f7231341..08d471f73 100644 --- a/src/basic_memory/config.py +++ b/src/basic_memory/config.py @@ -46,7 +46,7 @@ class BasicMemoryConfig(BaseSettings): projects: Dict[str, str] = Field( default_factory=lambda: { - "main": str(Path(os.getenv("BASIC_MEMORY_HOME", Path.home() / "basic-memory"))) + "main": Path(os.getenv("BASIC_MEMORY_HOME", Path.home() / "basic-memory")).as_posix() }, description="Mapping of project names to their filesystem paths", ) @@ -215,7 +215,7 @@ def add_project(self, name: str, path: str) -> ProjectConfig: # Load config, modify it, and save it config = self.load_config() - config.projects[name] = str(project_path) + config.projects[name] = project_path.as_posix() self.save_config(config) return ProjectConfig(name=name, home=project_path) diff --git a/src/basic_memory/markdown/utils.py b/src/basic_memory/markdown/utils.py index 8593e7d5d..89f820514 100644 --- a/src/basic_memory/markdown/utils.py +++ b/src/basic_memory/markdown/utils.py @@ -41,7 +41,7 @@ def entity_model_from_markdown( # Only update permalink if it exists in frontmatter, otherwise preserve existing if markdown.frontmatter.permalink is not None: model.permalink = markdown.frontmatter.permalink - model.file_path = str(file_path) + model.file_path = file_path.as_posix() model.content_type = "text/markdown" model.created_at = markdown.created model.updated_at = markdown.modified diff --git a/src/basic_memory/repository/entity_repository.py b/src/basic_memory/repository/entity_repository.py index 1159e2808..efc229ecd 100644 --- a/src/basic_memory/repository/entity_repository.py +++ b/src/basic_memory/repository/entity_repository.py @@ -57,7 +57,7 @@ async def get_by_file_path(self, file_path: Union[Path, str]) -> Optional[Entity """ query = ( self.select() - .where(Entity.file_path == str(file_path)) + .where(Entity.file_path == Path(file_path).as_posix()) .options(*self.get_load_options()) ) return await self.find_one(query) @@ -68,7 +68,7 @@ async def delete_by_file_path(self, file_path: Union[Path, str]) -> bool: Args: file_path: Path to the entity file (will be converted to string internally) """ - return await self.delete_by_fields(file_path=str(file_path)) + return await self.delete_by_fields(file_path=Path(file_path).as_posix()) def get_load_options(self) -> List[LoaderOption]: """Get SQLAlchemy loader options for eager loading relationships.""" diff --git a/src/basic_memory/repository/project_repository.py b/src/basic_memory/repository/project_repository.py index 5e05742c0..81c57ec30 100644 --- a/src/basic_memory/repository/project_repository.py +++ b/src/basic_memory/repository/project_repository.py @@ -46,7 +46,7 @@ async def get_by_path(self, path: Union[Path, str]) -> Optional[Project]: Args: path: Path to the project directory (will be converted to string internally) """ - query = self.select().where(Project.path == str(path)) + query = self.select().where(Project.path == Path(path).as_posix()) return await self.find_one(query) async def get_default_project(self) -> Optional[Project]: diff --git a/src/basic_memory/repository/search_repository.py b/src/basic_memory/repository/search_repository.py index 9c93cc6c4..e79e3ce26 100644 --- a/src/basic_memory/repository/search_repository.py +++ b/src/basic_memory/repository/search_repository.py @@ -6,6 +6,7 @@ from dataclasses import dataclass from datetime import datetime from typing import Any, Dict, List, Optional +from pathlib import Path from loguru import logger from sqlalchemy import Executable, Result, text @@ -59,8 +60,11 @@ def directory(self) -> str: if not self.type == SearchItemType.ENTITY.value and not self.file_path: return "" + # Normalize path separators to handle both Windows (\) and Unix (/) paths + normalized_path = Path(self.file_path).as_posix() + # Split the path by slashes - parts = self.file_path.split("/") + parts = normalized_path.split("/") # If there's only one part (e.g., "README.md"), it's at the root if len(parts) <= 1: diff --git a/src/basic_memory/services/entity_service.py b/src/basic_memory/services/entity_service.py index c33a8abd4..7ded793ac 100644 --- a/src/basic_memory/services/entity_service.py +++ b/src/basic_memory/services/entity_service.py @@ -91,7 +91,7 @@ async def resolve_permalink( Enhanced to detect and handle character-related conflicts. """ - file_path_str = str(file_path) + file_path_str = Path(file_path).as_posix() # Check for potential file path conflicts before resolving permalink conflicts = await self.detect_file_path_conflicts(file_path_str) @@ -119,7 +119,7 @@ async def resolve_permalink( if markdown and markdown.frontmatter.permalink: desired_permalink = markdown.frontmatter.permalink else: - desired_permalink = generate_permalink(file_path) + desired_permalink = generate_permalink(file_path_str) # Make unique if needed - enhanced to handle character conflicts permalink = desired_permalink @@ -283,7 +283,7 @@ async def update_entity(self, entity: EntityModel, schema: EntitySchema) -> Enti entity = await self.update_entity_and_observations(file_path, entity_markdown) # add relations - await self.update_entity_relations(str(file_path), entity_markdown) + await self.update_entity_relations(file_path.as_posix(), entity_markdown) # Set final checksum to match file entity = await self.repository.update(entity.id, {"checksum": checksum}) @@ -374,7 +374,7 @@ async def update_entity_and_observations( """ logger.debug(f"Updating entity and observations: {file_path}") - db_entity = await self.repository.get_by_file_path(str(file_path)) + db_entity = await self.repository.get_by_file_path(file_path.as_posix()) # Clear observations for entity await self.observation_repository.delete_by_fields(entity_id=db_entity.id) @@ -498,7 +498,7 @@ async def edit_entity( # Update entity and its relationships entity = await self.update_entity_and_observations(file_path, entity_markdown) - await self.update_entity_relations(str(file_path), entity_markdown) + await self.update_entity_relations(file_path.as_posix(), entity_markdown) # Set final checksum to match file entity = await self.repository.update(entity.id, {"checksum": checksum}) diff --git a/src/basic_memory/services/project_service.py b/src/basic_memory/services/project_service.py index 3ebe1ea2a..bd9e4621c 100644 --- a/src/basic_memory/services/project_service.py +++ b/src/basic_memory/services/project_service.py @@ -100,7 +100,7 @@ async def add_project(self, name: str, path: str, set_default: bool = False) -> raise ValueError("Repository is required for add_project") # Resolve to absolute path - resolved_path = os.path.abspath(os.path.expanduser(path)) + resolved_path = Path(os.path.abspath(os.path.expanduser(path))).as_posix() # First add to config file (this will validate the project doesn't exist) project_config = self.config_manager.add_project(name, resolved_path) @@ -323,7 +323,7 @@ async def move_project(self, name: str, new_path: str) -> None: raise ValueError("Repository is required for move_project") # Resolve to absolute path - resolved_path = os.path.abspath(os.path.expanduser(new_path)) + resolved_path = Path(os.path.abspath(os.path.expanduser(new_path))).as_posix() # Validate project exists in config if name not in self.config_manager.projects: @@ -378,7 +378,7 @@ async def update_project( # pragma: no cover # Update path if provided if updated_path: - resolved_path = os.path.abspath(os.path.expanduser(updated_path)) + resolved_path = Path(os.path.abspath(os.path.expanduser(updated_path))).as_posix() # Update in config config = self.config_manager.load_config() diff --git a/src/basic_memory/sync/sync_service.py b/src/basic_memory/sync/sync_service.py index a6568671e..a21f6c810 100644 --- a/src/basic_memory/sync/sync_service.py +++ b/src/basic_memory/sync/sync_service.py @@ -619,7 +619,7 @@ async def scan_directory(self, directory: Path) -> ScanResult: continue path = Path(root) / filename - rel_path = str(path.relative_to(directory)) + rel_path = path.relative_to(directory).as_posix() checksum = await self.file_service.compute_checksum(rel_path) result.files[rel_path] = checksum result.checksums[checksum] = rel_path diff --git a/src/basic_memory/sync/watch_service.py b/src/basic_memory/sync/watch_service.py index 022ef3ac3..a92c6a1fb 100644 --- a/src/basic_memory/sync/watch_service.py +++ b/src/basic_memory/sync/watch_service.py @@ -197,7 +197,7 @@ async def handle_changes(self, project: Project, changes: Set[FileChange]) -> No for change, path in changes: # convert to relative path - relative_path = str(Path(path).relative_to(directory)) + relative_path = Path(path).relative_to(directory).as_posix() # Skip .tmp files - they're temporary and shouldn't be synced if relative_path.endswith(".tmp"): diff --git a/src/basic_memory/utils.py b/src/basic_memory/utils.py index 8459c0d8f..07b446866 100644 --- a/src/basic_memory/utils.py +++ b/src/basic_memory/utils.py @@ -45,7 +45,7 @@ def generate_permalink(file_path: Union[Path, str, Any]) -> str: '中文/测试文档' """ # Convert Path to string if needed - path_str = str(file_path) + path_str = Path(str(file_path)).as_posix() # Remove extension base = os.path.splitext(path_str)[0] @@ -315,3 +315,15 @@ def validate_project_path(path: str, project_path: Path) -> bool: return resolved.is_relative_to(project_path.resolve()) except (ValueError, OSError): return False + + +def normalize_newlines(multiline: str) -> str: + """Replace any \r\n, \r, or \n with the native newline. + + Args: + multiline: String containing any mixture of newlines. + + Returns: + A string with normalized newlines native to the platform. + """ + return re.sub(r'\r\n?|\n', os.linesep, multiline) \ No newline at end of file diff --git a/tests/api/test_knowledge_router.py b/tests/api/test_knowledge_router.py index 26ce37967..6400746eb 100644 --- a/tests/api/test_knowledge_router.py +++ b/tests/api/test_knowledge_router.py @@ -10,6 +10,7 @@ EntityResponse, ) from basic_memory.schemas.search import SearchItemType, SearchResponse +from basic_memory.utils import normalize_newlines @pytest.mark.asyncio @@ -684,14 +685,14 @@ async def test_edit_entity_prepend(client: AsyncClient, project_url): file_content = response.text # Expected content with frontmatter preserved and content prepended to body - expected_content = """--- + expected_content = normalize_newlines("""--- title: Test Note type: note permalink: test/test-note --- Prepended content -Original content""" +Original content""") assert file_content.strip() == expected_content.strip() diff --git a/tests/api/test_project_router.py b/tests/api/test_project_router.py index 8e68fd99b..241efbf0f 100644 --- a/tests/api/test_project_router.py +++ b/tests/api/test_project_router.py @@ -168,8 +168,8 @@ async def test_update_project_path_endpoint( """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") + old_path = (tmp_path / "old-location").as_posix() + new_path = (tmp_path / "new-location").as_posix() await project_service.add_project(test_project_name, old_path) @@ -344,7 +344,8 @@ async def test_update_project_no_params_endpoint(test_config, client, project_se 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 + # On Windows the path is prepended with a drive letter + assert test_path in proj_info.path try: # Try to update with no parameters @@ -354,7 +355,8 @@ async def test_update_project_no_params_endpoint(test_config, client, project_se 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 + # On Windows the path is prepended with a drive letter + assert test_path in proj_info.path finally: # Clean up diff --git a/tests/api/test_resource_router.py b/tests/api/test_resource_router.py index 893286538..e1b2941b8 100644 --- a/tests/api/test_resource_router.py +++ b/tests/api/test_resource_router.py @@ -7,6 +7,7 @@ import pytest from basic_memory.schemas import EntityResponse +from basic_memory.utils import normalize_newlines @pytest.mark.asyncio @@ -35,7 +36,7 @@ async def test_get_resource_content(client, project_config, entity_repository, p response = await client.get(f"{project_url}/resource/{entity.permalink}") assert response.status_code == 200 assert response.headers["content-type"] == "text/markdown; charset=utf-8" - assert response.text == content + assert response.text == normalize_newlines(content) @pytest.mark.asyncio @@ -66,7 +67,7 @@ async def test_get_resource_pagination(client, project_config, entity_repository ) assert response.status_code == 200 assert response.headers["content-type"] == "text/markdown; charset=utf-8" - assert response.text == content + assert response.text == normalize_newlines(content) @pytest.mark.asyncio @@ -148,7 +149,7 @@ async def test_get_resource_observation(client, project_config, entity_repositor assert response.status_code == 200 assert response.headers["content-type"] == "text/markdown; charset=utf-8" assert ( - """ + normalize_newlines(""" --- title: Test Entity type: test @@ -158,7 +159,7 @@ async def test_get_resource_observation(client, project_config, entity_repositor # Test Content - [note] an observation. - """.strip() + """.strip()) in response.text ) @@ -196,7 +197,7 @@ async def test_get_resource_entities(client, project_config, entity_repository, assert response.status_code == 200 assert response.headers["content-type"] == "text/markdown; charset=utf-8" assert ( - f""" + normalize_newlines(f""" --- memory://test/test-entity {entity1.updated_at.isoformat()} {entity1.checksum[:8]} # Test Content @@ -206,7 +207,7 @@ async def test_get_resource_entities(client, project_config, entity_repository, # Related Content - links to [[Test Entity]] - """.strip() + """.strip()) in response.text ) @@ -249,7 +250,7 @@ async def test_get_resource_entities_pagination( assert response.status_code == 200 assert response.headers["content-type"] == "text/markdown; charset=utf-8" assert ( - """ + normalize_newlines(""" --- title: Related Entity type: test @@ -258,7 +259,7 @@ async def test_get_resource_entities_pagination( # Related Content - links to [[Test Entity]] -""".strip() +""".strip()) in response.text ) @@ -297,7 +298,7 @@ async def test_get_resource_relation(client, project_config, entity_repository, assert response.status_code == 200 assert response.headers["content-type"] == "text/markdown; charset=utf-8" assert ( - f""" + normalize_newlines(f""" --- memory://test/test-entity {entity1.updated_at.isoformat()} {entity1.checksum[:8]} # Test Content @@ -307,7 +308,7 @@ async def test_get_resource_relation(client, project_config, entity_repository, # Related Content - links to [[Test Entity]] - """.strip() + """.strip()) in response.text ) diff --git a/tests/mcp/test_tool_read_note.py b/tests/mcp/test_tool_read_note.py index 04dbc747e..cf7f4681a 100644 --- a/tests/mcp/test_tool_read_note.py +++ b/tests/mcp/test_tool_read_note.py @@ -10,6 +10,7 @@ from unittest.mock import MagicMock, patch from basic_memory.schemas.search import SearchResponse, SearchItemType +from basic_memory.utils import normalize_newlines @pytest_asyncio.fixture @@ -61,7 +62,7 @@ async def test_note_unicode_content(app): # Read back should preserve unicode result = await read_note.fn("test/unicode-test") - assert content in result + assert normalize_newlines(content) in result @pytest.mark.asyncio diff --git a/tests/mcp/test_tool_write_note.py b/tests/mcp/test_tool_write_note.py index 96baa5c8c..69d3869ed 100644 --- a/tests/mcp/test_tool_write_note.py +++ b/tests/mcp/test_tool_write_note.py @@ -4,6 +4,7 @@ import pytest from basic_memory.mcp.tools import write_note, read_note, delete_note +from basic_memory.utils import normalize_newlines @pytest.mark.asyncio @@ -33,7 +34,7 @@ async def test_write_note(app): # Try reading it back via permalink content = await read_note.fn("test/test-note") assert ( - dedent(""" + normalize_newlines(dedent(""" --- title: Test Note type: note @@ -45,7 +46,7 @@ async def test_write_note(app): # Test This is a test note - """).strip() + """).strip()) in content ) @@ -62,7 +63,7 @@ async def test_write_note_no_tags(app): # Should be able to read it back content = await read_note.fn("test/simple-note") assert ( - dedent(""" + normalize_newlines(dedent(""" --- title: Simple Note type: note @@ -70,7 +71,7 @@ async def test_write_note_no_tags(app): --- Just some text - """).strip() + """).strip()) in content ) @@ -114,7 +115,7 @@ async def test_write_note_update_existing(app): # Try reading it back content = await read_note.fn("test/test-note") assert ( - dedent( + normalize_newlines(dedent( """ --- title: Test Note @@ -128,7 +129,7 @@ async def test_write_note_update_existing(app): # Test This is an updated note """ - ).strip() + ).strip()) == content ) @@ -393,7 +394,7 @@ async def test_write_note_preserves_content_frontmatter(app): # Try reading it back via permalink content = await read_note.fn("test/test-note") assert ( - dedent( + normalize_newlines(dedent( """ --- title: Test Note @@ -410,7 +411,7 @@ async def test_write_note_preserves_content_frontmatter(app): This is a test note """ - ).strip() + ).strip()) in content ) @@ -497,7 +498,7 @@ async def test_write_note_with_custom_entity_type(app): # Verify the entity type is correctly set in the frontmatter content = await read_note.fn("guides/test-guide") assert ( - dedent(""" + normalize_newlines(dedent(""" --- title: Test Guide type: guide @@ -509,7 +510,7 @@ async def test_write_note_with_custom_entity_type(app): # Guide Content This is a guide - """).strip() + """).strip()) in content ) diff --git a/tests/services/test_entity_service.py b/tests/services/test_entity_service.py index 0b7707f33..6837c2e1d 100644 --- a/tests/services/test_entity_service.py +++ b/tests/services/test_entity_service.py @@ -592,7 +592,7 @@ async def test_create_with_no_frontmatter( created = await entity_service.create_entity_from_markdown(file_path, entity_markdown) file_content, _ = await file_service.read_file(created.file_path) - assert str(file_path) == str(created.file_path) + assert file_path.as_posix() == created.file_path assert created.title == "Git Workflow Guide" assert created.entity_type == "note" assert created.permalink is None @@ -898,7 +898,7 @@ async def test_create_entity_from_markdown_with_upsert( # Verify it created the entity successfully using the UPSERT approach assert result is not None assert result.title == "UPSERT Test" - assert result.file_path == str(file_path) + assert result.file_path == file_path.as_posix() # create_entity_from_markdown sets checksum to None (incomplete sync) assert result.checksum is None diff --git a/tests/services/test_project_service.py b/tests/services/test_project_service.py index faf599f12..fcc721731 100644 --- a/tests/services/test_project_service.py +++ b/tests/services/test_project_service.py @@ -167,7 +167,7 @@ async def test_get_project_info(project_service: ProjectService, test_graph, tes async def test_add_project_async(project_service: ProjectService, tmp_path): """Test adding a project with the updated async method.""" test_project_name = f"test-async-project-{os.urandom(4).hex()}" - test_project_path = str(tmp_path / "test-async-project") + test_project_path = (tmp_path / "test-async-project").as_posix() # Make sure the test directory exists os.makedirs(test_project_path, exist_ok=True) @@ -243,7 +243,7 @@ async def test_set_default_project_async(project_service: ProjectService, tmp_pa async def test_get_project_method(project_service: ProjectService, tmp_path): """Test the get_project method directly.""" test_project_name = f"test-get-project-{os.urandom(4).hex()}" - test_project_path = str(tmp_path / "test-get-project") + test_project_path = (tmp_path / "test-get-project").as_posix() # Make sure the test directory exists os.makedirs(test_project_path, exist_ok=True) @@ -539,8 +539,8 @@ async def test_synchronize_projects_normalizes_project_names( async def test_move_project(project_service: ProjectService, tmp_path): """Test moving a project to a new location.""" 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") + old_path = (tmp_path / "old-location").as_posix() + new_path = (tmp_path / "new-location").as_posix() # Create old directory os.makedirs(old_path, exist_ok=True) diff --git a/tests/services/test_project_service_operations.py b/tests/services/test_project_service_operations.py index 69f7ef377..cb3b6e2f9 100644 --- a/tests/services/test_project_service_operations.py +++ b/tests/services/test_project_service_operations.py @@ -72,8 +72,8 @@ async def test_update_project_path(project_service: ProjectService, tmp_path, co """Test updating a project's path.""" # Create a test project test_project = f"path-update-test-project-{os.urandom(4).hex()}" - original_path = str(tmp_path / "original-path") - new_path = str(tmp_path / "new-path") + original_path = (tmp_path / "original-path").as_posix() + new_path = (tmp_path / "new-path").as_posix() # Make sure directories exist os.makedirs(original_path, exist_ok=True) diff --git a/tests/sync/test_sync_service.py b/tests/sync/test_sync_service.py index 26f2780c6..317893d55 100644 --- a/tests/sync/test_sync_service.py +++ b/tests/sync/test_sync_service.py @@ -674,7 +674,7 @@ async def test_file_move_updates_search_index( # Check search index has updated path results = await search_service.search(SearchQuery(text="Content for move test")) assert len(results) == 1 - assert results[0].file_path == str(new_path.relative_to(project_dir)) + assert results[0].file_path == new_path.relative_to(project_dir).as_posix() @pytest.mark.asyncio diff --git a/tests/test_config.py b/tests/test_config.py index 6c03fbc0a..86fa84ac2 100644 --- a/tests/test_config.py +++ b/tests/test_config.py @@ -1,5 +1,6 @@ """Test configuration management.""" +from pathlib import Path from basic_memory.config import BasicMemoryConfig @@ -14,12 +15,12 @@ def test_default_behavior_without_basic_memory_home(self, config_home, monkeypat config = BasicMemoryConfig() # Should use the default path (home/basic-memory) - expected_path = str(config_home / "basic-memory") + expected_path = Path(config_home / "basic-memory").as_posix() assert config.projects["main"] == expected_path def test_respects_basic_memory_home_environment_variable(self, config_home, monkeypatch): """Test that config respects BASIC_MEMORY_HOME environment variable.""" - custom_path = str(config_home / "app" / "data") + custom_path = (config_home / "app" / "data").as_posix() monkeypatch.setenv("BASIC_MEMORY_HOME", custom_path) config = BasicMemoryConfig() @@ -46,11 +47,11 @@ def test_model_post_init_fallback_without_basic_memory_home(self, config_home, m monkeypatch.delenv("BASIC_MEMORY_HOME", raising=False) # Create config without main project - other_path = str(config_home / "some" / "path") + other_path = Path(config_home / "some" / "path").as_posix() config = BasicMemoryConfig(projects={"other": other_path}) # model_post_init should have added main project with default path - expected_path = str(config_home / "basic-memory") + expected_path = (config_home / "basic-memory").as_posix() assert "main" in config.projects assert config.projects["main"] == expected_path