From 92bd145b9b02f13aea6e8e20fcaff7e263ed98bc Mon Sep 17 00:00:00 2001 From: "claude[bot]" <41898282+claude[bot]@users.noreply.github.com> Date: Thu, 16 Oct 2025 19:57:37 +0000 Subject: [PATCH] feat: Convert file_service operations to use aiofiles for true async I/O MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- .../api/routers/resource_router.py | 2 +- src/basic_memory/file_utils.py | 15 ++++++++--- src/basic_memory/services/file_service.py | 25 +++++++++++++------ src/basic_memory/sync/sync_service.py | 6 ++--- 4 files changed, 32 insertions(+), 16 deletions(-) diff --git a/src/basic_memory/api/routers/resource_router.py b/src/basic_memory/api/routers/resource_router.py index 3df7f6bf..8904b58c 100644 --- a/src/basic_memory/api/routers/resource_router.py +++ b/src/basic_memory/api/routers/resource_router.py @@ -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 diff --git a/src/basic_memory/file_utils.py b/src/basic_memory/file_utils.py index fdb2dfa2..354ec0ac 100644 --- a/src/basic_memory/file_utils.py +++ b/src/basic_memory/file_utils.py @@ -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 = {} diff --git a/src/basic_memory/services/file_service.py b/src/basic_memory/services/file_service.py index b22c02a4..cb63df83 100644 --- a/src/basic_memory/services/file_service.py +++ b/src/basic_memory/services/file_service.py @@ -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. diff --git a/src/basic_memory/sync/sync_service.py b/src/basic_memory/sync/sync_service.py index ec341958..0a8ea236 100644 --- a/src/basic_memory/sync/sync_service.py +++ b/src/basic_memory/sync/sync_service.py @@ -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)