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:
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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."
|
||||
)
|
||||
@@ -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}"
|
||||
)
|
||||
|
||||
@@ -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]]"
|
||||
|
||||
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user