Skip to content

Commit 8adf1f4

Browse files
phernandezclaude
andcommitted
fix: use relative file paths in importers for cloud storage compatibility
Importers now use relative file paths (based on permalink) instead of absolute paths. This enables proper S3 key generation in cloud environments. Changes: - write_entity() accepts str | Path for file_path parameter - ensure_folder_exists() uses relative paths directly - All importers pass relative paths to FileService - FileService handles base_path resolution internally This prevents S3 keys from including container filesystem paths like `/app/basic-memory/imports/...` and instead uses clean relative paths like `imports/20241010-file.md`. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <[email protected]> Signed-off-by: phernandez <[email protected]>
1 parent 45ce181 commit 8adf1f4

File tree

6 files changed

+78
-57
lines changed

6 files changed

+78
-57
lines changed

src/basic_memory/importers/base.py

Lines changed: 5 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -55,7 +55,7 @@ async def import_data(self, source_data, destination_folder: str, **kwargs: Any)
5555
"""
5656
pass # pragma: no cover
5757

58-
async def write_entity(self, entity: EntityMarkdown, file_path: Path) -> str:
58+
async def write_entity(self, entity: EntityMarkdown, file_path: str | Path) -> str:
5959
"""Write entity to file using FileService.
6060
6161
This method serializes the entity to markdown and writes it using
@@ -64,7 +64,7 @@ async def write_entity(self, entity: EntityMarkdown, file_path: Path) -> str:
6464
6565
Args:
6666
entity: EntityMarkdown instance to write.
67-
file_path: Path to write the entity to.
67+
file_path: Relative path to write the entity to. FileService handles base_path.
6868
6969
Returns:
7070
Checksum of written file.
@@ -73,21 +73,16 @@ async def write_entity(self, entity: EntityMarkdown, file_path: Path) -> str:
7373
# FileService.write_file handles directory creation and returns checksum
7474
return await self.file_service.write_file(file_path, content)
7575

76-
async def ensure_folder_exists(self, folder: str) -> Path:
76+
async def ensure_folder_exists(self, folder: str) -> None:
7777
"""Ensure folder exists using FileService.
7878
7979
For cloud storage (S3), this is essentially a no-op since S3 doesn't
8080
have actual folders - they're just key prefixes.
8181
8282
Args:
83-
folder: Folder name or path within the project.
84-
85-
Returns:
86-
Path to the folder.
83+
folder: Relative folder path within the project. FileService handles base_path.
8784
"""
88-
folder_path = self.base_path / folder
89-
await self.file_service.ensure_directory(folder_path)
90-
return folder_path
85+
await self.file_service.ensure_directory(folder)
9186

