From dfa6d3daf9adf4758172b33853dd95edd7d1f331 Mon Sep 17 00:00:00 2001 From: phernandez Date: Wed, 4 Nov 2026 17:59:19 -0600 Subject: [PATCH] feat(core)!: remove note formatting and format_on_save 1.0 accepts a note's bytes before writing the file, so format_on_save stopped running for bm_write and bm_edit (#2005). Formatting the file after the write would make it differ from the accepted note: the next sync records it as an outside edit, and the agent's next expected_checksum conflicts. Notes are now stored exactly as written. BREAKING CHANGE: the format_on_save, formatter_command, formatters and formatter_timeout settings, the bm format command and the mdformat dependency are removed. Old config keys are ignored on load. Fixes #2005 Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01CMYVL4TcbWdXhdvAzxfqjo Signed-off-by: phernandez --- CHANGELOG.md | 14 + pyproject.toml | 7 +- src/basic_memory/cli/commands/__init__.py | 2 - src/basic_memory/cli/commands/format.py | 200 ----------- src/basic_memory/config_models.py | 22 -- src/basic_memory/file_utils.py | 175 +-------- src/basic_memory/man/bm.1 | 3 - .../markdown/markdown_processor.py | 11 +- src/basic_memory/services/entity_service.py | 4 +- src/basic_memory/services/file_service.py | 22 +- tests/cli/test_config_command.py | 5 +- .../document_ingestion/test_local_runtime.py | 20 -- .../test_entity_service_write_result.py | 34 -- tests/test_config.py | 113 ------ ...test_config_env_overrides_not_persisted.py | 12 +- tests/test_config_load_side_effects.py | 3 - tests/utils/test_file_utils.py | 340 ------------------ uv.lock | 69 +--- 18 files changed, 37 insertions(+), 1019 deletions(-) delete mode 100644 src/basic_memory/cli/commands/format.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 6617f6f45..99f239be9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,19 @@ # CHANGELOG +## Unreleased + +### Breaking Changes + +- **Note formatting is removed.** The `format_on_save`, `formatter_command`, + `formatters` and `formatter_timeout` settings, their `BASIC_MEMORY_*` environment + variables, the `bm format` command and the bundled mdformat dependency are gone. + Since 1.0 accepts a note's bytes before writing the file, `format_on_save` had + stopped running for `bm_write` and `bm_edit` (#2005). Formatting the file after the + write would make it differ from the accepted note, so the next sync would record it + as an outside edit and the agent's next `expected_checksum` would conflict. Notes + are now stored exactly as written. Old keys in `config.json` are ignored. To format + notes, run your formatter from your editor, or over the project folder yourself. + ## v1.0.0 (2026-10-29) Basic Memory 1.0 is a clean-house release. The MCP server exposes only the `bm_*` diff --git a/pyproject.toml b/pyproject.toml index 775324668..0f27b342b 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -37,9 +37,6 @@ dependencies = [ "asyncpg>=0.30.0", "pytest-asyncio>=1.2.0", "psycopg==3.3.1", - "mdformat>=0.7.22", - "mdformat-gfm>=0.3.7", - "mdformat-frontmatter>=2.0.8", "sniffio>=1.3.1", "anyio>=4.10.0", "httpx>=0.28.0", @@ -82,6 +79,8 @@ pdf = [ ] documents = [ "markitdown[docx,pptx]>=0.1.7,<0.2", + # Footnote parsing for the document page-citation tests. + "mdit-py-plugins>=0.6.1", ] [project.urls] @@ -158,6 +157,8 @@ dev = [ "pytest-timeout>=2.4.0", "pytest-split>=0.11.0", "markitdown[docx,pptx]>=0.1.7,<0.2", + # Footnote parsing for the document page-citation tests. + "mdit-py-plugins>=0.6.1", ] [tool.hatch.version] diff --git a/src/basic_memory/cli/commands/__init__.py b/src/basic_memory/cli/commands/__init__.py index e4457f118..c07a98e13 100644 --- a/src/basic_memory/cli/commands/__init__.py +++ b/src/basic_memory/cli/commands/__init__.py @@ -21,7 +21,6 @@ project, prune, config, - format, schema, update, wiki, @@ -46,7 +45,6 @@ "project", "prune", "config", - "format", "schema", "update", "wiki", diff --git a/src/basic_memory/cli/commands/format.py b/src/basic_memory/cli/commands/format.py deleted file mode 100644 index be3f5080f..000000000 --- a/src/basic_memory/cli/commands/format.py +++ /dev/null @@ -1,200 +0,0 @@ -"""Format command for basic-memory CLI.""" - -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.cli.commands.command_utils import run_with_cleanup -from basic_memory.config import ConfigManager, get_project_config -from basic_memory.file_utils import format_file -from basic_memory.runtime.storage import runtime_file_path_is_markdown_note -from basic_memory.cli.markup import literal - -console = Console() - - -def is_markdown_extension(path: Path) -> bool: - """Check if file has a markdown extension.""" - return runtime_file_path_is_markdown_note(path.as_posix()) - - -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: {literal(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: {literal(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]{literal(path)}[/red]: {literal(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: - bm format # Format all files in current project - bm format --project research # Format files in specific project - bm format notes/meeting.md # Format a specific file - bm format notes/ # Format all files in directory - """ - try: - run_with_cleanup(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: {literal(e)}[/red]") - raise typer.Exit(code=1) - raise diff --git a/src/basic_memory/config_models.py b/src/basic_memory/config_models.py index edcad0625..f8bdaa704 100644 --- a/src/basic_memory/config_models.py +++ b/src/basic_memory/config_models.py @@ -643,28 +643,6 @@ def __init__(self, **data: Any) -> None: ... 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, diff --git a/src/basic_memory/file_utils.py b/src/basic_memory/file_utils.py index d58ff9c26..4e8185ae8 100644 --- a/src/basic_memory/file_utils.py +++ b/src/basic_memory/file_utils.py @@ -1,9 +1,7 @@ """Utilities for file operations.""" -import asyncio import hashlib import os -import shlex from dataclasses import dataclass from datetime import datetime from pathlib import Path @@ -23,7 +21,7 @@ from basic_memory.yaml_policy import SimpleSafeDumper, SimpleSafeLoader if TYPE_CHECKING: # pragma: no cover - from basic_memory.config import BasicMemoryConfig + pass @dataclass @@ -211,177 +209,6 @@ def _create_atomic_temp_path(target: Path) -> tuple[Path, int | None]: raise FileWriteError(f"Failed to reserve atomic staging file for {target}") -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: # pragma: no cover - 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: - # Trigger: mdformat may rewrite markdown content, then the host platform - # decides the newline bytes for the follow-up async text write. - # Why: we want formatter output to preserve native newlines instead of - # forcing LF, and the authoritative checksum comes from rereading the - # stored file bytes later in FileService. - # Outcome: formatting remains compatible with local editors on Windows while - # checksum-based sync logic stays anchored to on-disk bytes. - 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: # pragma: no cover - 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 - try: - # Split the configured template first, then substitute into each token. - # Substituting into the command string and splitting afterwards makes the - # path's own spaces into argument boundaries: with the documented - # `npx prettier --write {file}`, a note at `~/My Notes/a.md` would run - # against `~/My` and `Notes/a.md` and format neither. A path containing a - # quote would also make shlex.split raise on the whole command. - args = [token.replace("{file}", str(path)) for token in shlex.split(formatter)] - - 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=shlex.split(formatter)[0] if formatter.strip() else "", - path=str(path), - ) - return None - except Exception as e: # pragma: no cover - logger.warning( - "Formatter failed", - path=str(path), - error=str(e), - ) - return None - - # A frontmatter fence is a line containing exactly `---`, optionally followed by # trailing horizontal whitespace. Anchoring to a full line (rather than a bare # substring/`startswith`) prevents single-line content like diff --git a/src/basic_memory/man/bm.1 b/src/basic_memory/man/bm.1 index 33dc785f5..97c37f53b 100644 --- a/src/basic_memory/man/bm.1 +++ b/src/basic_memory/man/bm.1 @@ -60,9 +60,6 @@ Run end-to-end file/database consistency checks. .TP .B bm orphans List entities with no relations in the knowledge graph. -.TP -.B bm format -Run configured formatters over note files. .PP Projects and schemas: .TP diff --git a/src/basic_memory/markdown/markdown_processor.py b/src/basic_memory/markdown/markdown_processor.py index f68a1a69e..4037e2d59 100644 --- a/src/basic_memory/markdown/markdown_processor.py +++ b/src/basic_memory/markdown/markdown_processor.py @@ -132,16 +132,7 @@ async def write_file( path.parent.mkdir(parents=True, exist_ok=True) await file_utils.write_file_atomic(path, 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( # pragma: no cover - path, self.app_config, is_markdown=True - ) - if formatted_content is not None: # pragma: no cover - content_for_checksum = formatted_content # pragma: no cover - - return await file_utils.compute_checksum(content_for_checksum) + return await file_utils.compute_checksum(final_content) def to_markdown_string(self, markdown: EntityMarkdown) -> str: """Convert EntityMarkdown to markdown string with frontmatter. diff --git a/src/basic_memory/services/entity_service.py b/src/basic_memory/services/entity_service.py index 8b59555af..81859c060 100644 --- a/src/basic_memory/services/entity_service.py +++ b/src/basic_memory/services/entity_service.py @@ -164,8 +164,8 @@ async def _read_persisted_write_snapshot( self, file_path: Path, ) -> tuple[EntityMarkdown, str, str]: - """Read and parse the stored markdown after write-time formatting has finished.""" - # Trigger: format-on-save or platform-specific text writes can change the stored markdown + """Read and parse the stored markdown exactly as it landed on disk.""" + # Trigger: platform-specific text writes can change the stored markdown # after prepare accepted the request. # Why: responses, search, and relation generations must all describe the same bytes that # landed on disk, not the pre-write parse paired with a later file snapshot. diff --git a/src/basic_memory/services/file_service.py b/src/basic_memory/services/file_service.py index 6cddb8522..95c5b65ae 100644 --- a/src/basic_memory/services/file_service.py +++ b/src/basic_memory/services/file_service.py @@ -255,15 +255,12 @@ async def write_file(self, path: FilePath, content: str) -> str: 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 (or formatted content if formatting enabled) + Checksum of the written file Raises: FileOperationError: If write fails @@ -290,14 +287,7 @@ async def write_file(self, path: FilePath, content: str) -> str: await file_utils.write_file_atomic(full_path, 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: - pass # pragma: no cover - - # Trigger: formatters and platform-specific text writers can change the + # Trigger: platform-specific text writers can change the # persisted bytes even when the logical content string is the same. # Why: sync and move detection compare against on-disk checksums, not # the pre-write Python string. @@ -562,14 +552,8 @@ async def update_frontmatter_with_result( await file_utils.write_file_atomic(full_path, final_content) - # Format file if configured - if self.app_config: - await file_utils.format_file( - full_path, self.app_config, is_markdown=self.is_markdown(path) - ) - # Trigger: frontmatter normalization may persist bytes that differ from the - # in-memory string because of formatter output or platform newline handling. + # in-memory string because of platform newline handling. # Why: generation publication must parse the same bytes whose checksum and # note_content generation are claimed; an LF payload paired with CRLF disk # bytes is not one coherent snapshot. diff --git a/tests/cli/test_config_command.py b/tests/cli/test_config_command.py index 0a57e7bc7..de1caf189 100644 --- a/tests/cli/test_config_command.py +++ b/tests/cli/test_config_command.py @@ -59,7 +59,6 @@ def _base_config(**overrides) -> dict[str, Any]: def test_configurable_fields_excludes_structured_types(): """Structured fields need dedicated commands or richer parsing, so they're excluded.""" assert "projects" not in config_cmd.CONFIGURABLE_FIELDS - assert "formatters" not in config_cmd.CONFIGURABLE_FIELDS assert "auto_update_last_checked_at" not in config_cmd.CONFIGURABLE_FIELDS @@ -186,10 +185,10 @@ def test_config_set_invalid_value_fails_with_pydantic_error(runner, write_config def test_config_set_coerces_bool_from_string(runner, write_config): write_config(_base_config()) - result = runner.invoke(app, ["config", "set", "format_on_save", "true"]) + result = runner.invoke(app, ["config", "set", "skip_initialization_sync", "true"]) assert result.exit_code == 0, result.output - assert "format_on_save = True" in result.output + assert "skip_initialization_sync = True" in result.output def test_config_set_default_project_rejects_unknown_project(runner, write_config): diff --git a/tests/document_ingestion/test_local_runtime.py b/tests/document_ingestion/test_local_runtime.py index 7f33f2c2a..c0483681f 100644 --- a/tests/document_ingestion/test_local_runtime.py +++ b/tests/document_ingestion/test_local_runtime.py @@ -12,7 +12,6 @@ from httpx import HTTPStatusError, Request, Response from basic_memory import file_utils -from basic_memory.config import BasicMemoryConfig from basic_memory.document_ingestion.csv_extractor import CSV_MEDIA_TYPE from basic_memory.document_ingestion.local_runtime import ( ApiDocumentSourceEntityResolver, @@ -313,25 +312,6 @@ async def test_writer_rejects_ignored_generated_paths_before_writing( assert knowledge.indexed == [] -@pytest.mark.asyncio -async def test_writer_reuses_and_refreshes_a_formatted_sidecar( - file_service: FileService, - app_config: BasicMemoryConfig, -) -> None: - file_service.app_config = app_config.model_copy(update={"format_on_save": True}) - writer = LocalRawDocumentWriter(file_service, knowledge_api()) - built = artifacts() - await writer.write(built) - persisted = await file_service.read_file_content(built.document_file_path) - assert persisted != built.document_markdown - - reused = await writer.write(built) - assert reused.document_created is False - assert reused.run_created is False - refreshed = await writer.write(artifacts(checksum_char="b")) - assert refreshed.document_created is True - - @pytest.mark.asyncio async def test_writer_reuses_an_identical_raw_projection(file_service: FileService) -> None: knowledge = knowledge_api() diff --git a/tests/services/test_entity_service_write_result.py b/tests/services/test_entity_service_write_result.py index b0852b828..3d6c804fd 100644 --- a/tests/services/test_entity_service_write_result.py +++ b/tests/services/test_entity_service_write_result.py @@ -1,17 +1,10 @@ """Tests for EntityWriteResult content variants.""" -import sys - import pytest from basic_memory.file_utils import remove_frontmatter from basic_memory.schemas import Entity as EntitySchema -skip_on_windows = pytest.mark.skipif( - sys.platform == "win32", - reason="formatter command uses POSIX shell redirection", -) - @pytest.mark.asyncio async def test_create_entity_with_content_returns_full_and_search_content( @@ -144,30 +137,3 @@ async def test_edit_entity_with_content_returns_full_and_search_content( assert result.content == file_content assert result.search_content == remove_frontmatter(file_content) assert result.search_content == "Edited body content" - - -@skip_on_windows -@pytest.mark.asyncio -async def test_create_entity_with_content_returns_persisted_content_after_format_on_save( - entity_service, file_service, app_config -) -> None: - app_config.format_on_save = True - app_config.formatter_command = "sh -c 'echo modified > {file}'" - file_service.app_config = app_config - - result = await entity_service.create_entity_with_content( - EntitySchema( - title="FormattedWriteResult", - directory="notes", - note_type="note", - content="Original body content", - ) - ) - - file_path = file_service.get_entity_path(result.entity) - file_content, _ = await file_service.read_file(file_path) - - assert file_content == "modified\n" - assert result.content == file_content - assert result.search_content == remove_frontmatter(file_content) - assert result.search_content == "modified" diff --git a/tests/test_config.py b/tests/test_config.py index ef17d0000..61cce188a 100644 --- a/tests/test_config.py +++ b/tests/test_config.py @@ -1290,119 +1290,6 @@ def test_date_order_is_settable_from_the_cli(self): assert "date_order" in CONFIGURABLE_FIELDS -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": {"path": 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 - - class TestProjectMode: """Test per-project routing mode configuration.""" diff --git a/tests/test_config_env_overrides_not_persisted.py b/tests/test_config_env_overrides_not_persisted.py index 10097626b..7eadda0f4 100644 --- a/tests/test_config_env_overrides_not_persisted.py +++ b/tests/test_config_env_overrides_not_persisted.py @@ -38,7 +38,7 @@ def file_manager(config_home: Path) -> ConfigManager: "projects": {"main": {"path": str(config_home / "main"), "mode": "local"}}, "default_project": "main", "log_level": "INFO", - "format_on_save": False, + "skip_initialization_sync": False, } ), encoding="utf-8", @@ -50,21 +50,21 @@ def test_project_add_keeps_file_values_under_env_override( file_manager: ConfigManager, config_home: Path, monkeypatch ): monkeypatch.setenv("BASIC_MEMORY_LOG_LEVEL", "DEBUG") - monkeypatch.setenv("BASIC_MEMORY_FORMAT_ON_SAVE", "true") + monkeypatch.setenv("BASIC_MEMORY_SKIP_INITIALIZATION_SYNC", "true") monkeypatch.setenv("BASIC_MEMORY_CLI_OUTPUT_STYLE", "plain") _reset_config_cache() # The env values are in effect for this process. loaded = file_manager.load_config() assert loaded.log_level == "DEBUG" - assert loaded.format_on_save is True + assert loaded.skip_initialization_sync is True file_manager.add_project("leak", str(config_home / "leak")) written = _read_file(file_manager) assert "leak" in written["projects"] assert written["log_level"] == "INFO" - assert written["format_on_save"] is False + assert written["skip_initialization_sync"] is False # The file never had this key, so the env value must not appear either. assert "cli_output_style" not in written @@ -124,7 +124,7 @@ def test_first_run_save_does_not_persist_env_values(config_home: Path, monkeypat def test_explicit_key_is_persisted_despite_env_override(file_manager: ConfigManager, monkeypatch): monkeypatch.setenv("BASIC_MEMORY_LOG_LEVEL", "DEBUG") - monkeypatch.setenv("BASIC_MEMORY_FORMAT_ON_SAVE", "true") + monkeypatch.setenv("BASIC_MEMORY_SKIP_INITIALIZATION_SYNC", "true") _reset_config_cache() config = file_manager.load_config() @@ -133,7 +133,7 @@ def test_explicit_key_is_persisted_despite_env_override(file_manager: ConfigMana written = _read_file(file_manager) assert written["log_level"] == "WARNING" - assert written["format_on_save"] is False + assert written["skip_initialization_sync"] is False def test_legacy_sync_env_keeps_the_file_legacy_value(config_home: Path, monkeypatch): diff --git a/tests/test_config_load_side_effects.py b/tests/test_config_load_side_effects.py index 8c165d934..12022df69 100644 --- a/tests/test_config_load_side_effects.py +++ b/tests/test_config_load_side_effects.py @@ -63,7 +63,6 @@ def test_existing_config_load_keeps_environment_precedence( monkeypatch.setenv("BASIC_MEMORY_CONFIG_DIR", str(config_dir)) monkeypatch.setenv("BASIC_MEMORY_LOG_LEVEL", "DEBUG") - monkeypatch.setenv("BASIC_MEMORY_FORMATTERS", '{"md":"formatter --write {file}"}') monkeypatch.setattr(config_module, "_CONFIG_CACHE", None) monkeypatch.setattr(config_module, "_CONFIG_MTIME", None) monkeypatch.setattr(config_module, "_CONFIG_SIZE", None) @@ -74,7 +73,6 @@ def test_existing_config_load_keeps_environment_precedence( "projects": {"custom": {"path": str(project_dir)}}, "default_project": "custom", "log_level": "INFO", - "formatters": {"md": "old-formatter {file}"}, } ), encoding="utf-8", @@ -83,4 +81,3 @@ def test_existing_config_load_keeps_environment_precedence( loaded = ConfigManager().load_config() assert loaded.log_level == "DEBUG" - assert loaded.formatters == {"md": "formatter --write {file}"} diff --git a/tests/utils/test_file_utils.py b/tests/utils/test_file_utils.py index 457b4a1d5..9789b01ee 100644 --- a/tests/utils/test_file_utils.py +++ b/tests/utils/test_file_utils.py @@ -12,15 +12,12 @@ import pytest -from basic_memory.config import BasicMemoryConfig from basic_memory.file_utils import ( FileError, FileWriteError, ParseError, compute_checksum, file_created_timestamp, - format_file, - format_markdown_builtin, has_frontmatter, parse_frontmatter, remove_frontmatter, @@ -340,294 +337,6 @@ def test_sanitize_for_directory_edge_cases(input_directory, expected): assert sanitize_for_directory(input_directory) == 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 # ============================================================================= @@ -677,55 +386,6 @@ def test_remove_frontmatter_strip_false_without_frontmatter_returns_content_unch assert remove_frontmatter(text, strip=False) == text -@skip_on_windows -@pytest.mark.asyncio -async def test_format_file_handles_a_path_containing_a_space(tmp_path: Path): - """A note whose path contains a space must reach the formatter as one argument. - - The template used to be substituted into the command string and split - afterwards, so `sh -c '...' {file}` with `~/My Notes/a.md` split the path in - two and the formatter ran against neither. Nothing raised — it silently - formatted the wrong thing (#1440 review). - """ - spaced_dir = tmp_path / "My Notes" - spaced_dir.mkdir() - test_file = spaced_dir / "test.md" - test_file.write_text("# Test\n") - - # Records the argv it was handed, so the assertion is on argument boundaries - # rather than on the formatter's output. - argv_log = tmp_path / "argv.txt" - config = BasicMemoryConfig( - format_on_save=True, - formatter_command=f'sh -c \'printf "%s" "$1" > {argv_log}\' _ {{file}}', - ) - - await format_file(test_file, config) - - assert argv_log.read_text() == str(test_file) - - -@skip_on_windows -@pytest.mark.asyncio -async def test_format_file_handles_a_path_containing_a_quote(tmp_path: Path): - """A quote in the path must not make the whole command unparseable. - - Substituting first meant `shlex.split` saw the quote as an opening one and - raised `ValueError: No closing quotation` for a perfectly legal filename. - """ - quoted_dir = tmp_path / "it's notes" - quoted_dir.mkdir() - test_file = quoted_dir / "test.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 - - def test_remove_frontmatter_keeps_a_fenced_block_that_is_not_a_yaml_mapping(): """A letterhead between horizontal rules, a bare scalar, or a list is body, not frontmatter; no frontmatter reader accepts it, so search must not lose it (#1451).""" diff --git a/uv.lock b/uv.lock index e8c0b645e..9da5fc64e 100644 --- a/uv.lock +++ b/uv.lock @@ -352,9 +352,6 @@ dependencies = [ { name = "loguru" }, { name = "markdown-it-py" }, { name = "mcp" }, - { name = "mdformat" }, - { name = "mdformat-frontmatter" }, - { name = "mdformat-gfm" }, { name = "openai" }, { name = "pathspec" }, { name = "pillow" }, @@ -383,6 +380,7 @@ dependencies = [ [package.optional-dependencies] documents = [ { name = "markitdown", extra = ["docx", "pptx"] }, + { name = "mdit-py-plugins" }, ] litellm = [ { name = "litellm" }, @@ -406,6 +404,7 @@ dev = [ { name = "libcst" }, { name = "logfire", extra = ["fastapi"] }, { name = "markitdown", extra = ["docx", "pptx"] }, + { name = "mdit-py-plugins" }, { name = "pdf-inspector" }, { name = "psycopg" }, { name = "pyright" }, @@ -444,9 +443,7 @@ requires-dist = [ { name = "markdown-it-py", specifier = ">=3.0.0" }, { name = "markitdown", extras = ["docx", "pptx"], marker = "extra == 'documents'", specifier = ">=0.1.7,<0.2" }, { name = "mcp", specifier = ">=2,<3" }, - { name = "mdformat", specifier = ">=0.7.22" }, - { name = "mdformat-frontmatter", specifier = ">=2.0.8" }, - { name = "mdformat-gfm", specifier = ">=0.3.7" }, + { name = "mdit-py-plugins", marker = "extra == 'documents'", specifier = ">=0.6.1" }, { name = "openai", specifier = ">=1.100.2" }, { name = "pathspec", specifier = ">=1.1.1,<2" }, { name = "pdf-inspector", marker = "extra == 'pdf'", specifier = ">=0.2.6,<2" }, @@ -485,6 +482,7 @@ dev = [ { name = "libcst", specifier = ">=1.8.6" }, { name = "logfire", extras = ["fastapi"], specifier = ">=4.19.0" }, { name = "markitdown", extras = ["docx", "pptx"], specifier = ">=0.1.7,<0.2" }, + { name = "mdit-py-plugins", specifier = ">=0.6.1" }, { name = "pdf-inspector", specifier = ">=0.2.6,<2" }, { name = "psycopg", specifier = ">=3.2.0" }, { name = "pyright", specifier = ">=1.1.408" }, @@ -2473,47 +2471,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/ce/48/32583066b155bb5d4507e7887dc88ee23118cc4de8e2a12870c49719e2a8/mcp_types-2.3.0-py3-none-any.whl", hash = "sha256:968efdbdaedfab06adae40d378a34395f1090c5921d4be3c9cde283aaf76d91d", size = 69108, upload-time = "2026-10-02T22:06:54.099Z" }, ] -[[package]] -name = "mdformat" -version = "1.0.0" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "markdown-it-py" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/3f/05/32b5e14b192b0a8a309f32232c580aefedd9d06017cb8fe8fce34bec654c/mdformat-1.0.0.tar.gz", hash = "sha256:4954045fcae797c29f86d4ad879e43bb151fa55dbaf74ac6eaeacf1d45bb3928", size = 56953, upload-time = "2025-10-16T12:05:03.695Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/54/9a/8fe71b95985ca7a4001effbcc58e5a07a1f2a2884203f74dcf48a3b08315/mdformat-1.0.0-py3-none-any.whl", hash = "sha256:bca015d65a1d063a02e885a91daee303057bc7829c2cd37b2075a50dbb65944b", size = 53288, upload-time = "2025-10-16T12:05:02.607Z" }, -] - -[[package]] -name = "mdformat-frontmatter" -version = "2.1.2" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "mdformat" }, - { name = "mdit-py-plugins" }, - { name = "ruamel-yaml" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/4d/90/ae8a3d20066295367d9048ad480bcde3afe694eb592fec9c35bbdb7d68eb/mdformat_frontmatter-2.1.2.tar.gz", hash = "sha256:9c9c3159c38407f47e567202749c74ce7680017516cd158c82400f015c6a01d1", size = 9425, upload-time = "2026-05-23T05:14:26.324Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/53/a0/20b6f53593139d8b64fa8e7f750033fbb580c096c241cd551b59907e6abe/mdformat_frontmatter-2.1.2-py3-none-any.whl", hash = "sha256:bcbb1dd7e35ce4387603acd32f2c092dd4518bbfaa2a4b801a0f29a4e69bf2ca", size = 4927, upload-time = "2026-05-23T05:14:24.747Z" }, -] - -[[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.6.1" @@ -4386,15 +4343,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/8c/97/d855d6b3c322d1f27e26f5241c42016b56cf01377ea8ed348285f54652f0/rpds_py-2026.6.3-cp315-cp315t-win_amd64.whl", hash = "sha256:ae3d4fe8c0b9213624fdce7279d70e3b148b682ca20719ebd193a23ebfa47324", size = 220719, upload-time = "2026-06-30T07:17:31.788Z" }, ] -[[package]] -name = "ruamel-yaml" -version = "0.19.1" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/c7/3b/ebda527b56beb90cb7652cb1c7e4f91f48649fbcd8d2eb2fb6e77cd3329b/ruamel_yaml-0.19.1.tar.gz", hash = "sha256:53eb66cd27849eff968ebf8f0bf61f46cdac2da1d1f3576dd4ccee9b25c31993", size = 142709, upload-time = "2026-01-02T16:50:31.84Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/b8/0c/51f6841f1d84f404f92463fc2b1ba0da357ca1e3db6b7fbda26956c3b82a/ruamel_yaml-0.19.1-py3-none-any.whl", hash = "sha256:27592957fedf6e0b62f281e96effd28043345e0e66001f97683aa9a40c667c93", size = 118102, upload-time = "2026-01-02T16:50:29.201Z" }, -] - [[package]] name = "ruff" version = "0.16.0" @@ -4931,15 +4879,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/46/4b/95ab2f256bb4af3cb2eb23b9317bda984ee6e0f11733a5c004a6c95b06e3/watchfiles-1.2.0-cp315-cp315-musllinux_1_1_x86_64.whl", hash = "sha256:922c0e019fe68b3ae392965a766b02a71ba1168c932cebc3733cd52c5fe5b377", size = 657684, upload-time = "2026-05-18T04:31:32.027Z" }, ] -[[package]] -name = "wcwidth" -version = "0.8.2" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/34/74/c6428f875774288bec1396f5bfcbc2d925700a4dad61727fd5f2b12f249d/wcwidth-0.8.2.tar.gz", hash = "sha256:91fbef97204b96a3d4d421609b80340b760cf33e26da123ff243d76b1fda8dda", size = 1466253, upload-time = "2026-06-29T18:11:11.601Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/96/42/3e5985a0a7e57de470b320c6d6a1a67c844f6737a587f3d44dd13d1819e7/wcwidth-0.8.2-py3-none-any.whl", hash = "sha256:d63947694a0539a1d51e01eda7caf800c291020e6cdd7e28ad7b14dd33ad4f85", size = 323166, upload-time = "2026-06-29T18:11:09.888Z" }, -] - [[package]] name = "websockets" version = "16.1"