mirror of
https://github.com/basicmachines-co/basic-memory
synced 2026-06-21 13:47:35 +00:00
feat: add auto-format files on save with built-in Python formatter (#474)
Signed-off-by: Cedric Hurst <cedric@spantree.net> Signed-off-by: phernandez <paul@basicmachines.co> Co-authored-by: Cedric Hurst <cedric@spantree.net> Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com> Co-authored-by: Sebastian B Otaegui <feniix@users.noreply.github.com> Co-authored-by: Cedric Hurst <cedric@divideby0.io>
This commit is contained in:
@@ -96,9 +96,7 @@ async def resolve_identifier(
|
||||
# Try to resolve the identifier
|
||||
entity = await link_resolver.resolve_link(data.identifier)
|
||||
if not entity:
|
||||
raise HTTPException(
|
||||
status_code=404, detail=f"Entity not found: '{data.identifier}'"
|
||||
)
|
||||
raise HTTPException(status_code=404, detail=f"Entity not found: '{data.identifier}'")
|
||||
|
||||
# Determine resolution method
|
||||
resolution_method = "search" # default
|
||||
|
||||
@@ -95,9 +95,7 @@ async def resolve_project_identifier(
|
||||
resolution_method = "name"
|
||||
|
||||
if not project:
|
||||
raise HTTPException(
|
||||
status_code=404, detail=f"Project not found: '{data.identifier}'"
|
||||
)
|
||||
raise HTTPException(status_code=404, detail=f"Project not found: '{data.identifier}'")
|
||||
|
||||
return ProjectResolveResponse(
|
||||
project_id=project.id,
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
"""CLI commands for basic-memory."""
|
||||
|
||||
from . import status, db, import_memory_json, mcp, import_claude_conversations
|
||||
from . import import_claude_projects, import_chatgpt, tool, project
|
||||
from . import import_claude_projects, import_chatgpt, tool, project, format
|
||||
|
||||
__all__ = [
|
||||
"status",
|
||||
@@ -13,4 +13,5 @@ __all__ = [
|
||||
"import_chatgpt",
|
||||
"tool",
|
||||
"project",
|
||||
"format",
|
||||
]
|
||||
|
||||
@@ -0,0 +1,198 @@
|
||||
"""Format command for basic-memory CLI."""
|
||||
|
||||
import asyncio
|
||||
from pathlib import Path
|
||||
from typing import Annotated, Optional
|
||||
|
||||
import typer
|
||||
from loguru import logger
|
||||
from rich.console import Console
|
||||
from rich.progress import Progress, SpinnerColumn, TextColumn
|
||||
|
||||
from basic_memory.cli.app import app
|
||||
from basic_memory.config import ConfigManager, get_project_config
|
||||
from basic_memory.file_utils import format_file
|
||||
|
||||
console = Console()
|
||||
|
||||
|
||||
def is_markdown_extension(path: Path) -> bool:
|
||||
"""Check if file has a markdown extension."""
|
||||
return path.suffix.lower() in (".md", ".markdown")
|
||||
|
||||
|
||||
async def format_single_file(file_path: Path, app_config) -> tuple[Path, bool, Optional[str]]:
|
||||
"""Format a single file.
|
||||
|
||||
Returns:
|
||||
Tuple of (path, success, error_message)
|
||||
"""
|
||||
try:
|
||||
result = await format_file(
|
||||
file_path, app_config, is_markdown=is_markdown_extension(file_path)
|
||||
)
|
||||
if result is not None:
|
||||
return (file_path, True, None)
|
||||
else:
|
||||
return (file_path, False, "No formatter configured or formatting skipped")
|
||||
except Exception as e:
|
||||
return (file_path, False, str(e))
|
||||
|
||||
|
||||
async def format_files(
|
||||
paths: list[Path], app_config, show_progress: bool = True
|
||||
) -> tuple[int, int, list[tuple[Path, str]]]:
|
||||
"""Format multiple files.
|
||||
|
||||
Returns:
|
||||
Tuple of (formatted_count, skipped_count, errors)
|
||||
"""
|
||||
formatted = 0
|
||||
skipped = 0
|
||||
errors: list[tuple[Path, str]] = []
|
||||
|
||||
if show_progress:
|
||||
with Progress(
|
||||
SpinnerColumn(),
|
||||
TextColumn("[progress.description]{task.description}"),
|
||||
console=console,
|
||||
) as progress:
|
||||
task = progress.add_task("Formatting files...", total=len(paths))
|
||||
|
||||
for file_path in paths:
|
||||
path, success, error = await format_single_file(file_path, app_config)
|
||||
if success:
|
||||
formatted += 1
|
||||
elif error and "No formatter configured" not in error:
|
||||
errors.append((path, error))
|
||||
else:
|
||||
skipped += 1
|
||||
progress.update(task, advance=1)
|
||||
else:
|
||||
for file_path in paths:
|
||||
path, success, error = await format_single_file(file_path, app_config)
|
||||
if success:
|
||||
formatted += 1
|
||||
elif error and "No formatter configured" not in error:
|
||||
errors.append((path, error))
|
||||
else:
|
||||
skipped += 1
|
||||
|
||||
return formatted, skipped, errors
|
||||
|
||||
|
||||
async def run_format(
|
||||
path: Optional[Path] = None,
|
||||
project: Optional[str] = None,
|
||||
) -> None:
|
||||
"""Run the format command."""
|
||||
app_config = ConfigManager().config
|
||||
|
||||
# Check if formatting is enabled
|
||||
if (
|
||||
not app_config.format_on_save
|
||||
and not app_config.formatter_command
|
||||
and not app_config.formatters
|
||||
):
|
||||
console.print(
|
||||
"[yellow]No formatters configured. Set format_on_save=true and "
|
||||
"formatter_command or formatters in your config.[/yellow]"
|
||||
)
|
||||
console.print(
|
||||
"\nExample config (~/.basic-memory/config.json):\n"
|
||||
' "format_on_save": true,\n'
|
||||
' "formatter_command": "prettier --write {file}"\n'
|
||||
)
|
||||
raise typer.Exit(1)
|
||||
|
||||
# Temporarily enable format_on_save for this command
|
||||
# (so format_file actually runs the formatter)
|
||||
original_format_on_save = app_config.format_on_save
|
||||
app_config.format_on_save = True
|
||||
|
||||
try:
|
||||
# Determine which files to format
|
||||
if path:
|
||||
# Format specific file or directory
|
||||
if path.is_file():
|
||||
files = [path]
|
||||
elif path.is_dir():
|
||||
# Find all markdown and json files
|
||||
files = (
|
||||
list(path.rglob("*.md"))
|
||||
+ list(path.rglob("*.json"))
|
||||
+ list(path.rglob("*.canvas"))
|
||||
)
|
||||
else:
|
||||
console.print(f"[red]Path not found: {path}[/red]")
|
||||
raise typer.Exit(1)
|
||||
else:
|
||||
# Format all files in project
|
||||
project_config = get_project_config(project)
|
||||
project_path = Path(project_config.home)
|
||||
|
||||
if not project_path.exists():
|
||||
console.print(f"[red]Project path not found: {project_path}[/red]")
|
||||
raise typer.Exit(1)
|
||||
|
||||
# Find all markdown and json files
|
||||
files = (
|
||||
list(project_path.rglob("*.md"))
|
||||
+ list(project_path.rglob("*.json"))
|
||||
+ list(project_path.rglob("*.canvas"))
|
||||
)
|
||||
|
||||
if not files:
|
||||
console.print("[yellow]No files found to format.[/yellow]")
|
||||
return
|
||||
|
||||
console.print(f"Found {len(files)} file(s) to format...")
|
||||
|
||||
formatted, skipped, errors = await format_files(files, app_config)
|
||||
|
||||
# Print summary
|
||||
console.print()
|
||||
if formatted > 0:
|
||||
console.print(f"[green]Formatted: {formatted} file(s)[/green]")
|
||||
if skipped > 0:
|
||||
console.print(f"[dim]Skipped: {skipped} file(s) (no formatter for extension)[/dim]")
|
||||
if errors:
|
||||
console.print(f"[red]Errors: {len(errors)} file(s)[/red]")
|
||||
for path, error in errors:
|
||||
console.print(f" [red]{path}[/red]: {error}")
|
||||
|
||||
finally:
|
||||
# Restore original setting
|
||||
app_config.format_on_save = original_format_on_save
|
||||
|
||||
|
||||
@app.command()
|
||||
def format(
|
||||
path: Annotated[
|
||||
Optional[Path],
|
||||
typer.Argument(help="File or directory to format. Defaults to current project."),
|
||||
] = None,
|
||||
project: Annotated[
|
||||
Optional[str],
|
||||
typer.Option("--project", "-p", help="Project name to format."),
|
||||
] = None,
|
||||
) -> None:
|
||||
"""Format files using configured formatters.
|
||||
|
||||
Uses the formatter_command or formatters settings from your config.
|
||||
By default, formats all .md, .json, and .canvas files in the current project.
|
||||
|
||||
Examples:
|
||||
basic-memory format # Format all files in current project
|
||||
basic-memory format --project research # Format files in specific project
|
||||
basic-memory format notes/meeting.md # Format a specific file
|
||||
basic-memory format notes/ # Format all files in directory
|
||||
"""
|
||||
try:
|
||||
asyncio.run(run_format(path, project))
|
||||
except Exception as e:
|
||||
if not isinstance(e, typer.Exit):
|
||||
logger.error(f"Error formatting files: {e}")
|
||||
console.print(f"[red]Error formatting files: {e}[/red]")
|
||||
raise typer.Exit(code=1)
|
||||
raise
|
||||
@@ -7,7 +7,7 @@ from typing import Annotated
|
||||
|
||||
import typer
|
||||
from basic_memory.cli.app import import_app
|
||||
from basic_memory.config import get_project_config
|
||||
from basic_memory.config import ConfigManager, get_project_config
|
||||
from basic_memory.importers import ChatGPTImporter
|
||||
from basic_memory.markdown import EntityParser, MarkdownProcessor
|
||||
from loguru import logger
|
||||
@@ -20,8 +20,9 @@ console = Console()
|
||||
async def get_markdown_processor() -> MarkdownProcessor:
|
||||
"""Get MarkdownProcessor instance."""
|
||||
config = get_project_config()
|
||||
app_config = ConfigManager().config
|
||||
entity_parser = EntityParser(config.home)
|
||||
return MarkdownProcessor(entity_parser)
|
||||
return MarkdownProcessor(entity_parser, app_config=app_config)
|
||||
|
||||
|
||||
@import_app.command(name="chatgpt", help="Import conversations from ChatGPT JSON export.")
|
||||
|
||||
@@ -7,7 +7,7 @@ from typing import Annotated
|
||||
|
||||
import typer
|
||||
from basic_memory.cli.app import claude_app
|
||||
from basic_memory.config import get_project_config
|
||||
from basic_memory.config import ConfigManager, get_project_config
|
||||
from basic_memory.importers.claude_conversations_importer import ClaudeConversationsImporter
|
||||
from basic_memory.markdown import EntityParser, MarkdownProcessor
|
||||
from loguru import logger
|
||||
@@ -20,8 +20,9 @@ console = Console()
|
||||
async def get_markdown_processor() -> MarkdownProcessor:
|
||||
"""Get MarkdownProcessor instance."""
|
||||
config = get_project_config()
|
||||
app_config = ConfigManager().config
|
||||
entity_parser = EntityParser(config.home)
|
||||
return MarkdownProcessor(entity_parser)
|
||||
return MarkdownProcessor(entity_parser, app_config=app_config)
|
||||
|
||||
|
||||
@claude_app.command(name="conversations", help="Import chat conversations from Claude.ai.")
|
||||
|
||||
@@ -7,7 +7,7 @@ from typing import Annotated
|
||||
|
||||
import typer
|
||||
from basic_memory.cli.app import claude_app
|
||||
from basic_memory.config import get_project_config
|
||||
from basic_memory.config import ConfigManager, get_project_config
|
||||
from basic_memory.importers.claude_projects_importer import ClaudeProjectsImporter
|
||||
from basic_memory.markdown import EntityParser, MarkdownProcessor
|
||||
from loguru import logger
|
||||
@@ -20,8 +20,9 @@ console = Console()
|
||||
async def get_markdown_processor() -> MarkdownProcessor:
|
||||
"""Get MarkdownProcessor instance."""
|
||||
config = get_project_config()
|
||||
app_config = ConfigManager().config
|
||||
entity_parser = EntityParser(config.home)
|
||||
return MarkdownProcessor(entity_parser)
|
||||
return MarkdownProcessor(entity_parser, app_config=app_config)
|
||||
|
||||
|
||||
@claude_app.command(name="projects", help="Import projects from Claude.ai.")
|
||||
|
||||
@@ -7,7 +7,7 @@ from typing import Annotated
|
||||
|
||||
import typer
|
||||
from basic_memory.cli.app import import_app
|
||||
from basic_memory.config import get_project_config
|
||||
from basic_memory.config import ConfigManager, get_project_config
|
||||
from basic_memory.importers.memory_json_importer import MemoryJsonImporter
|
||||
from basic_memory.markdown import EntityParser, MarkdownProcessor
|
||||
from loguru import logger
|
||||
@@ -20,8 +20,9 @@ console = Console()
|
||||
async def get_markdown_processor() -> MarkdownProcessor:
|
||||
"""Get MarkdownProcessor instance."""
|
||||
config = get_project_config()
|
||||
app_config = ConfigManager().config
|
||||
entity_parser = EntityParser(config.home)
|
||||
return MarkdownProcessor(entity_parser)
|
||||
return MarkdownProcessor(entity_parser, app_config=app_config)
|
||||
|
||||
|
||||
@import_app.command()
|
||||
|
||||
@@ -341,7 +341,9 @@ def set_default_project(
|
||||
target_project = response.json()
|
||||
|
||||
# Use v2 API with project ID
|
||||
response = await call_put(client, f"/v2/projects/{target_project['project_id']}/default")
|
||||
response = await call_put(
|
||||
client, f"/v2/projects/{target_project['project_id']}/default"
|
||||
)
|
||||
return ProjectStatusResponse.model_validate(response.json())
|
||||
|
||||
try:
|
||||
|
||||
@@ -165,6 +165,28 @@ class BasicMemoryConfig(BaseSettings):
|
||||
description="Skip expensive initialization synchronization. Useful for cloud/stateless deployments where project reconciliation is not needed.",
|
||||
)
|
||||
|
||||
# File formatting configuration
|
||||
format_on_save: bool = Field(
|
||||
default=False,
|
||||
description="Automatically format files after saving using configured formatter. Disabled by default.",
|
||||
)
|
||||
|
||||
formatter_command: Optional[str] = Field(
|
||||
default=None,
|
||||
description="External formatter command. Use {file} as placeholder for file path. If not set, uses built-in mdformat (Python, no Node.js required). Set to 'npx prettier --write {file}' for Prettier.",
|
||||
)
|
||||
|
||||
formatters: Dict[str, str] = Field(
|
||||
default_factory=dict,
|
||||
description="Per-extension formatters. Keys are extensions (without dot), values are commands. Example: {'md': 'prettier --write {file}', 'json': 'prettier --write {file}'}",
|
||||
)
|
||||
|
||||
formatter_timeout: float = Field(
|
||||
default=5.0,
|
||||
description="Maximum seconds to wait for formatter to complete",
|
||||
gt=0,
|
||||
)
|
||||
|
||||
# Project path constraints
|
||||
project_root: Optional[str] = Field(
|
||||
default=None,
|
||||
|
||||
@@ -351,24 +351,30 @@ async def get_entity_parser_v2(project_config: ProjectConfigV2Dep) -> EntityPars
|
||||
EntityParserV2Dep = Annotated["EntityParser", Depends(get_entity_parser_v2)]
|
||||
|
||||
|
||||
async def get_markdown_processor(entity_parser: EntityParserDep) -> MarkdownProcessor:
|
||||
return MarkdownProcessor(entity_parser)
|
||||
async def get_markdown_processor(
|
||||
entity_parser: EntityParserDep, app_config: AppConfigDep
|
||||
) -> MarkdownProcessor:
|
||||
return MarkdownProcessor(entity_parser, app_config=app_config)
|
||||
|
||||
|
||||
MarkdownProcessorDep = Annotated[MarkdownProcessor, Depends(get_markdown_processor)]
|
||||
|
||||
|
||||
async def get_markdown_processor_v2(entity_parser: EntityParserV2Dep) -> MarkdownProcessor:
|
||||
return MarkdownProcessor(entity_parser)
|
||||
async def get_markdown_processor_v2(
|
||||
entity_parser: EntityParserV2Dep, app_config: AppConfigDep
|
||||
) -> MarkdownProcessor:
|
||||
return MarkdownProcessor(entity_parser, app_config=app_config)
|
||||
|
||||
|
||||
MarkdownProcessorV2Dep = Annotated[MarkdownProcessor, Depends(get_markdown_processor_v2)]
|
||||
|
||||
|
||||
async def get_file_service(
|
||||
project_config: ProjectConfigDep, markdown_processor: MarkdownProcessorDep
|
||||
project_config: ProjectConfigDep,
|
||||
markdown_processor: MarkdownProcessorDep,
|
||||
app_config: AppConfigDep,
|
||||
) -> FileService:
|
||||
file_service = FileService(project_config.home, markdown_processor)
|
||||
file_service = FileService(project_config.home, markdown_processor, app_config=app_config)
|
||||
logger.debug(
|
||||
f"Created FileService for project: {project_config.name}, base_path: {project_config.home} "
|
||||
)
|
||||
@@ -379,9 +385,11 @@ FileServiceDep = Annotated[FileService, Depends(get_file_service)]
|
||||
|
||||
|
||||
async def get_file_service_v2(
|
||||
project_config: ProjectConfigV2Dep, markdown_processor: MarkdownProcessorV2Dep
|
||||
project_config: ProjectConfigV2Dep,
|
||||
markdown_processor: MarkdownProcessorV2Dep,
|
||||
app_config: AppConfigDep,
|
||||
) -> FileService:
|
||||
file_service = FileService(project_config.home, markdown_processor)
|
||||
file_service = FileService(project_config.home, markdown_processor, app_config=app_config)
|
||||
logger.debug(
|
||||
f"Created FileService for project: {project_config.name}, base_path: {project_config.home}"
|
||||
)
|
||||
|
||||
@@ -1,11 +1,13 @@
|
||||
"""Utilities for file operations."""
|
||||
|
||||
import asyncio
|
||||
import hashlib
|
||||
import shlex
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
import re
|
||||
from typing import Any, Dict, Union
|
||||
from typing import TYPE_CHECKING, Any, Dict, Optional, Union
|
||||
|
||||
import aiofiles
|
||||
import yaml
|
||||
@@ -14,6 +16,9 @@ from loguru import logger
|
||||
|
||||
from basic_memory.utils import FilePath
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from basic_memory.config import BasicMemoryConfig
|
||||
|
||||
|
||||
@dataclass
|
||||
class FileMetadata:
|
||||
@@ -70,7 +75,7 @@ async def compute_checksum(content: Union[str, bytes]) -> str:
|
||||
|
||||
|
||||
# UTF-8 BOM character that can appear at the start of files
|
||||
UTF8_BOM = '\ufeff'
|
||||
UTF8_BOM = "\ufeff"
|
||||
|
||||
|
||||
def strip_bom(content: str) -> str:
|
||||
@@ -122,6 +127,168 @@ async def write_file_atomic(path: FilePath, content: str) -> None:
|
||||
raise FileWriteError(f"Failed to write file {path}: {e}")
|
||||
|
||||
|
||||
async def format_markdown_builtin(path: Path) -> Optional[str]:
|
||||
"""
|
||||
Format a markdown file using the built-in mdformat formatter.
|
||||
|
||||
Uses mdformat with GFM (GitHub Flavored Markdown) support for consistent
|
||||
formatting without requiring Node.js or external tools.
|
||||
|
||||
Args:
|
||||
path: Path to the markdown file to format
|
||||
|
||||
Returns:
|
||||
Formatted content if successful, None if formatting failed.
|
||||
"""
|
||||
try:
|
||||
import mdformat
|
||||
except ImportError:
|
||||
logger.warning(
|
||||
"mdformat not installed, skipping built-in formatting",
|
||||
path=str(path),
|
||||
)
|
||||
return None
|
||||
|
||||
try:
|
||||
# Read original content
|
||||
async with aiofiles.open(path, mode="r", encoding="utf-8") as f:
|
||||
content = await f.read()
|
||||
|
||||
# Format using mdformat with GFM and frontmatter extensions
|
||||
# mdformat is synchronous, so we run it in a thread executor
|
||||
loop = asyncio.get_event_loop()
|
||||
formatted_content = await loop.run_in_executor(
|
||||
None,
|
||||
lambda: mdformat.text(
|
||||
content,
|
||||
extensions={"gfm", "frontmatter"}, # GFM + YAML frontmatter support
|
||||
options={"wrap": "no"}, # Don't wrap lines
|
||||
),
|
||||
)
|
||||
|
||||
# Only write if content changed
|
||||
if formatted_content != content:
|
||||
async with aiofiles.open(path, mode="w", encoding="utf-8") as f:
|
||||
await f.write(formatted_content)
|
||||
|
||||
logger.debug(
|
||||
"Formatted file with mdformat",
|
||||
path=str(path),
|
||||
changed=formatted_content != content,
|
||||
)
|
||||
return formatted_content
|
||||
|
||||
except Exception as e:
|
||||
logger.warning(
|
||||
"mdformat formatting failed",
|
||||
path=str(path),
|
||||
error=str(e),
|
||||
)
|
||||
return None
|
||||
|
||||
|
||||
async def format_file(
|
||||
path: Path,
|
||||
config: "BasicMemoryConfig",
|
||||
is_markdown: bool = False,
|
||||
) -> Optional[str]:
|
||||
"""
|
||||
Format a file using configured formatter.
|
||||
|
||||
By default, uses the built-in mdformat formatter for markdown files (pure Python,
|
||||
no Node.js required). External formatters like Prettier can be configured via
|
||||
formatter_command or per-extension formatters.
|
||||
|
||||
Args:
|
||||
path: File to format
|
||||
config: Configuration with formatter settings
|
||||
is_markdown: Whether this is a markdown file (caller should use FileService.is_markdown)
|
||||
|
||||
Returns:
|
||||
Formatted content if successful, None if formatting was skipped or failed.
|
||||
Failures are logged as warnings but don't raise exceptions.
|
||||
"""
|
||||
if not config.format_on_save:
|
||||
return None
|
||||
|
||||
extension = path.suffix.lstrip(".")
|
||||
formatter = config.formatters.get(extension) or config.formatter_command
|
||||
|
||||
# Use built-in mdformat for markdown files when no external formatter configured
|
||||
if not formatter:
|
||||
if is_markdown:
|
||||
return await format_markdown_builtin(path)
|
||||
else:
|
||||
logger.debug("No formatter configured for extension", extension=extension)
|
||||
return None
|
||||
|
||||
# Use external formatter
|
||||
# Replace {file} placeholder with the actual path
|
||||
cmd = formatter.replace("{file}", str(path))
|
||||
|
||||
try:
|
||||
# Parse command into args list for safer execution (no shell=True)
|
||||
args = shlex.split(cmd)
|
||||
|
||||
proc = await asyncio.create_subprocess_exec(
|
||||
*args,
|
||||
stdout=asyncio.subprocess.PIPE,
|
||||
stderr=asyncio.subprocess.PIPE,
|
||||
)
|
||||
|
||||
try:
|
||||
stdout, stderr = await asyncio.wait_for(
|
||||
proc.communicate(),
|
||||
timeout=config.formatter_timeout,
|
||||
)
|
||||
except asyncio.TimeoutError:
|
||||
proc.kill()
|
||||
await proc.wait()
|
||||
logger.warning(
|
||||
"Formatter timed out",
|
||||
path=str(path),
|
||||
timeout=config.formatter_timeout,
|
||||
)
|
||||
return None
|
||||
|
||||
if proc.returncode != 0:
|
||||
logger.warning(
|
||||
"Formatter exited with non-zero status",
|
||||
path=str(path),
|
||||
returncode=proc.returncode,
|
||||
stderr=stderr.decode("utf-8", errors="replace") if stderr else "",
|
||||
)
|
||||
# Still try to read the file - formatter may have partially worked
|
||||
# or the file may be unchanged
|
||||
|
||||
# Read formatted content
|
||||
async with aiofiles.open(path, mode="r", encoding="utf-8") as f:
|
||||
formatted_content = await f.read()
|
||||
|
||||
logger.debug(
|
||||
"Formatted file successfully",
|
||||
path=str(path),
|
||||
formatter=args[0] if args else formatter,
|
||||
)
|
||||
return formatted_content
|
||||
|
||||
except FileNotFoundError:
|
||||
# Formatter executable not found
|
||||
logger.warning(
|
||||
"Formatter executable not found",
|
||||
command=cmd.split()[0] if cmd else "",
|
||||
path=str(path),
|
||||
)
|
||||
return None
|
||||
except Exception as e:
|
||||
logger.warning(
|
||||
"Formatter failed",
|
||||
path=str(path),
|
||||
error=str(e),
|
||||
)
|
||||
return None
|
||||
|
||||
|
||||
def has_frontmatter(content: str) -> bool:
|
||||
"""
|
||||
Check if content contains valid YAML frontmatter.
|
||||
|
||||
@@ -230,6 +230,7 @@ class EntityParser:
|
||||
# Strip BOM before parsing (can be present in files from Windows or certain sources)
|
||||
# See issue #452
|
||||
from basic_memory.file_utils import strip_bom
|
||||
|
||||
content = strip_bom(content)
|
||||
|
||||
# Parse frontmatter with proper error handling for malformed YAML
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
from typing import TYPE_CHECKING, Optional
|
||||
from collections import OrderedDict
|
||||
|
||||
from frontmatter import Post
|
||||
@@ -11,6 +11,9 @@ from basic_memory.file_utils import dump_frontmatter
|
||||
from basic_memory.markdown.entity_parser import EntityParser
|
||||
from basic_memory.markdown.schemas import EntityMarkdown, Observation, Relation
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from basic_memory.config import BasicMemoryConfig
|
||||
|
||||
|
||||
class DirtyFileError(Exception):
|
||||
"""Raised when attempting to write to a file that has been modified."""
|
||||
@@ -36,9 +39,14 @@ class MarkdownProcessor:
|
||||
3. Track schema changes (that's done by the database)
|
||||
"""
|
||||
|
||||
def __init__(self, entity_parser: EntityParser):
|
||||
"""Initialize processor with base path and parser."""
|
||||
def __init__(
|
||||
self,
|
||||
entity_parser: EntityParser,
|
||||
app_config: Optional["BasicMemoryConfig"] = None,
|
||||
):
|
||||
"""Initialize processor with parser and optional config."""
|
||||
self.entity_parser = entity_parser
|
||||
self.app_config = app_config
|
||||
|
||||
async def read_file(self, path: Path) -> EntityMarkdown:
|
||||
"""Read and parse file into EntityMarkdown schema.
|
||||
@@ -123,7 +131,17 @@ class MarkdownProcessor:
|
||||
# Write atomically and return checksum of updated file
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
await file_utils.write_file_atomic(path, final_content)
|
||||
return await file_utils.compute_checksum(final_content)
|
||||
|
||||
# Format file if configured (MarkdownProcessor always handles markdown files)
|
||||
content_for_checksum = final_content
|
||||
if self.app_config:
|
||||
formatted_content = await file_utils.format_file(
|
||||
path, self.app_config, is_markdown=True
|
||||
)
|
||||
if formatted_content is not None:
|
||||
content_for_checksum = formatted_content
|
||||
|
||||
return await file_utils.compute_checksum(content_for_checksum)
|
||||
|
||||
def format_observations(self, observations: list[Observation]) -> str:
|
||||
"""Format observations section in standard way.
|
||||
|
||||
@@ -118,7 +118,11 @@ async def canvas(
|
||||
action = "Created"
|
||||
except Exception as e:
|
||||
# If creation failed due to conflict (already exists), try to update
|
||||
if "409" in str(e) or "conflict" in str(e).lower() or "already exists" in str(e).lower():
|
||||
if (
|
||||
"409" in str(e)
|
||||
or "conflict" in str(e).lower()
|
||||
or "already exists" in str(e).lower()
|
||||
):
|
||||
logger.info(f"Canvas file exists, updating instead: {file_path}")
|
||||
try:
|
||||
entity_id = await resolve_entity_id(client, active_project.id, file_path)
|
||||
|
||||
@@ -220,7 +220,9 @@ async def delete_note(
|
||||
|
||||
try:
|
||||
# Call the DELETE endpoint
|
||||
response = await call_delete(client, f"/v2/projects/{active_project.id}/knowledge/entities/{entity_id}")
|
||||
response = await call_delete(
|
||||
client, f"/v2/projects/{active_project.id}/knowledge/entities/{entity_id}"
|
||||
)
|
||||
result = DeleteEntitiesResponse.model_validate(response.json())
|
||||
|
||||
if result.deleted:
|
||||
|
||||
@@ -99,7 +99,9 @@ async def read_note(
|
||||
|
||||
# Get the file via REST API - first try direct identifier resolution
|
||||
entity_path = memory_url_path(identifier)
|
||||
logger.info(f"Attempting to read note from Project: {active_project.name} identifier: {entity_path}")
|
||||
logger.info(
|
||||
f"Attempting to read note from Project: {active_project.name} identifier: {entity_path}"
|
||||
)
|
||||
|
||||
try:
|
||||
# Try to resolve identifier to entity ID
|
||||
@@ -109,7 +111,7 @@ async def read_note(
|
||||
response = await call_get(
|
||||
client,
|
||||
f"/v2/projects/{active_project.id}/resource/{entity_id}",
|
||||
params={"page": page, "page_size": page_size}
|
||||
params={"page": page, "page_size": page_size},
|
||||
)
|
||||
|
||||
# If successful, return the content
|
||||
@@ -138,7 +140,7 @@ async def read_note(
|
||||
response = await call_get(
|
||||
client,
|
||||
f"/v2/projects/{active_project.id}/resource/{entity_id}",
|
||||
params={"page": page, "page_size": page_size}
|
||||
params={"page": page, "page_size": page_size},
|
||||
)
|
||||
|
||||
if response.status_code == 200:
|
||||
|
||||
@@ -451,9 +451,7 @@ async def resolve_entity_id(client: AsyncClient, project_id: int, identifier: st
|
||||
"""
|
||||
try:
|
||||
response = await call_post(
|
||||
client,
|
||||
f"/v2/projects/{project_id}/knowledge/resolve",
|
||||
json={"identifier": identifier}
|
||||
client, f"/v2/projects/{project_id}/knowledge/resolve", json={"identifier": identifier}
|
||||
)
|
||||
data = response.json()
|
||||
return data["entity_id"]
|
||||
|
||||
@@ -161,7 +161,11 @@ async def write_note(
|
||||
action = "Created"
|
||||
except Exception as e:
|
||||
# If creation failed due to conflict (already exists), try to update
|
||||
if "409" in str(e) or "conflict" in str(e).lower() or "already exists" in str(e).lower():
|
||||
if (
|
||||
"409" in str(e)
|
||||
or "conflict" in str(e).lower()
|
||||
or "already exists" in str(e).lower()
|
||||
):
|
||||
logger.debug(f"Entity exists, updating instead permalink={entity.permalink}")
|
||||
try:
|
||||
if not entity.permalink:
|
||||
|
||||
@@ -5,13 +5,16 @@ import hashlib
|
||||
import mimetypes
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
from typing import Any, Dict, Tuple, Union
|
||||
from typing import TYPE_CHECKING, Any, Dict, Optional, Tuple, Union
|
||||
|
||||
import aiofiles
|
||||
|
||||
import yaml
|
||||
|
||||
from basic_memory import file_utils
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from basic_memory.config import BasicMemoryConfig
|
||||
from basic_memory.file_utils import FileError, FileMetadata, ParseError
|
||||
from basic_memory.markdown.markdown_processor import MarkdownProcessor
|
||||
from basic_memory.models import Entity as EntityModel
|
||||
@@ -42,9 +45,11 @@ class FileService:
|
||||
base_path: Path,
|
||||
markdown_processor: MarkdownProcessor,
|
||||
max_concurrent_files: int = 10,
|
||||
app_config: Optional["BasicMemoryConfig"] = None,
|
||||
):
|
||||
self.base_path = base_path.resolve() # Get absolute path
|
||||
self.markdown_processor = markdown_processor
|
||||
self.app_config = app_config
|
||||
# Semaphore to limit concurrent file operations
|
||||
# Prevents OOM on large projects by processing files in batches
|
||||
self._file_semaphore = asyncio.Semaphore(max_concurrent_files)
|
||||
@@ -149,12 +154,15 @@ class FileService:
|
||||
Handles both absolute and relative paths. Relative paths are resolved
|
||||
against base_path.
|
||||
|
||||
If format_on_save is enabled in config, runs the configured formatter
|
||||
after writing and returns the checksum of the formatted content.
|
||||
|
||||
Args:
|
||||
path: Where to write (Path or string)
|
||||
content: Content to write
|
||||
|
||||
Returns:
|
||||
Checksum of written content
|
||||
Checksum of written content (or formatted content if formatting enabled)
|
||||
|
||||
Raises:
|
||||
FileOperationError: If write fails
|
||||
@@ -177,8 +185,17 @@ class FileService:
|
||||
|
||||
await file_utils.write_file_atomic(full_path, content)
|
||||
|
||||
# Compute and return checksum
|
||||
checksum = await file_utils.compute_checksum(content)
|
||||
# Format file if configured
|
||||
final_content = content
|
||||
if self.app_config:
|
||||
formatted_content = await file_utils.format_file(
|
||||
full_path, self.app_config, is_markdown=self.is_markdown(path)
|
||||
)
|
||||
if formatted_content is not None:
|
||||
final_content = formatted_content
|
||||
|
||||
# Compute and return checksum of final content
|
||||
checksum = await file_utils.compute_checksum(final_content)
|
||||
logger.debug(f"File write completed path={full_path}, {checksum=}")
|
||||
return checksum
|
||||
|
||||
@@ -405,7 +422,17 @@ class FileService:
|
||||
)
|
||||
|
||||
await file_utils.write_file_atomic(full_path, final_content)
|
||||
return await file_utils.compute_checksum(final_content)
|
||||
|
||||
# Format file if configured
|
||||
content_for_checksum = final_content
|
||||
if self.app_config:
|
||||
formatted_content = await file_utils.format_file(
|
||||
full_path, self.app_config, is_markdown=self.is_markdown(path)
|
||||
)
|
||||
if formatted_content is not None:
|
||||
content_for_checksum = formatted_content
|
||||
|
||||
return await file_utils.compute_checksum(content_for_checksum)
|
||||
|
||||
except Exception as e:
|
||||
# Only log real errors (not YAML parsing, which is handled above)
|
||||
|
||||
@@ -1220,8 +1220,8 @@ async def get_sync_service(project: Project) -> SyncService: # pragma: no cover
|
||||
|
||||
project_path = Path(project.path)
|
||||
entity_parser = EntityParser(project_path)
|
||||
markdown_processor = MarkdownProcessor(entity_parser)
|
||||
file_service = FileService(project_path, markdown_processor)
|
||||
markdown_processor = MarkdownProcessor(entity_parser, app_config=app_config)
|
||||
file_service = FileService(project_path, markdown_processor, app_config=app_config)
|
||||
|
||||
# Initialize repositories
|
||||
entity_repository = EntityRepository(session_maker, project_id=project.id)
|
||||
|
||||
Reference in New Issue
Block a user