9287
@abstractmethod
9388
def handle_error(

src/basic_memory/importers/chatgpt_importer.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -41,8 +41,8 @@ async def import_data(
4141
# Convert to entity
4242
entity = self._format_chat_content(destination_folder, chat)
4343

44-
# Write file
45-
file_path = self.base_path / f"{entity.frontmatter.metadata['permalink']}.md"
44+
# Write file using relative path - FileService handles base_path
45+
file_path = f"{entity.frontmatter.metadata['permalink']}.md"
4646
await self.write_entity(entity, file_path)
4747

4848
# Count messages

src/basic_memory/importers/claude_conversations_importer.py

Lines changed: 8 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,6 @@
22

33
import logging
44
from datetime import datetime
5-
from pathlib import Path
65
from typing import Any, Dict, List
76

87
from basic_memory.markdown.schemas import EntityFrontmatter, EntityMarkdown
@@ -31,7 +30,7 @@ async def import_data(
3130
"""
3231
try:
3332
# Ensure the destination folder exists
34-
folder_path = await self.ensure_folder_exists(destination_folder)
33+
await self.ensure_folder_exists(destination_folder)
3534

3635
conversations = source_data
3736

@@ -45,15 +44,15 @@ async def import_data(
4544

4645
# Convert to entity
4746
entity = self._format_chat_content(
48-
base_path=folder_path,
47+
folder=destination_folder,
4948
name=chat_name,
5049
messages=chat["chat_messages"],
5150
created_at=chat["created_at"],
5251
modified_at=chat["updated_at"],
5352
)
5453

55-
# Write file
56-
file_path = self.base_path / Path(f"{entity.frontmatter.metadata['permalink']}.md")
54+
# Write file using relative path - FileService handles base_path
55+
file_path = f"{entity.frontmatter.metadata['permalink']}.md"
5756
await self.write_entity(entity, file_path)
5857

5958
chats_imported += 1
@@ -72,7 +71,7 @@ async def import_data(
7271

7372
def _format_chat_content(
7473
self,
75-
base_path: Path,
74+
folder: str,
7675
name: str,
7776
messages: List[Dict[str, Any]],
7877
created_at: str,
@@ -81,7 +80,7 @@ def _format_chat_content(
8180
"""Convert chat messages to Basic Memory entity format.
8281
8382
Args:
84-
base_path: Base path for the entity.
83+
folder: Destination folder name (relative path).
8584
name: Chat name.
8685
messages: List of chat messages.
8786
created_at: Creation timestamp.
@@ -90,10 +89,10 @@ def _format_chat_content(
9089
Returns:
9190
EntityMarkdown instance representing the conversation.
9291
"""
93-
# Generate permalink
92+
# Generate permalink using folder name (relative path)
9493
date_prefix = datetime.fromisoformat(created_at.replace("Z", "+00:00")).strftime("%Y%m%d")
9594
clean_title = clean_filename(name)
96-
permalink = f"{base_path.name}/{date_prefix}-{clean_title}"
95+
permalink = f"{folder}/{date_prefix}-{clean_title}"
9796

9897
# Format content
9998
content = self._format_chat_markdown(

src/basic_memory/importers/claude_projects_importer.py

Lines changed: 35 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -29,9 +29,8 @@ async def import_data(
2929
"""
3030
try:
3131
# Ensure the base folder exists
32-
base_path = self.base_path
3332
if destination_folder:
34-
base_path = await self.ensure_folder_exists(destination_folder)
33+
await self.ensure_folder_exists(destination_folder)
3534

3635
projects = source_data
3736

@@ -42,20 +41,26 @@ async def import_data(
4241
for project in projects:
4342
project_dir = clean_filename(project["name"])
4443

45-
# Create project directories using FileService
46-
docs_dir = base_path / project_dir / "docs"
44+
# Create project directories using FileService with relative path
45+
docs_dir = (
46+
f"{destination_folder}/{project_dir}/docs"
47+
if destination_folder
48+
else f"{project_dir}/docs"
49+
)
4750
await self.file_service.ensure_directory(docs_dir)
4851

4952
# Import prompt template if it exists
50-
if prompt_entity := self._format_prompt_markdown(project):
51-
file_path = base_path / f"{prompt_entity.frontmatter.metadata['permalink']}.md"
53+
if prompt_entity := self._format_prompt_markdown(project, destination_folder):
54+
# Write file using relative path - FileService handles base_path
55+
file_path = f"{prompt_entity.frontmatter.metadata['permalink']}.md"
5256
await self.write_entity(prompt_entity, file_path)
5357
prompts_imported += 1
5458

5559
# Import project documents
5660
for doc in project.get("docs", []):
57-
entity = self._format_project_markdown(project, doc)
58-
file_path = base_path / f"{entity.frontmatter.metadata['permalink']}.md"
61+
entity = self._format_project_markdown(project, doc, destination_folder)
62+
# Write file using relative path - FileService handles base_path
63+
file_path = f"{entity.frontmatter.metadata['permalink']}.md"
5964
await self.write_entity(entity, file_path)
6065
docs_imported += 1
6166

@@ -71,13 +76,14 @@ async def import_data(
7176
return self.handle_error("Failed to import Claude projects", e) # pyright: ignore [reportReturnType]
7277

7378
def _format_project_markdown(
74-
self, project: Dict[str, Any], doc: Dict[str, Any]
79+
self, project: Dict[str, Any], doc: Dict[str, Any], destination_folder: str = ""
7580
) -> EntityMarkdown:
7681
"""Format a project document as a Basic Memory entity.
7782
7883
Args:
7984
project: Project data.
8085
doc: Document data.
86+
destination_folder: Optional destination folder prefix.
8187
8288
Returns:
8389
EntityMarkdown instance representing the document.
@@ -90,6 +96,13 @@ def _format_project_markdown(
9096
project_dir = clean_filename(project["name"])
9197
doc_file = clean_filename(doc["filename"])
9298

99+
# Build permalink with optional destination folder prefix
100+
permalink = (
101+
f"{destination_folder}/{project_dir}/docs/{doc_file}"
102+
if destination_folder
103+
else f"{project_dir}/docs/{doc_file}"
104+
)
105+
93106
# Create entity
94107
entity = EntityMarkdown(
95108
frontmatter=EntityFrontmatter(
@@ -98,7 +111,7 @@ def _format_project_markdown(
98111
"title": doc["filename"],
99112
"created": created_at,
100113
"modified": modified_at,
101-
"permalink": f"{project_dir}/docs/{doc_file}",
114+
"permalink": permalink,
102115
"project_name": project["name"],
103116
"project_uuid": project["uuid"],
104117
"doc_uuid": doc["uuid"],
@@ -109,11 +122,14 @@ def _format_project_markdown(
109122

110123
return entity
111124

112-
def _format_prompt_markdown(self, project: Dict[str, Any]) -> Optional[EntityMarkdown]:
125+
def _format_prompt_markdown(
126+
self, project: Dict[str, Any], destination_folder: str = ""
127+
) -> Optional[EntityMarkdown]:
113128
"""Format project prompt template as a Basic Memory entity.
114129
115130
Args:
116131
project: Project data.
132+
destination_folder: Optional destination folder prefix.
117133
118134
Returns:
119135
EntityMarkdown instance representing the prompt template, or None if
@@ -129,6 +145,13 @@ def _format_prompt_markdown(self, project: Dict[str, Any]) -> Optional[EntityMar
129145
# Generate clean project directory name
130146
project_dir = clean_filename(project["name"])
131147

148+
# Build permalink with optional destination folder prefix
149+
permalink = (
150+
f"{destination_folder}/{project_dir}/prompt-template"
151+
if destination_folder
152+
else f"{project_dir}/prompt-template"
153+
)
154+
132155
# Create entity
133156
entity = EntityMarkdown(
134157
frontmatter=EntityFrontmatter(
@@ -137,7 +160,7 @@ def _format_prompt_markdown(self, project: Dict[str, Any]) -> Optional[EntityMar
137160
"title": f"Prompt Template: {project['name']}",
138161
"created": created_at,
139162
"modified": modified_at,
140-
"permalink": f"{project_dir}/prompt-template",
163+
"permalink": permalink,
141164
"project_name": project["name"],
142165
"project_uuid": project["uuid"],
143166
}

src/basic_memory/importers/memory_json_importer.py

Lines changed: 18 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,6 @@
33
import logging
44
from typing import Any, Dict, List
55

6-
from basic_memory.config import get_project_config
76
from basic_memory.markdown.schemas import EntityFrontmatter, EntityMarkdown, Observation, Relation
87
from basic_memory.importers.base import Importer
98
from basic_memory.schemas.importer import EntityImportResult
@@ -27,17 +26,15 @@ async def import_data(
2726
Returns:
2827
EntityImportResult containing statistics and status of the import.
2928
"""
30-
config = get_project_config()
3129
try:
3230
# First pass - collect all relations by source entity
3331
entity_relations: Dict[str, List[Relation]] = {}
3432
entities: Dict[str, Dict[str, Any]] = {}
3533
skipped_entities: int = 0
3634

37-
# Ensure the base path exists
38-
base_path = config.home # pragma: no cover
35+
# Ensure the destination folder exists if provided
3936
if destination_folder: # pragma: no cover
40-
base_path = await self.ensure_folder_exists(destination_folder)
37+
await self.ensure_folder_exists(destination_folder)
4138

4239
# First pass - collect entities and relations
4340
for line in source_data:
@@ -68,8 +65,19 @@ async def import_data(
6865
# Get entity type with fallback
6966
entity_type = entity_data.get("entityType") or entity_data.get("type") or "entity"
7067

71-
# Ensure entity type directory exists using FileService
72-
entity_type_dir = base_path / entity_type
68+
# Build permalink with optional destination folder prefix
69+
permalink = (
70+
f"{destination_folder}/{entity_type}/{name}"
71+
if destination_folder
72+
else f"{entity_type}/{name}"
73+
)
74+
75+
# Ensure entity type directory exists using FileService with relative path
76+
entity_type_dir = (
77+
f"{destination_folder}/{entity_type}"
78+
if destination_folder
79+
else entity_type
80+
)
7381
await self.file_service.ensure_directory(entity_type_dir)
7482

7583
# Get observations with fallback to empty list
@@ -80,16 +88,16 @@ async def import_data(
8088
metadata={
8189
"type": entity_type,
8290
"title": name,
83-
"permalink": f"{entity_type}/{name}",
91+
"permalink": permalink,
8492
}
8593
),
8694
content=f"# {name}\n",
8795
observations=[Observation(content=obs) for obs in observations],
8896
relations=entity_relations.get(name, []),
8997
)
9098

91-
# Write entity file
92-
file_path = base_path / f"{entity_type}/{name}.md"
99+
# Write file using relative path - FileService handles base_path
100+
file_path = f"{entity.frontmatter.metadata['permalink']}.md"
93101
await self.write_entity(entity, file_path)
94102
entities_created += 1
95103

tests/importers/test_importer_base.py

Lines changed: 10 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -69,15 +69,15 @@ def test_importer(tmp_path, mock_markdown_processor, mock_file_service):
6969

7070

7171
@pytest.mark.asyncio
72-
async def test_import_data_success(test_importer, mock_file_service, tmp_path):
72+
async def test_import_data_success(test_importer, mock_file_service):
7373
"""Test successful import_data implementation."""
7474
result = await test_importer.import_data({}, "test_folder")
7575
assert result.success
7676
assert result.import_count == {"files": 1}
7777
assert result.error_message is None
7878

79-
# Verify file_service.ensure_directory was called
80-
mock_file_service.ensure_directory.assert_called_once_with(tmp_path / "test_folder")
79+
# Verify file_service.ensure_directory was called with relative path
80+
mock_file_service.ensure_directory.assert_called_once_with("test_folder")
8181

8282

8383
@pytest.mark.asyncio
@@ -105,19 +105,15 @@ async def test_write_entity(test_importer, mock_markdown_processor, mock_file_se
105105

106106

107107
@pytest.mark.asyncio
108-
async def test_ensure_folder_exists(test_importer, mock_file_service, tmp_path):
108+
async def test_ensure_folder_exists(test_importer, mock_file_service):
109109
"""Test ensure_folder_exists method."""
110-
# Test with simple folder
111-
folder_path = await test_importer.ensure_folder_exists("test_folder")
112-
assert folder_path == tmp_path / "test_folder"
110+
# Test with simple folder - now passes relative path to FileService
111+
await test_importer.ensure_folder_exists("test_folder")
112+
mock_file_service.ensure_directory.assert_called_with("test_folder")
113113

114-
# Verify file_service.ensure_directory was called
115-
mock_file_service.ensure_directory.assert_called_with(tmp_path / "test_folder")
116-
117-
# Test with nested folder
118-
nested_path = await test_importer.ensure_folder_exists("nested/folder/path")
119-
assert nested_path == tmp_path / "nested" / "folder" / "path"
120-
mock_file_service.ensure_directory.assert_called_with(tmp_path / "nested" / "folder" / "path")
114+
# Test with nested folder - FileService handles base_path resolution
115+
await test_importer.ensure_folder_exists("nested/folder/path")
116+
mock_file_service.ensure_directory.assert_called_with("nested/folder/path")
121117

122118

123119
@pytest.mark.asyncio

0 commit comments

Comments
 (0)