Addressed issues when running basic-memory on the Windows platform (#252)

Signed-off-by: Manuel Bliemel <manuel.bliemel@gmail.com>
This commit is contained in:
manuelbliemel
2025-08-25 04:12:40 +02:00
committed by GitHub
parent 7aff836c57
commit 9aa40246a8
21 changed files with 93 additions and 70 deletions
+2 -2
View File
@@ -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}
+4 -4
View File
@@ -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",
)
@@ -100,9 +100,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(
self.projects["main"] = (
Path(os.getenv("BASIC_MEMORY_HOME", Path.home() / "basic-memory"))
)
).as_posix()
# Ensure default project is valid
if self.default_project not in self.projects: # pragma: no cover
@@ -215,7 +215,7 @@ class ConfigManager:
# 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)
+1 -1
View File
@@ -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
@@ -57,7 +57,7 @@ class EntityRepository(Repository[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 @@ class EntityRepository(Repository[Entity]):
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."""
@@ -46,7 +46,7 @@ class ProjectRepository(Repository[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]:
@@ -6,6 +6,7 @@ import time
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 @@ class SearchIndexRow:
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:
+5 -5
View File
@@ -91,7 +91,7 @@ class EntityService(BaseService[EntityModel]):
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 @@ class EntityService(BaseService[EntityModel]):
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 @@ class EntityService(BaseService[EntityModel]):
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 @@ class EntityService(BaseService[EntityModel]):
"""
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 @@ class EntityService(BaseService[EntityModel]):
# 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})
+3 -3
View File
@@ -100,7 +100,7 @@ class ProjectService:
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 @@ class ProjectService:
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 @@ class ProjectService:
# 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()
+1 -1
View File
@@ -619,7 +619,7 @@ class SyncService:
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
+1 -1
View File
@@ -197,7 +197,7 @@ class WatchService:
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"):
+13 -1
View File
@@ -49,7 +49,7 @@ def generate_permalink(file_path: Union[Path, str, PathLike]) -> 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]
@@ -220,6 +220,18 @@ def parse_tags(tags: Union[List[str], str, None]) -> List[str]:
return []
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)
def normalize_file_path_for_comparison(file_path: str) -> str:
"""Normalize a file path for conflict detection.
+3 -2
View File
@@ -10,6 +10,7 @@ from basic_memory.schemas import (
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()
+8 -6
View File
@@ -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)
@@ -256,8 +256,8 @@ async def test_update_project_both_params_endpoint(
"""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")
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
+11 -10
View File
@@ -7,6 +7,7 @@ from pathlib import Path
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 @@ permalink: test/test-entity
# 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 @@ permalink: test/related-entity
# 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
)
+2 -1
View File
@@ -10,6 +10,7 @@ import pytest_asyncio
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
+11 -10
View File
@@ -4,6 +4,7 @@ from textwrap import dedent
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
)
+2 -2
View File
@@ -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
+10 -9
View File
@@ -1,6 +1,7 @@
"""Tests for ProjectService."""
import os
from pathlib import Path
import pytest
@@ -73,7 +74,7 @@ async def test_project_operations_sync_methods(
"""
# Generate a unique project name for testing
test_project_name = f"test-project-{os.urandom(4).hex()}"
test_project_path = str(tmp_path / "test-project")
test_project_path = (tmp_path / "test-project").as_posix()
# Make sure the test directory exists
os.makedirs(test_project_path, exist_ok=True)
@@ -167,7 +168,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 +244,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 +540,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)
@@ -590,8 +591,8 @@ async def test_move_project_nonexistent(project_service: ProjectService, tmp_pat
async def test_move_project_db_mismatch(project_service: ProjectService, tmp_path):
"""Test moving a project that exists in config but not in database."""
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")
old_path = (tmp_path / "old-location").as_posix()
new_path = (tmp_path / "new-location").as_posix()
# Create directories
os.makedirs(old_path, exist_ok=True)
@@ -624,7 +625,7 @@ async def test_move_project_db_mismatch(project_service: ProjectService, tmp_pat
async def test_move_project_expands_path(project_service: ProjectService, tmp_path):
"""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")
old_path = (tmp_path / "old-location").as_posix()
# Create old directory
os.makedirs(old_path, exist_ok=True)
@@ -635,7 +636,7 @@ async def test_move_project_expands_path(project_service: ProjectService, tmp_pa
# Use a relative path for the move
relative_new_path = "./new-location"
expected_absolute_path = os.path.abspath(relative_new_path)
expected_absolute_path = Path(os.path.abspath(relative_new_path)).as_posix()
# Move project using relative path
await project_service.move_project(test_project_name, relative_new_path)
@@ -48,7 +48,7 @@ async def test_add_project_to_config(project_service: ProjectService, tmp_path,
"""Test adding a project to the config manager."""
# Generate unique project name for testing
test_project_name = f"config-project-{os.urandom(4).hex()}"
test_path = str(tmp_path / "config-project")
test_path = (tmp_path / "config-project").as_posix()
# Make sure directory exists
os.makedirs(test_path, exist_ok=True)
@@ -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)
+1 -1
View File
@@ -680,7 +680,7 @@ Content for move test
# 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
+4 -4
View File
@@ -14,12 +14,12 @@ class TestBasicMemoryConfig:
config = BasicMemoryConfig()
# Should use the default path (home/basic-memory)
expected_path = str(config_home / "basic-memory")
expected_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 +46,11 @@ class TestBasicMemoryConfig:
monkeypatch.delenv("BASIC_MEMORY_HOME", raising=False)
# Create config without main project
other_path = str(config_home / "some" / "path")
other_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