Compare commits

...

1 Commits

Author SHA1 Message Date
claude[bot] 92bd145b9b feat: Convert file_service operations to use aiofiles for true async I/O
- Added aiofiles imports to file_utils.py and file_service.py
- Converted write_file_atomic() to use aiofiles.open() and aiofiles.os.replace()
- Converted update_frontmatter() to use aiofiles.open() for async reads
- Converted exists() to use aiofiles.ospath.exists()
- Converted read_file() to use aiofiles.open() for async reads
- Converted delete_file() to use aiofiles.os.remove()
- Converted compute_checksum() to use aiofiles.open() for both text and binary reads
- Converted file_stats() from sync to async using aiofiles.os.stat()
- Updated all callers of file_stats() to use await

This ensures all file I/O operations are truly asynchronous, improving
performance and preventing blocking of the event loop.

Resolves #371

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-authored-by: Paul Hernandez <phernandez@users.noreply.github.com>
2025-10-16 19:57:37 +00:00
4 changed files with 32 additions and 16 deletions
@@ -167,7 +167,7 @@ async def write_resource(
checksum = await file_service.write_file(full_path, content_str)
# Get file info
file_stats = file_service.file_stats(full_path)
file_stats = await file_service.file_stats(full_path)
# Determine file details
file_name = Path(file_path).name
+11 -4
View File
@@ -5,6 +5,8 @@ from pathlib import Path
import re
from typing import Any, Dict, Union
import aiofiles
import aiofiles.os
import yaml
import frontmatter
from loguru import logger
@@ -87,11 +89,15 @@ async def write_file_atomic(path: FilePath, content: str) -> None:
temp_path = path_obj.with_suffix(".tmp")
try:
temp_path.write_text(content, encoding="utf-8")
temp_path.replace(path_obj)
async with aiofiles.open(temp_path, "w", encoding="utf-8") as f:
await f.write(content)
await aiofiles.os.replace(temp_path, path_obj)
logger.debug("Wrote file atomically", path=str(path_obj), content_length=len(content))
except Exception as e: # pragma: no cover
temp_path.unlink(missing_ok=True)
try:
await aiofiles.os.remove(temp_path)
except FileNotFoundError:
pass
logger.error("Failed to write file", path=str(path_obj), error=str(e))
raise FileWriteError(f"Failed to write file {path}: {e}")
@@ -208,7 +214,8 @@ async def update_frontmatter(path: FilePath, updates: Dict[str, Any]) -> str:
path_obj = Path(path) if isinstance(path, str) else path
# Read current content
content = path_obj.read_text(encoding="utf-8")
async with aiofiles.open(path_obj, "r", encoding="utf-8") as f:
content = await f.read()
# Parse current frontmatter
current_fm = {}
+17 -8
View File
@@ -5,6 +5,9 @@ from os import stat_result
from pathlib import Path
from typing import Any, Dict, Tuple, Union
import aiofiles
import aiofiles.os
import aiofiles.ospath
from basic_memory import file_utils
from basic_memory.file_utils import FileError
from basic_memory.markdown.markdown_processor import MarkdownProcessor
@@ -97,9 +100,9 @@ class FileService:
path_obj = self.base_path / path if isinstance(path, str) else path
logger.debug(f"Checking file existence: path={path_obj}")
if path_obj.is_absolute():
return path_obj.exists()
return await aiofiles.ospath.exists(path_obj)
else:
return (self.base_path / path_obj).exists()
return await aiofiles.ospath.exists(self.base_path / path_obj)
except Exception as e:
logger.error("Failed to check file existence", path=str(path), error=str(e))
raise FileOperationError(f"Failed to check file existence: {e}")
@@ -169,7 +172,8 @@ class FileService:
try:
logger.debug("Reading file", operation="read_file", path=str(full_path))
content = full_path.read_text(encoding="utf-8")
async with aiofiles.open(full_path, "r", encoding="utf-8") as f:
content = await f.read()
checksum = await file_utils.compute_checksum(content)
logger.debug(
@@ -196,7 +200,10 @@ class FileService:
# Convert string to Path if needed
path_obj = self.base_path / path if isinstance(path, str) else path
full_path = path_obj if path_obj.is_absolute() else self.base_path / path_obj
full_path.unlink(missing_ok=True)
try:
await aiofiles.os.remove(full_path)
except FileNotFoundError:
pass # missing_ok=True behavior
async def update_frontmatter(self, path: FilePath, updates: Dict[str, Any]) -> str:
"""
@@ -233,17 +240,19 @@ class FileService:
try:
if self.is_markdown(path):
# read str
content = full_path.read_text(encoding="utf-8")
async with aiofiles.open(full_path, "r", encoding="utf-8") as f:
content = await f.read()
else:
# read bytes
content = full_path.read_bytes()
async with aiofiles.open(full_path, "rb") as f:
content = await f.read()
return await file_utils.compute_checksum(content)
except Exception as e: # pragma: no cover
logger.error("Failed to compute checksum", path=str(full_path), error=str(e))
raise FileError(f"Failed to compute checksum for {path}: {e}")
def file_stats(self, path: FilePath) -> stat_result:
async def file_stats(self, path: FilePath) -> stat_result:
"""Return file stats for a given path.
Args:
@@ -256,7 +265,7 @@ class FileService:
path_obj = self.base_path / path if isinstance(path, str) else path
full_path = path_obj if path_obj.is_absolute() else self.base_path / path_obj
# get file timestamps
return full_path.stat()
return await aiofiles.os.stat(full_path)
def content_type(self, path: FilePath) -> str:
"""Return content_type for a given path.
+3 -3
View File
@@ -548,7 +548,7 @@ class SyncService:
file_contains_frontmatter = has_frontmatter(file_content)
# Get file timestamps for tracking modification times
file_stats = self.file_service.file_stats(path)
file_stats = await self.file_service.file_stats(path)
created = datetime.fromtimestamp(file_stats.st_ctime).astimezone()
modified = datetime.fromtimestamp(file_stats.st_mtime).astimezone()
@@ -621,7 +621,7 @@ class SyncService:
await self.entity_service.resolve_permalink(path, skip_conflict_check=True)
# get file timestamps
file_stats = self.file_service.file_stats(path)
file_stats = await self.file_service.file_stats(path)
created = datetime.fromtimestamp(file_stats.st_ctime).astimezone()
modified = datetime.fromtimestamp(file_stats.st_mtime).astimezone()
@@ -668,7 +668,7 @@ class SyncService:
raise
else:
# Get file timestamps for updating modification time
file_stats = self.file_service.file_stats(path)
file_stats = await self.file_service.file_stats(path)
modified = datetime.fromtimestamp(file_stats.st_mtime).astimezone()
entity = await self.entity_repository.get_by_file_path(path)