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:
Paul Hernandez
2025-12-24 15:39:22 -06:00
committed by GitHub
parent 38919d11cb
commit 1fd680c3f1
33 changed files with 1249 additions and 124 deletions
+3
View File
@@ -38,6 +38,9 @@ dependencies = [
"nest-asyncio>=1.6.0", # For Alembic migrations with Postgres
"pytest-asyncio>=1.2.0",
"psycopg==3.3.1",
"mdformat>=0.7.22",
"mdformat-gfm>=0.3.7",
"mdformat-frontmatter>=2.0.8",
]
@@ -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,
+2 -1
View File
@@ -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",
]
+198
View File
@@ -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()
+3 -1
View File
@@ -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:
+22
View File
@@ -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,
+16 -8
View File
@@ -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}"
)
+169 -2
View File
@@ -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.
+5 -1
View File
@@ -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)
+3 -1
View File
@@ -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:
+5 -3
View File
@@ -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:
+1 -3
View File
@@ -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"]
+5 -1
View File
@@ -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:
+32 -5
View File
@@ -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)
+2 -2
View File
@@ -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)
@@ -65,7 +65,9 @@ async def test_create_project_basic_operation(mcp_server, app, test_project, tmp
"create_memory_project",
{
"project_name": "test-new-project",
"project_path": str(tmp_path.parent / (tmp_path.name + "-projects") / "project-test-new-project"),
"project_path": str(
tmp_path.parent / (tmp_path.name + "-projects") / "project-test-new-project"
),
},
)
@@ -97,7 +99,9 @@ async def test_create_project_with_default_flag(mcp_server, app, test_project, t
"create_memory_project",
{
"project_name": "test-default-project",
"project_path": str(tmp_path.parent / (tmp_path.name + "-projects") / "project-test-default-project"),
"project_path": str(
tmp_path.parent / (tmp_path.name + "-projects") / "project-test-default-project"
),
"set_default": True,
},
)
@@ -125,7 +129,9 @@ async def test_create_project_duplicate_name(mcp_server, app, test_project, tmp_
"create_memory_project",
{
"project_name": "duplicate-test",
"project_path": str(tmp_path.parent / (tmp_path.name + "-projects") / "project-duplicate-test-1"),
"project_path": str(
tmp_path.parent / (tmp_path.name + "-projects") / "project-duplicate-test-1"
),
},
)
@@ -135,7 +141,9 @@ async def test_create_project_duplicate_name(mcp_server, app, test_project, tmp_
"create_memory_project",
{
"project_name": "duplicate-test",
"project_path": str(tmp_path.parent / (tmp_path.name + "-projects") / "project-duplicate-test-2"),
"project_path": str(
tmp_path.parent / (tmp_path.name + "-projects") / "project-duplicate-test-2"
),
},
)
@@ -159,7 +167,9 @@ async def test_delete_project_basic_operation(mcp_server, app, test_project, tmp
"create_memory_project",
{
"project_name": "to-be-deleted",
"project_path": str(tmp_path.parent / (tmp_path.name + "-projects") / "project-to-be-deleted"),
"project_path": str(
tmp_path.parent / (tmp_path.name + "-projects") / "project-to-be-deleted"
),
},
)
@@ -245,7 +255,9 @@ async def test_project_lifecycle_workflow(mcp_server, app, test_project, tmp_pat
async with Client(mcp_server) as client:
project_name = "lifecycle-test"
project_path = str(tmp_path.parent / (tmp_path.name + "-projects") / "project-lifecycle-test")
project_path = str(
tmp_path.parent / (tmp_path.name + "-projects") / "project-lifecycle-test"
)
# 1. Create new project
create_result = await client.call_tool(
@@ -307,7 +319,11 @@ async def test_create_delete_project_edge_cases(mcp_server, app, test_project, t
"create_memory_project",
{
"project_name": special_name,
"project_path": str(tmp_path.parent / (tmp_path.name + "-projects") / "project-test-project-with-special-chars"),
"project_path": str(
tmp_path.parent
/ (tmp_path.name + "-projects")
/ "project-test-project-with-special-chars"
),
},
)
assert "" in create_result.content[0].text # pyright: ignore [reportAttributeAccessIssue]
@@ -343,7 +359,9 @@ async def test_case_insensitive_project_switching(mcp_server, app, test_project,
"create_memory_project",
{
"project_name": project_name,
"project_path": str(tmp_path.parent / (tmp_path.name + "-projects") / f"project-{project_name}"),
"project_path": str(
tmp_path.parent / (tmp_path.name + "-projects") / f"project-{project_name}"
),
},
)
assert "" in create_result.content[0].text # pyright: ignore [reportAttributeAccessIssue]
@@ -394,7 +412,9 @@ async def test_case_insensitive_project_operations(mcp_server, app, test_project
"create_memory_project",
{
"project_name": project_name,
"project_path": str(tmp_path.parent / (tmp_path.name + "-projects") / f"project-{project_name}"),
"project_path": str(
tmp_path.parent / (tmp_path.name + "-projects") / f"project-{project_name}"
),
},
)
assert "" in create_result.content[0].text # pyright: ignore [reportAttributeAccessIssue]
@@ -483,7 +503,9 @@ async def test_case_preservation_in_project_list(mcp_server, app, test_project,
"create_memory_project",
{
"project_name": project_name,
"project_path": str(tmp_path.parent / (tmp_path.name + "-projects") / f"project-{project_name}"),
"project_path": str(
tmp_path.parent / (tmp_path.name + "-projects") / f"project-{project_name}"
),
},
)
@@ -522,7 +544,9 @@ async def test_nested_project_paths_rejected(mcp_server, app, test_project, tmp_
async with Client(mcp_server) as client:
# Create a parent project
parent_name = "parent-project"
parent_path = str(tmp_path.parent / (tmp_path.name + "-projects") / "project-nested-test/parent")
parent_path = str(
tmp_path.parent / (tmp_path.name + "-projects") / "project-nested-test/parent"
)
await client.call_tool(
"create_memory_project",
@@ -534,7 +558,9 @@ async def test_nested_project_paths_rejected(mcp_server, app, test_project, tmp_
# Try to create a child project nested under the parent
child_name = "child-project"
child_path = str(tmp_path.parent / (tmp_path.name + "-projects") / "project-nested-test/parent/child")
child_path = str(
tmp_path.parent / (tmp_path.name + "-projects") / "project-nested-test/parent/child"
)
with pytest.raises(Exception) as exc_info:
await client.call_tool(
+3 -6
View File
@@ -251,10 +251,9 @@ async def test_update_project_active_status(
status_response = ProjectStatusResponse.model_validate(response.json())
assert status_response.status == "success"
@pytest.mark.asyncio
async def test_resolve_project_by_name(
client: AsyncClient, test_project: Project, v2_projects_url
):
async def test_resolve_project_by_name(client: AsyncClient, test_project: Project, v2_projects_url):
"""Test resolving a project by name returns correct project ID."""
resolve_data = {"identifier": test_project.name}
response = await client.post(f"{v2_projects_url}/resolve", json=resolve_data)
@@ -290,9 +289,7 @@ async def test_resolve_project_by_permalink(
@pytest.mark.asyncio
async def test_resolve_project_by_id(
client: AsyncClient, test_project: Project, v2_projects_url
):
async def test_resolve_project_by_id(client: AsyncClient, test_project: Project, v2_projects_url):
"""Test resolving a project by ID string returns correct project ID."""
resolve_data = {"identifier": str(test_project.id)}
response = await client.post(f"{v2_projects_url}/resolve", json=resolve_data)
+85
View File
@@ -0,0 +1,85 @@
"""Test that CLI tool commands exit cleanly without hanging.
This test ensures that CLI commands properly clean up database connections
on exit, preventing process hangs. See GitHub issue for details.
The issue occurs when:
1. ensure_initialization() calls asyncio.run(initialize_app())
2. initialize_app() creates global database connections via db.get_or_create_db()
3. When asyncio.run() completes, the event loop closes
4. But the global database engine holds async connections that prevent clean exit
5. Process hangs indefinitely
The fix ensures db.shutdown_db() is called before asyncio.run() returns.
"""
import subprocess
import sys
import pytest
class TestCLIToolExit:
"""Test that CLI tool commands exit cleanly."""
@pytest.mark.parametrize(
"command",
[
["tool", "--help"],
["tool", "write-note", "--help"],
["tool", "read-note", "--help"],
["tool", "search-notes", "--help"],
["tool", "build-context", "--help"],
],
)
def test_cli_command_exits_cleanly(self, command: list[str]):
"""Test that CLI commands exit without hanging.
Each command should complete within the timeout without requiring
manual termination (Ctrl+C).
"""
full_command = [sys.executable, "-m", "basic_memory.cli.main"] + command
try:
result = subprocess.run(
full_command,
capture_output=True,
text=True,
timeout=10.0, # 10 second timeout - commands should complete in ~2s
)
# Command should exit with code 0 for --help
assert result.returncode == 0, f"Command failed: {result.stderr}"
except subprocess.TimeoutExpired:
pytest.fail(
f"Command '{' '.join(command)}' hung and did not exit within timeout. "
"This indicates database connections are not being cleaned up properly."
)
def test_ensure_initialization_exits_cleanly(self):
"""Test that ensure_initialization doesn't cause process hang.
This test directly tests the initialization function that's called
by CLI commands, ensuring it cleans up database connections properly.
"""
code = """
import asyncio
from basic_memory.config import ConfigManager
from basic_memory.services.initialization import ensure_initialization
app_config = ConfigManager().config
ensure_initialization(app_config)
print("OK")
"""
try:
result = subprocess.run(
[sys.executable, "-c", code],
capture_output=True,
text=True,
timeout=10.0,
)
assert "OK" in result.stdout, f"Unexpected output: {result.stdout}"
except subprocess.TimeoutExpired:
pytest.fail(
"ensure_initialization() caused process hang. "
"Database connections are not being cleaned up before event loop closes."
)
+46 -35
View File
@@ -4,7 +4,6 @@ This test verifies issue #452 - Imported conversations not indexed correctly.
"""
import pytest
from pathlib import Path
from basic_memory.config import ProjectConfig
from basic_memory.importers.claude_conversations_importer import ClaudeConversationsImporter
@@ -40,44 +39,48 @@ async def test_imported_conversations_have_correct_permalink_and_title(
importer = ClaudeConversationsImporter(base_path, processor)
# Sample conversation data
conversations = [{
'uuid': 'test-123',
'name': 'My Test Conversation Title',
'created_at': '2025-01-15T10:00:00Z',
'updated_at': '2025-01-15T11:00:00Z',
'chat_messages': [
{
'uuid': 'msg-1',
'sender': 'human',
'created_at': '2025-01-15T10:00:00Z',
'text': 'Hello world',
'content': [{'type': 'text', 'text': 'Hello world'}],
'attachments': []
},
{
'uuid': 'msg-2',
'sender': 'assistant',
'created_at': '2025-01-15T10:01:00Z',
'text': 'Hello!',
'content': [{'type': 'text', 'text': 'Hello!'}],
'attachments': []
}
]
}]
conversations = [
{
"uuid": "test-123",
"name": "My Test Conversation Title",
"created_at": "2025-01-15T10:00:00Z",
"updated_at": "2025-01-15T11:00:00Z",
"chat_messages": [
{
"uuid": "msg-1",
"sender": "human",
"created_at": "2025-01-15T10:00:00Z",
"text": "Hello world",
"content": [{"type": "text", "text": "Hello world"}],
"attachments": [],
},
{
"uuid": "msg-2",
"sender": "assistant",
"created_at": "2025-01-15T10:01:00Z",
"text": "Hello!",
"content": [{"type": "text", "text": "Hello!"}],
"attachments": [],
},
],
}
]
# Run import
result = await importer.import_data(conversations, 'conversations')
result = await importer.import_data(conversations, "conversations")
assert result.success, f"Import failed: {result}"
assert result.conversations == 1
# Verify the file was created with correct content
conv_path = base_path / 'conversations' / '20250115-My_Test_Conversation_Title.md'
conv_path = base_path / "conversations" / "20250115-My_Test_Conversation_Title.md"
assert conv_path.exists(), f"Expected file at {conv_path}"
content = conv_path.read_text()
assert '---' in content, "File should have frontmatter markers"
assert 'title: My Test Conversation Title' in content, "File should have title in frontmatter"
assert 'permalink: conversations/20250115-My_Test_Conversation_Title' in content, "File should have permalink in frontmatter"
assert "---" in content, "File should have frontmatter markers"
assert "title: My Test Conversation Title" in content, "File should have title in frontmatter"
assert "permalink: conversations/20250115-My_Test_Conversation_Title" in content, (
"File should have permalink in frontmatter"
)
# Run sync to index the imported file
await sync_service.sync(base_path, project_config.name)
@@ -89,15 +92,23 @@ async def test_imported_conversations_have_correct_permalink_and_title(
entity = entities[0]
# These are the key assertions for issue #452
assert entity.title == 'My Test Conversation Title', f"Title should be from frontmatter, got: {entity.title}"
assert entity.permalink == 'conversations/20250115-My_Test_Conversation_Title', f"Permalink should be from frontmatter, got: {entity.permalink}"
assert entity.title == "My Test Conversation Title", (
f"Title should be from frontmatter, got: {entity.title}"
)
assert entity.permalink == "conversations/20250115-My_Test_Conversation_Title", (
f"Permalink should be from frontmatter, got: {entity.permalink}"
)
# Verify search index also has correct data
results = await search_service.search(SearchQuery(text='Test Conversation'))
results = await search_service.search(SearchQuery(text="Test Conversation"))
assert len(results) >= 1, "Should find the conversation in search"
# Find our entity in search results
search_result = next((r for r in results if r.entity_id == entity.id), None)
assert search_result is not None, "Entity should be in search results"
assert search_result.title == 'My Test Conversation Title', f"Search title should be from frontmatter, got: {search_result.title}"
assert search_result.permalink == 'conversations/20250115-My_Test_Conversation_Title', f"Search permalink should not be null, got: {search_result.permalink}"
assert search_result.title == "My Test Conversation Title", (
f"Search title should be from frontmatter, got: {search_result.title}"
)
assert search_result.permalink == "conversations/20250115-My_Test_Conversation_Title", (
f"Search permalink should not be null, got: {search_result.permalink}"
)
+4 -4
View File
@@ -90,10 +90,10 @@ async def test_parse_complete_file(project_config, entity_parser, valid_entity_c
Relation(type="links_to", target="Random Link", context=None) in entity.relations
or Relation(type="links to", target="Random Link", context=None) in entity.relations
), "missing [[Random Link]]"
assert (
Relation(type="links_to", target="Random Link with Title|Titled Link", context=None)
in entity.relations
or Relation(type="links to", target="Random Link with Title|Titled Link", context=None)
assert Relation(
type="links_to", target="Random Link with Title|Titled Link", context=None
) in entity.relations or Relation(
type="links to", target="Random Link with Title|Titled Link", context=None
), "missing [[Random Link with Title|Titled Link]]"
+17 -5
View File
@@ -140,7 +140,10 @@ class TestReadContentSecurityValidation:
with patch("basic_memory.mcp.tools.read_content.call_get") as mock_call_get:
with patch("basic_memory.mcp.tools.read_content.resolve_entity_id") as mock_resolve:
mock_response = MagicMock()
mock_response.headers = {"content-type": "text/markdown", "content-length": "100"}
mock_response.headers = {
"content-type": "text/markdown",
"content-length": "100",
}
mock_response.text = f"# Content for {safe_path}\nThis is test content."
mock_call_get.return_value = mock_response
mock_resolve.return_value = 123
@@ -223,7 +226,10 @@ class TestReadContentSecurityValidation:
with patch("basic_memory.mcp.tools.read_content.call_get") as mock_call_get:
with patch("basic_memory.mcp.tools.read_content.resolve_entity_id") as mock_resolve:
mock_response = MagicMock()
mock_response.headers = {"content-type": "text/markdown", "content-length": "100"}
mock_response.headers = {
"content-type": "text/markdown",
"content-length": "100",
}
mock_response.text = f"# Content for {safe_path}"
mock_call_get.return_value = mock_response
mock_resolve.return_value = 123
@@ -262,7 +268,9 @@ class TestReadContentFunctionality:
mock_call_get.return_value = mock_response
mock_resolve.return_value = 123
result = await read_content.fn(project=test_project.name, path="docs/test-document.md")
result = await read_content.fn(
project=test_project.name, path="docs/test-document.md"
)
assert isinstance(result, dict)
assert result["type"] == "text"
@@ -297,7 +305,9 @@ class TestReadContentFunctionality:
mock_img.getbands.return_value = ["R", "G", "B"]
mock_pil.open.return_value = mock_img
with patch("basic_memory.mcp.tools.read_content.optimize_image") as mock_optimize:
with patch(
"basic_memory.mcp.tools.read_content.optimize_image"
) as mock_optimize:
mock_optimize.return_value = b"optimized_image_data"
result = await read_content.fn(
@@ -379,7 +389,9 @@ class TestReadContentFunctionality:
mock_call_get.return_value = mock_response
mock_resolve.return_value = 123
result = await read_content.fn(project=test_project.name, path="files/safe-binary.bin")
result = await read_content.fn(
project=test_project.name, path="files/safe-binary.bin"
)
assert isinstance(result, dict)
assert result["type"] == "document"
+4 -1
View File
@@ -277,7 +277,10 @@ async def test_view_note_direct_success(app, test_project, mock_call_get):
# Verify direct lookup was used
mock_call_get.assert_called_once()
assert "test/test-note" in mock_call_get.call_args[0][1] or "/resource/123" in mock_call_get.call_args[0][1]
assert (
"test/test-note" in mock_call_get.call_args[0][1]
or "/resource/123" in mock_call_get.call_args[0][1]
)
# Verify result contains note content
assert 'Note retrieved: "test/test-note"' in result
+4 -2
View File
@@ -2102,12 +2102,14 @@ async def test_sync_handles_file_not_found_gracefully(
async def mock_read_that_fails(*args, **kwargs):
raise FileNotFoundError("Simulated file not found")
with patch.object(sync_service.file_service, "read_file_content", side_effect=mock_read_that_fails):
with patch.object(
sync_service.file_service, "read_file_content", side_effect=mock_read_that_fails
):
# Force full scan to detect the file
await force_full_scan(sync_service)
# Sync should handle the error gracefully and delete the orphaned entity
report = await sync_service.sync(project_dir)
await sync_service.sync(project_dir)
# Should not crash and should not have errors (FileNotFoundError is handled specially)
# The file should be treated as deleted
+113
View File
@@ -483,3 +483,116 @@ class TestPlatformNativePathSeparators:
else:
# Unix: should have forward slashes
assert "/" in main_path
class TestFormattingConfig:
"""Test file formatting configuration options."""
def test_format_on_save_defaults_to_false(self):
"""Test that format_on_save is disabled by default."""
config = BasicMemoryConfig()
assert config.format_on_save is False
def test_format_on_save_can_be_enabled(self):
"""Test that format_on_save can be set to True."""
config = BasicMemoryConfig(format_on_save=True)
assert config.format_on_save is True
def test_formatter_command_defaults_to_none(self):
"""Test that formatter_command defaults to None (uses built-in mdformat)."""
config = BasicMemoryConfig()
assert config.formatter_command is None
def test_formatter_command_can_be_set(self):
"""Test that formatter_command can be configured."""
config = BasicMemoryConfig(formatter_command="prettier --write {file}")
assert config.formatter_command == "prettier --write {file}"
def test_formatters_defaults_to_empty_dict(self):
"""Test that formatters defaults to empty dict."""
config = BasicMemoryConfig()
assert config.formatters == {}
def test_formatters_can_be_configured(self):
"""Test that per-extension formatters can be configured."""
config = BasicMemoryConfig(
formatters={
"md": "prettier --write {file}",
"json": "jq . {file} > {file}.tmp && mv {file}.tmp {file}",
}
)
assert config.formatters["md"] == "prettier --write {file}"
assert "json" in config.formatters
def test_formatter_timeout_defaults_to_5_seconds(self):
"""Test that formatter_timeout defaults to 5.0 seconds."""
config = BasicMemoryConfig()
assert config.formatter_timeout == 5.0
def test_formatter_timeout_can_be_customized(self):
"""Test that formatter_timeout can be set to a different value."""
config = BasicMemoryConfig(formatter_timeout=10.0)
assert config.formatter_timeout == 10.0
def test_formatter_timeout_must_be_positive(self):
"""Test that formatter_timeout validation rejects non-positive values."""
import pydantic
with pytest.raises(pydantic.ValidationError):
BasicMemoryConfig(formatter_timeout=0)
with pytest.raises(pydantic.ValidationError):
BasicMemoryConfig(formatter_timeout=-1)
def test_formatting_env_vars(self, monkeypatch):
"""Test that formatting config can be set via environment variables."""
monkeypatch.setenv("BASIC_MEMORY_FORMAT_ON_SAVE", "true")
monkeypatch.setenv("BASIC_MEMORY_FORMATTER_COMMAND", "prettier --write {file}")
monkeypatch.setenv("BASIC_MEMORY_FORMATTER_TIMEOUT", "10.0")
config = BasicMemoryConfig()
assert config.format_on_save is True
assert config.formatter_command == "prettier --write {file}"
assert config.formatter_timeout == 10.0
def test_formatters_env_var_json(self, monkeypatch):
"""Test that formatters dict can be set via JSON environment variable."""
import json
formatters_json = json.dumps({"md": "prettier --write {file}", "json": "jq . {file}"})
monkeypatch.setenv("BASIC_MEMORY_FORMATTERS", formatters_json)
config = BasicMemoryConfig()
assert config.formatters == {"md": "prettier --write {file}", "json": "jq . {file}"}
def test_save_and_load_formatting_config(self):
"""Test that formatting config survives save/load cycle."""
with tempfile.TemporaryDirectory() as temp_dir:
temp_path = Path(temp_dir)
config_manager = ConfigManager()
config_manager.config_dir = temp_path / "basic-memory"
config_manager.config_file = config_manager.config_dir / "config.json"
config_manager.config_dir.mkdir(parents=True, exist_ok=True)
# Create config with formatting settings
test_config = BasicMemoryConfig(
projects={"main": str(temp_path / "main")},
format_on_save=True,
formatter_command="prettier --write {file}",
formatters={"md": "prettier --write {file}", "json": "prettier --write {file}"},
formatter_timeout=10.0,
)
config_manager.save_config(test_config)
# Load and verify
loaded_config = config_manager.load_config()
assert loaded_config.format_on_save is True
assert loaded_config.formatter_command == "prettier --write {file}"
assert loaded_config.formatters == {
"md": "prettier --write {file}",
"json": "prettier --write {file}",
}
assert loaded_config.formatter_timeout == 10.0
+311 -10
View File
@@ -1,16 +1,20 @@
"""Tests for file utilities."""
import random
import string
import sys
from pathlib import Path
import pytest
import random
import string
from basic_memory.config import BasicMemoryConfig
from basic_memory.file_utils import (
FileError,
FileWriteError,
ParseError,
compute_checksum,
format_file,
format_markdown_builtin,
has_frontmatter,
parse_frontmatter,
remove_frontmatter,
@@ -19,6 +23,11 @@ from basic_memory.file_utils import (
write_file_atomic,
)
# Skip marker for tests that use Unix-specific commands (cat, sh, sleep, /dev/null)
skip_on_windows = pytest.mark.skipif(
sys.platform == "win32", reason="Test uses Unix-specific commands not available on Windows"
)
def get_random_word(length: int = 12, necessary_char: str | None = None) -> str:
letters = string.ascii_lowercase
@@ -177,7 +186,6 @@ title: Test""")
assert "Invalid frontmatter format" in str(exc.value)
@pytest.mark.asyncio
def test_sanitize_for_filename_removes_invalid_characters():
# Test all invalid characters listed in the regex
invalid_chars = '<>:"|?*'
@@ -216,6 +224,299 @@ def test_sanitize_for_folder_edge_cases(input_folder, expected):
assert sanitize_for_folder(input_folder) == expected
# =============================================================================
# format_file tests
# =============================================================================
@pytest.mark.asyncio
async def test_format_file_disabled_by_default(tmp_path: Path):
"""Test that format_file returns None when format_on_save is False (default)."""
test_file = tmp_path / "test.md"
test_file.write_text("# Test\n")
config = BasicMemoryConfig()
assert config.format_on_save is False
result = await format_file(test_file, config)
assert result is None
@pytest.mark.asyncio
async def test_format_file_no_formatter_uses_builtin_for_markdown(tmp_path: Path):
"""Test that format_file uses built-in mdformat for markdown when no external formatter configured."""
test_file = tmp_path / "test.md"
test_file.write_text("# Test\n")
# No external formatter configured - should use built-in mdformat for markdown
config = BasicMemoryConfig(format_on_save=True, formatter_command=None)
result = await format_file(test_file, config, is_markdown=True)
# mdformat should return formatted content
assert result is not None
assert "# Test" in result
@pytest.mark.asyncio
async def test_format_file_no_formatter_for_non_markdown(tmp_path: Path):
"""Test that format_file returns None for non-markdown files when no formatter configured."""
test_file = tmp_path / "test.txt"
test_file.write_text("Some text\n")
# No external formatter configured - should return None for non-markdown
config = BasicMemoryConfig(format_on_save=True, formatter_command=None)
result = await format_file(test_file, config, is_markdown=False)
assert result is None
@skip_on_windows
@pytest.mark.asyncio
async def test_format_file_with_global_formatter(tmp_path: Path):
"""Test formatting with global formatter_command."""
test_file = tmp_path / "test.md"
original_content = "# Test\n"
test_file.write_text(original_content)
# Use a simple formatter that just echoes content (cat)
config = BasicMemoryConfig(
format_on_save=True,
formatter_command="cat {file}", # This doesn't modify the file but runs successfully
)
result = await format_file(test_file, config)
assert result == original_content
@skip_on_windows
@pytest.mark.asyncio
async def test_format_file_with_extension_specific_formatter(tmp_path: Path):
"""Test formatting with extension-specific formatter."""
test_file = tmp_path / "test.json"
original_content = '{"key": "value"}'
test_file.write_text(original_content)
config = BasicMemoryConfig(
format_on_save=True,
formatter_command="echo global", # This should NOT be used
formatters={"json": "cat {file}"}, # Extension-specific should be used
)
result = await format_file(test_file, config)
assert result == original_content
@skip_on_windows
@pytest.mark.asyncio
async def test_format_file_extension_specific_overrides_global(tmp_path: Path):
"""Test that extension-specific formatter takes precedence over global."""
test_file = tmp_path / "test.md"
original_content = "# Test\n"
test_file.write_text(original_content)
# Use different commands to verify which one is used
# Since cat just reads the file, we can tell which was used by the content
config = BasicMemoryConfig(
format_on_save=True,
formatter_command="cat /dev/null", # Would return empty
formatters={"md": "cat {file}"}, # Should return original content
)
result = await format_file(test_file, config)
assert result == original_content
@skip_on_windows
@pytest.mark.asyncio
async def test_format_file_falls_back_to_global(tmp_path: Path):
"""Test that global formatter is used when no extension-specific one exists."""
test_file = tmp_path / "test.txt" # No extension-specific formatter for .txt
original_content = "Some text\n"
test_file.write_text(original_content)
config = BasicMemoryConfig(
format_on_save=True,
formatter_command="cat {file}",
formatters={"md": "echo wrong"}, # Only for .md, not .txt
)
result = await format_file(test_file, config)
assert result == original_content
@pytest.mark.asyncio
async def test_format_file_handles_nonexistent_formatter(tmp_path: Path):
"""Test that format_file handles missing formatter executable gracefully."""
test_file = tmp_path / "test.md"
test_file.write_text("# Test\n")
config = BasicMemoryConfig(
format_on_save=True,
formatter_command="nonexistent_formatter_executable_12345 {file}",
)
result = await format_file(test_file, config)
assert result is None # Should return None on error
@skip_on_windows
@pytest.mark.asyncio
async def test_format_file_handles_timeout(tmp_path: Path):
"""Test that format_file handles formatter timeout gracefully."""
test_file = tmp_path / "test.md"
test_file.write_text("# Test\n")
config = BasicMemoryConfig(
format_on_save=True,
formatter_command="sleep 10", # Will timeout
formatter_timeout=0.1, # Very short timeout
)
result = await format_file(test_file, config)
assert result is None # Should return None on timeout
@skip_on_windows
@pytest.mark.asyncio
async def test_format_file_handles_nonzero_exit(tmp_path: Path):
"""Test that format_file handles non-zero exit codes gracefully."""
test_file = tmp_path / "test.md"
original_content = "# Test\n"
test_file.write_text(original_content)
config = BasicMemoryConfig(
format_on_save=True,
formatter_command="sh -c 'exit 1'", # Non-zero exit
)
result = await format_file(test_file, config)
# Should still return file content even with non-zero exit
assert result == original_content
@skip_on_windows
@pytest.mark.asyncio
async def test_format_file_returns_modified_content(tmp_path: Path):
"""Test that format_file returns the modified file content after formatting."""
test_file = tmp_path / "test.md"
original_content = "original content"
test_file.write_text(original_content)
# This formatter modifies the file to contain different content
config = BasicMemoryConfig(
format_on_save=True,
formatter_command="sh -c 'echo modified > {file}'",
)
result = await format_file(test_file, config)
assert result == "modified\n"
assert test_file.read_text() == "modified\n"
@skip_on_windows
@pytest.mark.asyncio
async def test_format_file_with_spaces_in_path(tmp_path: Path):
"""Test formatting files with spaces in path."""
subdir = tmp_path / "path with spaces"
subdir.mkdir()
test_file = subdir / "my file.md"
original_content = "# Test\n"
test_file.write_text(original_content)
config = BasicMemoryConfig(
format_on_save=True,
formatter_command="cat {file}",
)
result = await format_file(test_file, config)
assert result == original_content
# =============================================================================
# format_markdown_builtin tests
# =============================================================================
@pytest.mark.asyncio
async def test_format_markdown_builtin_formats_content(tmp_path: Path):
"""Test that format_markdown_builtin formats markdown content."""
test_file = tmp_path / "test.md"
# Markdown with inconsistent formatting
test_file.write_text("# Title\n\n*emphasis* and **bold**\n")
result = await format_markdown_builtin(test_file)
assert result is not None
assert "# Title" in result
assert "*emphasis*" in result or "_emphasis_" in result
@pytest.mark.asyncio
async def test_format_markdown_builtin_preserves_frontmatter(tmp_path: Path):
"""Test that format_markdown_builtin preserves YAML frontmatter."""
test_file = tmp_path / "test.md"
content = """---
title: Test Note
tags:
- test
- markdown
---
# Content
Some text here.
"""
test_file.write_text(content)
result = await format_markdown_builtin(test_file)
assert result is not None
assert "---" in result
assert "title: Test Note" in result
assert "# Content" in result
@pytest.mark.asyncio
async def test_format_markdown_builtin_handles_gfm_tables(tmp_path: Path):
"""Test that format_markdown_builtin handles GFM tables."""
test_file = tmp_path / "test.md"
content = """# Table Test
| Column 1 | Column 2 |
|----------|----------|
| A | B |
| C | D |
"""
test_file.write_text(content)
result = await format_markdown_builtin(test_file)
assert result is not None
assert "Column 1" in result
assert "|" in result
@pytest.mark.asyncio
async def test_format_markdown_builtin_only_writes_if_changed(tmp_path: Path):
"""Test that format_markdown_builtin only writes if content changed."""
test_file = tmp_path / "test.md"
# Already well-formatted content
content = "# Title\n\nSome text.\n"
test_file.write_text(content)
test_file.stat().st_mtime
result = await format_markdown_builtin(test_file)
assert result is not None
# File should not have been rewritten if content didn't change
# (This is a best-effort check - mtime may or may not change depending on OS)
# =============================================================================
# BOM handling tests
# =============================================================================
class TestBOMHandling:
"""Test handling of Byte Order Mark (BOM) in frontmatter.
@@ -227,23 +528,23 @@ class TestBOMHandling:
def test_has_frontmatter_with_bom(self):
"""Test that has_frontmatter handles BOM correctly."""
# Content with UTF-8 BOM
content_with_bom = '\ufeff---\ntitle: Test\n---\nContent'
content_with_bom = "\ufeff---\ntitle: Test\n---\nContent"
assert has_frontmatter(content_with_bom), "Should detect frontmatter even with BOM"
def test_has_frontmatter_with_bom_and_windows_crlf(self):
"""Test BOM with Windows line endings."""
content = '\ufeff---\r\ntitle: Test\r\n---\r\nContent'
content = "\ufeff---\r\ntitle: Test\r\n---\r\nContent"
assert has_frontmatter(content), "Should detect frontmatter with BOM and CRLF"
def test_parse_frontmatter_with_bom(self):
"""Test that parse_frontmatter handles BOM correctly."""
content_with_bom = '\ufeff---\ntitle: Test Title\ntype: note\n---\nContent'
content_with_bom = "\ufeff---\ntitle: Test Title\ntype: note\n---\nContent"
result = parse_frontmatter(content_with_bom)
assert result['title'] == 'Test Title'
assert result['type'] == 'note'
assert result["title"] == "Test Title"
assert result["type"] == "note"
def test_remove_frontmatter_with_bom(self):
"""Test that remove_frontmatter handles BOM correctly."""
content_with_bom = '\ufeff---\ntitle: Test\n---\nContent here'
content_with_bom = "\ufeff---\ntitle: Test\n---\nContent here"
result = remove_frontmatter(content_with_bom)
assert result == 'Content here'
assert result == "Content here"
Generated
+121 -3
View File
@@ -129,6 +129,9 @@ dependencies = [
{ name = "loguru" },
{ name = "markdown-it-py" },
{ name = "mcp" },
{ name = "mdformat" },
{ name = "mdformat-frontmatter" },
{ name = "mdformat-gfm" },
{ name = "nest-asyncio" },
{ name = "pillow" },
{ name = "psycopg" },
@@ -177,6 +180,9 @@ requires-dist = [
{ name = "loguru", specifier = ">=0.7.3" },
{ name = "markdown-it-py", specifier = ">=3.0.0" },
{ name = "mcp", specifier = ">=1.2.0" },
{ name = "mdformat", specifier = ">=0.7.22" },
{ name = "mdformat-frontmatter", specifier = ">=2.0.8" },
{ name = "mdformat-gfm", specifier = ">=0.3.7" },
{ name = "nest-asyncio", specifier = ">=1.6.0" },
{ name = "pillow", specifier = ">=11.1.0" },
{ name = "psycopg", specifier = "==3.3.1" },
@@ -902,14 +908,14 @@ wheels = [
[[package]]
name = "markdown-it-py"
version = "4.0.0"
version = "3.0.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "mdurl" },
]
sdist = { url = "https://files.pythonhosted.org/packages/5b/f5/4ec618ed16cc4f8fb3b701563655a69816155e79e24a17b651541804721d/markdown_it_py-4.0.0.tar.gz", hash = "sha256:cb0a2b4aa34f932c007117b194e945bd74e0ec24133ceb5bac59009cda1cb9f3", size = 73070, upload-time = "2025-08-11T12:57:52.854Z" }
sdist = { url = "https://files.pythonhosted.org/packages/38/71/3b932df36c1a044d397a1f92d1cf91ee0a503d91e470cbd670aa66b07ed0/markdown-it-py-3.0.0.tar.gz", hash = "sha256:e3f60a94fa066dc52ec76661e37c851cb232d92f9886b15cb560aaada2df8feb", size = 74596, upload-time = "2023-06-03T06:41:14.443Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/94/54/e7d793b573f298e1c9013b8c4dade17d481164aa517d1d7148619c2cedbf/markdown_it_py-4.0.0-py3-none-any.whl", hash = "sha256:87327c59b172c5011896038353a81343b6754500a08cd7a4973bb48c6d578147", size = 87321, upload-time = "2025-08-11T12:57:51.923Z" },
{ url = "https://files.pythonhosted.org/packages/42/d7/1ec15b46af6af88f19b8e5ffea08fa375d433c998b8a7639e76935c14f1f/markdown_it_py-3.0.0-py3-none-any.whl", hash = "sha256:355216845c60bd96232cd8d8c40e8f9765cc86f46880e43a8fd22dc1a1a8cab1", size = 87528, upload-time = "2023-06-03T06:41:11.019Z" },
]
[[package]]
@@ -972,6 +978,59 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/8b/6b/46b8bcefc2ee9e2d2e8d2bd25f1c2512f5a879fac4619d716b194d6e7ccc/mcp-1.13.0-py3-none-any.whl", hash = "sha256:8b1a002ebe6e17e894ec74d1943cc09aa9d23cb931bf58d49ab2e9fa6bb17e4b", size = 160226, upload-time = "2025-08-14T15:03:56.641Z" },
]
[[package]]
name = "mdformat"
version = "0.7.22"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "markdown-it-py" },
]
sdist = { url = "https://files.pythonhosted.org/packages/fc/eb/b5cbf2484411af039a3d4aeb53a5160fae25dd8c84af6a4243bc2f3fedb3/mdformat-0.7.22.tar.gz", hash = "sha256:eef84fa8f233d3162734683c2a8a6222227a229b9206872e6139658d99acb1ea", size = 34610, upload-time = "2025-01-30T18:00:51.418Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/f2/6f/94a7344f6d634fe3563bea8b33bccedee37f2726f7807e9a58440dc91627/mdformat-0.7.22-py3-none-any.whl", hash = "sha256:61122637c9e1d9be1329054f3fa216559f0d1f722b7919b060a8c2a4ae1850e5", size = 34447, upload-time = "2025-01-30T18:00:48.708Z" },
]
[[package]]
name = "mdformat-frontmatter"
version = "2.0.8"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "mdformat" },
{ name = "mdit-py-plugins" },
{ name = "ruamel-yaml" },
]
sdist = { url = "https://files.pythonhosted.org/packages/71/aa/70876bec1e66f2b1ab26f31d226c09bf7b78d3d27f03c2642d1d69d1ae77/mdformat_frontmatter-2.0.8.tar.gz", hash = "sha256:c11190ae3f9c91ada78fbd820f5b221631b520484e0b644715aa0f6ed7f097ed", size = 3254, upload-time = "2023-11-07T06:53:18.258Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/5d/51/b3da1292c32819c52a4e4242ad94c3c07189ca70d228b3909a58f1f3a819/mdformat_frontmatter-2.0.8-py3-none-any.whl", hash = "sha256:577396695af96ad66dff1ff781284ff3764a10be3ab8659f2ef842ab42264ebb", size = 3747, upload-time = "2023-11-07T06:53:16.972Z" },
]
[[package]]
name = "mdformat-gfm"
version = "1.0.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "markdown-it-py" },
{ name = "mdformat" },
{ name = "mdit-py-plugins" },
{ name = "wcwidth" },
]
sdist = { url = "https://files.pythonhosted.org/packages/56/6f/a626ebb142a290474401b67e2d61e73ce096bf7798ee22dfe6270f924b3f/mdformat_gfm-1.0.0.tar.gz", hash = "sha256:d1d49a409a6acb774ce7635c72d69178df7dce1dc8cdd10e19f78e8e57b72623", size = 10112, upload-time = "2025-10-16T09:12:22.402Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/e6/18/6bc2189b744dd383cad03764f41f30352b1278d2205096f77a29c0b327ad/mdformat_gfm-1.0.0-py3-none-any.whl", hash = "sha256:7305a50efd2a140d7c83505b58e3ac5df2b09e293f9bbe72f6c7bee8c678b005", size = 10970, upload-time = "2025-10-16T09:12:21.276Z" },
]
[[package]]
name = "mdit-py-plugins"
version = "0.5.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "markdown-it-py" },
]
sdist = { url = "https://files.pythonhosted.org/packages/b2/fd/a756d36c0bfba5f6e39a1cdbdbfdd448dc02692467d83816dff4592a1ebc/mdit_py_plugins-0.5.0.tar.gz", hash = "sha256:f4918cb50119f50446560513a8e311d574ff6aaed72606ddae6d35716fe809c6", size = 44655, upload-time = "2025-08-11T07:25:49.083Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/fb/86/dd6e5db36df29e76c7a7699123569a4a18c1623ce68d826ed96c62643cae/mdit_py_plugins-0.5.0-py3-none-any.whl", hash = "sha256:07a08422fc1936a5d26d146759e9155ea466e842f5ab2f7d2266dd084c8dab1f", size = 57205, upload-time = "2025-08-11T07:25:47.597Z" },
]
[[package]]
name = "mdurl"
version = "0.1.2"
@@ -1755,6 +1814,56 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/e2/3f/d6c216ed5199c9ef79e2a33955601f454ed1e7420a93b89670133bca5ace/rpds_py-0.27.0-cp314-cp314t-win_amd64.whl", hash = "sha256:8a1dca5507fa1337f75dcd5070218b20bc68cf8844271c923c1b79dfcbc20391", size = 230993, upload-time = "2025-08-07T08:25:23.34Z" },
]
[[package]]
name = "ruamel-yaml"
version = "0.18.17"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "ruamel-yaml-clib", marker = "python_full_version < '3.15' and platform_python_implementation == 'CPython'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/3a/2b/7a1f1ebcd6b3f14febdc003e658778d81e76b40df2267904ee6b13f0c5c6/ruamel_yaml-0.18.17.tar.gz", hash = "sha256:9091cd6e2d93a3a4b157ddb8fabf348c3de7f1fb1381346d985b6b247dcd8d3c", size = 149602, upload-time = "2025-12-17T20:02:55.757Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/af/fe/b6045c782f1fd1ae317d2a6ca1884857ce5c20f59befe6ab25a8603c43a7/ruamel_yaml-0.18.17-py3-none-any.whl", hash = "sha256:9c8ba9eb3e793efdf924b60d521820869d5bf0cb9c6f1b82d82de8295e290b9d", size = 121594, upload-time = "2025-12-17T20:02:07.657Z" },
]
[[package]]
name = "ruamel-yaml-clib"
version = "0.2.15"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/ea/97/60fda20e2fb54b83a61ae14648b0817c8f5d84a3821e40bfbdae1437026a/ruamel_yaml_clib-0.2.15.tar.gz", hash = "sha256:46e4cc8c43ef6a94885f72512094e482114a8a706d3c555a34ed4b0d20200600", size = 225794, upload-time = "2025-11-16T16:12:59.761Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/72/4b/5fde11a0722d676e469d3d6f78c6a17591b9c7e0072ca359801c4bd17eee/ruamel_yaml_clib-0.2.15-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:cb15a2e2a90c8475df45c0949793af1ff413acfb0a716b8b94e488ea95ce7cff", size = 149088, upload-time = "2025-11-16T16:13:22.836Z" },
{ url = "https://files.pythonhosted.org/packages/85/82/4d08ac65ecf0ef3b046421985e66301a242804eb9a62c93ca3437dc94ee0/ruamel_yaml_clib-0.2.15-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:64da03cbe93c1e91af133f5bec37fd24d0d4ba2418eaf970d7166b0a26a148a2", size = 134553, upload-time = "2025-11-16T16:13:24.151Z" },
{ url = "https://files.pythonhosted.org/packages/b9/cb/22366d68b280e281a932403b76da7a988108287adff2bfa5ce881200107a/ruamel_yaml_clib-0.2.15-cp312-cp312-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:f6d3655e95a80325b84c4e14c080b2470fe4f33b6846f288379ce36154993fb1", size = 737468, upload-time = "2025-11-16T20:22:47.335Z" },
{ url = "https://files.pythonhosted.org/packages/71/73/81230babf8c9e33770d43ed9056f603f6f5f9665aea4177a2c30ae48e3f3/ruamel_yaml_clib-0.2.15-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:71845d377c7a47afc6592aacfea738cc8a7e876d586dfba814501d8c53c1ba60", size = 753349, upload-time = "2025-11-16T16:13:26.269Z" },
{ url = "https://files.pythonhosted.org/packages/61/62/150c841f24cda9e30f588ef396ed83f64cfdc13b92d2f925bb96df337ba9/ruamel_yaml_clib-0.2.15-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:11e5499db1ccbc7f4b41f0565e4f799d863ea720e01d3e99fa0b7b5fcd7802c9", size = 788211, upload-time = "2025-11-16T16:13:27.441Z" },
{ url = "https://files.pythonhosted.org/packages/30/93/e79bd9cbecc3267499d9ead919bd61f7ddf55d793fb5ef2b1d7d92444f35/ruamel_yaml_clib-0.2.15-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:4b293a37dc97e2b1e8a1aec62792d1e52027087c8eea4fc7b5abd2bdafdd6642", size = 743203, upload-time = "2025-11-16T16:13:28.671Z" },
{ url = "https://files.pythonhosted.org/packages/8d/06/1eb640065c3a27ce92d76157f8efddb184bd484ed2639b712396a20d6dce/ruamel_yaml_clib-0.2.15-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:512571ad41bba04eac7268fe33f7f4742210ca26a81fe0c75357fa682636c690", size = 747292, upload-time = "2025-11-16T20:22:48.584Z" },
{ url = "https://files.pythonhosted.org/packages/a5/21/ee353e882350beab65fcc47a91b6bdc512cace4358ee327af2962892ff16/ruamel_yaml_clib-0.2.15-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:e5e9f630c73a490b758bf14d859a39f375e6999aea5ddd2e2e9da89b9953486a", size = 771624, upload-time = "2025-11-16T16:13:29.853Z" },
{ url = "https://files.pythonhosted.org/packages/57/34/cc1b94057aa867c963ecf9ea92ac59198ec2ee3a8d22a126af0b4d4be712/ruamel_yaml_clib-0.2.15-cp312-cp312-win32.whl", hash = "sha256:f4421ab780c37210a07d138e56dd4b51f8642187cdfb433eb687fe8c11de0144", size = 100342, upload-time = "2025-11-16T16:13:31.067Z" },
{ url = "https://files.pythonhosted.org/packages/b3/e5/8925a4208f131b218f9a7e459c0d6fcac8324ae35da269cb437894576366/ruamel_yaml_clib-0.2.15-cp312-cp312-win_amd64.whl", hash = "sha256:2b216904750889133d9222b7b873c199d48ecbb12912aca78970f84a5aa1a4bc", size = 119013, upload-time = "2025-11-16T16:13:32.164Z" },
{ url = "https://files.pythonhosted.org/packages/17/5e/2f970ce4c573dc30c2f95825f2691c96d55560268ddc67603dc6ea2dd08e/ruamel_yaml_clib-0.2.15-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:4dcec721fddbb62e60c2801ba08c87010bd6b700054a09998c4d09c08147b8fb", size = 147450, upload-time = "2025-11-16T16:13:33.542Z" },
{ url = "https://files.pythonhosted.org/packages/d6/03/a1baa5b94f71383913f21b96172fb3a2eb5576a4637729adbf7cd9f797f8/ruamel_yaml_clib-0.2.15-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:65f48245279f9bb301d1276f9679b82e4c080a1ae25e679f682ac62446fac471", size = 133139, upload-time = "2025-11-16T16:13:34.587Z" },
{ url = "https://files.pythonhosted.org/packages/dc/19/40d676802390f85784235a05788fd28940923382e3f8b943d25febbb98b7/ruamel_yaml_clib-0.2.15-cp313-cp313-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:46895c17ead5e22bea5e576f1db7e41cb273e8d062c04a6a49013d9f60996c25", size = 731474, upload-time = "2025-11-16T20:22:49.934Z" },
{ url = "https://files.pythonhosted.org/packages/ce/bb/6ef5abfa43b48dd55c30d53e997f8f978722f02add61efba31380d73e42e/ruamel_yaml_clib-0.2.15-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3eb199178b08956e5be6288ee0b05b2fb0b5c1f309725ad25d9c6ea7e27f962a", size = 748047, upload-time = "2025-11-16T16:13:35.633Z" },
{ url = "https://files.pythonhosted.org/packages/ff/5d/e4f84c9c448613e12bd62e90b23aa127ea4c46b697f3d760acc32cb94f25/ruamel_yaml_clib-0.2.15-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4d1032919280ebc04a80e4fb1e93f7a738129857eaec9448310e638c8bccefcf", size = 782129, upload-time = "2025-11-16T16:13:36.781Z" },
{ url = "https://files.pythonhosted.org/packages/de/4b/e98086e88f76c00c88a6bcf15eae27a1454f661a9eb72b111e6bbb69024d/ruamel_yaml_clib-0.2.15-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:ab0df0648d86a7ecbd9c632e8f8d6b21bb21b5fc9d9e095c796cacf32a728d2d", size = 736848, upload-time = "2025-11-16T16:13:37.952Z" },
{ url = "https://files.pythonhosted.org/packages/0c/5c/5964fcd1fd9acc53b7a3a5d9a05ea4f95ead9495d980003a557deb9769c7/ruamel_yaml_clib-0.2.15-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:331fb180858dd8534f0e61aa243b944f25e73a4dae9962bd44c46d1761126bbf", size = 741630, upload-time = "2025-11-16T20:22:51.718Z" },
{ url = "https://files.pythonhosted.org/packages/07/1e/99660f5a30fceb58494598e7d15df883a07292346ef5696f0c0ae5dee8c6/ruamel_yaml_clib-0.2.15-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:fd4c928ddf6bce586285daa6d90680b9c291cfd045fc40aad34e445d57b1bf51", size = 766619, upload-time = "2025-11-16T16:13:39.178Z" },
{ url = "https://files.pythonhosted.org/packages/36/2f/fa0344a9327b58b54970e56a27b32416ffbcfe4dcc0700605516708579b2/ruamel_yaml_clib-0.2.15-cp313-cp313-win32.whl", hash = "sha256:bf0846d629e160223805db9fe8cc7aec16aaa11a07310c50c8c7164efa440aec", size = 100171, upload-time = "2025-11-16T16:13:40.456Z" },
{ url = "https://files.pythonhosted.org/packages/06/c4/c124fbcef0684fcf3c9b72374c2a8c35c94464d8694c50f37eef27f5a145/ruamel_yaml_clib-0.2.15-cp313-cp313-win_amd64.whl", hash = "sha256:45702dfbea1420ba3450bb3dd9a80b33f0badd57539c6aac09f42584303e0db6", size = 118845, upload-time = "2025-11-16T16:13:41.481Z" },
{ url = "https://files.pythonhosted.org/packages/3e/bd/ab8459c8bb759c14a146990bf07f632c1cbec0910d4853feeee4be2ab8bb/ruamel_yaml_clib-0.2.15-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:753faf20b3a5906faf1fc50e4ddb8c074cb9b251e00b14c18b28492f933ac8ef", size = 147248, upload-time = "2025-11-16T16:13:42.872Z" },
{ url = "https://files.pythonhosted.org/packages/69/f2/c4cec0a30f1955510fde498aac451d2e52b24afdbcb00204d3a951b772c3/ruamel_yaml_clib-0.2.15-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:480894aee0b29752560a9de46c0e5f84a82602f2bc5c6cde8db9a345319acfdf", size = 133764, upload-time = "2025-11-16T16:13:43.932Z" },
{ url = "https://files.pythonhosted.org/packages/82/c7/2480d062281385a2ea4f7cc9476712446e0c548cd74090bff92b4b49e898/ruamel_yaml_clib-0.2.15-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:4d3b58ab2454b4747442ac76fab66739c72b1e2bb9bd173d7694b9f9dbc9c000", size = 730537, upload-time = "2025-11-16T20:22:52.918Z" },
{ url = "https://files.pythonhosted.org/packages/75/08/e365ee305367559f57ba6179d836ecc3d31c7d3fdff2a40ebf6c32823a1f/ruamel_yaml_clib-0.2.15-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:bfd309b316228acecfa30670c3887dcedf9b7a44ea39e2101e75d2654522acd4", size = 746944, upload-time = "2025-11-16T16:13:45.338Z" },
{ url = "https://files.pythonhosted.org/packages/a1/5c/8b56b08db91e569d0a4fbfa3e492ed2026081bdd7e892f63ba1c88a2f548/ruamel_yaml_clib-0.2.15-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:2812ff359ec1f30129b62372e5f22a52936fac13d5d21e70373dbca5d64bb97c", size = 778249, upload-time = "2025-11-16T16:13:46.871Z" },
{ url = "https://files.pythonhosted.org/packages/6a/1d/70dbda370bd0e1a92942754c873bd28f513da6198127d1736fa98bb2a16f/ruamel_yaml_clib-0.2.15-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7e74ea87307303ba91073b63e67f2c667e93f05a8c63079ee5b7a5c8d0d7b043", size = 737140, upload-time = "2025-11-16T16:13:48.349Z" },
{ url = "https://files.pythonhosted.org/packages/5b/87/822d95874216922e1120afb9d3fafa795a18fdd0c444f5c4c382f6dac761/ruamel_yaml_clib-0.2.15-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:713cd68af9dfbe0bb588e144a61aad8dcc00ef92a82d2e87183ca662d242f524", size = 741070, upload-time = "2025-11-16T20:22:54.151Z" },
{ url = "https://files.pythonhosted.org/packages/b9/17/4e01a602693b572149f92c983c1f25bd608df02c3f5cf50fd1f94e124a59/ruamel_yaml_clib-0.2.15-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:542d77b72786a35563f97069b9379ce762944e67055bea293480f7734b2c7e5e", size = 765882, upload-time = "2025-11-16T16:13:49.526Z" },
{ url = "https://files.pythonhosted.org/packages/9f/17/7999399081d39ebb79e807314de6b611e1d1374458924eb2a489c01fc5ad/ruamel_yaml_clib-0.2.15-cp314-cp314-win32.whl", hash = "sha256:424ead8cef3939d690c4b5c85ef5b52155a231ff8b252961b6516ed7cf05f6aa", size = 102567, upload-time = "2025-11-16T16:13:50.78Z" },
{ url = "https://files.pythonhosted.org/packages/d2/67/be582a7370fdc9e6846c5be4888a530dcadd055eef5b932e0e85c33c7d73/ruamel_yaml_clib-0.2.15-cp314-cp314-win_amd64.whl", hash = "sha256:ac9b8d5fa4bb7fd2917ab5027f60d4234345fd366fe39aa711d5dca090aa1467", size = 122847, upload-time = "2025-11-16T16:13:51.807Z" },
]
[[package]]
name = "ruff"
version = "0.12.9"
@@ -2086,6 +2195,15 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/32/fa/a4f5c2046385492b2273213ef815bf71a0d4c1943b784fb904e184e30201/watchfiles-1.1.0-cp314-cp314t-musllinux_1_1_x86_64.whl", hash = "sha256:af06c863f152005c7592df1d6a7009c836a247c9d8adb78fef8575a5a98699db", size = 623315, upload-time = "2025-06-15T19:06:29.076Z" },
]
[[package]]
name = "wcwidth"
version = "0.2.14"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/24/30/6b0809f4510673dc723187aeaf24c7f5459922d01e2f794277a3dfb90345/wcwidth-0.2.14.tar.gz", hash = "sha256:4d478375d31bc5395a3c55c40ccdf3354688364cd61c4f6adacaa9215d0b3605", size = 102293, upload-time = "2025-09-22T16:29:53.023Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/af/b5/123f13c975e9f27ab9c0770f514345bd406d0e8d3b7a0723af9d43f710af/wcwidth-0.2.14-py2.py3-none-any.whl", hash = "sha256:a7bb560c8aee30f9957e5f9895805edd20602f2d7f720186dfd906e82b4982e1", size = 37286, upload-time = "2025-09-22T16:29:51.641Z" },
]
[[package]]
name = "websockets"
version = "15.0.1"