From 7f340e30b87c08c56bfbd65c80873d0270f0c82c Mon Sep 17 00:00:00 2001 From: phernandez Date: Mon, 27 Jul 2026 16:49:37 -0500 Subject: [PATCH 1/2] fix(mcp): handle related-only prompt context Signed-off-by: phernandez --- src/basic_memory/mcp/prompts/utils.py | 6 ++--- tests/mcp/test_prompts.py | 36 +++++++++++++++++++++++++++ 2 files changed, 39 insertions(+), 3 deletions(-) diff --git a/src/basic_memory/mcp/prompts/utils.py b/src/basic_memory/mcp/prompts/utils.py index ba0fe4643..bf539ec2d 100644 --- a/src/basic_memory/mcp/prompts/utils.py +++ b/src/basic_memory/mcp/prompts/utils.py @@ -133,7 +133,7 @@ def format_prompt_context(context: PromptContext) -> str: sections.append(section) if context_item.related_results: - section += dedent( + related_section = dedent( """ ## Related Context """ @@ -153,8 +153,8 @@ def format_prompt_context(context: PromptContext) -> str: f'You can view this file with: `read_file("{related.file_path}")`' ) - section += section_content - sections.append(section) + related_section += section_content + sections.append(related_section) # Add all sections summary += "\n".join(sections) diff --git a/tests/mcp/test_prompts.py b/tests/mcp/test_prompts.py index 6448a0d7b..2e6276f00 100644 --- a/tests/mcp/test_prompts.py +++ b/tests/mcp/test_prompts.py @@ -120,6 +120,42 @@ def test_prompt_context_with_file_path_no_permalink(): assert "read_file" in result +def test_prompt_context_with_related_results_but_no_primary_results(): + """Related context remains renderable when an item has no primary result.""" + from basic_memory.mcp.prompts.utils import ( + format_prompt_context, + PromptContext, + PromptContextItem, + ) + from basic_memory.schemas.memory import EntitySummary + + related_entity = EntitySummary( + external_id="550e8400-e29b-41d4-a716-446655440000", + entity_id=1, + type="entity", + title="Related File", + permalink=None, + file_path="related_file.pdf", + created_at=datetime.now(timezone.utc), + ) + context = PromptContext( + topic="Test Topic", + timeframe="1d", + results=[ + PromptContextItem( + primary_results=[], + related_results=[related_entity], + ) + ], + ) + + result = format_prompt_context(context) + + assert "## Related Context" in result + assert "Related File" in result + assert 'read_file("related_file.pdf")' in result + + # Recent activity prompt tests From 472719d4d0e53916863a66247592347225bbbb3c Mon Sep 17 00:00:00 2001 From: phernandez Date: Mon, 27 Jul 2026 16:49:58 -0500 Subject: [PATCH 2/2] chore(deps): upgrade ruff and ty with strict checking Signed-off-by: phernandez --- pyproject.toml | 24 ++--- src/basic_memory/api/template_loader.py | 20 ++++- .../api/v2/routers/importer_router.py | 10 ++- .../api/v2/routers/schema_router.py | 11 +-- src/basic_memory/cli/analytics.py | 4 +- src/basic_memory/cli/auth.py | 16 ++-- .../cli/commands/cloud/api_client.py | 11 ++- .../cli/commands/cloud/rclone_installer.py | 2 +- src/basic_memory/cli/commands/cloud/shares.py | 8 +- src/basic_memory/cli/commands/cloud/upload.py | 18 +++- src/basic_memory/cli/commands/hook.py | 42 ++++----- src/basic_memory/cli/commands/project.py | 8 +- src/basic_memory/cli/commands/schema.py | 8 +- src/basic_memory/config_models.py | 3 +- src/basic_memory/db.py | 23 ++++- src/basic_memory/deps/db.py | 5 +- src/basic_memory/hooks/envelope.py | 7 +- .../importers/chatgpt_importer.py | 4 +- .../claude_conversations_importer.py | 4 +- .../importers/claude_projects_importer.py | 4 +- .../importers/memory_json_importer.py | 4 +- src/basic_memory/index/local_dependencies.py | 10 ++- src/basic_memory/index/local_moves.py | 4 +- src/basic_memory/index/local_project.py | 9 +- src/basic_memory/index/local_runtime.py | 3 + src/basic_memory/index/local_schedulers.py | 2 +- .../index/repository_project_resolution.py | 3 +- .../indexing/external_file_delete_runner.py | 4 +- src/basic_memory/indexing/models.py | 3 +- .../indexing/project_index_maintenance.py | 5 +- .../indexing/relation_resolution.py | 2 + src/basic_memory/markdown/entity_parser.py | 2 +- src/basic_memory/markdown/schemas.py | 4 +- src/basic_memory/mcp/client_info.py | 3 +- src/basic_memory/mcp/clients/directory.py | 4 +- src/basic_memory/mcp/clients/memory.py | 6 +- .../mcp/prompts/continue_conversation.py | 4 +- src/basic_memory/mcp/prompts/search.py | 4 +- src/basic_memory/mcp/tools/build_context.py | 4 +- src/basic_memory/mcp/tools/delete_note.py | 4 +- src/basic_memory/mcp/tools/edit_note.py | 6 +- src/basic_memory/mcp/tools/move_note.py | 4 +- .../mcp/tools/project_management.py | 20 ++--- src/basic_memory/mcp/tools/read_content.py | 4 +- src/basic_memory/mcp/tools/read_note.py | 10 +-- src/basic_memory/mcp/tools/recent_activity.py | 16 ++-- src/basic_memory/mcp/tools/schema.py | 8 +- src/basic_memory/mcp/tools/search.py | 4 +- src/basic_memory/mcp/tools/workspaces.py | 4 +- src/basic_memory/mcp/tools/write_note.py | 6 +- .../mcp/workspace_project_index.py | 4 +- src/basic_memory/migration_loop.py | 6 +- src/basic_memory/models/knowledge.py | 10 ++- src/basic_memory/models/project.py | 3 +- src/basic_memory/picoschema/inference.py | 7 +- src/basic_memory/picoschema/parser.py | 5 +- src/basic_memory/picoschema/resolver.py | 9 +- src/basic_memory/picoschema/validator.py | 5 +- .../repository/embedding_provider_factory.py | 3 +- .../repository/entity_repository.py | 3 +- .../repository/fastembed_provider.py | 4 +- .../repository/note_content_repository.py | 3 +- .../repository/observation_repository.py | 3 +- .../repository/postgres_search_repository.py | 32 +++++-- .../repository/project_info_repository.py | 2 +- .../repository/project_repository.py | 5 +- .../repository/relation_repository.py | 3 +- src/basic_memory/repository/repository.py | 4 +- .../repository/search_index_row.py | 2 +- .../repository/search_repository.py | 6 +- .../repository/search_repository_base.py | 10 +-- .../repository/sqlite_search_repository.py | 27 ++++-- .../runtime/accepted_note_changes.py | 9 +- src/basic_memory/schemas/cloud.py | 8 +- src/basic_memory/schemas/schema.py | 3 +- src/basic_memory/schemas/search.py | 2 +- src/basic_memory/services/initialization.py | 8 -- src/basic_memory/services/search_service.py | 4 +- src/basic_memory/utils.py | 3 +- .../test_cli_tool_search_notes_category.py | 3 +- .../test_cli_tool_edit_note_integration.py | 7 +- test-int/conftest.py | 5 +- test-int/semantic/metrics.py | 5 +- test-int/semantic/report.py | 9 +- .../semantic/test_litellm_live_harness.py | 2 + test-int/test_picoschema/conftest.py | 11 +-- test-int/test_picoschema/helpers.py | 3 +- .../test_validator_integration.py | 7 +- .../api/v2/test_knowledge_router_telemetry.py | 4 +- tests/api/v2/test_project_router.py | 3 +- tests/api/v2/test_search_router_telemetry.py | 2 +- tests/api/v2/test_utils_telemetry.py | 3 +- .../cloud/test_cloud_api_client_and_utils.py | 3 +- tests/cli/cloud/test_project_sync_command.py | 41 ++++----- tests/cli/test_auto_update.py | 2 +- tests/cli/test_cli_telemetry.py | 2 +- tests/cli/test_cloud_status.py | 5 +- tests/cli/test_coding_session_context.py | 3 +- tests/cli/test_config_command.py | 5 +- tests/cli/test_hook_command.py | 10 +-- tests/cli/test_json_output.py | 5 +- tests/cli/test_project_add_with_local_path.py | 3 +- tests/cli/test_project_info_cloud_status.py | 3 +- tests/cli/test_project_list_and_ls.py | 3 +- tests/cloud/test_cloud_services.py | 7 +- tests/db/test_uvloop_policy.py | 18 ++-- tests/hooks/test_adapters.py | 3 +- tests/hooks/test_envelope.py | 3 +- tests/importers/test_importer_base.py | 3 + tests/index/test_local_project_index.py | 5 +- tests/index/test_local_watch_orchestration.py | 4 +- .../test_storage_event_operation_processor.py | 7 ++ .../index/test_storage_event_orchestration.py | 6 ++ tests/index/test_watch_service.py | 3 +- .../test_accepted_note_mutation_runner.py | 6 +- .../test_accepted_note_write_runner.py | 6 +- .../test_project_index_maintenance.py | 3 +- tests/indexing/test_relation_resolution.py | 4 +- tests/mcp/test_async_client_telemetry.py | 3 +- tests/mcp/test_client_telemetry.py | 5 +- tests/mcp/test_project_context_telemetry.py | 3 +- tests/mcp/test_server_telemetry.py | 3 +- tests/mcp/test_tool_read_note.py | 11 +++ tests/mcp/test_tool_search.py | 40 ++++----- tests/mcp/test_tool_telemetry.py | 9 +- tests/mcp/test_tool_utils.py | 2 +- tests/mcp/test_tool_utils_cloud_auth.py | 2 +- tests/mcp/tools/test_chatgpt_tools.py | 4 +- .../test_explicit_session_transactions.py | 5 +- tests/repository/test_fastembed_provider.py | 5 +- .../test_fastembed_rerank_provider.py | 3 +- tests/repository/test_hybrid_fusion.py | 11 ++- tests/repository/test_litellm_provider.py | 3 +- .../test_litellm_rerank_provider.py | 9 +- tests/repository/test_milvus_index.py | 6 +- .../test_note_content_repository.py | 3 +- .../test_note_file_vacate_repository.py | 4 +- .../repository/test_observation_repository.py | 24 ++--- .../test_postgres_search_repository.py | 3 + .../test_postgres_search_repository_unit.py | 5 +- tests/repository/test_prefixing_provider.py | 5 +- tests/repository/test_rerank_pipeline.py | 12 +-- .../test_rerank_provider_factory.py | 4 +- tests/repository/test_semantic_search_base.py | 19 ++-- tests/repository/test_semantic_vector_sync.py | 11 ++- .../test_sqlite_vector_search_repository.py | 4 +- tests/repository/test_vector_pagination.py | 13 ++- tests/repository/test_vector_threshold.py | 15 +++- .../services/test_entity_service_telemetry.py | 5 +- tests/services/test_initialization.py | 2 + tests/services/test_project_service.py | 3 +- tests/services/test_search_service.py | 7 +- .../services/test_search_service_telemetry.py | 5 +- .../test_upsert_entity_optimization.py | 16 ++-- tests/test_migration_loop.py | 2 +- tests/test_pr_title_workflow.py | 3 +- tests/test_rclone_commands.py | 3 +- tests/test_telemetry.py | 9 +- tests/utils/test_parse_tags.py | 3 +- tests/utils/test_setup_logging.py | 3 +- uv.lock | 88 +++++++++---------- 161 files changed, 754 insertions(+), 459 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 42b6b4392..8e8d8650b 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -110,9 +110,14 @@ markers = [ ] [tool.ruff] +extend-exclude = ["*.md"] line-length = 100 target-version = "py312" +[tool.ruff.lint] +# Ruff 0.16 expanded its defaults; preserve the repository's established lint baseline. +select = ["E4", "E7", "E9", "F"] + [dependency-groups] dev = [ "logfire>=4.19.0", @@ -123,13 +128,13 @@ dev = [ "pytest-mock>=3.12.0", "pytest-asyncio>=0.24.0", "pytest-xdist>=3.0.0", - "ruff>=0.1.6", + "ruff>=0.16.0", "freezegun>=1.5.5", "testcontainers[postgres]>=4.0.0", "psycopg>=3.2.0", "pyright>=1.1.408", "pytest-testmon>=2.2.0", - "ty>=0.0.18", + "ty>=0.0.64", "cst-lsp>=0.1.3", "libcst>=1.8.6", "pytest-timeout>=2.4.0", @@ -145,19 +150,8 @@ style = "pep440" bump = true fallback-version = "0.0.0" -[tool.pyright] -include = ["src/"] -exclude = ["**/__pycache__"] -ignore = ["test/"] -defineConstant = { DEBUG = true } -reportMissingImports = "error" -reportMissingTypeStubs = false -reportUnusedImport = "none" -pythonVersion = "3.12" - -[tool.ty.terminal] -# Keep advisory diagnostics visible without treating them as type errors. -error-on-warning = false +[tool.ty.rules] +all = "error" diff --git a/src/basic_memory/api/template_loader.py b/src/basic_memory/api/template_loader.py index e53f3911a..2b1c450ff 100644 --- a/src/basic_memory/api/template_loader.py +++ b/src/basic_memory/api/template_loader.py @@ -6,7 +6,7 @@ """ import textwrap -from typing import Dict, Any, Optional, Callable +from typing import Dict, Any, Optional, Callable, Protocol from pathlib import Path import json import datetime @@ -18,6 +18,18 @@ TEMPLATES_DIR = Path(__file__).parent.parent / "templates" +class CompiledTemplate(Protocol): + """Callable interface used by compiled Handlebars templates.""" + + def __call__( + self, + context: dict[str, Any], + /, + *, + helpers: dict[str, Callable[..., Any]], + ) -> str: ... + + # Custom helpers for Handlebars def _date_helper(this, *args): """Format a date using the given format string.""" @@ -215,11 +227,11 @@ def __init__(self, template_dir: Optional[str] = None): template_dir: Optional custom template directory path """ self.template_dir = Path(template_dir) if template_dir else TEMPLATES_DIR - self.template_cache: Dict[str, Callable] = {} + self.template_cache: Dict[str, CompiledTemplate] = {} self.compiler = pybars.Compiler() # Set up standard helpers - self.helpers = { + self.helpers: dict[str, Callable[..., Any]] = { "date": _date_helper, "default": _default_helper, "capitalize": _capitalize_helper, @@ -234,7 +246,7 @@ def __init__(self, template_dir: Optional[str] = None): logger.debug(f"Initialized template loader with directory: {self.template_dir}") - def get_template(self, template_path: str) -> Callable: + def get_template(self, template_path: str) -> CompiledTemplate: """Get a template by path, using cache if available. Args: diff --git a/src/basic_memory/api/v2/routers/importer_router.py b/src/basic_memory/api/v2/routers/importer_router.py index 9efa4dfa7..52291b8b1 100644 --- a/src/basic_memory/api/v2/routers/importer_router.py +++ b/src/basic_memory/api/v2/routers/importer_router.py @@ -20,6 +20,7 @@ from basic_memory.schemas.importer import ( ChatImportResult, EntityImportResult, + ImportResult, ProjectImportResult, ) @@ -165,9 +166,12 @@ async def import_memory_json( return result -async def import_file( - importer: Importer, file: UploadFile, destination_directory: str, max_bytes: int -): +async def import_file[ImportResultT: ImportResult]( + importer: Importer[ImportResultT], + file: UploadFile, + destination_directory: str, + max_bytes: int, +) -> ImportResultT: """Helper function to import a file using an importer instance. Args: diff --git a/src/basic_memory/api/v2/routers/schema_router.py b/src/basic_memory/api/v2/routers/schema_router.py index 995d8dde8..3b80cbbb9 100644 --- a/src/basic_memory/api/v2/routers/schema_router.py +++ b/src/basic_memory/api/v2/routers/schema_router.py @@ -37,6 +37,7 @@ from basic_memory.picoschema.inference import infer_schema, NoteData, ObservationData, RelationData from basic_memory.picoschema.diff import diff_schema from basic_memory.utils import generate_permalink +from typing import Any # Note: No prefix here -- it's added during registration as /v2/{project_id}/schema router = APIRouter(tags=["schema"]) @@ -75,7 +76,7 @@ def _entity_to_note_data(entity: Entity) -> NoteData: ) -def _entity_frontmatter(entity: Entity) -> dict: +def _entity_frontmatter(entity: Entity) -> dict[str, Any]: """Build a frontmatter dict from an entity's database metadata. Used for the notes being validated — their type and schema ref are @@ -90,7 +91,7 @@ def _entity_frontmatter(entity: Entity) -> dict: async def _schema_frontmatter_from_file( file_service: FileServiceV2ExternalDep, entity: Entity, -) -> dict: +) -> dict[str, Any]: """Read a schema entity's frontmatter directly from its file. Schema definitions (field declarations, validation mode) are the source @@ -163,7 +164,7 @@ async def validate_schema( frontmatter = _entity_frontmatter(entity) schema_ref = frontmatter.get("schema") - async def search_fn(query: str) -> list[dict]: + async def search_fn(query: str) -> list[dict[str, Any]]: entities = await _find_schema_entities( session, entity_repository, @@ -310,7 +311,7 @@ async def diff_schema_endpoint( fields, and cardinality changes. """ - async def search_fn(query: str) -> list[dict]: + async def search_fn(query: str) -> list[dict[str, Any]]: entities = await _find_schema_entities(session, entity_repository, query) return [await _schema_frontmatter_from_file(file_service, e) for e in entities] @@ -373,7 +374,7 @@ async def _validate_note_entities( frontmatter = _entity_frontmatter(entity) schema_ref = frontmatter.get("schema") - async def search_fn(query: str) -> list[dict]: + async def search_fn(query: str) -> list[dict[str, Any]]: found = await _find_schema_entities( session, entity_repository, diff --git a/src/basic_memory/cli/analytics.py b/src/basic_memory/cli/analytics.py index f1cd71c13..4d3d88e57 100644 --- a/src/basic_memory/cli/analytics.py +++ b/src/basic_memory/cli/analytics.py @@ -16,7 +16,7 @@ import os import threading import urllib.request -from typing import Optional +from typing import Any, Optional import basic_memory @@ -60,7 +60,7 @@ def _is_configured() -> bool: EVENT_CLOUD_LOGIN_SUB_REQUIRED = "cli-cloud-login-sub-required" -def track(event_name: str, data: Optional[dict] = None) -> None: +def track(event_name: str, data: Optional[dict[str, Any]] = None) -> None: """Send an analytics event to Umami. Non-blocking, silent on failure. Parameters diff --git a/src/basic_memory/cli/auth.py b/src/basic_memory/cli/auth.py index b869b5e7c..7051ec933 100644 --- a/src/basic_memory/cli/auth.py +++ b/src/basic_memory/cli/auth.py @@ -9,7 +9,7 @@ import webbrowser from contextlib import asynccontextmanager from collections.abc import AsyncIterator, Callable -from typing import AsyncContextManager +from typing import Any, AsyncContextManager import httpx from rich.console import Console @@ -65,7 +65,7 @@ def generate_pkce_pair(self) -> tuple[str, str]: return code_verifier, code_challenge - async def request_device_authorization(self) -> dict | None: + async def request_device_authorization(self) -> dict[str, Any] | None: """Request device authorization from WorkOS with PKCE.""" device_auth_url = f"{self.authkit_domain}/oauth2/device_authorization" @@ -94,7 +94,7 @@ async def request_device_authorization(self) -> dict | None: console.print(f"[red]Device authorization error: {e}[/red]") return None - def display_user_instructions(self, device_response: dict) -> None: + def display_user_instructions(self, device_response: dict[str, Any]) -> None: """Display user instructions for device authorization.""" user_code = device_response["user_code"] verification_uri = device_response["verification_uri"] @@ -118,7 +118,7 @@ def display_user_instructions(self, device_response: dict) -> None: console.print("\n[dim]Waiting for you to complete authentication in your browser...[/dim]") - async def poll_for_token(self, device_code: str, interval: int = 5) -> dict | None: + async def poll_for_token(self, device_code: str, interval: int = 5) -> dict[str, Any] | None: """Poll the token endpoint until user completes authentication.""" token_url = f"{self.authkit_domain}/oauth2/token" @@ -179,7 +179,7 @@ async def _async_sleep(self, seconds: int) -> None: await asyncio.sleep(seconds) - def save_tokens(self, tokens: dict) -> None: + def save_tokens(self, tokens: dict[str, Any]) -> None: """Save tokens to project root as .bm-auth.json.""" token_data = { "access_token": tokens["access_token"], @@ -196,7 +196,7 @@ def save_tokens(self, tokens: dict) -> None: console.print(f"[green]Tokens saved to {self.token_file}[/green]") - def load_tokens(self) -> dict | None: + def load_tokens(self) -> dict[str, Any] | None: """Load tokens from .bm-auth.json file.""" if not self.token_file.exists(): return None @@ -207,13 +207,13 @@ def load_tokens(self) -> dict | None: except (OSError, json.JSONDecodeError): return None - def is_token_valid(self, tokens: dict) -> bool: + def is_token_valid(self, tokens: dict[str, Any]) -> bool: """Check if stored token is still valid.""" expires_at = tokens.get("expires_at", 0) # Add 60 second buffer for clock skew return time.time() < (expires_at - 60) - async def refresh_token(self, refresh_token: str) -> dict | None: + async def refresh_token(self, refresh_token: str) -> dict[str, Any] | None: """Refresh access token using refresh token.""" token_url = f"{self.authkit_domain}/oauth2/token" diff --git a/src/basic_memory/cli/commands/cloud/api_client.py b/src/basic_memory/cli/commands/cloud/api_client.py index bd2ec55e7..52b578ae0 100644 --- a/src/basic_memory/cli/commands/cloud/api_client.py +++ b/src/basic_memory/cli/commands/cloud/api_client.py @@ -1,7 +1,7 @@ """Cloud API client utilities.""" from collections.abc import AsyncIterator -from typing import Optional +from typing import Any, Optional from contextlib import asynccontextmanager from typing import AsyncContextManager, Callable @@ -21,7 +21,10 @@ class CloudAPIError(Exception): """Exception raised for cloud API errors.""" def __init__( - self, message: str, status_code: Optional[int] = None, detail: Optional[dict] = None + self, + message: str, + status_code: Optional[int] = None, + detail: Optional[dict[str, Any]] = None, ): super().__init__(message) self.status_code = status_code @@ -79,8 +82,8 @@ async def _default_http_client(timeout: float) -> AsyncIterator[httpx.AsyncClien async def make_api_request( method: str, url: str, - headers: Optional[dict] = None, - json_data: Optional[dict] = None, + headers: Optional[dict[str, Any]] = None, + json_data: Optional[dict[str, Any]] = None, timeout: float = 30.0, *, auth: CLIAuth | None = None, diff --git a/src/basic_memory/cli/commands/cloud/rclone_installer.py b/src/basic_memory/cli/commands/cloud/rclone_installer.py index 1d1f95339..f1f4f0f5b 100644 --- a/src/basic_memory/cli/commands/cloud/rclone_installer.py +++ b/src/basic_memory/cli/commands/cloud/rclone_installer.py @@ -35,7 +35,7 @@ def get_platform() -> str: raise RcloneInstallError(f"Unsupported platform: {system}") -def run_command(command: list[str], check: bool = True) -> subprocess.CompletedProcess: +def run_command(command: list[str], check: bool = True) -> subprocess.CompletedProcess[str]: """Run a command with proper error handling.""" try: console.print(f"[dim]Running: {' '.join(command)}[/dim]") diff --git a/src/basic_memory/cli/commands/cloud/shares.py b/src/basic_memory/cli/commands/cloud/shares.py index d0cb9365c..7f0f94649 100644 --- a/src/basic_memory/cli/commands/cloud/shares.py +++ b/src/basic_memory/cli/commands/cloud/shares.py @@ -14,7 +14,7 @@ import asyncio from datetime import datetime -from typing import Optional +from typing import Any, Optional from urllib.parse import urlencode from uuid import UUID @@ -175,7 +175,7 @@ def _parse_expires_at(value: str) -> str: return dt.isoformat() -def _print_share_details(data: dict) -> None: +def _print_share_details(data: dict[str, Any]) -> None: """Print a single share's fields in the snapshot-style detail layout.""" console.print(f" Token: {data.get('token', 'unknown')}") console.print(f" URL: [blue underline]{data.get('share_url', '-')}[/blue underline]") @@ -220,7 +220,7 @@ def create( # Validate --expires-at before any async/API work so a parse error surfaces # a single clean message and exits, rather than being re-wrapped by the broad # handler below as "Unexpected error: 1" (typer.Exit subclasses Exception). - payload: dict = { + payload: dict[str, Any] = { "project_name": project, "note_permalink": permalink, } @@ -416,7 +416,7 @@ async def _update(): ) raise typer.Exit(1) - payload: dict = {} + payload: dict[str, Any] = {} if enable: payload["enabled"] = True if disable: diff --git a/src/basic_memory/cli/commands/cloud/upload.py b/src/basic_memory/cli/commands/cloud/upload.py index fd49a63d0..30f113877 100644 --- a/src/basic_memory/cli/commands/cloud/upload.py +++ b/src/basic_memory/cli/commands/cloud/upload.py @@ -3,7 +3,7 @@ import os from pathlib import Path from contextlib import AbstractAsyncContextManager -from typing import Callable +from typing import Callable, Protocol import aiofiles import httpx @@ -15,6 +15,20 @@ ARCHIVE_EXTENSIONS = {".zip", ".tar", ".gz", ".bz2", ".xz", ".7z", ".rar", ".tgz", ".tbz2"} +class PutFile(Protocol): + """Callable contract for injected WebDAV uploads.""" + + async def __call__( + self, + client: httpx.AsyncClient, + remote_path: str, + /, + *, + content: bytes, + headers: dict[str, str], + ) -> httpx.Response: ... + + async def upload_path( local_path: Path, project_name: str, @@ -23,7 +37,7 @@ async def upload_path( dry_run: bool = False, *, client_cm_factory: Callable[[], AbstractAsyncContextManager[httpx.AsyncClient]] | None = None, - put_func: Callable | None = None, + put_func: PutFile | None = None, ) -> bool: """ Upload a file or directory to cloud project via WebDAV. diff --git a/src/basic_memory/cli/commands/hook.py b/src/basic_memory/cli/commands/hook.py index 5f7c379bc..2311b5dfd 100644 --- a/src/basic_memory/cli/commands/hook.py +++ b/src/basic_memory/cli/commands/hook.py @@ -187,7 +187,7 @@ class HarnessProfile: # --- Hook stdin --- -def _read_stdin_payload() -> dict: +def _read_stdin_payload() -> dict[str, Any]: """Parse the harness's hook JSON from stdin; junk normalizes to {}. Interactive invocations (a human typing `bm hook session-start`) have no @@ -205,7 +205,7 @@ def _read_stdin_payload() -> dict: # --- Harness settings resolution (ported from the plugin hook scripts) --- -def _read_claude_block(path: Path) -> tuple[dict | None, bool]: +def _read_claude_block(path: Path) -> tuple[dict[str, Any] | None, bool]: """Read one Claude settings block and preserve malformed-file presence.""" try: data = json.loads(path.read_text(encoding="utf-8")) @@ -237,7 +237,7 @@ def _claude_project_dir(directory: Path) -> Path: current = current.parent -def load_claude_settings(directory: Path) -> tuple[dict, bool]: +def load_claude_settings(directory: Path) -> tuple[dict[str, Any], bool]: """Merge basicMemory blocks: user-level settings.json, then project settings. Precedence (lowest to highest): ``~/.claude/settings.json``, then the @@ -249,7 +249,7 @@ def load_claude_settings(directory: Path) -> tuple[dict, bool]: evaluation so a later source cannot rebuild routing from incomplete settings. """ - merged: dict = {"captureEvents": DEFAULT_CAPTURE_EVENTS} + merged: dict[str, Any] = {"captureEvents": DEFAULT_CAPTURE_EVENTS} found = False home = Path.home() sources: list[tuple[Path, tuple[str, ...]]] = [(home, ("settings.json",))] @@ -271,7 +271,7 @@ def load_claude_settings(directory: Path) -> tuple[dict, bool]: return merged, found -def _read_codex_block(path: Path) -> tuple[dict | None, bool]: +def _read_codex_block(path: Path) -> tuple[dict[str, Any] | None, bool]: """Read one Codex settings block and preserve malformed-file presence.""" try: data = json.loads(path.read_text(encoding="utf-8")) @@ -323,7 +323,7 @@ def _codex_default_capture_folder(directory: Path) -> str: return f"codex/{repo_dir}" -def load_codex_settings(directory: Path) -> tuple[dict, bool]: +def load_codex_settings(directory: Path) -> tuple[dict[str, Any], bool]: """Merge user and project Codex settings, then resolve checkout defaults. Precedence (lowest to highest): ``~/.codex/basic-memory.json``, then the @@ -333,7 +333,7 @@ def load_codex_settings(directory: Path) -> tuple[dict, bool]: configured and fails closed for the whole evaluation so a later source cannot rebuild routing from incomplete settings. """ - defaults: dict = { + defaults: dict[str, Any] = { "checkpointOnCompact": CODEX_DEFAULT_CHECKPOINT_ON_COMPACT, "captureEvents": DEFAULT_CAPTURE_EVENTS, "captureFolder": _codex_default_capture_folder(directory), @@ -367,13 +367,13 @@ def load_codex_settings(directory: Path) -> tuple[dict, bool]: return merged, found -def load_harness_settings(harness: Harness, directory: Path) -> tuple[dict, bool]: +def load_harness_settings(harness: Harness, directory: Path) -> tuple[dict[str, Any], bool]: if harness is Harness.claude: return load_claude_settings(directory) return load_codex_settings(directory) -def _shared_project_refs(cfg: dict, primary_project: str) -> tuple[list[str], bool]: +def _shared_project_refs(cfg: dict[str, Any], primary_project: str) -> tuple[list[str], bool]: """Resolve the shared/team read set: secondaryProjects + teamProjects keys. Dedup, preserve order, cap at MAX_SHARED. These are read-only recall @@ -409,7 +409,7 @@ def _mapping_dir(project_dir: Optional[Path], event_cwd: str) -> Path: def _capture_envelope( event: NormalizedHookEvent, envelope_event: str, - cfg: dict, + cfg: dict[str, Any], mapping_dir: Path, capture_folder: str, ) -> None: @@ -460,7 +460,7 @@ def _project_query_kwargs(project_ref: str) -> dict[str, str]: return {"project_id": project_id} if project_id else {"project": project or project_ref} -async def _query(project_ref: str | None, **filters: Any) -> dict | None: +async def _query(project_ref: str | None, **filters: Any) -> dict[str, Any] | None: """One best-effort structured search; any failure reads as 'no data'.""" # Deferred: importing basic_memory.mcp.tools loads the whole tool stack (#886). from basic_memory.mcp.tools import search_notes @@ -479,10 +479,10 @@ async def _query(project_ref: str | None, **filters: Any) -> dict | None: @dataclass class _BriefContext: - tasks: dict | None - decisions: dict | None - sessions: dict | None - shared: dict[str, dict | None] + tasks: dict[str, Any] | None + decisions: dict[str, Any] | None + sessions: dict[str, Any] | None + shared: dict[str, dict[str, Any] | None] async def _gather_context( @@ -528,16 +528,16 @@ async def _gather_context( ) -def _rows(result: dict | None) -> list[dict]: +def _rows(result: dict[str, Any] | None) -> list[dict[str, Any]]: return (result or {}).get("results") or [] -def _merge_search_results(results: list[dict | None]) -> dict | None: +def _merge_search_results(results: list[dict[str, Any] | None]) -> dict[str, Any] | None: """Merge bounded recall queries while preserving their priority order.""" if all(result is None for result in results): return None - merged: list[dict] = [] + merged: list[dict[str, Any]] = [] seen: set[str] = set() for result in results: for row in _rows(result): @@ -551,7 +551,7 @@ def _merge_search_results(results: list[dict | None]) -> dict | None: return {"results": merged} -def _label(result: dict) -> str: +def _label(result: dict[str, Any]) -> str: name = result.get("title") or result.get("file_path") or "(untitled)" ref = result.get("permalink") or result.get("file_path") or "" return f"- {name}" + (f" — {ref}" if ref else "") @@ -589,7 +589,7 @@ def _fence(data_lines: list[str]) -> tuple[str, list[str]]: def _build_brief( profile: HarnessProfile, - cfg: dict, + cfg: dict[str, Any], configured: bool, checkpoint_prompt: str | None = None, ) -> str: @@ -875,7 +875,7 @@ def _pull_request_context(directory: str) -> PullRequestContext | None: return None -def _coding_context(cfg: dict, directory: str) -> CodingContext: +def _coding_context(cfg: dict[str, Any], directory: str) -> CodingContext: repository = cfg.get("repository") if not isinstance(repository, str) or not repository.strip(): raise RuntimeError("coding session profile requires basicMemory.repository; rerun bm-setup") diff --git a/src/basic_memory/cli/commands/project.py b/src/basic_memory/cli/commands/project.py index fc2fe26f7..0173073a0 100644 --- a/src/basic_memory/cli/commands/project.py +++ b/src/basic_memory/cli/commands/project.py @@ -4,7 +4,7 @@ import os from datetime import datetime from pathlib import Path -from typing import cast +from typing import Any import typer from loguru import logger @@ -280,7 +280,7 @@ def _normalize_project_visibility(visibility: str | None) -> ProjectVisibility: normalized = visibility.strip().lower() if normalized in {"workspace", "shared", "private"}: - return cast(ProjectVisibility, normalized) + return normalized raise ValueError("Invalid visibility. Expected one of: workspace, shared, private.") @@ -581,7 +581,7 @@ def _select_attached_row_key( attached_row_by_permalink[permalink] = _select_attached_row_key(permalink, entry) # --- Build unified project list --- - project_rows: list[dict] = [] + project_rows: list[dict[str, Any]] = [] sorted_row_keys = sorted( row_names_by_key, key=lambda key: (row_names_by_key[key], key[0] or ""), @@ -1515,7 +1515,7 @@ def display_project_info( ) # --- Assemble dashboard --- - parts: list = [columns, ""] + parts: list[Table | Group | str] = [columns, ""] if cloud_section is not None: parts.extend([cloud_section, ""]) if bars_section: diff --git a/src/basic_memory/cli/commands/schema.py b/src/basic_memory/cli/commands/schema.py index 86f63e75b..d338b85d3 100644 --- a/src/basic_memory/cli/commands/schema.py +++ b/src/basic_memory/cli/commands/schema.py @@ -9,7 +9,7 @@ """ import json -from typing import Annotated, Optional +from typing import Any, Annotated, Optional import typer from loguru import logger @@ -46,7 +46,7 @@ def _resolve_project_name(project: Optional[str]) -> Optional[str]: # --- Rendering helpers --- -def _render_validate_table(data: dict) -> None: +def _render_validate_table(data: dict[str, Any]) -> None: """Render a validation report dict as a Rich table.""" note_type = data.get("note_type") title_label = note_type or "all" @@ -83,7 +83,7 @@ def _render_validate_table(data: dict) -> None: ) -def _render_infer_table(data: dict) -> None: +def _render_infer_table(data: dict[str, Any]) -> None: """Render an inference report dict as a Rich table.""" note_type = data.get("note_type", "") notes_analyzed = data.get("notes_analyzed", 0) @@ -125,7 +125,7 @@ def _render_infer_table(data: dict) -> None: console.print(json.dumps(suggested_schema, indent=2)) -def _render_diff_output(data: dict) -> None: +def _render_diff_output(data: dict[str, Any]) -> None: """Render a drift report dict as Rich output.""" note_type = data.get("note_type", "") new_fields = data.get("new_fields", []) diff --git a/src/basic_memory/config_models.py b/src/basic_memory/config_models.py index e165362ab..8689d4847 100644 --- a/src/basic_memory/config_models.py +++ b/src/basic_memory/config_models.py @@ -6,7 +6,7 @@ from datetime import datetime from enum import Enum from pathlib import Path -from typing import TYPE_CHECKING, Any, ClassVar, Dict, Literal, Optional, List +from typing import override, TYPE_CHECKING, Any, ClassVar, Dict, Literal, Optional, List from loguru import logger from pydantic import AliasChoices, BaseModel, Field, model_validator @@ -877,6 +877,7 @@ def get_project_path(self, project_name: Optional[str] = None) -> Path: # pragm return Path(self.projects[name].path) + @override def model_post_init(self, __context: Any) -> None: """Ensure configuration is valid after initialization.""" # Skip default-project seeding only for cloud/stateless deployments, where diff --git a/src/basic_memory/db.py b/src/basic_memory/db.py index 568a3b524..a28f11c68 100644 --- a/src/basic_memory/db.py +++ b/src/basic_memory/db.py @@ -4,7 +4,7 @@ from contextlib import asynccontextmanager, suppress from enum import Enum, auto from pathlib import Path -from typing import AsyncGenerator, Optional +from typing import Any, AsyncGenerator, Optional from basic_memory.config import BasicMemoryConfig, ConfigManager, DatabaseBackend from alembic import command @@ -21,9 +21,15 @@ ) from sqlalchemy.pool import AsyncAdaptedQueuePool, NullPool + # ----------------------------------------------------------------------------- # Windows event loop policy # ----------------------------------------------------------------------------- +def _install_event_loop_policy(policy: Any) -> None: + """Install the Python 3.12-3.15 process-wide compatibility policy.""" + asyncio.set_event_loop_policy(policy) # ty: ignore[deprecated] + + # On Windows, the default ProactorEventLoop has known rough edges with aiosqlite # during shutdown/teardown (threads posting results to a loop that's closing), # which can manifest as: @@ -34,7 +40,13 @@ # asyncio.create_subprocess_shell() (like sync_service._quick_count_files) must # detect Windows and use fallback implementations. if sys.platform == "win32": # pragma: no cover - asyncio.set_event_loop_policy(asyncio.WindowsSelectorEventLoopPolicy()) + # Constraint: Basic Memory supports Python 3.12-3.15, where the policy API + # remains the only process-wide way to cover every independently owned loop + # entrypoint. Replace this compatibility seam with loop factories before 3.16. + # The factory is a Windows-only asyncio export, so resolve it only after the + # runtime platform guard (also keeps all-platform static analysis portable). + windows_selector_policy = getattr(asyncio, "WindowsSelectorEventLoopPolicy") + _install_event_loop_policy(windows_selector_policy()) def maybe_install_uvloop(config: BasicMemoryConfig) -> bool: @@ -69,7 +81,10 @@ def maybe_install_uvloop(config: BasicMemoryConfig) -> bool: logger.warning("uvloop not available - using default event loop for Postgres backend") return False - asyncio.set_event_loop_policy(uvloop.EventLoopPolicy()) + # Constraint: CLI commands and FastMCP/AnyIO own several separate loop + # entrypoints. A single policy install keeps all of them on uvloop through + # Python 3.15; migrate those entrypoints to loop factories before 3.16. + _install_event_loop_policy(uvloop.EventLoopPolicy()) logger.info("Installed uvloop event-loop policy for Postgres backend") return True @@ -128,7 +143,7 @@ def get_db_url( def get_scoped_session_factory( session_maker: async_sessionmaker[AsyncSession], -) -> async_scoped_session: +) -> async_scoped_session[AsyncSession]: """Create a scoped session factory scoped to current task.""" return async_scoped_session(session_maker, scopefunc=asyncio.current_task) diff --git a/src/basic_memory/deps/db.py b/src/basic_memory/deps/db.py index 84c13c7d3..daecb107c 100644 --- a/src/basic_memory/deps/db.py +++ b/src/basic_memory/deps/db.py @@ -58,7 +58,10 @@ async def get_session_maker(engine_factory: EngineFactoryDep) -> async_sessionma return session_maker -SessionMakerDep = Annotated[async_sessionmaker, Depends(get_session_maker)] +SessionMakerDep = Annotated[ + async_sessionmaker[AsyncSession], + Depends(get_session_maker), +] async def get_session(session_maker: SessionMakerDep) -> AsyncGenerator[AsyncSession, None]: diff --git a/src/basic_memory/hooks/envelope.py b/src/basic_memory/hooks/envelope.py index fe1fd3edd..b6a21a0dd 100644 --- a/src/basic_memory/hooks/envelope.py +++ b/src/basic_memory/hooks/envelope.py @@ -18,6 +18,7 @@ from pydantic import BaseModel, ConfigDict, Field, field_validator from basic_memory.hooks._uuid7 import uuid7 +from typing import Any ENVELOPE_VERSION = 1 @@ -73,7 +74,9 @@ class Envelope(BaseModel): actor: str = ACTOR_RUNTIME # "runtime" | "user" | routine name caused_by: str | None = None # id of the triggering event, when known promotion_status: str = PROMOTION_RAW - payload: dict = Field(default_factory=dict) # bounded lifecycle metadata only + payload: dict[str, Any] = Field( + default_factory=dict[str, Any] + ) # bounded lifecycle metadata only @field_validator("envelope_version") @classmethod @@ -118,7 +121,7 @@ def create_envelope( ts: str | None = None, actor: str = ACTOR_RUNTIME, caused_by: str | None = None, - payload: dict | None = None, + payload: dict[str, Any] | None = None, ) -> Envelope: """Factory: build a producer envelope from normalized hook inputs. diff --git a/src/basic_memory/importers/chatgpt_importer.py b/src/basic_memory/importers/chatgpt_importer.py index 97111bfde..97348535c 100644 --- a/src/basic_memory/importers/chatgpt_importer.py +++ b/src/basic_memory/importers/chatgpt_importer.py @@ -2,7 +2,7 @@ import logging from datetime import datetime -from typing import Any, Dict, List, Optional, Set +from typing import override, Any, Dict, List, Optional, Set from basic_memory.markdown.schemas import EntityFrontmatter, EntityMarkdown from basic_memory.importers.base import Importer @@ -15,6 +15,7 @@ class ChatGPTImporter(Importer[ChatImportResult]): """Service for importing ChatGPT conversations.""" + @override def handle_error( # pragma: no cover self, message: str, error: Optional[Exception] = None ) -> ChatImportResult: @@ -28,6 +29,7 @@ def handle_error( # pragma: no cover messages=0, ) + @override async def import_data( self, source_data, destination_folder: str, **kwargs: Any ) -> ChatImportResult: diff --git a/src/basic_memory/importers/claude_conversations_importer.py b/src/basic_memory/importers/claude_conversations_importer.py index 14ca65d3e..efa3bae33 100644 --- a/src/basic_memory/importers/claude_conversations_importer.py +++ b/src/basic_memory/importers/claude_conversations_importer.py @@ -2,7 +2,7 @@ import logging from datetime import datetime -from typing import Any, Dict, List, Optional +from typing import override, Any, Dict, List, Optional from basic_memory.markdown.schemas import EntityFrontmatter, EntityMarkdown from basic_memory.importers.base import Importer @@ -15,6 +15,7 @@ class ClaudeConversationsImporter(Importer[ChatImportResult]): """Service for importing Claude conversations.""" + @override def handle_error( # pragma: no cover self, message: str, error: Optional[Exception] = None ) -> ChatImportResult: @@ -28,6 +29,7 @@ def handle_error( # pragma: no cover messages=0, ) + @override async def import_data( self, source_data, destination_folder: str, **kwargs: Any ) -> ChatImportResult: diff --git a/src/basic_memory/importers/claude_projects_importer.py b/src/basic_memory/importers/claude_projects_importer.py index f3e50c358..77ff13848 100644 --- a/src/basic_memory/importers/claude_projects_importer.py +++ b/src/basic_memory/importers/claude_projects_importer.py @@ -1,7 +1,7 @@ """Claude projects import service for Basic Memory.""" import logging -from typing import Any, Dict, Optional +from typing import override, Any, Dict, Optional from basic_memory.markdown.schemas import EntityFrontmatter, EntityMarkdown from basic_memory.importers.base import Importer @@ -14,6 +14,7 @@ class ClaudeProjectsImporter(Importer[ProjectImportResult]): """Service for importing Claude projects.""" + @override def handle_error( # pragma: no cover self, message: str, error: Optional[Exception] = None ) -> ProjectImportResult: @@ -27,6 +28,7 @@ def handle_error( # pragma: no cover prompts=0, ) + @override async def import_data( self, source_data, destination_folder: str, **kwargs: Any ) -> ProjectImportResult: diff --git a/src/basic_memory/importers/memory_json_importer.py b/src/basic_memory/importers/memory_json_importer.py index 1dcba3875..26e60d377 100644 --- a/src/basic_memory/importers/memory_json_importer.py +++ b/src/basic_memory/importers/memory_json_importer.py @@ -1,7 +1,7 @@ """Memory JSON import service for Basic Memory.""" import logging -from typing import Any, Dict, List, Optional +from typing import override, Any, Dict, List, Optional from basic_memory.markdown.schemas import EntityFrontmatter, EntityMarkdown, Observation, Relation from basic_memory.importers.base import Importer @@ -13,6 +13,7 @@ class MemoryJsonImporter(Importer[EntityImportResult]): """Service for importing memory.json format data.""" + @override def handle_error( # pragma: no cover self, message: str, error: Optional[Exception] = None ) -> EntityImportResult: @@ -27,6 +28,7 @@ def handle_error( # pragma: no cover skipped_entities=0, ) + @override async def import_data( self, source_data, destination_folder: str = "", **kwargs: Any ) -> EntityImportResult: diff --git a/src/basic_memory/index/local_dependencies.py b/src/basic_memory/index/local_dependencies.py index 88ccd4dcd..77d6d01a6 100644 --- a/src/basic_memory/index/local_dependencies.py +++ b/src/basic_memory/index/local_dependencies.py @@ -6,7 +6,7 @@ from dataclasses import dataclass from datetime import datetime from pathlib import Path -from typing import Any, Protocol +from typing import override, Any, Protocol from loguru import logger from sqlalchemy import Select @@ -126,10 +126,11 @@ class LocalIndexEntityRepository( @property def project_id(self) -> ProjectId | None: ... - def select(self, *entities: Any) -> Select: + def select(self, *entities: Any) -> Select[Any]: """Project-scoped SELECT builder used for stat-only watermark scans.""" ... + @override async def get_by_file_path( self, session: AsyncSession, @@ -138,12 +139,14 @@ async def get_by_file_path( load_relations: bool = True, ) -> Entity | None: ... + @override async def get_by_file_paths( self, session: AsyncSession, file_paths: Sequence[Path | str], ) -> Sequence[IndexedFileChecksumRow]: ... + @override async def find_by_ids( self, session: AsyncSession, @@ -163,6 +166,7 @@ async def update( entity_data: dict[str, object] | Entity, ) -> Entity | None: ... + @override async def delete_by_fields( self, session: AsyncSession, @@ -237,6 +241,7 @@ class LocalIndexFileBatchIndexer(IndexFileBatchIndexer[IndexInputFile]): batch_runtime: IndexBatchRuntime[Entity, IndexInputFile] + @override async def index_files( self, files: Mapping[str, IndexInputFile], @@ -266,6 +271,7 @@ class LocalMarkdownFileIndexer(IndexFileExecutor): search_service: IndexEntitySearchWriter note_content_reconciler: IndexMarkdownNoteContentReconciler + @override async def index_file( self, file_path: RuntimeFilePath, diff --git a/src/basic_memory/index/local_moves.py b/src/basic_memory/index/local_moves.py index d2cb1926b..2f71275e8 100644 --- a/src/basic_memory/index/local_moves.py +++ b/src/basic_memory/index/local_moves.py @@ -5,7 +5,7 @@ from collections.abc import Mapping, Sequence from dataclasses import dataclass from pathlib import Path -from typing import Protocol +from typing import override, Protocol import yaml from loguru import logger @@ -106,6 +106,7 @@ class LocalProjectIndexMoveContentUpdater(ProjectIndexMoveContentUpdater): entity_service: LocalMoveEntityService file_service: FileService + @override async def plan_moved_file_content( self, session: AsyncSession, @@ -144,6 +145,7 @@ async def plan_moved_file_content( markdown_content=planned_content, ) + @override async def write_moved_file_content( self, moved_file: ProjectIndexMovedFile, diff --git a/src/basic_memory/index/local_project.py b/src/basic_memory/index/local_project.py index c9bd53be2..fb3a0b41f 100644 --- a/src/basic_memory/index/local_project.py +++ b/src/basic_memory/index/local_project.py @@ -7,7 +7,7 @@ from collections.abc import Mapping, Sequence from dataclasses import dataclass from pathlib import Path -from typing import Protocol +from typing import Any, override, Protocol from loguru import logger from sqlalchemy import Select @@ -129,7 +129,7 @@ async def load_indexed_file_stats(self) -> Mapping[str, IndexedFileStat]: ... class LocalProjectIndexStatRepository(Protocol): """Project-scoped select capability for reading indexed file stat rows.""" - def select(self, *entities: object) -> Select: ... + def select(self, *entities: object) -> Select[Any]: ... @dataclass(frozen=True, slots=True) @@ -139,6 +139,7 @@ class RepositoryLocalProjectIndexedFileStatSource(LocalProjectIndexedFileStatSou session_maker: async_sessionmaker[AsyncSession] entity_repository: LocalProjectIndexStatRepository + @override async def load_indexed_file_stats(self) -> dict[str, IndexedFileStat]: # Only the three stat columns are projected (not full entities) so this # stays a cheap single query even for large projects. @@ -286,6 +287,7 @@ class LocalProjectIndexObservedFileSource(ProjectIndexObservedFileSource): ignore_patterns: LocalProjectIndexIgnorePatterns | None = None indexed_stat_source: LocalProjectIndexedFileStatSource | None = None + @override async def list_observed_index_files(self) -> tuple[RuntimeObservedIndexFile, ...]: scan = await asyncio.to_thread( scan_local_project_index_files, @@ -413,6 +415,7 @@ class LocalProjectIndexDeletePathVerifier(ProjectIndexDeletePathVerifier): file_service: FileService + @override async def confirm_deleted_paths(self, paths: Sequence[str]) -> frozenset[str]: confirmed_paths: set[str] = set() for path in paths: @@ -483,6 +486,7 @@ class LocalIndexFileBatchReader(IndexFileBatchReader[IndexInputFile]): file_service: FileService + @override async def read_current_files( self, file_paths: Sequence[str], @@ -543,6 +547,7 @@ class LocalProjectIndexBatchEnqueuer(ProjectIndexBatchEnqueuer): read_max_concurrent: int = 8 index_max_concurrent: int = 8 + @override async def enqueue_index_file_batch( self, request: RuntimeIndexFileBatchJobRequest, diff --git a/src/basic_memory/index/local_runtime.py b/src/basic_memory/index/local_runtime.py index 8ae8c3a6f..4a2dc8c96 100644 --- a/src/basic_memory/index/local_runtime.py +++ b/src/basic_memory/index/local_runtime.py @@ -67,6 +67,7 @@ ) from basic_memory.services import FileService from basic_memory.services.exceptions import FileOperationError +from typing import override @dataclass(frozen=True, slots=True) @@ -76,6 +77,7 @@ class LocalStorageEventProjectResolver(StorageEventProjectResolver): project: ProjectRuntimeReference project_prefix: ProjectPath + @override async def resolve_project(self, project_path: ProjectPath) -> ProjectRuntimeReference | None: if project_path != self.project_prefix: return None @@ -239,6 +241,7 @@ class LocalStorageEventOperationProcessorFactory(StorageEventOperationProcessorF runtime: InlineStorageEventIndexRuntime + @override def processor_for_project( self, project: ProjectRuntimeReference, diff --git a/src/basic_memory/index/local_schedulers.py b/src/basic_memory/index/local_schedulers.py index dce1e6fd7..99dd6ca4c 100644 --- a/src/basic_memory/index/local_schedulers.py +++ b/src/basic_memory/index/local_schedulers.py @@ -25,7 +25,7 @@ # --- Background Task Machinery --- -def _log_task_failure(completed: asyncio.Task) -> None: +def _log_task_failure(completed: asyncio.Task[object]) -> None: if completed.cancelled(): return try: diff --git a/src/basic_memory/index/repository_project_resolution.py b/src/basic_memory/index/repository_project_resolution.py index 604830249..64e76fd90 100644 --- a/src/basic_memory/index/repository_project_resolution.py +++ b/src/basic_memory/index/repository_project_resolution.py @@ -1,7 +1,7 @@ """Repository-backed storage-event project resolution.""" from dataclasses import dataclass -from typing import Protocol +from typing import override, Protocol from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker @@ -33,6 +33,7 @@ class RepositoryStorageEventProjectResolver(StorageEventProjectResolver): session_maker: async_sessionmaker[AsyncSession] resolution_logger: StorageEventProjectResolutionLogger + @override async def resolve_project(self, project_path: ProjectPath) -> ProjectRuntimeReference | None: project = await self.find_project_by_bucket_prefix(project_path) if project is None: diff --git a/src/basic_memory/indexing/external_file_delete_runner.py b/src/basic_memory/indexing/external_file_delete_runner.py index e6ade5ed5..e88d51f54 100644 --- a/src/basic_memory/indexing/external_file_delete_runner.py +++ b/src/basic_memory/indexing/external_file_delete_runner.py @@ -3,7 +3,7 @@ from __future__ import annotations from dataclasses import dataclass -from typing import Protocol +from typing import override, Protocol from sqlalchemy import select from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker @@ -89,6 +89,7 @@ class RepositoryExternalFileDeleteEntities(ExternalFileDeleteEntities): session_maker: async_sessionmaker[AsyncSession] entity_repository: ExternalFileDeleteEntityRepository + @override async def find_entity_by_file_path( self, file_path: RuntimeFilePath, @@ -96,6 +97,7 @@ async def find_entity_by_file_path( async with db.scoped_session(self.session_maker) as session: return await self.entity_repository.get_by_file_path(session, file_path) + @override async def delete_entity_if_file_path_matches( self, *, diff --git a/src/basic_memory/indexing/models.py b/src/basic_memory/indexing/models.py index cd60f2a8f..d31610ba4 100644 --- a/src/basic_memory/indexing/models.py +++ b/src/basic_memory/indexing/models.py @@ -6,7 +6,7 @@ from dataclasses import dataclass, field from datetime import datetime from enum import StrEnum -from typing import Any, Protocol, TYPE_CHECKING +from typing import override, Any, Protocol, TYPE_CHECKING from basic_memory.indexing.embedding_index_planning import ( EmbeddingIndexJobRequest, @@ -848,6 +848,7 @@ class StorageIndexFileWriter(IndexFileWriter): storage: IndexFrontmatterStorage + @override async def write_frontmatter( self, update: IndexFrontmatterUpdate ) -> IndexFrontmatterWriteResult: diff --git a/src/basic_memory/indexing/project_index_maintenance.py b/src/basic_memory/indexing/project_index_maintenance.py index 324e9b868..57f84e451 100644 --- a/src/basic_memory/indexing/project_index_maintenance.py +++ b/src/basic_memory/indexing/project_index_maintenance.py @@ -4,7 +4,7 @@ from collections.abc import Mapping, Sequence from dataclasses import dataclass -from typing import Protocol +from typing import override, Protocol from loguru import logger from sqlalchemy import RowMapping, bindparam, case, column, delete, select, table, text, update @@ -86,6 +86,7 @@ class TrustPlannedProjectIndexDeleteVerifier(ProjectIndexDeletePathVerifier): a probing verifier instead. """ + @override async def confirm_deleted_paths(self, paths: Sequence[str]) -> frozenset[str]: return frozenset(paths) @@ -888,6 +889,7 @@ class StoreProjectIndexMaintenanceRunner(ProjectIndexMaintenanceRunner): move_store: RepositoryProjectIndexMaintenanceStore delete_store: RepositoryProjectIndexMaintenanceStore + @override async def run_move_batches( self, *, @@ -900,6 +902,7 @@ async def run_move_batches( move_store=self.move_store, ) + @override async def run_delete_batches( self, *, diff --git a/src/basic_memory/indexing/relation_resolution.py b/src/basic_memory/indexing/relation_resolution.py index bdd13e7cb..d47b07289 100644 --- a/src/basic_memory/indexing/relation_resolution.py +++ b/src/basic_memory/indexing/relation_resolution.py @@ -291,6 +291,8 @@ async def resolve_relations( affected_entity_ids: AffectedEntityIds = { refresh.entity_id for refresh in pending_refreshes } + source_entities: Sequence[Entity] = () + note_contents: Sequence[RelationResolutionNoteContent] = () if affected_entity_ids: sorted_affected_entity_ids = sorted(affected_entity_ids) source_entities = await self.entity_repository.find_by_ids( diff --git a/src/basic_memory/markdown/entity_parser.py b/src/basic_memory/markdown/entity_parser.py index 47487b205..72a3e2365 100644 --- a/src/basic_memory/markdown/entity_parser.py +++ b/src/basic_memory/markdown/entity_parser.py @@ -104,7 +104,7 @@ def _coerce_to_string(value: Any) -> str: return str(value) -def normalize_frontmatter_metadata(metadata: dict) -> dict: +def normalize_frontmatter_metadata(metadata: dict[str, Any]) -> dict[str, Any]: """Normalize all values in frontmatter metadata dict. Converts date/datetime objects to ISO format strings to prevent diff --git a/src/basic_memory/markdown/schemas.py b/src/basic_memory/markdown/schemas.py index cde881cc1..88432b46a 100644 --- a/src/basic_memory/markdown/schemas.py +++ b/src/basic_memory/markdown/schemas.py @@ -1,7 +1,7 @@ """Schema models for entity markdown files.""" from datetime import datetime -from typing import TYPE_CHECKING, Any, List, Optional +from typing import override, TYPE_CHECKING, Any, List, Optional from pydantic import BaseModel, Field, model_validator @@ -14,6 +14,7 @@ class Observation(BaseModel): tags: Optional[List[str]] = None context: Optional[str] = None + @override def __str__(self) -> str: obs_string = f"- [{self.category}] {self.content}" if self.context: @@ -28,6 +29,7 @@ class Relation(BaseModel): target: str context: Optional[str] = None + @override def __str__(self) -> str: rel_string = f"- {self.type} [[{self.target}]]" if self.context: diff --git a/src/basic_memory/mcp/client_info.py b/src/basic_memory/mcp/client_info.py index dd05acfe5..65ac7c08a 100644 --- a/src/basic_memory/mcp/client_info.py +++ b/src/basic_memory/mcp/client_info.py @@ -3,7 +3,7 @@ from __future__ import annotations from collections.abc import Mapping -from typing import Any, cast +from typing import override, Any, cast import mcp.types as mt from fastmcp import Context @@ -17,6 +17,7 @@ class MCPClientInfoMiddleware(Middleware): """Persist sanitized initialize clientInfo in FastMCP session state.""" + @override async def on_initialize( self, context: MiddlewareContext[mt.InitializeRequest], diff --git a/src/basic_memory/mcp/clients/directory.py b/src/basic_memory/mcp/clients/directory.py index 9bce762a8..65dd80945 100644 --- a/src/basic_memory/mcp/clients/directory.py +++ b/src/basic_memory/mcp/clients/directory.py @@ -3,7 +3,7 @@ Encapsulates all /v2/projects/{project_id}/directory/* endpoints. """ -from typing import Optional +from typing import Any, Optional from httpx import AsyncClient @@ -65,7 +65,7 @@ async def list( """ from basic_memory.mcp.tools.utils import call_get - params: dict = { + params: dict[str, Any] = { "dir_name": dir_name, "depth": depth, "page": page, diff --git a/src/basic_memory/mcp/clients/memory.py b/src/basic_memory/mcp/clients/memory.py index e3a241fd0..dc18c28c1 100644 --- a/src/basic_memory/mcp/clients/memory.py +++ b/src/basic_memory/mcp/clients/memory.py @@ -3,7 +3,7 @@ Encapsulates all /v2/projects/{project_id}/memory/* endpoints. """ -from typing import Optional +from typing import Any, Optional from httpx import AsyncClient @@ -68,7 +68,7 @@ async def build_context( """ from basic_memory.mcp.tools.utils import call_get - params: dict = { + params: dict[str, Any] = { "depth": depth, "page": page, "page_size": page_size, @@ -120,7 +120,7 @@ async def recent( """ from basic_memory.mcp.tools.utils import call_get - params: dict = { + params: dict[str, Any] = { "timeframe": timeframe, "depth": depth, "page": page, diff --git a/src/basic_memory/mcp/prompts/continue_conversation.py b/src/basic_memory/mcp/prompts/continue_conversation.py index 9020a7430..52b280f6f 100644 --- a/src/basic_memory/mcp/prompts/continue_conversation.py +++ b/src/basic_memory/mcp/prompts/continue_conversation.py @@ -5,7 +5,7 @@ """ from textwrap import dedent -from typing import Annotated, Optional +from typing import Any, Annotated, Optional from loguru import logger from pydantic import Field @@ -107,7 +107,7 @@ async def continue_conversation( return prompt -def _format_continuation_results(results: list[dict], topic: str) -> str: +def _format_continuation_results(results: list[dict[str, Any]], topic: str) -> str: """Format search result dicts for conversation continuation context.""" if not results: return f"No previous context found for '{topic}'." diff --git a/src/basic_memory/mcp/prompts/search.py b/src/basic_memory/mcp/prompts/search.py index 8038efd40..3266cb7f5 100644 --- a/src/basic_memory/mcp/prompts/search.py +++ b/src/basic_memory/mcp/prompts/search.py @@ -4,7 +4,7 @@ """ from textwrap import dedent -from typing import Annotated, Optional +from typing import Any, Annotated, Optional from loguru import logger from pydantic import Field @@ -71,7 +71,7 @@ async def search_prompt( """) -def _format_search_results(results: list[dict], query: str) -> str: +def _format_search_results(results: list[dict[str, Any]], query: str) -> str: """Format search result dicts into readable markdown.""" if not results: return f"No results found for '{query}'." diff --git a/src/basic_memory/mcp/tools/build_context.py b/src/basic_memory/mcp/tools/build_context.py index edc0ef15a..9160674ad 100644 --- a/src/basic_memory/mcp/tools/build_context.py +++ b/src/basic_memory/mcp/tools/build_context.py @@ -1,6 +1,6 @@ """Build context tool for Basic Memory MCP server.""" -from typing import Annotated, Optional, Literal +from typing import Any, Annotated, Optional, Literal import logfire from loguru import logger @@ -177,7 +177,7 @@ async def build_context( ] = 10, output_format: Literal["json", "text"] = "json", context: Context | None = None, -) -> dict | str: +) -> dict[str, Any] | str: """Get context needed to continue a discussion within a specific project. This tool enables natural continuation of discussions by loading relevant context diff --git a/src/basic_memory/mcp/tools/delete_note.py b/src/basic_memory/mcp/tools/delete_note.py index 07f6f32a1..c0963efce 100644 --- a/src/basic_memory/mcp/tools/delete_note.py +++ b/src/basic_memory/mcp/tools/delete_note.py @@ -1,5 +1,5 @@ from textwrap import dedent -from typing import Annotated, Optional, Literal +from typing import Any, Annotated, Optional, Literal from loguru import logger from fastmcp import Context @@ -202,7 +202,7 @@ async def delete_note( project_id: Optional[str] = None, output_format: Literal["text", "json"] = "text", context: Context | None = None, -) -> bool | str | dict: +) -> bool | str | dict[str, Any]: """Delete a note or directory from the knowledge base. Permanently removes a note or directory from the specified project. For single notes, diff --git a/src/basic_memory/mcp/tools/edit_note.py b/src/basic_memory/mcp/tools/edit_note.py index d4153c622..a08dc8a71 100644 --- a/src/basic_memory/mcp/tools/edit_note.py +++ b/src/basic_memory/mcp/tools/edit_note.py @@ -1,6 +1,6 @@ """Edit note tool for Basic Memory MCP server.""" -from typing import TYPE_CHECKING, Annotated, Literal, Optional +from typing import Any, TYPE_CHECKING, Annotated, Literal, Optional import logfire from httpx import HTTPStatusError @@ -391,10 +391,10 @@ async def edit_note( ] = None, expected_replacements: Optional[int] = None, replace_subsections: Optional[bool] = None, - metadata: Annotated[Optional[dict], BeforeValidator(coerce_dict)] = None, + metadata: Annotated[Optional[dict[str, Any]], BeforeValidator(coerce_dict)] = None, output_format: Literal["text", "json"] = "text", context: Context | None = None, -) -> str | dict: +) -> str | dict[str, Any]: """Edit an existing markdown note in the knowledge base. Makes targeted changes to existing notes without rewriting the entire content. diff --git a/src/basic_memory/mcp/tools/move_note.py b/src/basic_memory/mcp/tools/move_note.py index bb086d2f2..cbcb3835c 100644 --- a/src/basic_memory/mcp/tools/move_note.py +++ b/src/basic_memory/mcp/tools/move_note.py @@ -2,7 +2,7 @@ from pathlib import Path, PureWindowsPath from textwrap import dedent -from typing import Annotated, Optional, Literal +from typing import Any, Annotated, Optional, Literal from loguru import logger from fastmcp import Context @@ -384,7 +384,7 @@ async def move_note( project_id: Optional[str] = None, output_format: Literal["text", "json"] = "text", context: Context | None = None, -) -> str | dict: +) -> str | dict[str, Any]: """Move a note or directory to a new location within the same project. Moves a note or directory from one location to another within the project, diff --git a/src/basic_memory/mcp/tools/project_management.py b/src/basic_memory/mcp/tools/project_management.py index aeafee93e..e59bbee78 100644 --- a/src/basic_memory/mcp/tools/project_management.py +++ b/src/basic_memory/mcp/tools/project_management.py @@ -5,7 +5,7 @@ """ import os -from typing import Literal +from typing import Any, Literal from fastmcp import Context from loguru import logger @@ -55,7 +55,7 @@ def _merge_projects( cloud_workspace_tenant_id: str | None = None, cloud_workspace_slug: str | None = None, cloud_workspace_is_default: bool = False, -) -> list[dict]: +) -> list[dict[str, Any]]: """Merge local and cloud project lists by permalink. Returns a sorted list of dicts with unified project metadata. @@ -77,7 +77,7 @@ def _merge_projects( names_by_permalink[permalink] = project.name cloud_by_permalink[permalink] = project - merged: list[dict] = [] + merged: list[dict[str, Any]] = [] for permalink in sorted(names_by_permalink): name = names_by_permalink[permalink] local_proj = local_by_permalink.get(permalink) @@ -208,7 +208,7 @@ def _merge_workspace_projects( cloud_entries: tuple[WorkspaceProjectEntry, ...], *, config: BasicMemoryConfig | None = None, -) -> list[dict]: +) -> list[dict[str, Any]]: """Merge local projects with cloud projects from every accessible workspace.""" local_by_permalink: dict[str, ProjectItem] = {} if local_list: @@ -235,7 +235,7 @@ def _merge_workspace_projects( ) cloud_permalinks = {entry.project.permalink for entry in cloud_entries} - merged: list[dict] = [] + merged: list[dict[str, Any]] = [] for entry in sorted( cloud_entries, @@ -303,7 +303,7 @@ def _merge_workspace_projects( return merged -def _format_project_list_text(merged: list[dict]) -> str: +def _format_project_list_text(merged: list[dict[str, Any]]) -> str: """Format merged project list as human-readable text.""" result = "Available projects:\n" @@ -344,10 +344,10 @@ def _format_project_list_text(merged: list[dict]) -> str: def _format_project_list_json( - merged: list[dict], + merged: list[dict[str, Any]], default_project: str | None, constrained_project: str | None, -) -> dict: +) -> dict[str, Any]: """Format merged project list as structured JSON.""" return { "projects": merged, @@ -370,7 +370,7 @@ def _format_project_list_json( async def list_memory_projects( output_format: Literal["text", "json"] = "text", context: Context | None = None, -) -> str | dict: +) -> str | dict[str, Any]: """List all available projects with their status. Shows projects from both local and cloud sources when cloud credentials @@ -556,7 +556,7 @@ async def create_memory_project( workspace: str | None = None, output_format: Literal["text", "json"] = "text", context: Context | None = None, -) -> str | dict: +) -> str | dict[str, Any]: """Create a new Basic Memory project. Creates a new project with the specified name and path. The project directory diff --git a/src/basic_memory/mcp/tools/read_content.py b/src/basic_memory/mcp/tools/read_content.py index 140b132af..957577b10 100644 --- a/src/basic_memory/mcp/tools/read_content.py +++ b/src/basic_memory/mcp/tools/read_content.py @@ -8,7 +8,7 @@ import base64 import io -from typing import Annotated, Optional +from typing import Any, Annotated, Optional from loguru import logger from PIL import Image as PILImage @@ -176,7 +176,7 @@ async def read_content( project: Optional[str] = None, project_id: Optional[str] = None, context: Context | None = None, -) -> dict: +) -> dict[str, Any]: """Read a file's raw content by path or permalink. This tool provides direct access to file content in the knowledge base, diff --git a/src/basic_memory/mcp/tools/read_note.py b/src/basic_memory/mcp/tools/read_note.py index bdb9e8f69..42aaaab14 100644 --- a/src/basic_memory/mcp/tools/read_note.py +++ b/src/basic_memory/mcp/tools/read_note.py @@ -1,7 +1,7 @@ """Read note tool for Basic Memory MCP server.""" from textwrap import dedent -from typing import Annotated, Optional, Literal, cast +from typing import Any, Annotated, Optional, Literal, cast import logfire import yaml @@ -38,7 +38,7 @@ def _is_exact_title_match(identifier: str, title: str) -> bool: return identifier.strip().casefold() == title.strip().casefold() -def _parse_opening_frontmatter(content: str) -> tuple[str, dict | None]: +def _parse_opening_frontmatter(content: str) -> tuple[str, dict[str, Any] | None]: """Parse opening YAML frontmatter and return (body, frontmatter). Mirrors CLI behavior: only parses a frontmatter block at the very top. @@ -108,7 +108,7 @@ async def read_note( output_format: Literal["text", "json"] = "text", include_frontmatter: bool = False, context: Context | None = None, -) -> str | dict: +) -> str | dict[str, Any]: """Return the raw markdown for a note, or guidance text if no match is found. Finds and retrieves a note by its title, permalink, or content search, @@ -259,7 +259,7 @@ async def read_note( knowledge_client = KnowledgeClient(client, active_project.external_id) resource_client = ResourceClient(client, active_project.external_id) - async def _read_json_payload(entity_id: str) -> dict: + async def _read_json_payload(entity_id: str) -> dict[str, Any]: with logfire.span( "mcp.read_note.shape_response", domain="mcp", @@ -278,7 +278,7 @@ async def _read_json_payload(entity_id: str) -> dict: "frontmatter": parsed_frontmatter, } - def _empty_json_payload() -> dict: + def _empty_json_payload() -> dict[str, Any]: return { "title": None, "permalink": None, diff --git a/src/basic_memory/mcp/tools/recent_activity.py b/src/basic_memory/mcp/tools/recent_activity.py index e19086c02..b83656b24 100644 --- a/src/basic_memory/mcp/tools/recent_activity.py +++ b/src/basic_memory/mcp/tools/recent_activity.py @@ -2,7 +2,7 @@ from datetime import timezone from pathlib import PurePosixPath -from typing import Annotated, List, Union, Optional, Literal +from typing import Any, Annotated, List, Union, Optional, Literal from loguru import logger from fastmcp import Context @@ -72,7 +72,7 @@ async def recent_activity( project_id: Optional[str] = None, output_format: Literal["text", "json"] = "text", context: Context | None = None, -) -> str | list[dict]: +) -> str | list[dict[str, Any]]: """Get recent activity for a specific project or across all projects. Project Resolution: @@ -151,7 +151,7 @@ async def recent_activity( raise ValueError(f"page_size must be <= 100, got {page_size}") # Build common parameters for API calls - params: dict = { + params: dict[str, Any] = { "page": page, "page_size": page_size, "max_related": 10, @@ -245,7 +245,7 @@ async def recent_activity( most_active_project = project_info.name if output_format == "json": - rows: list[dict] = [] + rows: list[dict[str, Any]] = [] for project_name, project_activity in projects_activity.items(): rows.extend(_extract_recent_rows(project_activity.activity, project_name)) return rows @@ -344,7 +344,7 @@ async def recent_activity( async def _get_project_activity( - client, project_info: ProjectItem, params: dict, depth: int + client, project_info: ProjectItem, params: dict[str, Any], depth: int ) -> ProjectActivity: """Get activity data for a single project. @@ -398,9 +398,9 @@ async def _get_project_activity( def _extract_recent_rows( activity_data: GraphContext, project_name: Optional[str] = None -) -> list[dict]: +) -> list[dict[str, Any]]: """Flatten GraphContext into a list of recent rows.""" - rows: list[dict] = [] + rows: list[dict[str, Any]] = [] for result in activity_data.results: primary = result.primary_result row = { @@ -417,7 +417,7 @@ def _extract_recent_rows( def _format_discovery_output( - projects_activity: dict, summary: ActivityStats, timeframe: str, guidance: str + projects_activity: dict[str, Any], summary: ActivityStats, timeframe: str, guidance: str ) -> str: """Format discovery mode output as human-readable text.""" lines = [f"## Recent Activity Summary ({timeframe})"] diff --git a/src/basic_memory/mcp/tools/schema.py b/src/basic_memory/mcp/tools/schema.py index c99701b6f..23cc10dfd 100644 --- a/src/basic_memory/mcp/tools/schema.py +++ b/src/basic_memory/mcp/tools/schema.py @@ -4,7 +4,7 @@ These tools call the schema API endpoints via the typed SchemaClient. """ -from typing import Literal, Optional +from typing import Any, Literal, Optional from loguru import logger from fastmcp import Context @@ -254,7 +254,7 @@ async def schema_validate( project_id: Optional[str] = None, output_format: Literal["text", "json"] = "text", context: Context | None = None, -) -> ValidationReport | str | dict: +) -> ValidationReport | str | dict[str, Any]: """Validate notes against their resolved schema. Validates a specific note (by identifier), all notes of a given type, or — @@ -393,7 +393,7 @@ async def schema_infer( project_id: Optional[str] = None, output_format: Literal["text", "json"] = "text", context: Context | None = None, -) -> str | dict: +) -> str | dict[str, Any]: """Analyze existing notes and suggest a schema definition. Examines observation categories and relation types across all notes @@ -520,7 +520,7 @@ async def schema_diff( project_id: Optional[str] = None, output_format: Literal["text", "json"] = "text", context: Context | None = None, -) -> str | dict: +) -> str | dict[str, Any]: """Detect drift between a schema definition and actual note usage. Compares the existing schema for a note type against how notes of diff --git a/src/basic_memory/mcp/tools/search.py b/src/basic_memory/mcp/tools/search.py index 086251ad8..7e79d108d 100644 --- a/src/basic_memory/mcp/tools/search.py +++ b/src/basic_memory/mcp/tools/search.py @@ -575,7 +575,7 @@ async def _search_all_projects( status: str | None, min_similarity: float | None, context: Context | None, -) -> dict | str: +) -> dict[str, Any] | str: """Search every accessible project when the caller explicitly opts in.""" requested_page = max(page, 1) requested_page_size = max(page_size, 1) @@ -789,7 +789,7 @@ async def search_notes( ), ] = None, context: Context | None = None, -) -> dict | str: +) -> dict[str, Any] | str: """Search across all content in the knowledge base with comprehensive syntax support. This tool searches the knowledge base using full-text search, pattern matching, diff --git a/src/basic_memory/mcp/tools/workspaces.py b/src/basic_memory/mcp/tools/workspaces.py index 41cab27e9..fdaeb08e3 100644 --- a/src/basic_memory/mcp/tools/workspaces.py +++ b/src/basic_memory/mcp/tools/workspaces.py @@ -1,6 +1,6 @@ """Workspace discovery MCP tool.""" -from typing import Literal +from typing import Any, Literal from fastmcp import Context @@ -58,7 +58,7 @@ def _workspace_list_response(workspaces: list[WorkspaceInfo]) -> WorkspaceListRe async def list_workspaces( output_format: Literal["text", "json"] = "text", context: Context | None = None, -) -> str | dict: +) -> str | dict[str, Any]: """List workspaces available to the current cloud user. Args: diff --git a/src/basic_memory/mcp/tools/write_note.py b/src/basic_memory/mcp/tools/write_note.py index ae3d27986..edd823635 100644 --- a/src/basic_memory/mcp/tools/write_note.py +++ b/src/basic_memory/mcp/tools/write_note.py @@ -2,7 +2,7 @@ import textwrap from pathlib import Path -from typing import Annotated, List, Union, Optional, Literal +from typing import Any, Annotated, List, Union, Optional, Literal import logfire from loguru import logger @@ -79,11 +79,11 @@ async def write_note( project_id: Optional[str] = None, tags: list[str] | str | None = None, note_type: str = "note", - metadata: Annotated[dict | None, BeforeValidator(coerce_dict)] = None, + metadata: Annotated[dict[str, Any] | None, BeforeValidator(coerce_dict)] = None, overwrite: bool | None = None, output_format: Literal["text", "json"] = "text", context: Context | None = None, -) -> str | dict: +) -> str | dict[str, Any]: """Write a markdown note to the knowledge base. Creates a markdown note with semantic observations and relations. diff --git a/src/basic_memory/mcp/workspace_project_index.py b/src/basic_memory/mcp/workspace_project_index.py index 16a47158c..f40c780f9 100644 --- a/src/basic_memory/mcp/workspace_project_index.py +++ b/src/basic_memory/mcp/workspace_project_index.py @@ -3,7 +3,7 @@ from __future__ import annotations from dataclasses import dataclass, field -from typing import TYPE_CHECKING, Optional, Sequence, cast +from typing import Any, TYPE_CHECKING, Optional, Sequence, cast from uuid import UUID from basic_memory.schemas.cloud import WorkspaceInfo @@ -169,7 +169,7 @@ def workspace_project_index_from_state(raw: object) -> WorkspaceProjectIndex | N ) -def workspace_project_index_to_state(index: WorkspaceProjectIndex) -> dict: +def workspace_project_index_to_state(index: WorkspaceProjectIndex) -> dict[str, Any]: """Serialize a workspace project index for MCP context state.""" return { "workspaces": [workspace.model_dump() for workspace in index.workspaces], diff --git a/src/basic_memory/migration_loop.py b/src/basic_memory/migration_loop.py index d9179b1d9..d6c15bfe7 100644 --- a/src/basic_memory/migration_loop.py +++ b/src/basic_memory/migration_loop.py @@ -27,4 +27,8 @@ def running_on_uvloop() -> bool: import uvloop except ImportError: return False - return isinstance(asyncio.get_event_loop_policy(), uvloop.EventLoopPolicy) + # Constraint: on supported Python 3.12-3.13 this helper runs before a loop + # exists, so the active policy is the only observable uvloop signal. Python + # 3.14+ skips this helper in alembic/env.py; remove it with the policy seam. + policy = asyncio.get_event_loop_policy() # ty: ignore[deprecated] + return isinstance(policy, uvloop.EventLoopPolicy) diff --git a/src/basic_memory/models/knowledge.py b/src/basic_memory/models/knowledge.py index d73d432b1..9349e5d48 100644 --- a/src/basic_memory/models/knowledge.py +++ b/src/basic_memory/models/knowledge.py @@ -4,7 +4,7 @@ import uuid from datetime import datetime from basic_memory.utils import ensure_timezone_aware -from typing import Optional +from typing import Any, override, Optional from sqlalchemy import ( BigInteger, @@ -69,7 +69,7 @@ class Entity(Base): external_id: Mapped[str] = mapped_column(String, unique=True, default=lambda: str(uuid.uuid4())) title: Mapped[str] = mapped_column(String) note_type: Mapped[str] = mapped_column(String) - entity_metadata: Mapped[Optional[dict]] = mapped_column(JSON, nullable=True) + entity_metadata: Mapped[Optional[dict[str, Any]]] = mapped_column(JSON, nullable=True) content_type: Mapped[str] = mapped_column(String) # Project reference @@ -142,6 +142,7 @@ def is_markdown(self): """Check if the entity is a markdown file.""" return self.content_type == RUNTIME_MARKDOWN_CONTENT_TYPE + @override def __getattribute__(self, name): """Override attribute access to ensure datetime fields are timezone-aware.""" value = super().__getattribute__(name) @@ -152,6 +153,7 @@ def __getattribute__(self, name): return value + @override def __repr__(self) -> str: return f"Entity(id={self.id}, external_id='{self.external_id}', name='{self.title}', type='{self.note_type}', checksum='{self.checksum}')" @@ -217,6 +219,7 @@ class NoteContent(Base): entity = relationship("Entity", back_populates="note_content") + @override def __repr__(self) -> str: # pragma: no cover return ( f"NoteContent(entity_id={self.entity_id}, external_id='{self.external_id}', " @@ -264,6 +267,7 @@ class NoteFileVacate(Base): default=lambda: datetime.now().astimezone(), ) + @override def __repr__(self) -> str: # pragma: no cover return ( f"NoteFileVacate(project_id={self.project_id}, entity_id={self.entity_id}, " @@ -322,6 +326,7 @@ def permalink(self) -> str: f"{self.entity.permalink}/observations/{self.category}/{content_for_permalink}" ) + @override def __repr__(self) -> str: # pragma: no cover return f"Observation(id={self.id}, entity_id={self.entity_id}, content='{self.content}')" @@ -371,5 +376,6 @@ def permalink(self) -> str: return generate_permalink(f"{from_permalink}/{self.relation_type}/{to_permalink}") return generate_permalink(f"{from_permalink}/{self.relation_type}/{self.to_name}") + @override def __repr__(self) -> str: return f"Relation(id={self.id}, from_id={self.from_id}, to_id={self.to_id}, to_name={self.to_name}, type='{self.relation_type}')" # pragma: no cover diff --git a/src/basic_memory/models/project.py b/src/basic_memory/models/project.py index bf15c426e..a39ddaacf 100644 --- a/src/basic_memory/models/project.py +++ b/src/basic_memory/models/project.py @@ -2,7 +2,7 @@ import uuid from datetime import datetime, UTC -from typing import Optional +from typing import override, Optional from sqlalchemy import ( Integer, @@ -74,6 +74,7 @@ class Project(Base): # These relationships will be established once we add project_id to those models entities = relationship("Entity", back_populates="project", cascade="all, delete-orphan") + @override def __repr__(self) -> str: # pragma: no cover return f"Project(id={self.id}, external_id='{self.external_id}', name='{self.name}', permalink='{self.permalink}', path='{self.path}')" diff --git a/src/basic_memory/picoschema/inference.py b/src/basic_memory/picoschema/inference.py index d458aa3cf..9193dcfaa 100644 --- a/src/basic_memory/picoschema/inference.py +++ b/src/basic_memory/picoschema/inference.py @@ -14,6 +14,7 @@ from collections import Counter from dataclasses import dataclass, field +from typing import Any # --- Result Data Model --- @@ -40,7 +41,7 @@ class InferenceResult: note_type: str notes_analyzed: int field_frequencies: list[FieldFrequency] - suggested_schema: dict # Ready-to-use Picoschema YAML dict + suggested_schema: dict[str, Any] # Ready-to-use Picoschema YAML dict suggested_required: list[str] suggested_optional: list[str] excluded: list[str] # Below threshold @@ -290,12 +291,12 @@ def _build_picoschema_dict( frequencies: list[FieldFrequency], required_threshold: float, optional_threshold: float, -) -> dict: +) -> dict[str, Any]: """Build a Picoschema YAML dict from field frequencies. Only includes fields at or above the optional threshold. """ - schema: dict = {} + schema: dict[str, Any] = {} for freq in frequencies: if freq.percentage < optional_threshold: diff --git a/src/basic_memory/picoschema/parser.py b/src/basic_memory/picoschema/parser.py index 2d0f26852..ab61fd8fa 100644 --- a/src/basic_memory/picoschema/parser.py +++ b/src/basic_memory/picoschema/parser.py @@ -18,6 +18,7 @@ import re from dataclasses import dataclass, field +from typing import Any # --- Data Model --- @@ -197,7 +198,7 @@ def _parse_enum_string(value: str) -> tuple[list[str], str | None]: # --- Main Parser --- -def parse_picoschema(yaml_dict: dict) -> list[SchemaField]: +def parse_picoschema(yaml_dict: dict[str, Any]) -> list[SchemaField]: """Parse a Picoschema YAML dict into a list of SchemaField objects. This is the core parser that converts YAML frontmatter schema definitions @@ -277,7 +278,7 @@ def parse_picoschema(yaml_dict: dict) -> list[SchemaField]: return fields -def parse_schema_note(frontmatter: dict) -> SchemaDefinition: +def parse_schema_note(frontmatter: dict[str, Any]) -> SchemaDefinition: """Parse a full schema note's frontmatter into a SchemaDefinition. A schema note has type: schema and contains: diff --git a/src/basic_memory/picoschema/resolver.py b/src/basic_memory/picoschema/resolver.py index 063c06c70..760a7b4bd 100644 --- a/src/basic_memory/picoschema/resolver.py +++ b/src/basic_memory/picoschema/resolver.py @@ -14,15 +14,16 @@ from collections.abc import Callable, Awaitable from basic_memory.picoschema.parser import SchemaDefinition, parse_picoschema, parse_schema_note +from typing import Any # Type alias for the search function dependency. # Given a query string, returns a list of frontmatter dicts from matching schema notes. -type SchemaSearchFn = Callable[[str], Awaitable[list[dict]]] +type SchemaSearchFn = Callable[[str], Awaitable[list[dict[str, Any]]]] async def resolve_schema( - note_frontmatter: dict, + note_frontmatter: dict[str, Any], search_fn: SchemaSearchFn, ) -> SchemaDefinition | None: """Resolve the schema for a note based on its frontmatter. @@ -74,7 +75,9 @@ async def resolve_schema( return None -def _schema_from_inline(schema_dict: dict, frontmatter: dict) -> SchemaDefinition: +def _schema_from_inline( + schema_dict: dict[str, Any], frontmatter: dict[str, Any] +) -> SchemaDefinition: """Build a SchemaDefinition from an inline schema dict. For inline schemas, we derive metadata from the note's own frontmatter diff --git a/src/basic_memory/picoschema/validator.py b/src/basic_memory/picoschema/validator.py index b09ca323c..6be9da851 100644 --- a/src/basic_memory/picoschema/validator.py +++ b/src/basic_memory/picoschema/validator.py @@ -19,6 +19,7 @@ from basic_memory.picoschema.inference import ObservationData, RelationData from basic_memory.picoschema.parser import SchemaDefinition, SchemaField +from typing import Any # --- Result Data Model --- @@ -56,7 +57,7 @@ def validate_note( schema: SchemaDefinition, observations: list[ObservationData], relations: list[RelationData], - frontmatter: dict | None = None, + frontmatter: dict[str, Any] | None = None, ) -> ValidationResult: """Validate a note against a schema definition. @@ -261,7 +262,7 @@ def _validate_enum_field( def _validate_frontmatter_field( schema_field: SchemaField, - frontmatter: dict, + frontmatter: dict[str, Any], ) -> FieldResult: """Validate a single frontmatter key against a schema field declaration. diff --git a/src/basic_memory/repository/embedding_provider_factory.py b/src/basic_memory/repository/embedding_provider_factory.py index 3a472a306..96d4c32a5 100644 --- a/src/basic_memory/repository/embedding_provider_factory.py +++ b/src/basic_memory/repository/embedding_provider_factory.py @@ -14,6 +14,7 @@ normalize_embedding_prefix, prefixing_embedding_identity, ) +from typing import Any # Cache key fields are limited to values that change the *identity* of the loaded # provider instance (provider, model_name, explicit LiteLLM endpoint/key routing, @@ -229,7 +230,7 @@ def create_embedding_provider(app_config: BasicMemoryConfig) -> EmbeddingProvide if cached_provider := _EMBEDDING_PROVIDER_CACHE.get(cache_key): return cached_provider - extra_kwargs: dict = {} + extra_kwargs: dict[str, Any] = {} if app_config.semantic_embedding_dimensions is not None: extra_kwargs["dimensions"] = app_config.semantic_embedding_dimensions diff --git a/src/basic_memory/repository/entity_repository.py b/src/basic_memory/repository/entity_repository.py index abba067de..5b8b8f21b 100644 --- a/src/basic_memory/repository/entity_repository.py +++ b/src/basic_memory/repository/entity_repository.py @@ -3,7 +3,7 @@ from dataclasses import dataclass from datetime import datetime from pathlib import Path -from typing import List, Optional, Sequence, Union, Any +from typing import override, List, Optional, Sequence, Union, Any from loguru import logger @@ -377,6 +377,7 @@ async def delete_by_file_path(self, session: AsyncSession, file_path: Union[Path """ return await self.delete_by_fields(session, file_path=Path(file_path).as_posix()) + @override def get_load_options(self) -> List[LoaderOption]: """Get SQLAlchemy loader options for eager loading relationships.""" return [ diff --git a/src/basic_memory/repository/fastembed_provider.py b/src/basic_memory/repository/fastembed_provider.py index 3ea4e07c4..7e14cb3f0 100644 --- a/src/basic_memory/repository/fastembed_provider.py +++ b/src/basic_memory/repository/fastembed_provider.py @@ -6,7 +6,7 @@ import math import shutil from pathlib import Path -from typing import TYPE_CHECKING +from typing import Any, TYPE_CHECKING from loguru import logger @@ -91,7 +91,7 @@ def _create_model(self) -> "TextEmbedding": # long-running process it leaks tens of GB (#872). FastEmbed exposes # enable_cpu_mem_arena via its session-option kwargs, so we disable the arena to # let any transient extra load free memory. - model_kwargs: dict = { + model_kwargs: dict[str, Any] = { "model_name": resolved_model_name, "enable_cpu_mem_arena": False, } diff --git a/src/basic_memory/repository/note_content_repository.py b/src/basic_memory/repository/note_content_repository.py index 3b92d63df..4a0351e98 100644 --- a/src/basic_memory/repository/note_content_repository.py +++ b/src/basic_memory/repository/note_content_repository.py @@ -3,7 +3,7 @@ from dataclasses import dataclass from datetime import datetime from pathlib import Path -from typing import Any, Mapping, Optional, Sequence, cast +from typing import override, Any, Mapping, Optional, Sequence, cast from sqlalchemy import select, update from sqlalchemy.engine import CursorResult @@ -138,6 +138,7 @@ async def get_by_file_path( result = await session.execute(query) return result.scalars().first() + @override async def create( self, session: AsyncSession, data: Mapping[str, Any] | NoteContent ) -> NoteContent: diff --git a/src/basic_memory/repository/observation_repository.py b/src/basic_memory/repository/observation_repository.py index 655c58f35..89e1021b3 100644 --- a/src/basic_memory/repository/observation_repository.py +++ b/src/basic_memory/repository/observation_repository.py @@ -1,7 +1,7 @@ """Repository for managing Observation objects.""" from dataclasses import dataclass -from typing import Dict, List, Sequence +from typing import override, Dict, List, Sequence from sqlalchemy import select from sqlalchemy.ext.asyncio import AsyncSession @@ -38,6 +38,7 @@ def __init__(self, project_id: int): """ super().__init__(Observation, project_id=project_id) + @override def get_load_options(self) -> List[LoaderOption]: """Eager-load parent entity to prevent N+1 if obs.entity is accessed.""" return [selectinload(Observation.entity)] diff --git a/src/basic_memory/repository/postgres_search_repository.py b/src/basic_memory/repository/postgres_search_repository.py index 231d94459..487fc8e9e 100644 --- a/src/basic_memory/repository/postgres_search_repository.py +++ b/src/basic_memory/repository/postgres_search_repository.py @@ -5,7 +5,7 @@ import re from collections.abc import Sequence from datetime import datetime -from typing import List, Optional +from typing import Any, override, List, Optional import logfire from loguru import logger @@ -37,7 +37,7 @@ from basic_memory.schemas.search import SearchItemType, SearchRetrievalMode -def _strip_nul_from_row(row_data: dict) -> dict: +def _strip_nul_from_row(row_data: dict[str, Any]) -> dict[str, Any]: """Strip NUL bytes from all string values in a row dict. Secondary defense: PostgreSQL text columns cannot store \\x00. @@ -120,6 +120,7 @@ def __init__( self._semantic_vector_index_name = effective_name self._semantic_vector_index = vector_index + @override async def init_search_index(self): """Create Postgres table with tsvector column and GIN indexes. @@ -134,6 +135,7 @@ async def init_search_index(self): if self._semantic_enabled: await self._ensure_vector_tables() + @override async def index_item(self, search_index_row: SearchIndexRow) -> None: """Index or update a single item using UPSERT. @@ -196,6 +198,7 @@ async def index_item(self, search_index_row: SearchIndexRow) -> None: # tsquery preparation (backend-specific) # ------------------------------------------------------------------ + @override def _prepare_search_term(self, term: str, is_prefix: bool = True) -> str: """Prepare a search term for tsquery format. @@ -319,6 +322,7 @@ def _prepare_single_term(self, term: str, is_prefix: bool = True) -> str: # Abstract hook implementations (vector/semantic, Postgres-specific) # ------------------------------------------------------------------ + @override async def _ensure_vector_tables(self) -> None: self._assert_semantic_available() if not hasattr(self, "_semantic_vector_index"): @@ -384,18 +388,21 @@ async def _ensure_vector_tables(self) -> None: logger.debug(f"Postgres vector tables ready (dimensions={self._vector_dimensions})") self._vector_tables_initialized = True + @override async def _run_vector_query( self, session: AsyncSession, query_embedding: list[float], candidate_limit: int, - ) -> list[dict]: + ) -> list[dict[str, Any]]: return await super()._run_vector_query(session, query_embedding, candidate_limit) + @override def _vector_prepare_window_size(self) -> int: """Use a bounded config-driven prepare window for Postgres vector sync.""" return self._semantic_postgres_prepare_concurrency + @override async def _upsert_scheduled_chunk_records( self, session: AsyncSession, @@ -472,6 +479,7 @@ async def _upsert_scheduled_chunk_records( for record in scheduled_records ] + @override async def _delete_entity_chunks( self, session: AsyncSession, @@ -485,6 +493,7 @@ async def _delete_entity_chunks( expected_deletions=expected_deletions, ) + @override async def _delete_stale_chunks( self, session: AsyncSession, @@ -500,6 +509,7 @@ async def _delete_stale_chunks( expected_deletions=expected_deletions, ) + @override def _distance_to_similarity(self, distance: float) -> float: """Convert pgvector cosine distance to cosine similarity. @@ -508,6 +518,7 @@ def _distance_to_similarity(self, distance: float) -> float: """ return max(0.0, 1.0 - distance) + @override def _timestamp_now_expr(self) -> str: return "NOW()" @@ -515,6 +526,7 @@ def _timestamp_now_expr(self) -> str: # Index / bulk index overrides (Postgres UPSERT) # ------------------------------------------------------------------ + @override async def bulk_index_items(self, search_index_rows: List[SearchIndexRow]) -> None: """Index multiple items in a single batch operation using UPSERT. @@ -608,8 +620,8 @@ async def _build_fts_query_parts( after_date: Optional[datetime] = None, search_item_types: Optional[List[SearchItemType]] = None, categories: Optional[List[str]] = None, - metadata_filters: Optional[dict] = None, - ) -> tuple[str, str, dict, str, str]: + metadata_filters: Optional[dict[str, Any]] = None, + ) -> tuple[str, str, dict[str, Any], str, str]: """Build Postgres FTS FROM/WHERE params shared by search and count.""" conditions = [] params = {} @@ -795,6 +807,7 @@ async def _build_fts_query_parts( return from_clause, where_clause, params, order_by_clause, score_expr + @override async def search( self, search_text: Optional[str] = None, @@ -805,7 +818,7 @@ async def search( after_date: Optional[datetime] = None, search_item_types: Optional[List[SearchItemType]] = None, categories: Optional[List[str]] = None, - metadata_filters: Optional[dict] = None, + metadata_filters: Optional[dict[str, Any]] = None, retrieval_mode: SearchRetrievalMode = SearchRetrievalMode.FTS, min_similarity: Optional[float] = None, limit: int = 10, @@ -885,7 +898,7 @@ async def search( use_savepoint = session is not None or allow_relaxed - async def execute_rows(active_session: AsyncSession, query_params: dict): + async def execute_rows(active_session: AsyncSession, query_params: dict[str, Any]): # PostgreSQL leaves a transaction unusable after invalid tsquery syntax. # Scope retryable or caller-owned attempts to a savepoint so a relaxed # retry—and any caller continuing to use its session—starts healthy. @@ -960,6 +973,7 @@ async def run_search(active_session: AsyncSession): return results + @override async def count( self, search_text: Optional[str] = None, @@ -970,7 +984,7 @@ async def count( after_date: Optional[datetime] = None, search_item_types: Optional[List[SearchItemType]] = None, categories: Optional[List[str]] = None, - metadata_filters: Optional[dict] = None, + metadata_filters: Optional[dict[str, Any]] = None, retrieval_mode: SearchRetrievalMode = SearchRetrievalMode.FTS, min_similarity: Optional[float] = None, allow_relaxed: bool = False, @@ -1011,7 +1025,7 @@ async def count( sql = f"SELECT COUNT(*) FROM {from_clause} WHERE {where_clause}" logger.trace(f"Count {sql} params: {params}") - async def execute_count(active_session: AsyncSession, query_params: dict) -> int: + async def execute_count(active_session: AsyncSession, query_params: dict[str, Any]) -> int: if allow_relaxed: async with active_session.begin_nested(): result = await active_session.execute(text(sql), query_params) diff --git a/src/basic_memory/repository/project_info_repository.py b/src/basic_memory/repository/project_info_repository.py index 4448f23b8..c862ac940 100644 --- a/src/basic_memory/repository/project_info_repository.py +++ b/src/basic_memory/repository/project_info_repository.py @@ -2,7 +2,7 @@ from basic_memory.models.project import Project -class ProjectInfoRepository(Repository): +class ProjectInfoRepository(Repository[Project]): """Repository for statistics queries.""" def __init__(self): diff --git a/src/basic_memory/repository/project_repository.py b/src/basic_memory/repository/project_repository.py index 6971c3c5b..7bd6b0280 100644 --- a/src/basic_memory/repository/project_repository.py +++ b/src/basic_memory/repository/project_repository.py @@ -1,7 +1,7 @@ """Repository for managing projects in Basic Memory.""" from pathlib import Path -from typing import Optional, Sequence, Union +from typing import Any, override, Optional, Sequence, Union from loguru import logger @@ -182,6 +182,7 @@ async def set_as_default(self, session: AsyncSession, project_id: int) -> Option return target_project return None # pragma: no cover + @override async def delete(self, session: AsyncSession, entity_id: int) -> bool: """Delete a project and its derived search rows in one transaction. @@ -258,7 +259,7 @@ async def delete(self, session: AsyncSession, entity_id: int) -> bool: return True async def scalar_vec_query( - self, session: AsyncSession, query: Executable, params: Optional[dict] = None + self, session: AsyncSession, query: Executable, params: Optional[dict[str, Any]] = None ) -> Optional[int]: """Run a scalar COUNT query that reads the sqlite-vec vec0 table. diff --git a/src/basic_memory/repository/relation_repository.py b/src/basic_memory/repository/relation_repository.py index 59317dc61..eb2d62f69 100644 --- a/src/basic_memory/repository/relation_repository.py +++ b/src/basic_memory/repository/relation_repository.py @@ -1,7 +1,7 @@ """Repository for managing Relation objects.""" from dataclasses import dataclass -from typing import Sequence, List, Optional, Any, cast +from typing import override, Sequence, List, Optional, Any, cast from sqlalchemy import and_, case, delete, select, update from sqlalchemy.engine import CursorResult @@ -388,5 +388,6 @@ async def replace_accepted_outgoing_relations( # the unique (from_id, to_name, relation_type) constraint from aborting. await self.add_all_ignore_duplicates(session, rows) + @override def get_load_options(self) -> List[LoaderOption]: return [selectinload(Relation.from_entity), selectinload(Relation.to_entity)] diff --git a/src/basic_memory/repository/repository.py b/src/basic_memory/repository/repository.py index 4947e2e4f..0c40586b6 100644 --- a/src/basic_memory/repository/repository.py +++ b/src/basic_memory/repository/repository.py @@ -62,7 +62,7 @@ def get_model_data(self, entity_data): } return model_data - def _add_project_filter(self, query: Select) -> Select: + def _add_project_filter[RowT: tuple[Any, ...]](self, query: Select[RowT]) -> Select[RowT]: """Add project_id filter to query if applicable. Args: @@ -165,7 +165,7 @@ async def add_all_no_return(self, session: AsyncSession, models: List[T]) -> int logger.debug(f"Added {len(models)} {self.Model.__name__} records") return len(models) - def select(self, *entities: Any) -> Select: + def select(self, *entities: Any) -> Select[Any]: """Create a new SELECT statement. Returns: diff --git a/src/basic_memory/repository/search_index_row.py b/src/basic_memory/repository/search_index_row.py index 891b8738c..01d63879c 100644 --- a/src/basic_memory/repository/search_index_row.py +++ b/src/basic_memory/repository/search_index_row.py @@ -25,7 +25,7 @@ class SearchIndexRow: updated_at: datetime permalink: Optional[str] = None - metadata: Optional[dict] = None + metadata: Optional[dict[str, Any]] = None # assigned in result score: Optional[float] = None diff --git a/src/basic_memory/repository/search_repository.py b/src/basic_memory/repository/search_repository.py index 16e018c46..d3bf051ed 100644 --- a/src/basic_memory/repository/search_repository.py +++ b/src/basic_memory/repository/search_repository.py @@ -50,7 +50,7 @@ async def search( after_date: Optional[datetime] = None, search_item_types: Optional[List[SearchItemType]] = None, categories: Optional[List[str]] = None, - metadata_filters: Optional[dict] = None, + metadata_filters: Optional[dict[str, Any]] = None, retrieval_mode: SearchRetrievalMode = SearchRetrievalMode.FTS, min_similarity: Optional[float] = None, limit: int = 10, @@ -71,7 +71,7 @@ async def count( after_date: Optional[datetime] = None, search_item_types: Optional[List[SearchItemType]] = None, categories: Optional[List[str]] = None, - metadata_filters: Optional[dict] = None, + metadata_filters: Optional[dict[str, Any]] = None, retrieval_mode: SearchRetrievalMode = SearchRetrievalMode.FTS, min_similarity: Optional[float] = None, allow_relaxed: bool = False, @@ -136,7 +136,7 @@ async def sync_entity_vectors_batch( """Sync semantic vector chunks for a batch of entities.""" ... - async def execute_query(self, query, params: dict) -> Result: + async def execute_query(self, query, params: dict[str, Any]) -> Result[Any]: """Execute a raw SQL query.""" ... diff --git a/src/basic_memory/repository/search_repository_base.py b/src/basic_memory/repository/search_repository_base.py index dc899f27b..a24227eda 100644 --- a/src/basic_memory/repository/search_repository_base.py +++ b/src/basic_memory/repository/search_repository_base.py @@ -233,7 +233,7 @@ async def _run_vector_query( session: AsyncSession, query_embedding: list[float], candidate_limit: int, - ) -> list[dict]: + ) -> list[dict[str, Any]]: """Query the configured adapter and hydrate only live, ready manifest rows.""" if candidate_limit <= 0: return [] @@ -272,7 +272,7 @@ async def _hydrate_vector_matches( self, session: AsyncSession, matches: list[VectorMatch], - ) -> list[dict]: + ) -> list[dict[str, Any]]: """Resolve adapter matches through the authoritative ready manifest.""" if not matches: return [] @@ -1535,7 +1535,7 @@ async def _dispatch_retrieval_mode( after_date: Optional[datetime], search_item_types: Optional[List[SearchItemType]], categories: Optional[List[str]], - metadata_filters: Optional[dict], + metadata_filters: Optional[dict[str, Any]], retrieval_mode: SearchRetrievalMode, min_similarity: Optional[float] = None, limit: int, @@ -1743,7 +1743,7 @@ async def _search_vector_only( after_date: Optional[datetime], search_item_types: Optional[List[SearchItemType]], categories: Optional[List[str]], - metadata_filters: Optional[dict], + metadata_filters: Optional[dict[str, Any]], min_similarity: Optional[float] = None, limit: int, offset: int, @@ -2021,7 +2021,7 @@ async def _search_hybrid( after_date: Optional[datetime], search_item_types: Optional[List[SearchItemType]], categories: Optional[List[str]], - metadata_filters: Optional[dict], + metadata_filters: Optional[dict[str, Any]], min_similarity: Optional[float] = None, limit: int, offset: int, diff --git a/src/basic_memory/repository/sqlite_search_repository.py b/src/basic_memory/repository/sqlite_search_repository.py index 8965e5637..459d16c4c 100644 --- a/src/basic_memory/repository/sqlite_search_repository.py +++ b/src/basic_memory/repository/sqlite_search_repository.py @@ -5,7 +5,7 @@ from collections.abc import Sequence from contextlib import asynccontextmanager from datetime import datetime -from typing import List, Optional +from typing import Any, override, List, Optional import logfire from loguru import logger @@ -102,6 +102,7 @@ async def _get_entity_columns(self) -> set[str]: self._entity_columns = {row[1] for row in result.fetchall()} return self._entity_columns + @override async def init_search_index(self): """Create FTS5 virtual table for search if it doesn't exist. @@ -370,6 +371,7 @@ def _prepare_single_term(self, term: str, is_prefix: bool = True) -> str: return term + @override def _prepare_search_term(self, term: str, is_prefix: bool = True) -> str: """Prepare a search term for FTS5 query. @@ -462,6 +464,7 @@ async def _ensure_sqlite_vec_loaded(self, session) -> None: # Abstract hook implementations (vector/semantic, SQLite-specific) # ------------------------------------------------------------------ + @override async def _ensure_vector_tables(self) -> None: self._assert_semantic_available() if not hasattr(self, "_semantic_vector_index"): @@ -528,6 +531,7 @@ async def _ensure_vector_tables(self) -> None: logger.debug(f"SQLite vector tables ready (dimensions={self._vector_dimensions})") self._vector_tables_initialized = True + @override async def _prepare_vector_session(self, session: AsyncSession) -> None: """Load sqlite-vec extension for the session.""" await self._ensure_sqlite_vec_loaded(session) @@ -535,14 +539,16 @@ async def _prepare_vector_session(self, session: AsyncSession) -> None: # sqlite-vec hard limit for knn k parameter SQLITE_VEC_MAX_K = 4096 + @override async def _run_vector_query( self, session: AsyncSession, query_embedding: list[float], candidate_limit: int, - ) -> list[dict]: + ) -> list[dict[str, Any]]: return await super()._run_vector_query(session, query_embedding, candidate_limit) + @override async def _delete_entity_chunks( self, session: AsyncSession, @@ -556,6 +562,7 @@ async def _delete_entity_chunks( expected_deletions=expected_deletions, ) + @override async def _delete_stale_chunks( self, session: AsyncSession, @@ -571,6 +578,7 @@ async def _delete_stale_chunks( expected_deletions=expected_deletions, ) + @override async def _delete_project_builtin_vector_rows(self, session: AsyncSession) -> None: """Delete sqlite-vec rows atomically with their project manifest.""" table_result = await session.execute( @@ -613,6 +621,7 @@ async def drop_vector_tables(self) -> None: await session.commit() self._vector_tables_initialized = False + @override def _distance_to_similarity(self, distance: float) -> float: """Convert L2 distance to cosine similarity for normalized embeddings. @@ -622,6 +631,7 @@ def _distance_to_similarity(self, distance: float) -> float: return max(0.0, 1.0 - (distance * distance) / 2.0) @asynccontextmanager + @override async def _prepare_entity_write_scope(self): """SQLite keeps the shared read window, but funnels prepare writes through one lock.""" # Trigger: the shared prepare window fans out per entity after batched reads. @@ -632,6 +642,7 @@ async def _prepare_entity_write_scope(self): async with self._sqlite_prepare_write_lock: yield + @override def _prepare_window_existing_rows_sql(self, placeholders: str) -> str: """Use the authoritative SQL manifest for adapter-independent readiness.""" return super()._prepare_window_existing_rows_sql(placeholders) @@ -640,6 +651,7 @@ def _prepare_window_existing_rows_sql(self, placeholders: str) -> str: # Index / bulk index overrides (FTS-only, no vector side-effects) # ------------------------------------------------------------------ + @override async def index_item(self, search_index_row: SearchIndexRow) -> None: """Index a single row in FTS only. @@ -647,6 +659,7 @@ async def index_item(self, search_index_row: SearchIndexRow) -> None: """ await super().index_item(search_index_row) + @override async def bulk_index_items(self, search_index_rows: List[SearchIndexRow]) -> None: """Index multiple rows in FTS only.""" await super().bulk_index_items(search_index_rows) @@ -669,8 +682,8 @@ async def _build_fts_query_parts( after_date: Optional[datetime] = None, search_item_types: Optional[List[SearchItemType]] = None, categories: Optional[List[str]] = None, - metadata_filters: Optional[dict] = None, - ) -> tuple[str, str, dict, str]: + metadata_filters: Optional[dict[str, Any]] = None, + ) -> tuple[str, str, dict[str, Any], str]: """Build SQLite FTS FROM/WHERE params shared by search and count.""" conditions = [] match_conditions = [] @@ -876,6 +889,7 @@ async def _build_fts_query_parts( where_clause = " AND ".join(conditions) if conditions else "1=1" return from_clause, where_clause, params, order_by_clause + @override async def search( self, search_text: Optional[str] = None, @@ -886,7 +900,7 @@ async def search( after_date: Optional[datetime] = None, search_item_types: Optional[List[SearchItemType]] = None, categories: Optional[List[str]] = None, - metadata_filters: Optional[dict] = None, + metadata_filters: Optional[dict[str, Any]] = None, retrieval_mode: SearchRetrievalMode = SearchRetrievalMode.FTS, min_similarity: Optional[float] = None, limit: int = 10, @@ -1020,6 +1034,7 @@ async def run_search(active_session: AsyncSession): return results + @override async def count( self, search_text: Optional[str] = None, @@ -1030,7 +1045,7 @@ async def count( after_date: Optional[datetime] = None, search_item_types: Optional[List[SearchItemType]] = None, categories: Optional[List[str]] = None, - metadata_filters: Optional[dict] = None, + metadata_filters: Optional[dict[str, Any]] = None, retrieval_mode: SearchRetrievalMode = SearchRetrievalMode.FTS, min_similarity: Optional[float] = None, allow_relaxed: bool = False, diff --git a/src/basic_memory/runtime/accepted_note_changes.py b/src/basic_memory/runtime/accepted_note_changes.py index 0ccce22f3..f4c198897 100644 --- a/src/basic_memory/runtime/accepted_note_changes.py +++ b/src/basic_memory/runtime/accepted_note_changes.py @@ -4,7 +4,7 @@ from dataclasses import dataclass from enum import StrEnum -from typing import Protocol +from typing import Generic, Protocol, TypeVar from uuid import UUID from basic_memory.runtime.note_content_deletes import ( @@ -204,12 +204,15 @@ def plan_accepted_note_content_write( ) +_PayloadT_co = TypeVar("_PayloadT_co", covariant=True) + + @dataclass(frozen=True, slots=True) -class RuntimeAcceptedNoteChange[PayloadT]: +class RuntimeAcceptedNoteChange(Generic[_PayloadT_co]): """Accepted note response plus any post-commit runtime follow-up work.""" status_code: int - payload: PayloadT + payload: _PayloadT_co materialization: RuntimePendingNoteMaterialization | None = None file_delete: RuntimePendingNoteFileDelete | None = None diff --git a/src/basic_memory/schemas/cloud.py b/src/basic_memory/schemas/cloud.py index 18cdd1354..41dd8ee30 100644 --- a/src/basic_memory/schemas/cloud.py +++ b/src/basic_memory/schemas/cloud.py @@ -1,6 +1,6 @@ """Schemas for cloud-related API responses.""" -from typing import Literal +from typing import Any, Literal from pydantic import BaseModel, Field @@ -52,8 +52,10 @@ class CloudProjectCreateResponse(BaseModel): message: str = Field(..., description="Status message about the project creation") status: str = Field(..., description="Status of the creation (success or error)") default: bool = Field(..., description="True if the project was set as the default") - old_project: dict | None = Field(None, description="Information about the previous project") - new_project: dict | None = Field( + old_project: dict[str, Any] | None = Field( + None, description="Information about the previous project" + ) + new_project: dict[str, Any] | None = Field( None, description="Information about the newly created project" ) diff --git a/src/basic_memory/schemas/schema.py b/src/basic_memory/schemas/schema.py index cc5a0b126..9c0b50c87 100644 --- a/src/basic_memory/schemas/schema.py +++ b/src/basic_memory/schemas/schema.py @@ -7,6 +7,7 @@ """ from pydantic import BaseModel, Field +from typing import Any # --- Validation Response Models --- @@ -97,7 +98,7 @@ class InferenceReport(BaseModel): note_type: str notes_analyzed: int field_frequencies: list[FieldFrequencyResponse] = Field(default_factory=list) - suggested_schema: dict = Field( + suggested_schema: dict[str, Any] = Field( default_factory=dict, description="Ready-to-use Picoschema YAML dict", ) diff --git a/src/basic_memory/schemas/search.py b/src/basic_memory/schemas/search.py index ea525cf97..675d5d266 100644 --- a/src/basic_memory/schemas/search.py +++ b/src/basic_memory/schemas/search.py @@ -131,7 +131,7 @@ class SearchResult(BaseModel): file_path: str updated_at: Optional[datetime] = None - metadata: Optional[dict] = None + metadata: Optional[dict[str, Any]] = None # IDs for v2 API consistency entity_id: Optional[int] = None # Entity ID (always present for entities) diff --git a/src/basic_memory/services/initialization.py b/src/basic_memory/services/initialization.py index 5c87b057b..7ce0d7815 100644 --- a/src/basic_memory/services/initialization.py +++ b/src/basic_memory/services/initialization.py @@ -6,7 +6,6 @@ import asyncio import os -import sys from pathlib import Path from typing import TYPE_CHECKING @@ -353,12 +352,5 @@ async def _init_and_cleanup(): # Always cleanup database connections to prevent process hang await db.shutdown_db() - # On Windows, use SelectorEventLoop to avoid ProactorEventLoop cleanup issues - # The ProactorEventLoop can raise "IndexError: pop from an empty deque" during - # event loop cleanup when there are pending handles. SelectorEventLoop is more - # stable for our use case (no subprocess pipes or named pipes needed). - if sys.platform == "win32": - asyncio.set_event_loop_policy(asyncio.WindowsSelectorEventLoopPolicy()) - asyncio.run(_init_and_cleanup()) logger.info("Initialization completed successfully") diff --git a/src/basic_memory/services/search_service.py b/src/basic_memory/services/search_service.py index df409771c..f853cfb97 100644 --- a/src/basic_memory/services/search_service.py +++ b/src/basic_memory/services/search_service.py @@ -565,7 +565,9 @@ async def sync_entity_vectors_batch( ) return batch_result - async def reindex_vectors(self, progress_callback=None, force_full: bool = False) -> dict: + async def reindex_vectors( + self, progress_callback=None, force_full: bool = False + ) -> dict[str, Any]: """Rebuild vector embeddings for all entities. Args: diff --git a/src/basic_memory/utils.py b/src/basic_memory/utils.py index 5df2b1ee2..a04408ca5 100644 --- a/src/basic_memory/utils.py +++ b/src/basic_memory/utils.py @@ -8,7 +8,7 @@ import sys from datetime import datetime, timezone from pathlib import Path -from typing import Any, Protocol, Union, runtime_checkable, List, Optional +from typing import override, Any, Protocol, Union, runtime_checkable, List, Optional from loguru import logger from unidecode import unidecode @@ -63,6 +63,7 @@ def normalize_project_path(path: str) -> str: class PathLike(Protocol): """Protocol for objects that can be used as paths.""" + @override def __str__(self) -> str: ... diff --git a/test-int/bughunt_fixes/test_cli_tool_search_notes_category.py b/test-int/bughunt_fixes/test_cli_tool_search_notes_category.py index 9ed0a0564..e9ed2c89e 100644 --- a/test-int/bughunt_fixes/test_cli_tool_search_notes_category.py +++ b/test-int/bughunt_fixes/test_cli_tool_search_notes_category.py @@ -11,11 +11,12 @@ from typer.testing import CliRunner from basic_memory.cli.main import app as cli_app +from typing import Any runner = CliRunner() -def _write_note(title: str, folder: str, content: str) -> dict: +def _write_note(title: str, folder: str, content: str) -> dict[str, Any]: result = runner.invoke( cli_app, [ diff --git a/test-int/cli/test_cli_tool_edit_note_integration.py b/test-int/cli/test_cli_tool_edit_note_integration.py index 7baa86e7c..49f62dc2b 100644 --- a/test-int/cli/test_cli_tool_edit_note_integration.py +++ b/test-int/cli/test_cli_tool_edit_note_integration.py @@ -5,11 +5,14 @@ from typer.testing import CliRunner from basic_memory.cli.main import app as cli_app +from typing import Any runner = CliRunner() -def _write_note(title: str, folder: str, content: str, project: str | None = None) -> dict: +def _write_note( + title: str, folder: str, content: str, project: str | None = None +) -> dict[str, Any]: args = [ "tool", "write-note", @@ -28,7 +31,7 @@ def _write_note(title: str, folder: str, content: str, project: str | None = Non return json.loads(result.stdout) -def _read_note(identifier: str, project: str | None = None) -> dict: +def _read_note(identifier: str, project: str | None = None) -> dict[str, Any]: args = ["tool", "read-note", identifier] if project: args.extend(["--project", project]) diff --git a/test-int/conftest.py b/test-int/conftest.py index 954922966..6d3256a7c 100644 --- a/test-int/conftest.py +++ b/test-int/conftest.py @@ -285,7 +285,10 @@ async def engine_factory( postgres_container, postgres_engine, tmp_path, -) -> AsyncGenerator[tuple, None]: +) -> AsyncGenerator[ + tuple[AsyncEngine, async_sessionmaker[AsyncSession]], + None, +]: """Create engine and session factory for the configured database backend.""" from basic_memory.models.search import CREATE_SEARCH_INDEX from basic_memory import db diff --git a/test-int/semantic/metrics.py b/test-int/semantic/metrics.py index 0d228f4f0..565acdeb4 100644 --- a/test-int/semantic/metrics.py +++ b/test-int/semantic/metrics.py @@ -14,6 +14,7 @@ from statistics import mean from basic_memory.repository.search_index_row import SearchIndexRow +from typing import Any # --- Relevance helpers --- @@ -42,7 +43,7 @@ class QualityMetrics: hits_at_1: int = 0 hits_at_5: int = 0 reciprocal_rank_sum: float = 0.0 - per_query: list[dict] = field(default_factory=list) + per_query: list[dict[str, Any]] = field(default_factory=list) # Timing data: per-query latencies in seconds latencies: list[float] = field(default_factory=list) @@ -81,7 +82,7 @@ def total_time_ms(self) -> float: def avg_latency_ms(self) -> float: return mean(self.latencies) * 1000 if self.latencies else 0.0 - def as_dict(self) -> dict: + def as_dict(self) -> dict[str, Any]: return { "combo": self.combo, "suite": self.suite, diff --git a/test-int/semantic/report.py b/test-int/semantic/report.py index 51d1b6fd5..4a0b132a2 100644 --- a/test-int/semantic/report.py +++ b/test-int/semantic/report.py @@ -17,9 +17,10 @@ from rich.console import Console from rich.table import Table from rich.text import Text +from typing import Any -def load_benchmarks(path: Path) -> list[dict]: +def load_benchmarks(path: Path) -> list[dict[str, Any]]: """Load benchmark records from a JSONL file.""" records = [] with path.open("r", encoding="utf-8") as f: @@ -68,7 +69,7 @@ def _latency_cell(value: float) -> Text: } -def build_table(records: list[dict], title: str = "Semantic Search Benchmarks") -> Table: +def build_table(records: list[dict[str, Any]], title: str = "Semantic Search Benchmarks") -> Table: """Build a rich Table from benchmark records.""" table = Table(title=title, show_lines=False) table.add_column("Combo", style="cyan", no_wrap=True) @@ -98,10 +99,10 @@ def build_table(records: list[dict], title: str = "Semantic Search Benchmarks") return table -def build_summary_table(records: list[dict]) -> Table: +def build_summary_table(records: list[dict[str, Any]]) -> Table: """Build a summary table comparing combos across suites.""" # Group by combo - combos: dict[str, dict[str, dict]] = {} + combos: dict[str, dict[str, dict[str, Any]]] = {} for rec in records: m = rec["metrics"] key = m["combo"] diff --git a/test-int/semantic/test_litellm_live_harness.py b/test-int/semantic/test_litellm_live_harness.py index 8b06b4a69..376b7dac7 100644 --- a/test-int/semantic/test_litellm_live_harness.py +++ b/test-int/semantic/test_litellm_live_harness.py @@ -12,6 +12,7 @@ evaluate_case, load_custom_cases, ) +from typing import override class FakeProvider: @@ -32,6 +33,7 @@ async def embed_query(self, text: str) -> list[float]: class WrongRankingProvider(FakeProvider): """Provider double that ranks the distractor document higher.""" + @override async def embed_documents(self, texts: list[str]) -> list[list[float]]: assert len(texts) == 2 return [[0.0, 1.0], [1.0, 0.0]] diff --git a/test-int/test_picoschema/conftest.py b/test-int/test_picoschema/conftest.py index e09ed70a1..213864952 100644 --- a/test-int/test_picoschema/conftest.py +++ b/test-int/test_picoschema/conftest.py @@ -18,6 +18,7 @@ DRIFT_PEOPLE_DIR, parse_frontmatter, ) +from typing import Any @pytest.fixture @@ -56,30 +57,30 @@ def drift_people_dir() -> Path: @pytest.fixture -def person_schema_frontmatter(schemas_dir) -> dict: +def person_schema_frontmatter(schemas_dir) -> dict[str, Any]: """Load Person schema frontmatter from fixture.""" return parse_frontmatter(schemas_dir / "Person.md") @pytest.fixture -def book_schema_frontmatter(schemas_dir) -> dict: +def book_schema_frontmatter(schemas_dir) -> dict[str, Any]: """Load Book schema frontmatter from fixture.""" return parse_frontmatter(schemas_dir / "Book.md") @pytest.fixture -def meeting_schema_frontmatter(schemas_dir) -> dict: +def meeting_schema_frontmatter(schemas_dir) -> dict[str, Any]: """Load Meeting schema frontmatter from fixture.""" return parse_frontmatter(schemas_dir / "Meeting.md") @pytest.fixture -def software_project_schema_frontmatter(schemas_dir) -> dict: +def software_project_schema_frontmatter(schemas_dir) -> dict[str, Any]: """Load SoftwareProject schema frontmatter from fixture.""" return parse_frontmatter(schemas_dir / "SoftwareProject.md") @pytest.fixture -def strict_schema_frontmatter(schemas_dir) -> dict: +def strict_schema_frontmatter(schemas_dir) -> dict[str, Any]: """Load StrictSchema frontmatter from fixture.""" return parse_frontmatter(schemas_dir / "StrictSchema.md") diff --git a/test-int/test_picoschema/helpers.py b/test-int/test_picoschema/helpers.py index 4d1c75c51..7d63abd87 100644 --- a/test-int/test_picoschema/helpers.py +++ b/test-int/test_picoschema/helpers.py @@ -10,6 +10,7 @@ import yaml from basic_memory.picoschema.inference import ObservationData, RelationData +from typing import Any # --- Fixture Paths --- @@ -27,7 +28,7 @@ # --- Frontmatter Parsing --- -def parse_frontmatter(filepath: Path) -> dict: +def parse_frontmatter(filepath: Path) -> dict[str, Any]: """Extract YAML frontmatter from a markdown file.""" text = filepath.read_text(encoding="utf-8") match = re.match(r"^---\n(.*?)\n---", text, re.DOTALL) diff --git a/test-int/test_picoschema/test_validator_integration.py b/test-int/test_picoschema/test_validator_integration.py index 435821900..148101804 100644 --- a/test-int/test_picoschema/test_validator_integration.py +++ b/test-int/test_picoschema/test_validator_integration.py @@ -15,6 +15,7 @@ EDGE_CASES_DIR, SCHEMAS_DIR, ) +from typing import Any class TestValidNotesPassValidation: @@ -140,7 +141,7 @@ async def test_inline_schema_overrides_type(self): filepath = EDGE_CASES_DIR / "inline-and-type.md" frontmatter = parse_frontmatter(filepath) - async def mock_search(query: str) -> list[dict]: + async def mock_search(query: str) -> list[dict[str, Any]]: if query == "Person": return [parse_frontmatter(SCHEMAS_DIR / "Person.md")] return [] @@ -156,7 +157,7 @@ async def test_explicit_ref_overrides_type(self): filepath = EDGE_CASES_DIR / "explicit-overrides-type.md" frontmatter = parse_frontmatter(filepath) - async def mock_search(query: str) -> list[dict]: + async def mock_search(query: str) -> list[dict[str, Any]]: if query == "Meeting": return [parse_frontmatter(SCHEMAS_DIR / "Meeting.md")] if query == "Person": @@ -172,7 +173,7 @@ async def test_no_schema_returns_none(self): filepath = VALID_DIR / "no-schema-note.md" frontmatter = parse_frontmatter(filepath) - async def mock_search(query: str) -> list[dict]: + async def mock_search(query: str) -> list[dict[str, Any]]: return [] schema = await resolve_schema(frontmatter, mock_search) diff --git a/tests/api/v2/test_knowledge_router_telemetry.py b/tests/api/v2/test_knowledge_router_telemetry.py index 38d5e61fa..af024f448 100644 --- a/tests/api/v2/test_knowledge_router_telemetry.py +++ b/tests/api/v2/test_knowledge_router_telemetry.py @@ -19,7 +19,7 @@ def _capture_spans(): - spans: list[tuple[str, dict]] = [] + spans: list[tuple[str, dict[str, Any]]] = [] @contextmanager def fake_span(name: str, **attrs): @@ -49,7 +49,7 @@ def _fake_entity(*, external_id: str = "entity-123", file_path: str = "notes/tes ) -def _assert_only_root_span(spans: list[tuple[str, dict]], expected_name: str) -> None: +def _assert_only_root_span(spans: list[tuple[str, dict[str, Any]]], expected_name: str) -> None: assert [name for name, _ in spans] == [expected_name] diff --git a/tests/api/v2/test_project_router.py b/tests/api/v2/test_project_router.py index 7675ae4e1..5cb5e2ef9 100644 --- a/tests/api/v2/test_project_router.py +++ b/tests/api/v2/test_project_router.py @@ -11,6 +11,7 @@ from basic_memory.models import Project from basic_memory.schemas.project_info import ProjectItem, ProjectStatusResponse from basic_memory.schemas.v2 import ProjectResolveResponse +from typing import Any def _project_item(project: ProjectItem | None) -> ProjectItem: @@ -38,7 +39,7 @@ async def _get_default_project(project_repository, session_maker): return await project_repository.get_default_project(session) -async def _update_project(project_repository, session_maker, project_id: int, data: dict): +async def _update_project(project_repository, session_maker, project_id: int, data: dict[str, Any]): async with db.scoped_session(session_maker) as session: return await project_repository.update(session, project_id, data) diff --git a/tests/api/v2/test_search_router_telemetry.py b/tests/api/v2/test_search_router_telemetry.py index 5809402f0..f4d2e7e54 100644 --- a/tests/api/v2/test_search_router_telemetry.py +++ b/tests/api/v2/test_search_router_telemetry.py @@ -17,7 +17,7 @@ @pytest.mark.asyncio async def test_search_router_wraps_request_in_manual_operation(monkeypatch) -> None: router = cast(Any, search_router_module) - operations: list[tuple[str, dict]] = [] + operations: list[tuple[str, dict[str, Any]]] = [] class FakeSearchService: async def search(self, query, *, limit, offset): diff --git a/tests/api/v2/test_utils_telemetry.py b/tests/api/v2/test_utils_telemetry.py index e1618dc7e..fc145433b 100644 --- a/tests/api/v2/test_utils_telemetry.py +++ b/tests/api/v2/test_utils_telemetry.py @@ -11,12 +11,13 @@ import pytest from basic_memory.repository.search_index_row import SearchIndexRow +from typing import Any utils_module = importlib.import_module("basic_memory.api.v2.utils") def _capture_spans(): - spans: list[tuple[str, dict]] = [] + spans: list[tuple[str, dict[str, Any]]] = [] @contextmanager def fake_span(name: str, **attrs): diff --git a/tests/cli/cloud/test_cloud_api_client_and_utils.py b/tests/cli/cloud/test_cloud_api_client_and_utils.py index 0325a0c8d..88b936178 100644 --- a/tests/cli/cloud/test_cloud_api_client_and_utils.py +++ b/tests/cli/cloud/test_cloud_api_client_and_utils.py @@ -16,6 +16,7 @@ project_exists, ) from basic_memory.config import ProjectMode +from typing import Any @pytest.mark.asyncio @@ -176,7 +177,7 @@ async def test_create_cloud_project_accepts_visibility_override(config_home, con config.cloud_host = "https://cloud.example.test" config_manager.save_config(config) - seen_payload: dict | None = None + seen_payload: dict[str, Any] | None = None async def api_request(**kwargs): nonlocal seen_payload diff --git a/tests/cli/cloud/test_project_sync_command.py b/tests/cli/cloud/test_project_sync_command.py index 792dfcf69..3055930ab 100644 --- a/tests/cli/cloud/test_project_sync_command.py +++ b/tests/cli/cloud/test_project_sync_command.py @@ -11,6 +11,7 @@ from basic_memory.cli.commands.cloud.rclone_commands import RcloneError, TransferPlan from basic_memory.config import ProjectEntry, ProjectMode from basic_memory.schemas.cloud import WorkspaceInfo +from typing import Any runner = CliRunner() @@ -204,7 +205,7 @@ def test_cloud_sync_blocks_organization_workspace(monkeypatch, config_manager): def test_cloud_sync_allows_personal_workspace(monkeypatch, config_manager): """Personal workspaces keep the one-way mirror sync available.""" project_sync_command = importlib.import_module("basic_memory.cli.commands.cloud.project_sync") - routing: dict = {} + routing: dict[str, Any] = {} config = config_manager.load_config() config.cloud_api_key = "bmc_test" @@ -476,7 +477,7 @@ def test_cloud_pull_aborts_on_conflict_by_default(monkeypatch, config_manager): """Pull refuses to clobber: it lists conflicts and exits without transferring.""" module = importlib.import_module("basic_memory.cli.commands.cloud.project_sync") plan = TransferPlan(new=["new.md"], conflicts=["notes/dup.md"], dest_only=[], errors=[]) - recorder: dict = {} + recorder: dict[str, Any] = {} _stub_transfer_env(monkeypatch, module, plan=plan, recorder=recorder) result = runner.invoke(app, ["cloud", "pull", "--name", "research"]) @@ -492,7 +493,7 @@ def test_cloud_pull_clean_transfers(monkeypatch, config_manager): """With no conflicts, pull proceeds and reports success.""" module = importlib.import_module("basic_memory.cli.commands.cloud.project_sync") plan = TransferPlan(new=["new.md"], conflicts=[], dest_only=["local-only.md"], errors=[]) - recorder: dict = {} + recorder: dict[str, Any] = {} _stub_transfer_env(monkeypatch, module, plan=plan, recorder=recorder) result = runner.invoke(app, ["cloud", "pull", "--name", "research"]) @@ -510,7 +511,7 @@ def test_cloud_pull_keep_cloud_resolves_conflict(monkeypatch, config_manager): """An explicit --on-conflict strategy lets pull proceed through conflicts.""" module = importlib.import_module("basic_memory.cli.commands.cloud.project_sync") plan = TransferPlan(new=[], conflicts=["notes/dup.md"], dest_only=[], errors=[]) - recorder: dict = {} + recorder: dict[str, Any] = {} _stub_transfer_env(monkeypatch, module, plan=plan, recorder=recorder) result = runner.invoke( @@ -525,7 +526,7 @@ def test_cloud_pull_aborts_on_compare_errors(monkeypatch, config_manager): """If rclone cannot read/hash files, pull aborts before transferring.""" module = importlib.import_module("basic_memory.cli.commands.cloud.project_sync") plan = TransferPlan(new=[], conflicts=[], dest_only=[], errors=["bad.md"]) - recorder: dict = {} + recorder: dict[str, Any] = {} _stub_transfer_env(monkeypatch, module, plan=plan, recorder=recorder) result = runner.invoke(app, ["cloud", "pull", "--name", "research"]) @@ -539,7 +540,7 @@ def test_cloud_push_aborts_on_conflict_by_default(monkeypatch, config_manager): """Push aborts on conflicts like a rejected git push (pull first).""" module = importlib.import_module("basic_memory.cli.commands.cloud.project_sync") plan = TransferPlan(new=["new.md"], conflicts=["notes/dup.md"], dest_only=[], errors=[]) - recorder: dict = {} + recorder: dict[str, Any] = {} _stub_transfer_env(monkeypatch, module, plan=plan, recorder=recorder) result = runner.invoke(app, ["cloud", "push", "--name", "research"]) @@ -553,7 +554,7 @@ def test_cloud_push_keep_local_resolves_conflict(monkeypatch, config_manager): """Push with --on-conflict keep-local overwrites cloud and reports the direction.""" module = importlib.import_module("basic_memory.cli.commands.cloud.project_sync") plan = TransferPlan(new=[], conflicts=["notes/dup.md"], dest_only=[], errors=[]) - recorder: dict = {} + recorder: dict[str, Any] = {} _stub_transfer_env(monkeypatch, module, plan=plan, recorder=recorder) result = runner.invoke( @@ -571,7 +572,7 @@ def test_cloud_push_allows_organization_workspace(monkeypatch, config_manager): org_ws = _workspace("team-tenant", "organization", "acme", is_default=False) plan = TransferPlan(new=["new.md"], conflicts=[], dest_only=[], errors=[]) - recorder: dict = {} + recorder: dict[str, Any] = {} _stub_transfer_env(monkeypatch, module, plan=plan, recorder=recorder, workspace=org_ws) result = runner.invoke(app, ["cloud", "push", "--name", "research"]) @@ -588,7 +589,7 @@ def test_cloud_pull_workspace_override_routes_through_workspace_remote(monkeypat org_ws = _workspace("team-tenant", "organization", "acme", is_default=False) plan = TransferPlan(new=["new.md"], conflicts=[], dest_only=[], errors=[]) - recorder: dict = {} + recorder: dict[str, Any] = {} # _get_workspace_for_project must receive the override and return the org workspace. def _resolve(_name, _config, *, workspace_override=None): @@ -611,7 +612,7 @@ def test_cloud_push_errors_when_workspace_remote_not_set_up(monkeypatch, config_ org_ws = _workspace("team-tenant", "organization", "acme", is_default=False) plan = TransferPlan(new=["new.md"], conflicts=[], dest_only=[], errors=[]) - recorder: dict = {} + recorder: dict[str, Any] = {} _stub_transfer_env(monkeypatch, module, plan=plan, recorder=recorder, workspace=org_ws) # Override: this workspace has not been set up yet. monkeypatch.setattr(module, "rclone_remote_exists", lambda _remote: False) @@ -737,7 +738,7 @@ def _fake_prune(*args, **kwargs): def test_cloud_prune_dry_run_previews_without_deleting(monkeypatch, config_manager): """--dry-run lists the matching cloud files and never deletes.""" module = importlib.import_module("basic_memory.cli.commands.cloud.project_sync") - recorder: dict = {} + recorder: dict[str, Any] = {} _stub_prune_env( monkeypatch, module, matches=["secret.env", "secrets/leak.md"], recorder=recorder ) @@ -756,7 +757,7 @@ def test_cloud_prune_dry_run_previews_without_deleting(monkeypatch, config_manag def test_cloud_prune_confirmation_declined_deletes_nothing(monkeypatch, config_manager): """Answering 'n' at the prompt cancels cleanly without deleting.""" module = importlib.import_module("basic_memory.cli.commands.cloud.project_sync") - recorder: dict = {} + recorder: dict[str, Any] = {} _stub_prune_env(monkeypatch, module, matches=["secret.env"], recorder=recorder) result = runner.invoke(app, ["cloud", "prune", "--name", "research"], input="n\n") @@ -769,7 +770,7 @@ def test_cloud_prune_confirmation_declined_deletes_nothing(monkeypatch, config_m def test_cloud_prune_confirmation_accepted_deletes(monkeypatch, config_manager): """Answering 'y' at the prompt runs the deletion.""" module = importlib.import_module("basic_memory.cli.commands.cloud.project_sync") - recorder: dict = {} + recorder: dict[str, Any] = {} _stub_prune_env(monkeypatch, module, matches=["secret.env"], recorder=recorder) result = runner.invoke(app, ["cloud", "prune", "--name", "research"], input="y\n") @@ -784,7 +785,7 @@ def test_cloud_prune_confirmation_accepted_deletes(monkeypatch, config_manager): def test_cloud_prune_routes_to_non_default_personal_workspace(monkeypatch, config_manager): """Prune must not fall back to a same-named project in the default workspace.""" module = importlib.import_module("basic_memory.cli.commands.cloud.project_sync") - recorder: dict = {} + recorder: dict[str, Any] = {} workspace = _workspace("personal-alt-tenant", "personal", "personal-alt") _stub_prune_env( monkeypatch, @@ -806,7 +807,7 @@ def test_cloud_prune_routes_to_non_default_personal_workspace(monkeypatch, confi def test_cloud_prune_yes_skips_confirmation(monkeypatch, config_manager): """--yes deletes without prompting (no stdin supplied).""" module = importlib.import_module("basic_memory.cli.commands.cloud.project_sync") - recorder: dict = {} + recorder: dict[str, Any] = {} _stub_prune_env(monkeypatch, module, matches=["secret.env"], recorder=recorder) result = runner.invoke(app, ["cloud", "prune", "--name", "research", "--yes"]) @@ -819,7 +820,7 @@ def test_cloud_prune_yes_skips_confirmation(monkeypatch, config_manager): def test_cloud_prune_nothing_to_delete(monkeypatch, config_manager): """An empty preview exits successfully without prompting or deleting.""" module = importlib.import_module("basic_memory.cli.commands.cloud.project_sync") - recorder: dict = {} + recorder: dict[str, Any] = {} _stub_prune_env(monkeypatch, module, matches=[], recorder=recorder) result = runner.invoke(app, ["cloud", "prune", "--name", "research"]) @@ -937,7 +938,7 @@ def _fake_configure(**kwargs): def test_cloud_setup_workspace_configures_named_remote(monkeypatch): """`bm cloud setup --workspace acme` provisions the acme tenant's own remote.""" core = importlib.import_module("basic_memory.cli.commands.cloud.core_commands") - recorder: dict = {} + recorder: dict[str, Any] = {} _stub_setup_env(monkeypatch, core, remote_exists=False, recorder=recorder) result = runner.invoke(app, ["cloud", "setup", "--workspace", "acme"]) @@ -949,7 +950,7 @@ def test_cloud_setup_workspace_configures_named_remote(monkeypatch): def test_cloud_setup_aborts_when_remote_exists_without_force(monkeypatch): """Setup refuses to overwrite an existing remote, and mints nothing (the #922 footgun).""" core = importlib.import_module("basic_memory.cli.commands.cloud.core_commands") - recorder: dict = {} + recorder: dict[str, Any] = {} _stub_setup_env(monkeypatch, core, remote_exists=True, recorder=recorder) result = runner.invoke(app, ["cloud", "setup", "--workspace", "acme"]) @@ -966,7 +967,7 @@ def test_cloud_setup_aborts_when_remote_exists_without_force(monkeypatch): def test_cloud_setup_force_overwrites_existing_remote(monkeypatch): """--force reconfigures an existing remote.""" core = importlib.import_module("basic_memory.cli.commands.cloud.core_commands") - recorder: dict = {} + recorder: dict[str, Any] = {} _stub_setup_env(monkeypatch, core, remote_exists=True, recorder=recorder) result = runner.invoke(app, ["cloud", "setup", "--workspace", "acme", "--force"]) @@ -980,7 +981,7 @@ def test_cloud_setup_default_workspace_aborts_when_remote_exists(monkeypatch): """The original footgun: `bm cloud setup` (no --workspace) must not clobber the shared basic-memory-cloud remote without --force.""" core = importlib.import_module("basic_memory.cli.commands.cloud.core_commands") - recorder: dict = {} + recorder: dict[str, Any] = {} _stub_setup_env(monkeypatch, core, remote_exists=True, recorder=recorder) result = runner.invoke(app, ["cloud", "setup"]) # no --workspace → basic-memory-cloud diff --git a/tests/cli/test_auto_update.py b/tests/cli/test_auto_update.py index 3751d0c3b..574b7aefd 100644 --- a/tests/cli/test_auto_update.py +++ b/tests/cli/test_auto_update.py @@ -267,7 +267,7 @@ def test_mcp_silent_mode_suppresses_subprocess_output(monkeypatch, tmp_path): lambda: (True, "9.9.9"), ) - captured_kwargs: list[dict] = [] + captured_kwargs: list[dict[str, Any]] = [] def _fake_run_subprocess(command, **kwargs): captured_kwargs.append(kwargs) diff --git a/tests/cli/test_cli_telemetry.py b/tests/cli/test_cli_telemetry.py index 884521df8..b30298ed5 100644 --- a/tests/cli/test_cli_telemetry.py +++ b/tests/cli/test_cli_telemetry.py @@ -26,7 +26,7 @@ def call_on_close(self, callback) -> None: def test_app_callback_registers_command_operation(monkeypatch) -> None: - operations: list[tuple[str, dict]] = [] + operations: list[tuple[str, dict[str, Any]]] = [] resource = object() monkeypatch.setattr(cli_app, "init_cli_logging", lambda: None) diff --git a/tests/cli/test_cloud_status.py b/tests/cli/test_cloud_status.py index a3fbead0c..c3594b184 100644 --- a/tests/cli/test_cloud_status.py +++ b/tests/cli/test_cloud_status.py @@ -10,6 +10,7 @@ from basic_memory.cli.app import app from basic_memory.cli.commands.cloud.api_client import CloudAPIError +from typing import Any # --- status command integration tests --- @@ -19,7 +20,7 @@ class _FakeTokens: """Provides canned token data for CLIAuth stubs.""" @classmethod - def valid(cls) -> dict: + def valid(cls) -> dict[str, Any]: return { "access_token": "fake-access-token", "refresh_token": "rt_test", @@ -27,7 +28,7 @@ def valid(cls) -> dict: } @classmethod - def expired(cls) -> dict: + def expired(cls) -> dict[str, Any]: return { "access_token": "fake-access-token", "refresh_token": "rt_test", diff --git a/tests/cli/test_coding_session_context.py b/tests/cli/test_coding_session_context.py index d80f263d0..eb6b8245e 100644 --- a/tests/cli/test_coding_session_context.py +++ b/tests/cli/test_coding_session_context.py @@ -11,6 +11,7 @@ from basic_memory.cli.commands import hook as hook_module from basic_memory.cli.main import app as cli_app +from typing import Any runner = CliRunner() @@ -152,7 +153,7 @@ def test_coding_profile_uses_dedicated_schema_for_both_harnesses() -> None: def test_coding_recall_filters_by_repository_and_merges_codex_sessions() -> None: queries: list[dict[str, object]] = [] - async def fake_query(project: str | None, **filters: object) -> dict: + async def fake_query(project: str | None, **filters: object) -> dict[str, Any]: queries.append({"project": project, **filters}) if filters.get("note_types") == ["coding_session"]: return {"results": [{"title": "Coding", "permalink": "sessions/coding"}]} diff --git a/tests/cli/test_config_command.py b/tests/cli/test_config_command.py index d79a5973c..eb39ab2c8 100644 --- a/tests/cli/test_config_command.py +++ b/tests/cli/test_config_command.py @@ -12,6 +12,7 @@ # Importing registers the config subcommands on the shared app instance. import basic_memory.cli.commands.config as config_cmd # noqa: F401 +from typing import Any @pytest.fixture @@ -23,7 +24,7 @@ def runner(): def write_config(tmp_path, monkeypatch): """Write config.json under a temporary HOME and return the file path.""" - def _write(config_data: dict) -> Path: + def _write(config_data: dict[str, Any]) -> Path: from basic_memory import config as config_module config_module._CONFIG_CACHE = None @@ -40,7 +41,7 @@ def _write(config_data: dict) -> Path: return _write -def _base_config(**overrides) -> dict: +def _base_config(**overrides) -> dict[str, Any]: data = { "env": "dev", "projects": {"main": {"path": "/tmp/main", "mode": "local"}}, diff --git a/tests/cli/test_hook_command.py b/tests/cli/test_hook_command.py index fa5abe7de..c6f1c2908 100644 --- a/tests/cli/test_hook_command.py +++ b/tests/cli/test_hook_command.py @@ -32,7 +32,7 @@ def _hook_probe_ok(monkeypatch: pytest.MonkeyPatch) -> None: monkeypatch.setattr(hook_module, "_supports_hook", lambda binary: True) -def _search_result(*titles: str) -> dict: +def _search_result(*titles: str) -> dict[str, Any]: return { "results": [ {"title": title, "permalink": f"notes/{title.lower().replace(' ', '-')}"} @@ -60,7 +60,7 @@ def claude_project(tmp_path: Path) -> Path: return project -def _write_claude_settings(project: Path, block: dict) -> None: +def _write_claude_settings(project: Path, block: dict[str, Any]) -> None: (project / ".claude" / "settings.json").write_text( json.dumps({"basicMemory": block}), encoding="utf-8" ) @@ -127,7 +127,7 @@ def _init_git_repo(project: Path) -> None: subprocess.run(["git", "init"], cwd=project, check=True, capture_output=True) -def _inbox_envelopes(bm_home: Path) -> list[dict]: +def _inbox_envelopes(bm_home: Path) -> list[dict[str, Any]]: inbox_dir = bm_home / "inbox" return [ json.loads(path.read_text(encoding="utf-8")) for path in sorted(inbox_dir.glob("*.json")) @@ -1217,7 +1217,7 @@ def _codex_hooks_path() -> Path: return Path.home() / ".codex" / "hooks.json" -def _read_json(path: Path) -> dict: +def _read_json(path: Path) -> dict[str, Any]: return json.loads(path.read_text(encoding="utf-8")) @@ -1552,7 +1552,7 @@ def test_install_skips_stale_basic_memory_and_uses_uvx(monkeypatch: pytest.Monke def test_supports_hook_true_on_zero_exit(monkeypatch: pytest.MonkeyPatch) -> None: - captured: dict = {} + captured: dict[str, Any] = {} def fake_run(cmd, **kwargs): captured["cmd"] = cmd diff --git a/tests/cli/test_json_output.py b/tests/cli/test_json_output.py index b9139ae71..a99630c6c 100644 --- a/tests/cli/test_json_output.py +++ b/tests/cli/test_json_output.py @@ -23,11 +23,12 @@ # Importing registers subcommands on the shared app instance. import basic_memory.cli.commands.project as project_cmd # noqa: F401 +from typing import Any runner = CliRunner() -def _parse_json_output(output: str) -> dict: +def _parse_json_output(output: str) -> dict[str, Any]: """Extract and parse the JSON object from CLI output. The CliRunner may capture log lines before the JSON payload. @@ -417,7 +418,7 @@ def test_schema_diff_json(mock_mcp, mock_config_cls): def write_config(tmp_path, monkeypatch): """Write config.json under a temporary HOME and return the file path.""" - def _write(config_data: dict): + def _write(config_data: dict[str, Any]): from basic_memory import config as config_module config_module._CONFIG_CACHE = None diff --git a/tests/cli/test_project_add_with_local_path.py b/tests/cli/test_project_add_with_local_path.py index a0cd695a9..81364bd42 100644 --- a/tests/cli/test_project_add_with_local_path.py +++ b/tests/cli/test_project_add_with_local_path.py @@ -13,6 +13,7 @@ # Importing registers project subcommands on the shared app instance. import basic_memory.cli.commands.project as project_cmd # noqa: F401 +from typing import Any def _workspace( @@ -94,7 +95,7 @@ async def fake_get_client(*, workspace=None): }, } - calls: list[dict] = [] + calls: list[dict[str, Any]] = [] async def fake_create_project(self, project_data): calls.append(project_data) diff --git a/tests/cli/test_project_info_cloud_status.py b/tests/cli/test_project_info_cloud_status.py index 07088b254..77cad2aad 100644 --- a/tests/cli/test_project_info_cloud_status.py +++ b/tests/cli/test_project_info_cloud_status.py @@ -22,6 +22,7 @@ # Importing registers project subcommands on the shared app instance. import basic_memory.cli.commands.project as project_cmd # noqa: F401 +from typing import Any @pytest.fixture @@ -34,7 +35,7 @@ def write_config(tmp_path, monkeypatch): """Write config.json under a temporary HOME and return the file path.""" from basic_memory import config as config_module - def _write(config_data: dict) -> Path: + def _write(config_data: dict[str, Any]) -> Path: config_module._CONFIG_CACHE = None config_module._CONFIG_MTIME = None config_module._CONFIG_SIZE = None diff --git a/tests/cli/test_project_list_and_ls.py b/tests/cli/test_project_list_and_ls.py index 5a8988d66..6bd06095a 100644 --- a/tests/cli/test_project_list_and_ls.py +++ b/tests/cli/test_project_list_and_ls.py @@ -15,6 +15,7 @@ # Importing registers project subcommands on the shared app instance. import basic_memory.cli.commands.project as project_cmd # noqa: F401 +from typing import Any @pytest.fixture @@ -26,7 +27,7 @@ def runner(): def write_config(tmp_path, monkeypatch): """Write config.json under a temporary HOME and return the file path.""" - def _write(config_data: dict) -> Path: + def _write(config_data: dict[str, Any]) -> Path: from basic_memory import config as config_module config_module._CONFIG_CACHE = None diff --git a/tests/cloud/test_cloud_services.py b/tests/cloud/test_cloud_services.py index 6782a4736..b37626cd9 100644 --- a/tests/cloud/test_cloud_services.py +++ b/tests/cloud/test_cloud_services.py @@ -3,7 +3,7 @@ from contextlib import asynccontextmanager from datetime import UTC, datetime from types import SimpleNamespace -from typing import Any, cast +from typing import override, Any, cast from uuid import uuid4 import pytest @@ -130,11 +130,11 @@ async def enqueue_directory_file_delete( class FakeReadRepairFileReader: def __init__(self, markdown_content: str) -> None: self.markdown_content = markdown_content - self.targets: list[NoteContentReadRepairTarget] = [] + self.targets: list[NoteContentReadRepairTarget[Any, Any]] = [] async def read_note_content_repair_file( self, - target: NoteContentReadRepairTarget, + target: NoteContentReadRepairTarget[Any, Any], ) -> NoteContentReadRepairFile: self.targets.append(target) return NoteContentReadRepairFile( @@ -794,6 +794,7 @@ async def refresh_relation_sources(self, entity_ids) -> None: self.refreshed.append(list(entity_ids)) class StoreWithSurvivingSources(FakeDirectoryDeleteStore): + @override async def delete_directory_entities( self, session: AsyncSession, diff --git a/tests/db/test_uvloop_policy.py b/tests/db/test_uvloop_policy.py index b093a5cd3..3e3f0de72 100644 --- a/tests/db/test_uvloop_policy.py +++ b/tests/db/test_uvloop_policy.py @@ -21,11 +21,12 @@ @pytest.fixture def restore_event_loop_policy(): """Save/restore the global event-loop policy around a test.""" - original = asyncio.get_event_loop_policy() + # These tests deliberately pin the Python 3.12-3.15 compatibility seam. + original = asyncio.get_event_loop_policy() # ty: ignore[deprecated] try: yield finally: - asyncio.set_event_loop_policy(original) + asyncio.set_event_loop_policy(original) # ty: ignore[deprecated] def _postgres_config() -> BasicMemoryConfig: @@ -48,18 +49,21 @@ def test_installs_uvloop_for_postgres_backend(restore_event_loop_policy): installed = maybe_install_uvloop(_postgres_config()) assert installed is True - assert isinstance(asyncio.get_event_loop_policy(), uvloop.EventLoopPolicy) + assert isinstance( + asyncio.get_event_loop_policy(), # ty: ignore[deprecated] + uvloop.EventLoopPolicy, + ) def test_no_uvloop_for_sqlite_backend(restore_event_loop_policy): """SQLite users keep the default loop - the helper is a no-op.""" - before = asyncio.get_event_loop_policy() + before = asyncio.get_event_loop_policy() # ty: ignore[deprecated] installed = maybe_install_uvloop(_sqlite_config()) assert installed is False # Policy must be unchanged for the default (SQLite) path. - assert asyncio.get_event_loop_policy() is before + assert asyncio.get_event_loop_policy() is before # ty: ignore[deprecated] @pytest.mark.skipif(sys.platform == "win32", reason="uvloop is not available on Windows") @@ -76,8 +80,8 @@ def _fail_uvloop_import(name, *args, **kwargs): monkeypatch.setattr(builtins, "__import__", _fail_uvloop_import) - before = asyncio.get_event_loop_policy() + before = asyncio.get_event_loop_policy() # ty: ignore[deprecated] installed = maybe_install_uvloop(_postgres_config()) assert installed is False - assert asyncio.get_event_loop_policy() is before + assert asyncio.get_event_loop_policy() is before # ty: ignore[deprecated] diff --git a/tests/hooks/test_adapters.py b/tests/hooks/test_adapters.py index 4d13df814..463b27aa2 100644 --- a/tests/hooks/test_adapters.py +++ b/tests/hooks/test_adapters.py @@ -7,11 +7,12 @@ from basic_memory.hooks.adapters import for_harness from basic_memory.hooks.envelope import COMPACTION_IMMINENT, SESSION_STARTED +from typing import Any FIXTURES_DIR = Path(__file__).parent / "fixtures" -def load_fixture(name: str) -> dict: +def load_fixture(name: str) -> dict[str, Any]: return json.loads((FIXTURES_DIR / name).read_text(encoding="utf-8")) diff --git a/tests/hooks/test_envelope.py b/tests/hooks/test_envelope.py index 2e552325c..b4ad5cbc9 100644 --- a/tests/hooks/test_envelope.py +++ b/tests/hooks/test_envelope.py @@ -15,10 +15,11 @@ envelope_to_json, idempotency_key, ) +from typing import Any def _envelope(**overrides): - kwargs: dict = { + kwargs: dict[str, Any] = { "source": "claude-code", "event": COMPACTION_IMMINENT, "session_id": "session-1", diff --git a/tests/importers/test_importer_base.py b/tests/importers/test_importer_base.py index f1b8b05c8..9d9667f6f 100644 --- a/tests/importers/test_importer_base.py +++ b/tests/importers/test_importer_base.py @@ -8,12 +8,14 @@ from basic_memory.markdown.schemas import EntityFrontmatter, EntityMarkdown from basic_memory.schemas.importer import ImportResult from basic_memory.services.file_service import FileService +from typing import override # Create a concrete implementation of the abstract class for testing class ConcreteTestImporter(Importer[ImportResult]): """Test implementation of Importer base class.""" + @override async def import_data(self, source_data, destination_folder: str, **kwargs): """Implement the abstract method for testing.""" try: @@ -27,6 +29,7 @@ async def import_data(self, source_data, destination_folder: str, **kwargs): except Exception as e: return self.handle_error("Test import failed", e) + @override def handle_error(self, message: str, error=None) -> ImportResult: """Implement the abstract handle_error method.""" import logging diff --git a/tests/index/test_local_project_index.py b/tests/index/test_local_project_index.py index 2d868fb5b..7b2ff09a0 100644 --- a/tests/index/test_local_project_index.py +++ b/tests/index/test_local_project_index.py @@ -8,7 +8,7 @@ from datetime import datetime, timezone from hashlib import sha256 from pathlib import Path -from typing import Any +from typing import override, Any from sqlalchemy import Select from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker @@ -962,6 +962,7 @@ async def test_run_local_project_index_resolves_relations_after_inline_fanout() maintenance_runner = RecordingMaintenanceRunner() class EventBatchEnqueuer(RecordingBatchEnqueuer): + @override async def enqueue_index_file_batch(self, request: RuntimeIndexFileBatchJobRequest) -> None: events.append("batch") await super().enqueue_index_file_batch(request) @@ -2494,7 +2495,7 @@ async def index_file(self, file_path: str, *, source: str) -> FileIndexResult: class RuntimeFactoryEntityRepository: project_id: int | None = 12 - def select(self, *entities: Any) -> Select: + def select(self, *entities: Any) -> Select[Any]: # Runtime-factory composition tests never run a watermark scan, so the # stat-projection query builder is unused here. raise NotImplementedError diff --git a/tests/index/test_local_watch_orchestration.py b/tests/index/test_local_watch_orchestration.py index 745c95ef4..3f86a8693 100644 --- a/tests/index/test_local_watch_orchestration.py +++ b/tests/index/test_local_watch_orchestration.py @@ -4,7 +4,7 @@ from dataclasses import dataclass, field from pathlib import Path from types import SimpleNamespace -from typing import cast +from typing import override, cast import pytest from watchfiles import Change @@ -52,6 +52,7 @@ class RecordingProjectResolver(StorageEventProjectResolver): project_path: str requested_paths: list[str] = field(default_factory=list) + @override async def resolve_project(self, project_path: str) -> ProjectRuntimeReference | None: self.requested_paths.append(project_path) if project_path != self.project_path: @@ -87,6 +88,7 @@ async def event_failed( class RecordingProcessorFactory(StorageEventOperationProcessorFactory): processor: RecordingProcessor + @override def processor_for_project( self, project: ProjectRuntimeReference, diff --git a/tests/index/test_storage_event_operation_processor.py b/tests/index/test_storage_event_operation_processor.py index d263dc209..a17020cdb 100644 --- a/tests/index/test_storage_event_operation_processor.py +++ b/tests/index/test_storage_event_operation_processor.py @@ -18,6 +18,7 @@ StorageObjectIdentity, StorageObjectVersion, ) +from typing import override def storage_event( @@ -61,6 +62,7 @@ class ProjectOperationContext: class RecordingIndexRunner(StorageEventIndexOperationRunner[ProjectOperationContext]): calls: list[tuple[str, str]] = field(default_factory=list) + @override async def index_file( self, context: ProjectOperationContext, @@ -75,6 +77,7 @@ class RecordingDeleteResourcesFactory( ): calls: list[str] = field(default_factory=list) + @override async def create_delete_resources(self, context: ProjectOperationContext) -> str: self.calls.append(context.project_name) return f"{context.project_name}-resources" @@ -84,6 +87,7 @@ async def create_delete_resources(self, context: ProjectOperationContext) -> str class RecordingDeleteRunner(StorageEventDeleteOperationRunner[ProjectOperationContext, str, str]): calls: list[tuple[str, str, str]] = field(default_factory=list) + @override async def delete_file( self, context: ProjectOperationContext, @@ -101,6 +105,7 @@ class RecordingOperationObserver(StorageEventOperationObserver[ProjectOperationC deleted: list[tuple[str, str]] = field(default_factory=list) failed: list[tuple[str, str]] = field(default_factory=list) + @override async def skip_event( self, context: ProjectOperationContext, @@ -110,6 +115,7 @@ async def skip_event( raise AssertionError("skip operation missing reason") self.skipped.append((context.project_name, operation.skip_reason)) + @override async def delete_file_completed( self, context: ProjectOperationContext, @@ -118,6 +124,7 @@ async def delete_file_completed( ) -> None: self.deleted.append((operation.require_relative_path(), result)) + @override async def event_failed( self, context: ProjectOperationContext, diff --git a/tests/index/test_storage_event_orchestration.py b/tests/index/test_storage_event_orchestration.py index cbf9f7a5a..b7669a992 100644 --- a/tests/index/test_storage_event_orchestration.py +++ b/tests/index/test_storage_event_orchestration.py @@ -24,6 +24,7 @@ StorageObjectIdentity, StorageObjectVersion, ) +from typing import override def storage_event( @@ -57,6 +58,7 @@ class RecordingProjectResolver(StorageEventProjectResolver): projects_by_path: dict[str, ProjectRuntimeReference] requested_paths: list[str] = field(default_factory=list) + @override async def resolve_project(self, project_path: str) -> ProjectRuntimeReference | None: self.requested_paths.append(project_path) return self.projects_by_path.get(project_path) @@ -96,6 +98,7 @@ class RecordingProcessorFactory(StorageEventOperationProcessorFactory): fail_relative_path: str | None = None processors: list[RecordingStorageEventProcessor] = field(default_factory=list) + @override def processor_for_project( self, project: ProjectRuntimeReference, @@ -126,6 +129,7 @@ class RecordingBucketContextResolver(StorageEventBucketContextResolver[BucketRun contexts_by_bucket: dict[StorageBucketName, BucketRuntimeContext] requested_buckets: list[tuple[StorageBucketName, int]] = field(default_factory=list) + @override async def resolve_bucket_context( self, bucket_name: StorageBucketName, @@ -144,6 +148,7 @@ class RecordingBucketContextProcessor(StorageEventBucketContextProcessor[BucketR calls: list[tuple[StorageBucketName, str, tuple[str, ...]]] = field(default_factory=list) failures: list[tuple[StorageBucketName, int, str]] = field(default_factory=list) + @override async def process_bucket_context_events( self, bucket_name: StorageBucketName, @@ -157,6 +162,7 @@ async def process_bucket_context_events( raise RuntimeError("bucket context failed") return RuntimeJobCounts(processed=len(events)) + @override async def bucket_failed( self, bucket_name: StorageBucketName, diff --git a/tests/index/test_watch_service.py b/tests/index/test_watch_service.py index f05a460c6..e83d05e37 100644 --- a/tests/index/test_watch_service.py +++ b/tests/index/test_watch_service.py @@ -4,7 +4,7 @@ import asyncio from types import SimpleNamespace -from typing import cast +from typing import override, cast import pytest @@ -23,6 +23,7 @@ async def test_handle_changes_isolated_contains_one_project_failure( handled: list[str] = [] class FailingWatchService(WatchService): + @override async def handle_changes(self, project, changes) -> None: # type: ignore[override] handled.append(project.name) if project.name == "boom": diff --git a/tests/indexing/test_accepted_note_mutation_runner.py b/tests/indexing/test_accepted_note_mutation_runner.py index a4621509e..1320e6c51 100644 --- a/tests/indexing/test_accepted_note_mutation_runner.py +++ b/tests/indexing/test_accepted_note_mutation_runner.py @@ -7,7 +7,7 @@ from datetime import UTC, datetime from pathlib import Path from types import SimpleNamespace -from typing import cast +from typing import Any, cast from unittest.mock import AsyncMock from uuid import UUID @@ -191,7 +191,7 @@ def __init__( str | None, int, bool, - dict | None, + dict[str, Any] | None, AsyncSession | None, ] ] = [] @@ -241,7 +241,7 @@ async def prepare_edit_entity_content( find_text: str | None = None, expected_replacements: int = 1, replace_subsections: bool = True, - metadata: dict | None = None, + metadata: dict[str, Any] | None = None, session: AsyncSession | None = None, ) -> PreparedEntityWrite: self.edit_calls.append( diff --git a/tests/indexing/test_accepted_note_write_runner.py b/tests/indexing/test_accepted_note_write_runner.py index a89b446a0..fbaadb2bb 100644 --- a/tests/indexing/test_accepted_note_write_runner.py +++ b/tests/indexing/test_accepted_note_write_runner.py @@ -7,7 +7,7 @@ from datetime import UTC, datetime from hashlib import sha256 from pathlib import Path -from typing import cast +from typing import Any, cast import pytest from sqlalchemy.ext.asyncio import AsyncSession @@ -264,7 +264,7 @@ def __init__(self, prepared: PreparedEntityWrite) -> None: str | None, int, bool, - dict | None, + dict[str, Any] | None, AsyncSession | None, ] ] = [] @@ -280,7 +280,7 @@ async def prepare_edit_entity_content( find_text: str | None = None, expected_replacements: int = 1, replace_subsections: bool = True, - metadata: dict | None = None, + metadata: dict[str, Any] | None = None, session: AsyncSession | None = None, ) -> PreparedEntityWrite: self.calls.append( diff --git a/tests/indexing/test_project_index_maintenance.py b/tests/indexing/test_project_index_maintenance.py index 6f06eafc9..994364856 100644 --- a/tests/indexing/test_project_index_maintenance.py +++ b/tests/indexing/test_project_index_maintenance.py @@ -4,7 +4,7 @@ from contextlib import asynccontextmanager from dataclasses import dataclass, field from types import SimpleNamespace -from typing import cast +from typing import override, cast import basic_memory.indexing.project_index_maintenance as project_index_maintenance_module import basic_memory.repository.accepted_note_vector_cleanup as accepted_note_vector_cleanup_module @@ -1093,6 +1093,7 @@ async def fake_scoped_session( class FailingUpdateProjectIndexSession(FakeProjectIndexSession): """Fail the batch on its first UPDATE, simulating an intra-batch rollback.""" + @override async def execute( self, statement: object, diff --git a/tests/indexing/test_relation_resolution.py b/tests/indexing/test_relation_resolution.py index 0b71dd75b..f4ff440fd 100644 --- a/tests/indexing/test_relation_resolution.py +++ b/tests/indexing/test_relation_resolution.py @@ -3,7 +3,7 @@ from collections.abc import Mapping, Sequence from dataclasses import FrozenInstanceError, dataclass from datetime import timedelta -from typing import cast +from typing import override, cast import pytest from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker @@ -250,6 +250,7 @@ async def index_entities( class MissingFileEntityIndexer(StubEntityIndexer): """Model SearchService's disk fallback for entities without accepted content.""" + @override async def index_entities( self, entities: Sequence[Entity], @@ -650,6 +651,7 @@ def __init__( super().__init__(targets) self.ambiguous = ambiguous + @override async def resolve_link( self, link_text: str, diff --git a/tests/mcp/test_async_client_telemetry.py b/tests/mcp/test_async_client_telemetry.py index 57dd9003f..b2f5d17bf 100644 --- a/tests/mcp/test_async_client_telemetry.py +++ b/tests/mcp/test_async_client_telemetry.py @@ -8,13 +8,14 @@ import logfire import pytest +from typing import Any async_client_module = importlib.import_module("basic_memory.mcp.async_client") @pytest.mark.asyncio async def test_resolve_cloud_token_emits_failure_span(monkeypatch) -> None: - spans: list[tuple[str, dict]] = [] + spans: list[tuple[str, dict[str, Any]]] = [] error_messages: list[str] = [] class FakeAuth: diff --git a/tests/mcp/test_client_telemetry.py b/tests/mcp/test_client_telemetry.py index f589c02b1..df6a90ddd 100644 --- a/tests/mcp/test_client_telemetry.py +++ b/tests/mcp/test_client_telemetry.py @@ -9,6 +9,7 @@ import logfire import pytest from mcp.server.fastmcp.exceptions import ToolError +from typing import Any knowledge_client_module = importlib.import_module("basic_memory.mcp.clients.knowledge") search_client_module = importlib.import_module("basic_memory.mcp.clients.search") @@ -16,7 +17,7 @@ def _capture_spans(): - spans: list[tuple[str, dict]] = [] + spans: list[tuple[str, dict[str, Any]]] = [] @contextmanager def fake_span(name: str, **attrs): @@ -26,7 +27,7 @@ class FakeSpan: def set_attribute(self, key: str, value) -> None: attrs[key] = value - def set_attributes(self, new_attrs: dict) -> None: + def set_attributes(self, new_attrs: dict[str, Any]) -> None: attrs.update(new_attrs) yield FakeSpan() diff --git a/tests/mcp/test_project_context_telemetry.py b/tests/mcp/test_project_context_telemetry.py index 88d9e92bf..4097fb330 100644 --- a/tests/mcp/test_project_context_telemetry.py +++ b/tests/mcp/test_project_context_telemetry.py @@ -11,12 +11,13 @@ from basic_memory.config import ProjectEntry from basic_memory.schemas.cloud import WorkspaceInfo from tests.mcp.conftest import ContextState, ctx +from typing import Any project_context = importlib.import_module("basic_memory.mcp.project_context") def _capture_spans(): - spans: list[tuple[str, dict]] = [] + spans: list[tuple[str, dict[str, Any]]] = [] @contextmanager def fake_span(name: str, **attrs): diff --git a/tests/mcp/test_server_telemetry.py b/tests/mcp/test_server_telemetry.py index 65968d944..465c398ff 100644 --- a/tests/mcp/test_server_telemetry.py +++ b/tests/mcp/test_server_telemetry.py @@ -8,11 +8,12 @@ import pytest from basic_memory.mcp.server import lifespan, mcp +from typing import Any @pytest.mark.asyncio async def test_mcp_lifespan_wraps_startup_and_shutdown(config_manager, monkeypatch) -> None: - spans: list[tuple[str, dict]] = [] + spans: list[tuple[str, dict[str, Any]]] = [] @contextmanager def fake_span(name: str, **attrs): diff --git a/tests/mcp/test_tool_read_note.py b/tests/mcp/test_tool_read_note.py index 9229c8b9c..610f1912a 100644 --- a/tests/mcp/test_tool_read_note.py +++ b/tests/mcp/test_tool_read_note.py @@ -10,6 +10,7 @@ from basic_memory.mcp.tools import write_note, read_note from basic_memory.mcp.tools.read_note import _parse_opening_frontmatter from tests.mcp.conftest import ContextState, ctx +from typing import override def test_parse_opening_frontmatter_handles_crlf(): @@ -56,6 +57,7 @@ async def test_read_note_title_search_fallback_fetches_by_permalink(monkeypatch, direct_identifier = memory_url_path("Fallback Title Note") class SelectiveKnowledgeClient(OriginalKnowledgeClient): + @override async def resolve_entity(self, identifier: str, *, strict: bool = False) -> str: # Fail on the direct identifier to force fallback to title search if identifier == direct_identifier: @@ -108,6 +110,7 @@ async def fake_search_notes_fn(*, query, search_type, **kwargs): # Ensure direct resolution doesn't short-circuit the fallback logic. class FailingKnowledgeClient(OriginalKnowledgeClient): + @override async def resolve_entity(self, identifier: str, *, strict: bool = False) -> str: raise RuntimeError("force fallback") @@ -168,6 +171,7 @@ async def fake_search_notes_fn(*, query, search_type, page, page_size, **kwargs) return {"results": [], "current_page": page, "page_size": page_size} class FailingKnowledgeClient(OriginalKnowledgeClient): + @override async def resolve_entity(self, identifier: str, *, strict: bool = False) -> str: raise RuntimeError("force fallback") @@ -208,6 +212,7 @@ async def test_read_note_title_fallback_finds_exact_match_on_later_page( direct_identifier = memory_url_path("Paged Title Note") class SelectiveKnowledgeClient(OriginalKnowledgeClient): + @override async def resolve_entity(self, identifier: str, *, strict: bool = False) -> str: # Fail on the direct identifier to force fallback to title search if identifier == direct_identifier: @@ -252,6 +257,7 @@ async def test_read_note_title_fallback_finds_exact_match_with_small_page_size( direct_identifier = memory_url_path("Foo Bar") class SelectiveKnowledgeClient(OriginalKnowledgeClient): + @override async def resolve_entity(self, identifier: str, *, strict: bool = False) -> str: # Fail on the direct identifier to force fallback to title search if identifier == direct_identifier: @@ -315,6 +321,7 @@ async def test_read_note_title_fallback_pages_past_higher_ranked_fuzzy_titles( direct_identifier = memory_url_path("Deep Page Note") class SelectiveKnowledgeClient(OriginalKnowledgeClient): + @override async def resolve_entity(self, identifier: str, *, strict: bool = False) -> str: # Fail on the direct identifier to force fallback to title search if identifier == direct_identifier: @@ -362,6 +369,7 @@ async def fake_search_notes_fn(*, query, search_type, page, page_size, **kwargs) return {"results": [], "current_page": page, "page_size": page_size} class FailingKnowledgeClient(OriginalKnowledgeClient): + @override async def resolve_entity(self, identifier: str, *, strict: bool = False) -> str: raise RuntimeError("force fallback") @@ -402,6 +410,7 @@ async def fake_search_notes_fn(*, query, search_type, **kwargs): return {"results": candidates, "current_page": 1, "page_size": 10} class FailingKnowledgeClient(OriginalKnowledgeClient): + @override async def resolve_entity(self, identifier: str, *, strict: bool = False) -> str: raise RuntimeError("force fallback") @@ -437,6 +446,7 @@ async def test_read_note_title_fallback_requires_exact_title_match(monkeypatch, OriginalKnowledgeClient = clients_mod.KnowledgeClient class StrictFailingKnowledgeClient(OriginalKnowledgeClient): + @override async def resolve_entity(self, identifier: str, *, strict: bool = False) -> str: if strict: raise RuntimeError("force strict direct lookup failure") @@ -951,6 +961,7 @@ async def test_read_note_memory_url_fallback_uses_search_tool_normalization( search_calls: list[tuple[str, str, str | None]] = [] class SelectiveKnowledgeClient(OriginalKnowledgeClient): + @override async def resolve_entity(self, identifier: str, *, strict: bool = False) -> str: if strict and identifier.endswith("test/memory-url-fallback-note"): raise RuntimeError("force direct lookup failure") diff --git a/tests/mcp/test_tool_search.py b/tests/mcp/test_tool_search.py index 45d64303d..21b6ad53c 100644 --- a/tests/mcp/test_tool_search.py +++ b/tests/mcp/test_tool_search.py @@ -6,7 +6,7 @@ from contextlib import asynccontextmanager from datetime import datetime, timedelta from types import SimpleNamespace -from typing import cast +from typing import Any, cast from pydantic import TypeAdapter @@ -719,7 +719,7 @@ async def fake_resolve_project_and_path( ): return StubProject(), identifier, False - captured_payload: dict = {} + captured_payload: dict[str, Any] = {} class MockSearchClient: def __init__(self, *args, **kwargs): @@ -771,7 +771,7 @@ async def fake_resolve_project_and_path( ): return StubProject(), identifier, False - captured_payload: dict = {} + captured_payload: dict[str, Any] = {} class MockSearchClient: def __init__(self, *args, **kwargs): @@ -817,7 +817,7 @@ class StubProject: async def fake_get_project_client(*args, **kwargs): yield (object(), StubProject()) - captured_payload: dict = {} + captured_payload: dict[str, Any] = {} class MockSearchClient: def __init__(self, *args, **kwargs): @@ -860,7 +860,7 @@ class StubProject: async def fake_get_project_client(*args, **kwargs): yield (object(), StubProject()) - captured_payload: dict = {} + captured_payload: dict[str, Any] = {} class MockSearchClient: def __init__(self, *args, **kwargs): @@ -972,7 +972,7 @@ async def fake_resolve_project_and_path( ): return StubProject(), identifier, False - captured_payload: dict = {} + captured_payload: dict[str, Any] = {} class MockSearchClient: def __init__(self, *args, **kwargs): @@ -1019,7 +1019,7 @@ async def fake_resolve_project_and_path( ): return StubProject(), identifier, False - captured_payload: dict = {} + captured_payload: dict[str, Any] = {} class MockSearchClient: def __init__(self, *args, **kwargs): @@ -1081,7 +1081,7 @@ async def fake_resolve_project_and_path( ): return StubProject(), identifier, False - captured_payload: dict = {} + captured_payload: dict[str, Any] = {} class MockSearchClient: def __init__(self, *args, **kwargs): @@ -1143,7 +1143,7 @@ async def fake_resolve_project_and_path( ): return StubProject(), identifier, False - captured_payload: dict = {} + captured_payload: dict[str, Any] = {} class MockSearchClient: def __init__(self, *args, **kwargs): @@ -1203,7 +1203,7 @@ async def fake_resolve_project_and_path( ): return StubProject(), identifier, False - captured_payload: dict = {} + captured_payload: dict[str, Any] = {} class MockSearchClient: def __init__(self, *args, **kwargs): @@ -1269,7 +1269,7 @@ async def fake_resolve_project_and_path( ): return StubProject(), identifier, False - captured_payload: dict = {} + captured_payload: dict[str, Any] = {} class MockSearchClient: def __init__(self, *args, **kwargs): @@ -1338,7 +1338,7 @@ async def fake_resolve_project_and_path( ): return StubProject(), identifier, False - captured_payload: dict = {} + captured_payload: dict[str, Any] = {} class MockSearchClient: def __init__(self, *args, **kwargs): @@ -1382,7 +1382,7 @@ async def fake_resolve_project_and_path( ): return StubProject(), identifier, False - captured_payload: dict = {} + captured_payload: dict[str, Any] = {} class MockSearchClient: def __init__(self, *args, **kwargs): @@ -1430,7 +1430,7 @@ async def fake_resolve_project_and_path( ): return StubProject(), identifier, False - captured_payload: dict = {} + captured_payload: dict[str, Any] = {} class MockSearchClient: def __init__(self, *args, **kwargs): @@ -1473,7 +1473,7 @@ class StubProject: async def fake_get_project_client(*args, **kwargs): yield (object(), StubProject()) - captured_payload: dict = {} + captured_payload: dict[str, Any] = {} class MockSearchClient: def __init__(self, *args, **kwargs): @@ -1514,7 +1514,7 @@ class StubProject: async def fake_get_project_client(*args, **kwargs): yield (object(), StubProject()) - captured_payload: dict = {} + captured_payload: dict[str, Any] = {} class MockSearchClient: def __init__(self, *args, **kwargs): @@ -1555,7 +1555,7 @@ class StubProject: async def fake_get_project_client(*args, **kwargs): yield (object(), StubProject()) - captured_payload: dict = {} + captured_payload: dict[str, Any] = {} class MockSearchClient: def __init__(self, *args, **kwargs): @@ -1596,7 +1596,7 @@ class StubProject: async def fake_get_project_client(*args, **kwargs): yield (object(), StubProject()) - captured_payload: dict = {} + captured_payload: dict[str, Any] = {} class MockSearchClient: def __init__(self, *args, **kwargs): @@ -2002,7 +2002,7 @@ async def fake_resolve_project_and_path( ): return StubProject(), identifier, False - captured_payload: dict = {} + captured_payload: dict[str, Any] = {} class MockSearchClient: def __init__(self, *args, **kwargs): @@ -2047,7 +2047,7 @@ async def fake_resolve_project_and_path( ): return StubProject(), identifier, False - captured_payload: dict = {} + captured_payload: dict[str, Any] = {} class MockSearchClient: def __init__(self, *args, **kwargs): diff --git a/tests/mcp/test_tool_telemetry.py b/tests/mcp/test_tool_telemetry.py index d50139cd9..36bcc96a2 100644 --- a/tests/mcp/test_tool_telemetry.py +++ b/tests/mcp/test_tool_telemetry.py @@ -7,6 +7,7 @@ import logfire import pytest +from typing import Any build_context_module = importlib.import_module("basic_memory.mcp.tools.build_context") edit_note_module = importlib.import_module("basic_memory.mcp.tools.edit_note") @@ -18,7 +19,7 @@ class _NoopSpan: """Minimal stand-in for a live logfire span during tests.""" - def set_attributes(self, attrs: dict) -> None: + def set_attributes(self, attrs: dict[str, Any]) -> None: pass def set_attribute(self, key: str, value) -> None: @@ -26,7 +27,7 @@ def set_attribute(self, key: str, value) -> None: def _recording_spans(): - spans: list[tuple[str, dict]] = [] + spans: list[tuple[str, dict[str, Any]]] = [] @contextmanager def fake_span(name: str, **attrs): @@ -36,7 +37,9 @@ def fake_span(name: str, **attrs): return spans, fake_span -def _contains_span_attrs(spans: list[tuple[str, dict]], name: str, expected: dict) -> bool: +def _contains_span_attrs( + spans: list[tuple[str, dict[str, Any]]], name: str, expected: dict[str, Any] +) -> bool: return any( span_name == name and expected.items() <= attrs.items() for span_name, attrs in spans ) diff --git a/tests/mcp/test_tool_utils.py b/tests/mcp/test_tool_utils.py index 605e19b55..d11d240de 100644 --- a/tests/mcp/test_tool_utils.py +++ b/tests/mcp/test_tool_utils.py @@ -46,7 +46,7 @@ def raise_for_status(self): class _Client: def __init__(self): - self.calls: list[tuple[str, tuple, dict]] = [] + self.calls: list[tuple[str, tuple[Any, ...], dict[str, Any]]] = [] self._responses: dict[str, object] = {} def set_response(self, method: str, response): diff --git a/tests/mcp/test_tool_utils_cloud_auth.py b/tests/mcp/test_tool_utils_cloud_auth.py index dc182b19d..9c412be8d 100644 --- a/tests/mcp/test_tool_utils_cloud_auth.py +++ b/tests/mcp/test_tool_utils_cloud_auth.py @@ -10,7 +10,7 @@ class _MockResponse: - def __init__(self, status_code: int, payload: dict): + def __init__(self, status_code: int, payload: dict[str, Any]): self.status_code = status_code self._payload = payload self.is_success = status_code < 400 diff --git a/tests/mcp/tools/test_chatgpt_tools.py b/tests/mcp/tools/test_chatgpt_tools.py index 394a252ef..2ada010f4 100644 --- a/tests/mcp/tools/test_chatgpt_tools.py +++ b/tests/mcp/tools/test_chatgpt_tools.py @@ -86,7 +86,7 @@ async def test_search_uses_dynamic_default_search_type( """ChatGPT adapter should not hardcode search_type so search_notes can pick defaults.""" import basic_memory.mcp.tools.chatgpt_tools as chatgpt_tools - captured_kwargs: dict = {} + captured_kwargs: dict[str, Any] = {} async def fake_search_notes_fn(*args, **kwargs): captured_kwargs.update(kwargs) @@ -108,7 +108,7 @@ async def test_search_delegates_to_search_notes_without_project_iteration( """ChatGPT search is only a compatibility wrapper around search_notes.""" import basic_memory.mcp.tools.chatgpt_tools as chatgpt_tools - captured_kwargs: dict = {} + captured_kwargs: dict[str, Any] = {} async def fake_search_notes_fn(*args, **kwargs): captured_kwargs.update(kwargs) diff --git a/tests/repository/test_explicit_session_transactions.py b/tests/repository/test_explicit_session_transactions.py index 40c68f2ed..3fefa351d 100644 --- a/tests/repository/test_explicit_session_transactions.py +++ b/tests/repository/test_explicit_session_transactions.py @@ -11,9 +11,10 @@ from basic_memory.repository.observation_repository import ObservationRepository from basic_memory.repository.project_repository import ProjectRepository from basic_memory.repository.relation_repository import RelationRepository +from typing import Any -def _entity_payload(project_id: int, title: str, file_path: str) -> dict: +def _entity_payload(project_id: int, title: str, file_path: str) -> dict[str, Any]: """Build the minimal entity payload used by transaction tests.""" return { "project_id": project_id, @@ -27,7 +28,7 @@ def _entity_payload(project_id: int, title: str, file_path: str) -> dict: } -def _note_content_payload(entity_id: int) -> dict: +def _note_content_payload(entity_id: int) -> dict[str, Any]: """Build the minimal note_content payload used by transaction tests.""" return { "entity_id": entity_id, diff --git a/tests/repository/test_fastembed_provider.py b/tests/repository/test_fastembed_provider.py index af486c5a0..703e32d5e 100644 --- a/tests/repository/test_fastembed_provider.py +++ b/tests/repository/test_fastembed_provider.py @@ -9,6 +9,7 @@ from basic_memory.repository.fastembed_provider import FastEmbedEmbeddingProvider from basic_memory.repository.semantic_errors import SemanticDependenciesMissingError +from typing import Any class _StubVector: @@ -21,8 +22,8 @@ def tolist(self): class _StubTextEmbedding: init_count = 0 - last_init_kwargs: dict = {} - last_embed_kwargs: dict = {} + last_init_kwargs: dict[str, Any] = {} + last_embed_kwargs: dict[str, Any] = {} def __init__( self, diff --git a/tests/repository/test_fastembed_rerank_provider.py b/tests/repository/test_fastembed_rerank_provider.py index 5c9e1ccf1..c3f8daec6 100644 --- a/tests/repository/test_fastembed_rerank_provider.py +++ b/tests/repository/test_fastembed_rerank_provider.py @@ -14,11 +14,12 @@ RerankTransientError, SemanticDependenciesMissingError, ) +from typing import Any class _StubCrossEncoder: init_count = 0 - last_init_kwargs: dict = {} + last_init_kwargs: dict[str, Any] = {} def __init__(self, model_name: str, cache_dir: str | None = None, threads: int | None = None): _StubCrossEncoder.last_init_kwargs = { diff --git a/tests/repository/test_hybrid_fusion.py b/tests/repository/test_hybrid_fusion.py index 16a6a47ab..63806e95f 100644 --- a/tests/repository/test_hybrid_fusion.py +++ b/tests/repository/test_hybrid_fusion.py @@ -8,7 +8,7 @@ from dataclasses import dataclass from datetime import datetime -from typing import Any, Optional, cast +from typing import override, Any, Optional, cast from unittest.mock import AsyncMock, patch import pytest @@ -56,12 +56,15 @@ def __init__(self): self.session_maker = None self.project_id = 1 + @override async def init_search_index(self): pass # pragma: no cover + @override def _prepare_search_term(self, term, is_prefix=True): return term # pragma: no cover + @override async def search( self, search_text: Optional[str] = None, @@ -81,18 +84,23 @@ async def search( ) -> list[SearchIndexRow]: return [] # pragma: no cover + @override async def _ensure_vector_tables(self): pass # pragma: no cover + @override async def _run_vector_query(self, session, query_embedding, candidate_limit): return [] # pragma: no cover + @override async def _write_embeddings(self, session, jobs, embeddings): pass # pragma: no cover + @override async def _delete_entity_chunks(self, session, entity_id, *, expected_deletions=None): return [] # pragma: no cover + @override async def _delete_stale_chunks( self, session, @@ -106,6 +114,7 @@ async def _delete_stale_chunks( async def _update_timestamp_sql(self): return "CURRENT_TIMESTAMP" # pragma: no cover + @override def _distance_to_similarity(self, distance: float) -> float: return 1.0 / (1.0 + max(distance, 0.0)) # pragma: no cover diff --git a/tests/repository/test_litellm_provider.py b/tests/repository/test_litellm_provider.py index 912bdb984..a89fb587a 100644 --- a/tests/repository/test_litellm_provider.py +++ b/tests/repository/test_litellm_provider.py @@ -16,6 +16,7 @@ ) from basic_memory.repository.litellm_provider import LiteLLMEmbeddingProvider from basic_memory.repository.semantic_errors import SemanticDependenciesMissingError +from typing import Any def _make_embedding_response(inputs: list[str], dim: int = 3): @@ -34,7 +35,7 @@ def _make_embedding_response(inputs: list[str], dim: int = 3): def _install_litellm_stub(monkeypatch, dim: int = 3): """Install a fake litellm module and return the mock aembedding callable.""" - calls: list[dict] = [] + calls: list[dict[str, Any]] = [] async def _aembedding(**kwargs): calls.append(kwargs) diff --git a/tests/repository/test_litellm_rerank_provider.py b/tests/repository/test_litellm_rerank_provider.py index a6d89d481..5f238e9a4 100644 --- a/tests/repository/test_litellm_rerank_provider.py +++ b/tests/repository/test_litellm_rerank_provider.py @@ -10,6 +10,7 @@ RerankProviderContractError, RerankTransientError, ) +from typing import Any class _Response: @@ -26,10 +27,10 @@ class _BadGatewayError(RuntimeError): class _SDKRerankResponse(BaseModel): - results: list[dict] + results: list[dict[str, Any]] -def _fake_litellm(response, recorder: dict, *, exc: Exception | None = None): +def _fake_litellm(response, recorder: dict[str, Any], *, exc: Exception | None = None): async def arerank(**params): recorder.update(params) if exc is not None: @@ -50,7 +51,7 @@ async def arerank(**params): @pytest.mark.asyncio async def test_rerank_realigns_out_of_order_indexed_results(monkeypatch): """Rerank responses are indexed and may arrive out of order; realign to input.""" - recorder: dict = {} + recorder: dict[str, Any] = {} response = _Response( [ {"index": 2, "relevance_score": 0.9}, @@ -73,7 +74,7 @@ async def test_rerank_realigns_out_of_order_indexed_results(monkeypatch): @pytest.mark.asyncio async def test_rerank_forwards_routing_params(monkeypatch): - recorder: dict = {} + recorder: dict[str, Any] = {} response = _Response( [{"index": 0, "relevance_score": 0.7}, {"index": 1, "relevance_score": 0.2}] ) diff --git a/tests/repository/test_milvus_index.py b/tests/repository/test_milvus_index.py index 7b2e1d766..c87160792 100644 --- a/tests/repository/test_milvus_index.py +++ b/tests/repository/test_milvus_index.py @@ -5,7 +5,7 @@ import asyncio import threading from collections.abc import Iterator, Sequence -from typing import Any +from typing import override, Any import pytest @@ -118,6 +118,7 @@ def _block_mutation(self) -> None: if not self.release_mutation.wait(timeout=5): raise TimeoutError("test did not release the Milvus mutation") + @override def upsert( self, collection_name: str, @@ -126,6 +127,7 @@ def upsert( self._block_mutation() super().upsert(collection_name, records) + @override def delete_records( self, collection_name: str, @@ -134,10 +136,12 @@ def delete_records( self._block_mutation() super().delete_records(collection_name, records) + @override def delete_entity(self, collection_name: str, entity_id: int) -> None: self._block_mutation() super().delete_entity(collection_name, entity_id) + @override def delete_ids(self, collection_name: str, record_ids: Sequence[str]) -> None: self._block_mutation() super().delete_ids(collection_name, record_ids) diff --git a/tests/repository/test_note_content_repository.py b/tests/repository/test_note_content_repository.py index 8e7358a7a..5d9b7402e 100644 --- a/tests/repository/test_note_content_repository.py +++ b/tests/repository/test_note_content_repository.py @@ -13,9 +13,10 @@ NoteContentVersionConflict, ) from basic_memory.repository.project_repository import ProjectRepository +from typing import Any -def build_note_content_payload(entity_id: int) -> dict: +def build_note_content_payload(entity_id: int) -> dict[str, Any]: """Build a minimal payload for note_content writes.""" return { "entity_id": entity_id, diff --git a/tests/repository/test_note_file_vacate_repository.py b/tests/repository/test_note_file_vacate_repository.py index 775fbb1d1..b28b991be 100644 --- a/tests/repository/test_note_file_vacate_repository.py +++ b/tests/repository/test_note_file_vacate_repository.py @@ -4,7 +4,7 @@ import pytest import pytest_asyncio -from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine +from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine from basic_memory.models.base import Base from basic_memory.repository.note_file_vacate_repository import ( @@ -14,7 +14,7 @@ @pytest_asyncio.fixture -async def session_maker() -> AsyncIterator[async_sessionmaker]: +async def session_maker() -> AsyncIterator[async_sessionmaker[AsyncSession]]: engine = create_async_engine("sqlite+aiosqlite://") async with engine.begin() as conn: await conn.run_sync(Base.metadata.create_all) diff --git a/tests/repository/test_observation_repository.py b/tests/repository/test_observation_repository.py index 25a40afb2..8978c0317 100644 --- a/tests/repository/test_observation_repository.py +++ b/tests/repository/test_observation_repository.py @@ -4,7 +4,7 @@ import pytest import pytest_asyncio -from sqlalchemy.ext.asyncio import async_sessionmaker +from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker from sqlalchemy.exc import IntegrityError from basic_memory import db @@ -97,7 +97,9 @@ async def test_find_by_context( @pytest.mark.asyncio -async def test_delete_observations(session_maker: async_sessionmaker, repo, test_project: Project): +async def test_delete_observations( + session_maker: async_sessionmaker[AsyncSession], repo, test_project: Project +): """Test deleting observations by entity_id.""" # Create test entity async with db.scoped_session(session_maker) as session: @@ -139,7 +141,7 @@ async def test_delete_observations(session_maker: async_sessionmaker, repo, test @pytest.mark.asyncio async def test_delete_observation_by_id( - session_maker: async_sessionmaker, repo, test_project: Project + session_maker: async_sessionmaker[AsyncSession], repo, test_project: Project ): """Test deleting a single observation by its ID.""" # Create test entity @@ -177,7 +179,7 @@ async def test_delete_observation_by_id( @pytest.mark.asyncio async def test_delete_observation_by_content( - session_maker: async_sessionmaker, repo, test_project: Project + session_maker: async_sessionmaker[AsyncSession], repo, test_project: Project ): """Test deleting observations by content.""" # Create test entity @@ -220,7 +222,9 @@ async def test_delete_observation_by_content( @pytest.mark.asyncio -async def test_find_by_category(session_maker: async_sessionmaker, repo, test_project: Project): +async def test_find_by_category( + session_maker: async_sessionmaker[AsyncSession], repo, test_project: Project +): """Test finding observations by their category.""" # Create test entity async with db.scoped_session(session_maker) as session: @@ -284,7 +288,7 @@ async def test_find_by_category(session_maker: async_sessionmaker, repo, test_pr @pytest.mark.asyncio async def test_observation_categories( - session_maker: async_sessionmaker, repo, test_project: Project + session_maker: async_sessionmaker[AsyncSession], repo, test_project: Project ): """Test retrieving distinct observation categories.""" # Create test entity @@ -355,7 +359,7 @@ async def test_find_by_category_with_empty_db(repo, session_maker): @pytest.mark.asyncio async def test_find_by_category_case_sensitivity( - session_maker: async_sessionmaker, repo, test_project: Project + session_maker: async_sessionmaker[AsyncSession], repo, test_project: Project ): """Test how category search handles case sensitivity.""" async with db.scoped_session(session_maker) as session: @@ -395,7 +399,7 @@ async def test_find_by_category_case_sensitivity( @pytest.mark.asyncio async def test_observation_permalink_truncates_long_content( - session_maker: async_sessionmaker, repo, test_project: Project + session_maker: async_sessionmaker[AsyncSession], repo, test_project: Project ): """Test that observation permalinks truncate long content. @@ -452,7 +456,7 @@ async def test_observation_permalink_truncates_long_content( @pytest.mark.asyncio async def test_observation_permalink_short_content_unchanged( - session_maker: async_sessionmaker, repo, test_project: Project + session_maker: async_sessionmaker[AsyncSession], repo, test_project: Project ): """Test that short observation content is not unnecessarily truncated.""" async with db.scoped_session(session_maker) as session: @@ -489,7 +493,7 @@ async def test_observation_permalink_short_content_unchanged( @pytest.mark.asyncio async def test_observation_permalink_disambiguates_truncated_content( - session_maker: async_sessionmaker, repo, test_project: Project + session_maker: async_sessionmaker[AsyncSession], repo, test_project: Project ): """Regression test for issue #909: shared 200-char prefixes must not collide. diff --git a/tests/repository/test_postgres_search_repository.py b/tests/repository/test_postgres_search_repository.py index fcb100319..61f571067 100644 --- a/tests/repository/test_postgres_search_repository.py +++ b/tests/repository/test_postgres_search_repository.py @@ -20,6 +20,7 @@ from basic_memory.repository.semantic_errors import SemanticSearchDisabledError from basic_memory.repository.search_index_row import SearchIndexRow from basic_memory.schemas.search import SearchItemType, SearchRetrievalMode +from typing import override pytestmark = pytest.mark.postgres @@ -75,9 +76,11 @@ def __init__( query_input_type=query_input_type, ) + @override async def embed_query(self, text: str) -> list[float]: return StubEmbeddingProvider._vectorize(text) + @override async def embed_documents(self, texts: list[str]) -> list[list[float]]: return [StubEmbeddingProvider._vectorize(text) for text in texts] diff --git a/tests/repository/test_postgres_search_repository_unit.py b/tests/repository/test_postgres_search_repository_unit.py index 9c9fdfaa3..1303da36e 100644 --- a/tests/repository/test_postgres_search_repository_unit.py +++ b/tests/repository/test_postgres_search_repository_unit.py @@ -23,6 +23,7 @@ SemanticSearchDisabledError, SemanticVectorIndexExtensionError, ) +from typing import Any # --- Helpers --------------------------------------------------------------- @@ -442,7 +443,7 @@ async def _stub_flush(flush_jobs, entity_runtime, synced_entity_ids): synced_entity_ids.add(job.entity_id) return (3.0, 1.0) - completion_records: list[dict] = [] + completion_records: list[dict[str, Any]] = [] def _capture_log(**kwargs): completion_records.append(kwargs) @@ -528,7 +529,7 @@ async def _stub_flush(flush_jobs, entity_runtime, synced_entity_ids): runtime.write_seconds += 0.25 return (1.5, 0.75) - completion_records: list[dict] = [] + completion_records: list[dict[str, Any]] = [] def _capture_log(**kwargs): completion_records.append(kwargs) diff --git a/tests/repository/test_prefixing_provider.py b/tests/repository/test_prefixing_provider.py index 70d7fda57..12f15ccc7 100644 --- a/tests/repository/test_prefixing_provider.py +++ b/tests/repository/test_prefixing_provider.py @@ -1,7 +1,7 @@ """Tests for role-specific literal embedding text prefixes.""" import hashlib -from typing import Any +from typing import override, Any import pytest @@ -22,14 +22,17 @@ def __init__(self) -> None: self.document_calls: list[list[str]] = [] self.query_calls: list[str] = [] + @override async def embed_query(self, text: str) -> list[float]: self.query_calls.append(text) return [1.0, 0.0, 0.0] + @override async def embed_documents(self, texts: list[str]) -> list[list[float]]: self.document_calls.append(texts) return [[0.0, 1.0, 0.0] for _ in texts] + @override def runtime_log_attrs(self) -> dict[str, Any]: return {"provider_batch_size": 7} diff --git a/tests/repository/test_rerank_pipeline.py b/tests/repository/test_rerank_pipeline.py index 80dfe1b3b..76b805954 100644 --- a/tests/repository/test_rerank_pipeline.py +++ b/tests/repository/test_rerank_pipeline.py @@ -37,7 +37,7 @@ async def embed_query(self, text: str) -> list[float]: async def embed_documents(self, texts: list[str]) -> list[list[float]]: return [self._vectorize(t) for t in texts] - def runtime_log_attrs(self) -> dict: + def runtime_log_attrs(self) -> dict[str, Any]: return {} @staticmethod @@ -71,7 +71,7 @@ async def rerank(self, query: str, documents: list[str]) -> list[float]: scores.append(score) return scores - def runtime_log_attrs(self) -> dict: + def runtime_log_attrs(self) -> dict[str, Any]: return {} @@ -81,7 +81,7 @@ class _BadReranker: async def rerank(self, query: str, documents: list[str]) -> list[float]: return [] # deliberately misaligned (no exception) - def runtime_log_attrs(self) -> dict: + def runtime_log_attrs(self) -> dict[str, Any]: return {} @@ -93,7 +93,7 @@ class _ExplodingReranker: async def rerank(self, query: str, documents: list[str]) -> list[float]: raise RerankTransientError("cross-encoder backend unreachable") - def runtime_log_attrs(self) -> dict: + def runtime_log_attrs(self) -> dict[str, Any]: return {} @@ -111,7 +111,7 @@ async def rerank(self, query: str, documents: list[str]) -> list[float]: raise RerankTransientError("cross-encoder backend unreachable") return [0.1, 0.9] - def runtime_log_attrs(self) -> dict: + def runtime_log_attrs(self) -> dict[str, Any]: return {} @@ -126,7 +126,7 @@ def __init__(self, exc: Exception): async def rerank(self, query: str, documents: list[str]) -> list[float]: raise self._exc - def runtime_log_attrs(self) -> dict: + def runtime_log_attrs(self) -> dict[str, Any]: return {} diff --git a/tests/repository/test_rerank_provider_factory.py b/tests/repository/test_rerank_provider_factory.py index 705e5be41..62edeac95 100644 --- a/tests/repository/test_rerank_provider_factory.py +++ b/tests/repository/test_rerank_provider_factory.py @@ -13,6 +13,7 @@ create_rerank_provider, reset_rerank_provider_cache, ) +from typing import Any, override def _config(**overrides) -> BasicMemoryConfig: @@ -217,11 +218,12 @@ def test_concurrent_race_returns_winning_provider(monkeypatch): """ winner = object() - class _RacyCache(dict): + class _RacyCache(dict[str, Any]): def __init__(self): super().__init__() self._gets = 0 + @override def get(self, key, default=None): # First check (outside lock) misses so we build; second check (in lock) # finds the winner another thread inserted mid-flight. diff --git a/tests/repository/test_semantic_search_base.py b/tests/repository/test_semantic_search_base.py index f096e79e5..e45caced0 100644 --- a/tests/repository/test_semantic_search_base.py +++ b/tests/repository/test_semantic_search_base.py @@ -6,7 +6,7 @@ from contextlib import asynccontextmanager from datetime import datetime from types import SimpleNamespace -from typing import Any +from typing import override, Any from unittest.mock import AsyncMock, Mock import pytest @@ -51,12 +51,15 @@ def __init__(self): self.session_maker = None self.project_id = 1 + @override async def init_search_index(self): pass + @override def _prepare_search_term(self, term, is_prefix=True): return term + @override async def search( self, search_text: str | None = None, @@ -76,18 +79,23 @@ async def search( ) -> list[SearchIndexRow]: return [] + @override async def _ensure_vector_tables(self): pass + @override async def _run_vector_query(self, session, query_embedding, candidate_limit): return [] + @override async def _write_embeddings(self, session, jobs, embeddings): pass + @override async def _delete_entity_chunks(self, session, entity_id, *, expected_deletions=None): return [] + @override async def _delete_stale_chunks( self, session, @@ -101,6 +109,7 @@ async def _delete_stale_chunks( async def _update_timestamp_sql(self): return "CURRENT_TIMESTAMP" + @override def _distance_to_similarity(self, distance: float) -> float: return 1.0 / (1.0 + max(distance, 0.0)) @@ -1187,7 +1196,7 @@ async def _stub_flush(flush_jobs, entity_runtime, synced_entity_ids): synced_entity_ids.add(job.entity_id) return (3.0, 1.0) - logged_completion: list[dict] = [] + logged_completion: list[dict[str, Any]] = [] def _capture_log(**kwargs): logged_completion.append(kwargs) @@ -1298,8 +1307,8 @@ async def _stub_flush(flush_jobs, entity_runtime, synced_entity_ids): synced_entity_ids.add(job.entity_id) return (2.0, 1.0) - histogram_calls: list[tuple[str, float, dict]] = [] - counter_calls: list[tuple[str, float, dict]] = [] + histogram_calls: list[tuple[str, float, dict[str, Any]]] = [] + counter_calls: list[tuple[str, float, dict[str, Any]]] = [] perf_counter_values = iter([0.0, 3.0, 4.5, 6.0]) class _FakeHistogram: @@ -1372,7 +1381,7 @@ async def _stub_prepare_window(entity_ids: list[int]): for entity_id in entity_ids ] - info_calls: list[tuple[str, dict]] = [] + info_calls: list[tuple[str, dict[str, Any]]] = [] def _capture_info(message: str, **kwargs): info_calls.append((message, kwargs)) diff --git a/tests/repository/test_semantic_vector_sync.py b/tests/repository/test_semantic_vector_sync.py index 7c9d7dae2..9232dacbf 100644 --- a/tests/repository/test_semantic_vector_sync.py +++ b/tests/repository/test_semantic_vector_sync.py @@ -3,7 +3,7 @@ from contextlib import asynccontextmanager from datetime import datetime from types import SimpleNamespace -from typing import Any +from typing import override, Any from unittest.mock import AsyncMock, Mock import pytest @@ -30,12 +30,15 @@ def __init__(self): self.session_maker = None self.project_id = 1 + @override async def init_search_index(self): pass + @override def _prepare_search_term(self, term, is_prefix=True): return term + @override async def search( self, search_text: str | None = None, @@ -55,18 +58,23 @@ async def search( ) -> list[SearchIndexRow]: return [] + @override async def _ensure_vector_tables(self): pass + @override async def _run_vector_query(self, session, query_embedding, candidate_limit): return [] + @override async def _write_embeddings(self, session, jobs, embeddings): pass + @override async def _delete_entity_chunks(self, session, entity_id, *, expected_deletions=None): return [] + @override async def _delete_stale_chunks( self, session, @@ -77,6 +85,7 @@ async def _delete_stale_chunks( ): return [] + @override def _distance_to_similarity(self, distance: float) -> float: return 1.0 / (1.0 + max(distance, 0.0)) diff --git a/tests/repository/test_sqlite_vector_search_repository.py b/tests/repository/test_sqlite_vector_search_repository.py index 7875d22a6..2da7de361 100644 --- a/tests/repository/test_sqlite_vector_search_repository.py +++ b/tests/repository/test_sqlite_vector_search_repository.py @@ -5,7 +5,7 @@ from collections.abc import Sequence from contextlib import asynccontextmanager from datetime import datetime, timezone -from typing import cast +from typing import Any, cast from unittest.mock import AsyncMock, MagicMock import pytest @@ -1392,7 +1392,7 @@ async def test_run_vector_query_caps_k_at_sqlite_vec_limit(search_repository, mo await search_repository.init_search_index() index = cast(SQLiteVecIndex, search_repository._semantic_vector_index) - captured_params: list[dict] = [] + captured_params: list[dict[str, Any]] = [] session = AsyncMock() async def capturing_execute(stmt, params=None): diff --git a/tests/repository/test_vector_pagination.py b/tests/repository/test_vector_pagination.py index 90686c47e..dfc8faf3c 100644 --- a/tests/repository/test_vector_pagination.py +++ b/tests/repository/test_vector_pagination.py @@ -7,7 +7,7 @@ from contextlib import asynccontextmanager from dataclasses import dataclass from datetime import datetime -from typing import Any +from typing import override, Any from unittest.mock import AsyncMock, patch import pytest @@ -41,12 +41,15 @@ def __init__(self): self.session_maker = None self.project_id = 1 + @override async def init_search_index(self): pass # pragma: no cover + @override def _prepare_search_term(self, term, is_prefix=True): return term # pragma: no cover + @override async def search( self, search_text: str | None = None, @@ -66,18 +69,23 @@ async def search( ) -> list[SearchIndexRow]: return [] # pragma: no cover + @override async def _ensure_vector_tables(self): pass # pragma: no cover + @override async def _run_vector_query(self, session, query_embedding, candidate_limit): return [] # pragma: no cover + @override async def _write_embeddings(self, session, jobs, embeddings): pass # pragma: no cover + @override async def _delete_entity_chunks(self, session, entity_id, *, expected_deletions=None): return [] # pragma: no cover + @override async def _delete_stale_chunks( self, session, @@ -91,6 +99,7 @@ async def _delete_stale_chunks( async def _update_timestamp_sql(self): return "CURRENT_TIMESTAMP" # pragma: no cover + @override def _distance_to_similarity(self, distance: float) -> float: return 1.0 / (1.0 + max(distance, 0.0)) @@ -114,7 +123,7 @@ def runtime_log_attrs(self) -> dict[str, object]: return {} -def _make_descending_vector_rows(count: int) -> list[dict]: +def _make_descending_vector_rows(count: int) -> list[dict[str, Any]]: """Build vector rows with scores descending from ~1.0 to ~0.5.""" rows = [] for i in range(count): diff --git a/tests/repository/test_vector_threshold.py b/tests/repository/test_vector_threshold.py index ed8d17329..35864e521 100644 --- a/tests/repository/test_vector_threshold.py +++ b/tests/repository/test_vector_threshold.py @@ -3,7 +3,7 @@ from contextlib import asynccontextmanager from dataclasses import dataclass from datetime import datetime -from typing import Any, Optional, cast +from typing import override, Any, Optional, cast from unittest.mock import AsyncMock, patch import pytest @@ -45,12 +45,15 @@ def __init__(self): # --- Abstract method stubs (not exercised by these tests) --- + @override async def init_search_index(self): pass # pragma: no cover + @override def _prepare_search_term(self, term, is_prefix=True): return term # pragma: no cover + @override async def search( self, search_text: Optional[str] = None, @@ -70,18 +73,23 @@ async def search( ) -> list[SearchIndexRow]: return [] # pragma: no cover + @override async def _ensure_vector_tables(self): pass # pragma: no cover + @override async def _run_vector_query(self, session, query_embedding, candidate_limit): return [] # pragma: no cover + @override async def _write_embeddings(self, session, jobs, embeddings): pass # pragma: no cover + @override async def _delete_entity_chunks(self, session, entity_id, *, expected_deletions=None): return [] # pragma: no cover + @override async def _delete_stale_chunks( self, session, @@ -95,11 +103,12 @@ async def _delete_stale_chunks( async def _update_timestamp_sql(self): return "CURRENT_TIMESTAMP" # pragma: no cover + @override def _distance_to_similarity(self, distance: float) -> float: return 1.0 / (1.0 + max(distance, 0.0)) -def _make_vector_rows(scores: list[float]) -> list[dict]: +def _make_vector_rows(scores: list[float]) -> list[dict[str, Any]]: """Build fake vector query rows with controlled distances. Distance = (1/score) - 1 inverts the similarity formula: @@ -341,7 +350,7 @@ async def test_matched_chunk_text_populated_on_vector_results(): assert results[1].matched_chunk_text == "chunk text for entity:1:0" -def _make_multi_chunk_vector_rows(si_id: int, scores: list[float]) -> list[dict]: +def _make_multi_chunk_vector_rows(si_id: int, scores: list[float]) -> list[dict[str, Any]]: """Build multiple fake vector chunks for a single search_index row. Each chunk gets a unique chunk_index within the same si_id. diff --git a/tests/services/test_entity_service_telemetry.py b/tests/services/test_entity_service_telemetry.py index 4c62149d8..4280e0a87 100644 --- a/tests/services/test_entity_service_telemetry.py +++ b/tests/services/test_entity_service_telemetry.py @@ -8,10 +8,11 @@ import pytest from basic_memory.schemas import Entity as EntitySchema +from typing import Any def _capture_spans(): - spans: list[tuple[str, dict]] = [] + spans: list[tuple[str, dict[str, Any]]] = [] @contextmanager def fake_span(name: str, **attrs): @@ -21,7 +22,7 @@ def fake_span(name: str, **attrs): return spans, fake_span -def _span_names(spans: list[tuple[str, dict]]) -> list[str]: +def _span_names(spans: list[tuple[str, dict[str, Any]]]) -> list[str]: return [name for name, _ in spans] diff --git a/tests/services/test_initialization.py b/tests/services/test_initialization.py index c758126d7..6ad901862 100644 --- a/tests/services/test_initialization.py +++ b/tests/services/test_initialization.py @@ -23,6 +23,7 @@ initialize_file_indexing, reconcile_projects_with_config, ) +from typing import override @pytest.mark.asyncio @@ -236,6 +237,7 @@ async def test_initialize_file_indexing_releases_startup_after_recovery( recovery_complete = asyncio.Event() class RecoveryAwareWatchService(_FakeWatchService): + @override async def run(self) -> None: assert recovery_complete.is_set() diff --git a/tests/services/test_project_service.py b/tests/services/test_project_service.py index e89883ec2..9f9498fa2 100644 --- a/tests/services/test_project_service.py +++ b/tests/services/test_project_service.py @@ -16,6 +16,7 @@ ) from basic_memory.services.project_service import ProjectService from basic_memory.config import ConfigManager, DatabaseBackend +from typing import Any async def _get_project(project_service: ProjectService, name: str) -> Project | None: @@ -33,7 +34,7 @@ async def _find_projects(project_service: ProjectService) -> list[Project]: return list(await project_service.repository.find_all(session)) -async def _create_project(project_service: ProjectService, data: dict) -> Project: +async def _create_project(project_service: ProjectService, data: dict[str, Any]) -> Project: async with db.scoped_session(project_service.session_maker) as session: return await project_service.repository.create(session, data) diff --git a/tests/services/test_search_service.py b/tests/services/test_search_service.py index 0e80a1173..31bc0aeff 100644 --- a/tests/services/test_search_service.py +++ b/tests/services/test_search_service.py @@ -9,6 +9,7 @@ from basic_memory.repository.search_index_row import SearchIndexRow from basic_memory.schemas.search import SearchQuery, SearchItemType, SearchRetrievalMode from basic_memory.services.search_service import _strip_nul +from typing import Any async def _create_entity(session_maker, entity_repo, data): @@ -559,7 +560,7 @@ async def test_boolean_operators_detection(search_service): @pytest.mark.asyncio async def test_plain_multiterm_fts_enables_repository_relaxed_fallback(search_service, monkeypatch): """Plain multi-term FTS should let the repository render relaxed backend syntax.""" - calls: list[dict] = [] + calls: list[dict[str, Any]] = [] now = datetime.now().astimezone() fallback_row = SearchIndexRow( @@ -596,7 +597,7 @@ async def test_plain_cjk_multiterm_fts_enables_repository_relaxed_fallback( search_service, monkeypatch ): """Whitespace-separated CJK terms need backend prefix relaxed rendering.""" - calls: list[dict] = [] + calls: list[dict[str, Any]] = [] now = datetime.now().astimezone() fallback_row = SearchIndexRow( @@ -633,7 +634,7 @@ async def test_plain_cjk_multiterm_count_enables_repository_relaxed_fallback( search_service, monkeypatch ): """Count should use the same backend relaxed fallback as search.""" - calls: list[dict] = [] + calls: list[dict[str, Any]] = [] async def fake_count(**kwargs): calls.append(kwargs) diff --git a/tests/services/test_search_service_telemetry.py b/tests/services/test_search_service_telemetry.py index 820ae351a..9ce6b45c3 100644 --- a/tests/services/test_search_service_telemetry.py +++ b/tests/services/test_search_service_telemetry.py @@ -7,10 +7,11 @@ import pytest from basic_memory.schemas.search import SearchQuery +from typing import Any def _capture_spans(): - spans: list[tuple[str, dict]] = [] + spans: list[tuple[str, dict[str, Any]]] = [] @contextmanager def fake_span(name: str, **attrs): @@ -48,7 +49,7 @@ async def test_search_service_delegates_relaxed_retry(search_service, monkeypatc import logfire spans, fake_span = _capture_spans() - calls: list[dict] = [] + calls: list[dict[str, Any]] = [] async def fake_repository_search(**kwargs): calls.append(kwargs) diff --git a/tests/services/test_upsert_entity_optimization.py b/tests/services/test_upsert_entity_optimization.py index 5c38d3af7..6b1ff206f 100644 --- a/tests/services/test_upsert_entity_optimization.py +++ b/tests/services/test_upsert_entity_optimization.py @@ -12,6 +12,7 @@ from datetime import datetime, timezone from pathlib import Path from types import SimpleNamespace +from typing import Any, TYPE_CHECKING from unittest.mock import AsyncMock import pytest @@ -26,13 +27,16 @@ from basic_memory.schemas import Entity as EntitySchema from basic_memory.services.entity_service import EntityService +if TYPE_CHECKING: + from loguru import Record + # --- Helpers --- def _make_markdown( title: str = "Test Entity", - observations: list | None = None, - relations: list | None = None, + observations: list[MarkdownObservation] | None = None, + relations: list[MarkdownRelation] | None = None, ) -> EntityMarkdown: frontmatter = EntityFrontmatter(metadata={"title": title, "type": "note"}) return EntityMarkdown( @@ -131,7 +135,7 @@ async def test_create_or_update_entity_uses_lightweight_exact_resolution( content="# Create Or Update", ) sentinel_entity = SimpleNamespace(file_path="notes/existing.md") - resolve_calls: list[tuple[str, dict]] = [] + resolve_calls: list[tuple[str, dict[str, Any]]] = [] async def fake_resolve_link(link_text: str, **kwargs): resolve_calls.append((link_text, kwargs)) @@ -173,7 +177,7 @@ async def test_upsert_with_relations_uses_lightweight_exact_resolution( content="# Lightweight Source", ) ) - resolve_calls: list[tuple[str, dict]] = [] + resolve_calls: list[tuple[str, dict[str, Any]]] = [] async def fake_resolve_link(link_text: str, **kwargs): resolve_calls.append((link_text, kwargs)) @@ -333,7 +337,7 @@ async def failing_resolve_link(link_text: str, **kwargs): monkeypatch.setattr(entity_service.link_resolver, "resolve_link", failing_resolve_link) - records: list[dict] = [] + records: list[Record] = [] sink_id = logger.add(lambda message: records.append(message.record), level="WARNING") try: markdown = _make_markdown( @@ -489,7 +493,7 @@ async def test_edit_entity_uses_lightweight_identifier_resolution( ) ) original_resolve_link = entity_service.link_resolver.resolve_link - resolve_calls: list[tuple[str, dict]] = [] + resolve_calls: list[tuple[str, dict[str, Any]]] = [] async def spy_resolve_link(link_text: str, **kwargs): resolve_calls.append((link_text, kwargs)) diff --git a/tests/test_migration_loop.py b/tests/test_migration_loop.py index 98d4c77d2..defe2f42a 100644 --- a/tests/test_migration_loop.py +++ b/tests/test_migration_loop.py @@ -23,7 +23,7 @@ def test_running_on_uvloop_true_when_policy_is_uvloop(monkeypatch): def test_running_on_uvloop_false_for_default_policy(monkeypatch): - monkeypatch.setattr(asyncio, "get_event_loop_policy", lambda: asyncio.DefaultEventLoopPolicy()) + monkeypatch.setattr(asyncio, "get_event_loop_policy", lambda: object()) assert migration_loop.running_on_uvloop() is False diff --git a/tests/test_pr_title_workflow.py b/tests/test_pr_title_workflow.py index e1e51ed55..0e5cb4e18 100644 --- a/tests/test_pr_title_workflow.py +++ b/tests/test_pr_title_workflow.py @@ -1,9 +1,10 @@ from pathlib import Path import yaml +from typing import Any -def _semantic_pr_action_inputs() -> dict: +def _semantic_pr_action_inputs() -> dict[str, Any]: workflow = yaml.safe_load(Path(".github/workflows/pr-title.yml").read_text(encoding="utf-8")) steps = workflow["jobs"]["main"]["steps"] action_step = next( diff --git a/tests/test_rclone_commands.py b/tests/test_rclone_commands.py index e3dec6b45..d3a6c7c39 100644 --- a/tests/test_rclone_commands.py +++ b/tests/test_rclone_commands.py @@ -30,6 +30,7 @@ project_transfer, supports_create_empty_src_dirs, ) +from typing import Any class _RunResult: @@ -41,7 +42,7 @@ def __init__(self, returncode: int = 0, stdout: str = "", stderr: str = ""): class _Runner: def __init__(self, *, returncode: int = 0, stdout: str = "", stderr: str = ""): - self.calls: list[tuple[list[str], dict]] = [] + self.calls: list[tuple[list[str], dict[str, Any]]] = [] self._returncode = returncode self._stdout = stdout self._stderr = stderr diff --git a/tests/test_telemetry.py b/tests/test_telemetry.py index 719fefa2b..23322cb06 100644 --- a/tests/test_telemetry.py +++ b/tests/test_telemetry.py @@ -4,6 +4,7 @@ from basic_memory import __version__, telemetry from basic_memory.config import init_api_logging, init_cli_logging, init_mcp_logging +from typing import Any class FakeLogfire: @@ -15,14 +16,14 @@ def __init__(self, **kwargs): def __init__(self, *, fail_on_send_to_logfire: bool = False) -> None: self.fail_on_send_to_logfire = fail_on_send_to_logfire - self.configure_calls: list[dict] = [] + self.configure_calls: list[dict[str, Any]] = [] def configure(self, **kwargs) -> None: self.configure_calls.append(kwargs) if self.fail_on_send_to_logfire and "send_to_logfire" in kwargs: raise TypeError("send_to_logfire not supported") - def loguru_handler(self) -> dict: + def loguru_handler(self) -> dict[str, Any]: return {"sink": "fake-logfire", "level": "INFO"} @@ -85,8 +86,8 @@ def test_configure_telemetry_clears_handler_when_disabled(monkeypatch) -> None: def test_init_logging_functions_configure_telemetry_and_logging(monkeypatch) -> None: - telemetry_calls: list[dict] = [] - setup_calls: list[dict] = [] + telemetry_calls: list[dict[str, Any]] = [] + setup_calls: list[dict[str, Any]] = [] class StubConfig: logfire_enabled = True diff --git a/tests/utils/test_parse_tags.py b/tests/utils/test_parse_tags.py index 358c10879..5f30446db 100644 --- a/tests/utils/test_parse_tags.py +++ b/tests/utils/test_parse_tags.py @@ -1,6 +1,6 @@ """Tests for parse_tags utility function.""" -from typing import Any, List, Union, cast +from typing import override, Any, List, Union, cast import pytest @@ -53,6 +53,7 @@ def test_parse_tags_special_case() -> None: # Test with custom object that has __str__ method class TagObject: + @override def __str__(self) -> str: return "tag1,tag2" diff --git a/tests/utils/test_setup_logging.py b/tests/utils/test_setup_logging.py index 448701b38..31d38f9c3 100644 --- a/tests/utils/test_setup_logging.py +++ b/tests/utils/test_setup_logging.py @@ -5,6 +5,7 @@ from pathlib import Path from basic_memory import utils +from typing import Any def test_setup_logging_uses_shared_log_file_off_windows(monkeypatch, tmp_path) -> None: @@ -123,7 +124,7 @@ def test_setup_logging_honors_basic_memory_config_dir(monkeypatch, tmp_path) -> def test_setup_logging_test_env_uses_stderr_only(monkeypatch) -> None: """Test mode should add one stderr sink and return before other branches run.""" added_sinks: list[object] = [] - configured_calls: list[dict] = [] + configured_calls: list[dict[str, Any]] = [] monkeypatch.setenv("BASIC_MEMORY_ENV", "test") monkeypatch.setattr(utils.logger, "remove", lambda *args, **kwargs: None) diff --git a/uv.lock b/uv.lock index e90379b0f..6940a9a2d 100644 --- a/uv.lock +++ b/uv.lock @@ -424,9 +424,9 @@ dev = [ { name = "pytest-testmon", specifier = ">=2.2.0" }, { name = "pytest-timeout", specifier = ">=2.4.0" }, { name = "pytest-xdist", specifier = ">=3.0.0" }, - { name = "ruff", specifier = ">=0.1.6" }, + { name = "ruff", specifier = ">=0.16.0" }, { name = "testcontainers", extras = ["postgres"], specifier = ">=4.0.0" }, - { name = "ty", specifier = ">=0.0.18" }, + { name = "ty", specifier = ">=0.0.64" }, ] [[package]] @@ -3888,27 +3888,27 @@ wheels = [ [[package]] name = "ruff" -version = "0.15.21" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/0f/36/6f65aa9989acdec45d417192d8f4e7921931d8a6cf87ac74bce3eed98a8e/ruff-0.15.21.tar.gz", hash = "sha256:d0cfc841c572283c36548f82664a54ce6565567f1b0d5b4cf2caac693d8b7500", size = 4769401, upload-time = "2026-07-09T20:01:34.005Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/d0/c6/ede15cac6839f3dbce52565c8f5164a8210e669c7bc4decb03e5bdf47d0d/ruff-0.15.21-py3-none-linux_armv6l.whl", hash = "sha256:63ea0e965e5d73c90e95b2434beeafc70820536717f561b32ab6e777cb9bdf5d", size = 10854342, upload-time = "2026-07-09T20:00:53.998Z" }, - { url = "https://files.pythonhosted.org/packages/28/9d/d825b07ee7ea9e2d61df92a860033c94e06e7300d50a1c2653aac27d24fe/ruff-0.15.21-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:0f212c5d7d54c01bbfe6dcab02b724a39300f3e34ed7acbe995ccb320a2c58bd", size = 11139539, upload-time = "2026-07-09T20:00:57.809Z" }, - { url = "https://files.pythonhosted.org/packages/f5/de/3b107712e642f063c7a9e0887c427b22cb44097de5aab36c05f2e280670c/ruff-0.15.21-py3-none-macosx_11_0_arm64.whl", hash = "sha256:e6312e41bc96791299614995ea3a977c5857c3b5662b1ecef6755b02b87cb646", size = 10595437, upload-time = "2026-07-09T20:01:00.006Z" }, - { url = "https://files.pythonhosted.org/packages/9a/6f/b4523cc90ba239ede441447a19d0c968846a3012e5a0b0c5b62831a3d5e3/ruff-0.15.21-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:01d65b4831c6b2a4ba8ee6faa84049d44d982b7a706e622c4094c509e51673be", size = 10990053, upload-time = "2026-07-09T20:01:02.187Z" }, - { url = "https://files.pythonhosted.org/packages/92/cc/c6a9872a5375f0628875481cf2f66b13d7d865bf3ca2e57f91c7e762d976/ruff-0.15.21-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:2c5a913a589120ce67933d5d05fd6ddbcc2481c6a054980ee767f7414c72b4fd", size = 10666096, upload-time = "2026-07-09T20:01:04.299Z" }, - { url = "https://files.pythonhosted.org/packages/ab/97/c621f7a17e097f1790fa3af6374138823b330b2d03fc38337945daca212c/ruff-0.15.21-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:5ef04b681d02ad4dc9620f00f83ac5c22f652d0e9a9cfe431d219b16ad5ccc41", size = 11537011, upload-time = "2026-07-09T20:01:06.771Z" }, - { url = "https://files.pythonhosted.org/packages/ea/51/d928727e476e25ccc57c6f449ffd80241a651a973ad949d39cfb2a771d28/ruff-0.15.21-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:16d090c0740916594157e75b80d666eab8e78083b39b3b0e1d698f4670a17b86", size = 12347101, upload-time = "2026-07-09T20:01:08.859Z" }, - { url = "https://files.pythonhosted.org/packages/1e/88/8cd62026802b16018ad06931d87997cf795ba2a6239ab659606c87d96bf0/ruff-0.15.21-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:3a10e74757dd65004d779b73e2f3c5210156d9980b41224d50d2ebcf1db51e67", size = 11572001, upload-time = "2026-07-09T20:01:11.092Z" }, - { url = "https://files.pythonhosted.org/packages/b2/97/f63084cf55444fc110e8cb985ebfcc592af47f597d44453d778cb81bc156/ruff-0.15.21-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:bab0905d2f29e0d9fbc3c373ed23db0095edaa3f71f1f4f519ec15134d9e85c8", size = 11549239, upload-time = "2026-07-09T20:01:13.27Z" }, - { url = "https://files.pythonhosted.org/packages/9d/77/f107da4a2874b7715914b03f09ba9c54424de3ff8a1cc5d015d3ee2ce0ac/ruff-0.15.21-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:00eca240af5789fec6fe7df74c088cc1f9644ed83027113468efba7c92b94075", size = 11535340, upload-time = "2026-07-09T20:01:15.206Z" }, - { url = "https://files.pythonhosted.org/packages/d5/e9/601deb322d3303a7bf212b0100ead6f2ee3f6a044d89c30f2f92bf83c731/ruff-0.15.21-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:262ab31557a75141325e32d3357f3597645a7f084e732b6b054dde428ecd9341", size = 10964048, upload-time = "2026-07-09T20:01:17.723Z" }, - { url = "https://files.pythonhosted.org/packages/ea/2e/0f2176d1e99c15192caea19c8c3a0a955246b4cb4de795042eeb616345cd/ruff-0.15.21-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:659c4e7a4212f83306045ec7c5e5a356d16d9a6ef4ae0c7a4d872914fc655d9d", size = 10667055, upload-time = "2026-07-09T20:01:19.73Z" }, - { url = "https://files.pythonhosted.org/packages/48/60/abd74a02e0c4214f12a68becfd30af7165cfdcb0e661ecdc60bbb949c09a/ruff-0.15.21-py3-none-musllinux_1_2_i686.whl", hash = "sha256:9e866eab611a5f959d36df2d10e446973a3610bc42b0c15b31dc27977d59c233", size = 11242043, upload-time = "2026-07-09T20:01:21.947Z" }, - { url = "https://files.pythonhosted.org/packages/b2/c6/583075d8ccabb4b229345edcaf1545eb3d8d6be90f686a479d7e94088bbf/ruff-0.15.21-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:e89bc93c0d3803ba870b55c29671bad9dc6d94bb1eb181b056b52eb05b52854f", size = 11648064, upload-time = "2026-07-09T20:01:24.023Z" }, - { url = "https://files.pythonhosted.org/packages/3a/3c/37d0ecb729a7cc2d393ea7dce316fc585680f35d93b8d62139d7d0a3700c/ruff-0.15.21-py3-none-win32.whl", hash = "sha256:01f8d5be84823c172b389e123174f781f9daf86d6c58719d603f941932195cdd", size = 10896555, upload-time = "2026-07-09T20:01:26.941Z" }, - { url = "https://files.pythonhosted.org/packages/c0/b8/e43466b2a6067ce91e669068f6e28d6c719a920f014b070d5c8731725de3/ruff-0.15.21-py3-none-win_amd64.whl", hash = "sha256:d4b8d9a2f0f12b816b50447f6eccb9f4bb01a6b82c86b50fb3b5354b458dc6d3", size = 12038772, upload-time = "2026-07-09T20:01:29.497Z" }, - { url = "https://files.pythonhosted.org/packages/dd/75/e90ab9aeece218a9fc5a5bc3ec97d0ee6bb3c4ff95869463c1de58e29a1c/ruff-0.15.21-py3-none-win_arm64.whl", hash = "sha256:6e83115d4b9377c1cbc13abf0e051f069fab0ef815ea0504a8a008cee24dd0a8", size = 11375265, upload-time = "2026-07-09T20:01:31.772Z" }, +version = "0.16.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/4d/94/1e5e4967626faf12fa56999cd6222dff6992ceb086ad7945756baf70c7a7/ruff-0.16.0.tar.gz", hash = "sha256:e460aafd5495ec89efaa6ced2e4a9a581116451e1c88b9d37ef497e0f8e93982", size = 4790557, upload-time = "2026-07-23T19:11:30.981Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/4b/81/1c8818fee7ce1a04cd7d1b3172e0a8f8e4f1dc4feb7fc390e16daa8af323/ruff-0.16.0-py3-none-linux_armv6l.whl", hash = "sha256:e5115729eb08c585e5121978ba5d5b60caeae394ce21b9fb5e6cd33a1c6c9b1e", size = 10754633, upload-time = "2026-07-23T19:10:46.415Z" }, + { url = "https://files.pythonhosted.org/packages/23/df/beaf59c09d68db84304d555f188b276a77132a5d5b0b67a5c762aa143628/ruff-0.16.0-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:3c954b1d580bfa035b41654f7858cc7e71d5fc3ac5b723dd62bd9133830ed522", size = 10969164, upload-time = "2026-07-23T19:10:50.271Z" }, + { url = "https://files.pythonhosted.org/packages/42/ce/741cd197496a1abbf51352710fd15ed995d2a2be87189c1da26a450d6e83/ruff-0.16.0-py3-none-macosx_11_0_arm64.whl", hash = "sha256:e01c21d10eb1b29f47b7454e1f4056db9a3f0260c646aa88457c610291db9f81", size = 10488846, upload-time = "2026-07-23T19:10:52.639Z" }, + { url = "https://files.pythonhosted.org/packages/52/2a/a2db8e88cade358f5cdcb05674a917751074109315d014eb6352d9a893f7/ruff-0.16.0-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:6e364e5ed22ed8dc05082fd78e35308618260907ac2d3c1d637b2e682415b6c9", size = 10889729, upload-time = "2026-07-23T19:10:54.89Z" }, + { url = "https://files.pythonhosted.org/packages/42/65/62a771694ebd63029dc953e27dbad40e1588bd4860ff9fe881018fddaa49/ruff-0.16.0-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:d327b8fc113a1d4421a04f3839d3752057c8dd1ee320223a6f3f52d04ada462a", size = 10568275, upload-time = "2026-07-23T19:10:56.993Z" }, + { url = "https://files.pythonhosted.org/packages/3f/e2/ced249fe8af5f086c5c58cc21cc3356d50f32f7401c5df87050c999620a7/ruff-0.16.0-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:a9b50c55e263103586b3dcf5f73d479eb8cb5fdb6098fec59a62891dab653717", size = 11385112, upload-time = "2026-07-23T19:10:59.615Z" }, + { url = "https://files.pythonhosted.org/packages/87/0b/05154977a8fd69eeb6c103271f55403bfd8711f5c0f8ed07489d95a504e7/ruff-0.16.0-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:0ff4a79ce3ec0172f3241943835de1c4cb4e2dcd07f0f8c2d02603dbbbee4b17", size = 12207008, upload-time = "2026-07-23T19:11:02.154Z" }, + { url = "https://files.pythonhosted.org/packages/fb/29/98225831a3a1eab0e02f4acc6ca6559a98611dcc68b6965ff4b7234627c1/ruff-0.16.0-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:e95c448fca1fb2a18372a9440926c5a6ee789639bb975c72e7ae6d0b04218ab4", size = 11650842, upload-time = "2026-07-23T19:11:04.557Z" }, + { url = "https://files.pythonhosted.org/packages/91/66/6bd3cf90500653d55dc0ffc8507aa8300bd49d0214b2e8cb4d3fef2943ba/ruff-0.16.0-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:4f11a8d11010301d0a398a2fdef67691feca7294da6aef55e2150e8fa2cd520b", size = 11400718, upload-time = "2026-07-23T19:11:09.233Z" }, + { url = "https://files.pythonhosted.org/packages/8e/a2/a54eb4eae05d66364050a5d3b8a9c5ef88196531b3cbe7109d873f87f819/ruff-0.16.0-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:48044c678e9cb8698246c99b14aaccfa6601dea7379eb48a6f8f73f7a6d86cd0", size = 11426177, upload-time = "2026-07-23T19:11:11.994Z" }, + { url = "https://files.pythonhosted.org/packages/1a/be/16e3eea4b2a478a496919f5e36f17c4559e54620bd3bbac5d6affa068006/ruff-0.16.0-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:7aa0959bad8eb8bef50340154fc9b58678dae31fa4293afa38b44b6e552c0213", size = 10856126, upload-time = "2026-07-23T19:11:14.221Z" }, + { url = "https://files.pythonhosted.org/packages/a2/84/252eb8b868a16eec7257c14f504f77537e734b2d69c762e639e588e304a3/ruff-0.16.0-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:28ea2b7df8ebf7f9da6b7d47b230ab48f387c0a29be3b474c4d0740e197bb9af", size = 10571208, upload-time = "2026-07-23T19:11:16.378Z" }, + { url = "https://files.pythonhosted.org/packages/21/09/817a482f542f7570cbb4554b26e896610c7114f539b1d9e2d2145bf6bef6/ruff-0.16.0-py3-none-musllinux_1_2_i686.whl", hash = "sha256:33a3dfac8c35f81498dea9181bccc2f4c4bc8f1521a1dd9406e77643e0f0fb09", size = 11063329, upload-time = "2026-07-23T19:11:19.173Z" }, + { url = "https://files.pythonhosted.org/packages/2e/23/9403c180ca1cb9b1f7335f5c3e5305c09d49ea5b345196682a36028bde4a/ruff-0.16.0-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:a5237a0bda500d30d81b8e07a6973a5cbc772864cbf746ae2f4e8a2e01c9f4ed", size = 11489751, upload-time = "2026-07-23T19:11:21.74Z" }, + { url = "https://files.pythonhosted.org/packages/b2/1d/1b2ef7bcde851c78d7f17f1cca13fd6dc695fc4b3d6197941e72cae5b132/ruff-0.16.0-py3-none-win32.whl", hash = "sha256:7fab76fa065c873f41ff744347c6e77bcc3dfec4bcc754dc26b63d23c0f7f5fb", size = 10785885, upload-time = "2026-07-23T19:11:23.947Z" }, + { url = "https://files.pythonhosted.org/packages/b2/a3/d5e4ef7a56be3f928ffb90b94c25ba7d3cb9c7fe0736aeaaedf361770712/ruff-0.16.0-py3-none-win_amd64.whl", hash = "sha256:429c117f022bf481fabd9d551e7a3952b24c65e6ef44337ea09d90bebef14472", size = 11923141, upload-time = "2026-07-23T19:11:26.409Z" }, + { url = "https://files.pythonhosted.org/packages/cb/9a/8415f2657cbe200f41a4531ccededf135505a92d4a012229121f885b26f9/ruff-0.16.0-py3-none-win_arm64.whl", hash = "sha256:14296fedcd2705c77ab8235439278bbb38f285cf7da5528b00b3e330c3d4872d", size = 11273407, upload-time = "2026-07-23T19:11:28.705Z" }, ] [[package]] @@ -4156,27 +4156,27 @@ wheels = [ [[package]] name = "ty" -version = "0.0.59" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/95/b0/84ae7b3bf6e3e9f57eb9635eeff5a80b36e57aa089f40be0fb5c384fa176/ty-0.0.59.tar.gz", hash = "sha256:53e53ffeed78ad59cd237fa8ea1316d2b94e13efdea9a945698acab549e005aa", size = 6145435, upload-time = "2026-07-12T20:22:02.781Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/57/e8/650b42fbef4d48e6ca682b0b6e9b68fa8fcf55cbb0a6892ab89990018b6f/ty-0.0.59-py3-none-linux_armv6l.whl", hash = "sha256:f8fb08a767ef8f11ea3c537b9d77860726cc2bc39e6f77ad13c02d5b289f20a7", size = 11700328, upload-time = "2026-07-12T20:21:26.046Z" }, - { url = "https://files.pythonhosted.org/packages/22/ac/0ca3a89d5f59ae5f308e5e83428cac5f9143200767743e052fba90b4b81e/ty-0.0.59-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:c7f4d5630836c8a0ba13dd4ac7bdae080a7d6ebe965b817ff642dc961bcf2a53", size = 11494310, upload-time = "2026-07-12T20:21:28.491Z" }, - { url = "https://files.pythonhosted.org/packages/f3/f8/5076de6001cefbccd8e6dc8472262697e43308ff66b0e87c72abba136357/ty-0.0.59-py3-none-macosx_11_0_arm64.whl", hash = "sha256:872f6fb02c6db5553c4d5fb283b3d50f0985fb9a29a910e4fda4793a775c1926", size = 11026797, upload-time = "2026-07-12T20:21:30.879Z" }, - { url = "https://files.pythonhosted.org/packages/2e/0f/fca28481b6a138e2b798ad9fdc98a095475f9104948ba242fce4b477782b/ty-0.0.59-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2af8eefbfe806337770eec12c0c819c5f1b8f5b85f8369cb1cc9fa25234a2208", size = 11475304, upload-time = "2026-07-12T20:21:33.041Z" }, - { url = "https://files.pythonhosted.org/packages/08/4b/1fed8b81b389ef4bbc0400f19e05fc16496b162577779dc0e5fc65ac216c/ty-0.0.59-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:0acf8b76a1c9a7ddef460b42475f6c76193164426ab080783af1c3175b4b999b", size = 11533131, upload-time = "2026-07-12T20:21:35.189Z" }, - { url = "https://files.pythonhosted.org/packages/5f/fc/04eec35e05a10e0fea1c6503a290ccc3935efda9c845aff64e83282c1af7/ty-0.0.59-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:043c2e00eb1d7475f928af7dedd71f69b64e69bfca55e36f4c968479e1373fc4", size = 12205932, upload-time = "2026-07-12T20:21:37.324Z" }, - { url = "https://files.pythonhosted.org/packages/a9/dd/a61de859659fa11b55917ad38340a8f2c61f5ae17d1874929f29084c6990/ty-0.0.59-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:f0d688d857441df57f48fca66c029d85cf737c510e7be1d01144cdad1e58d968", size = 12758406, upload-time = "2026-07-12T20:21:39.525Z" }, - { url = "https://files.pythonhosted.org/packages/c6/e8/fa66f05997eab8ca75fc4f17320140e25467849e0cc75597f898cc22099c/ty-0.0.59-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:a96c9f88394a3b42c737e2125b2330543f0d90a43b49761f377d96f8c3ee0d62", size = 12288176, upload-time = "2026-07-12T20:21:41.784Z" }, - { url = "https://files.pythonhosted.org/packages/15/68/0fca59963bd5123f42d5f7da50667e7a52e8e9615e3a16d8c2c0d3b2d143/ty-0.0.59-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f08dbcb268edcafcb152e59475b5b495ce28d0b340a395c09943557678f4d5a6", size = 12028471, upload-time = "2026-07-12T20:21:43.82Z" }, - { url = "https://files.pythonhosted.org/packages/e1/5b/cd7dabbbab392578f11179919da5c25d8c3322e5388a688f539ea0539603/ty-0.0.59-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:8812764b9a40fdc98df1272826e73a298ef56b06681135e643bcf90aad1896f7", size = 12297646, upload-time = "2026-07-12T20:21:45.76Z" }, - { url = "https://files.pythonhosted.org/packages/1d/37/2e9c94f0b383d8cbe1a35517ab470b7810bc9d7501603ab532bcd5be5e90/ty-0.0.59-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:fd53b8581641d8dad7bfac6d5ea589e91a883d6837e0b9a286fdae30722b7c69", size = 11432519, upload-time = "2026-07-12T20:21:47.694Z" }, - { url = "https://files.pythonhosted.org/packages/9d/0a/af93e9785200f11ac416cc20235fc2464c9bd978e791190684ea0e458795/ty-0.0.59-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:86da5872124a41877d95058bc17d33ddcff034b587eb5f1e2917ab88ba227dac", size = 11554993, upload-time = "2026-07-12T20:21:49.671Z" }, - { url = "https://files.pythonhosted.org/packages/4b/dd/651bf87e20d00376c81b19124756491cffaf20eb8bec05a8794e5a8cf641/ty-0.0.59-py3-none-musllinux_1_2_i686.whl", hash = "sha256:6a233eef5f2fd4d894881e4a0aec83c9f172bfae1d787d6596ee1939fcc7723e", size = 11818230, upload-time = "2026-07-12T20:21:51.659Z" }, - { url = "https://files.pythonhosted.org/packages/16/50/c947c4155fea751d135b19affdf734bbce72a94e446b866cf0c62f8bed69/ty-0.0.59-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:7ff678c18b5f1e3128b75a35e50dee7908dea55155baa31cd790619d5014cbf5", size = 12135194, upload-time = "2026-07-12T20:21:53.796Z" }, - { url = "https://files.pythonhosted.org/packages/b1/13/e5feb138888de1e95037c843571bbbd4ac21bf0a190507468098599a321f/ty-0.0.59-py3-none-win32.whl", hash = "sha256:cf8abb4b8095c5fe39102b8127f5886db308c8d4600909ddbc905512ce9c8163", size = 11179249, upload-time = "2026-07-12T20:21:55.752Z" }, - { url = "https://files.pythonhosted.org/packages/76/dd/52914dcbeeba92c207de40ef7109a58dcb5527aeb21c8f8feb7402aa9e29/ty-0.0.59-py3-none-win_amd64.whl", hash = "sha256:1dde20a82243d24407869e5a608c2f15efddd5cefc662aef461a5af84bfb3f8b", size = 12251079, upload-time = "2026-07-12T20:21:58.1Z" }, - { url = "https://files.pythonhosted.org/packages/d4/8f/ac36fde77e223297454c1e0aeb8888c169eaacf3163bb609e3af942c88cb/ty-0.0.59-py3-none-win_arm64.whl", hash = "sha256:987043ee9e021f49493d9135891ac69c1affeee0d4ad4480c5fa4d9c975fc91b", size = 11650921, upload-time = "2026-07-12T20:22:00.348Z" }, +version = "0.0.64" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/8e/aa/14c9965d3b173105692473897cc89c34cd91241368b2044e43167e1c17ff/ty-0.0.64.tar.gz", hash = "sha256:d12ddbb05f15158bb518af619378b385486450def95fb06f8ab98037febe9f2c", size = 6350966, upload-time = "2026-07-27T18:32:45.403Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ae/4c/c54937e4ff3fa7b34a99ea3387ec766bf0ad98dc8df8d792e89b388e658e/ty-0.0.64-py3-none-linux_armv6l.whl", hash = "sha256:3830a6675ab43635ced1c4c557f380ac4a49e9414e03975e4e4e8db644c64944", size = 12118357, upload-time = "2026-07-27T18:32:08.702Z" }, + { url = "https://files.pythonhosted.org/packages/ce/aa/a839ee2bc78e943d079e6abe199a97b4eeffb7e5c9a57326d69de452186a/ty-0.0.64-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:3ff07d7bc32a2135f58afe57393789a32ea2fed1a66216129a8e535e76043903", size = 11790882, upload-time = "2026-07-27T18:32:10.997Z" }, + { url = "https://files.pythonhosted.org/packages/4c/de/19f14357888a7198438926303753cf749428e3d62e8980ff1e9a72a78402/ty-0.0.64-py3-none-macosx_11_0_arm64.whl", hash = "sha256:4f6d1c7f897cca05d12bacbf1435150d5ffa496099515aa6ed303c8b29e1d0bb", size = 11317394, upload-time = "2026-07-27T18:32:13.162Z" }, + { url = "https://files.pythonhosted.org/packages/08/2f/f54462300535ab99b551eda733177be2eef5dbc2997d3fdb357c4ddd760a/ty-0.0.64-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:138d6c37ad4bf8583aa7a9b29d90954151d7856f9910a03eae3eb34b34c57215", size = 11863042, upload-time = "2026-07-27T18:32:15.307Z" }, + { url = "https://files.pythonhosted.org/packages/5e/95/dbecf745520ebe8bd7b02fc55eee6441c9be312ebf6addce605eb52740dd/ty-0.0.64-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:1ed9719d1b7b66fb8efe073d860208a44c40af1f6cd5c2364aa9b323a1e579b4", size = 11910730, upload-time = "2026-07-27T18:32:17.467Z" }, + { url = "https://files.pythonhosted.org/packages/3b/26/12cfd40028e51ceed7b3cb645281c61c02eb64ff9fb0c09231d65c30ff25/ty-0.0.64-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:d68b23e5169e2137b5f1de7169ab0cebecab6c8eda374c34c1f6394308f58242", size = 12631936, upload-time = "2026-07-27T18:32:19.533Z" }, + { url = "https://files.pythonhosted.org/packages/a0/d0/65ffc2b0a686347193c6f98e9421a7fc2a96fc3cd0b1001cf7cf284baff9/ty-0.0.64-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:f41cb07d89d32626fcaf3ed4d262778fcb28b2628b6ed1e172cd7b18820668d8", size = 13171049, upload-time = "2026-07-27T18:32:22.026Z" }, + { url = "https://files.pythonhosted.org/packages/f6/a4/975a5961842dcd6fa60f0770a102c0bba7509da909ab652919bfcdcd4fc7/ty-0.0.64-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:5f24e1504ab9e212f92356b82fe088fdeb3a39f9a2f4ff25e505d2e1d0db9056", size = 12826438, upload-time = "2026-07-27T18:32:24.178Z" }, + { url = "https://files.pythonhosted.org/packages/af/ef/dfb9b7f9bcc032d3b540b0d1f55f532a336e2fb41b1bd539c05ae81a151a/ty-0.0.64-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:86db830cb914bb33bb8247b66ccea58de4496c2391bd42658aba744b439f3290", size = 12440880, upload-time = "2026-07-27T18:32:26.341Z" }, + { url = "https://files.pythonhosted.org/packages/ee/d0/bedac20505e8a8f5501ad73d7d15d8e421a563fef59993909a23036929a4/ty-0.0.64-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:652ee3d6d03bea76cd2fe8949c78bb5970394ebd7c5fd270e9518c0dee1b931d", size = 12782439, upload-time = "2026-07-27T18:32:28.642Z" }, + { url = "https://files.pythonhosted.org/packages/79/d5/795733f13ceff1378f08b3de0c49d0f518df220ed856b0dfac869f3b7c81/ty-0.0.64-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:4838768295774a86e95f9ec5633e739d016adbbb69dcbdc62f3549578a11f624", size = 11814821, upload-time = "2026-07-27T18:32:30.632Z" }, + { url = "https://files.pythonhosted.org/packages/52/3e/9d99cd1e1831003434f508ed9f258a56543194afc3bbe051eba2545fa676/ty-0.0.64-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:5a3d700669868599edf39ce5125196682f15a8880cc8c669bc2a83b99984d99b", size = 11928678, upload-time = "2026-07-27T18:32:32.888Z" }, + { url = "https://files.pythonhosted.org/packages/b8/3d/448f49a3503fb119a34348a5714bb92001f252fadbbb12d645f2e744b557/ty-0.0.64-py3-none-musllinux_1_2_i686.whl", hash = "sha256:b161f0a82a8e2f2432db3bf7702b4d3924fa9486ba0014f6710a160fc157df0d", size = 12202249, upload-time = "2026-07-27T18:32:34.905Z" }, + { url = "https://files.pythonhosted.org/packages/dd/9b/75768e562cec990d189dc05807ae72890b20ffbd1e1f43597bb98db63c60/ty-0.0.64-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:39b9dd42908df47c2dc57dda87e656fab97097ffd2618474bbdea986af0d6a9d", size = 12548817, upload-time = "2026-07-27T18:32:36.995Z" }, + { url = "https://files.pythonhosted.org/packages/34/89/44cc276ea6ca0245495014758ffeadde1798fb0b5840c9cf36d8d2ed3250/ty-0.0.64-py3-none-win32.whl", hash = "sha256:d0676ab0e0935795e5843baa28dd5e366dc343d7c0945a996df9eab8e0644885", size = 11545474, upload-time = "2026-07-27T18:32:39.389Z" }, + { url = "https://files.pythonhosted.org/packages/01/7e/d1c8a871a38d17c8f168b9a6975f6247f7660f8334e517656a5e4b4a4858/ty-0.0.64-py3-none-win_amd64.whl", hash = "sha256:dcb9bd31f54097e362b776c26ab4564d4564cdd1355cb883481167a26c03cc3f", size = 12542987, upload-time = "2026-07-27T18:32:41.412Z" }, + { url = "https://files.pythonhosted.org/packages/35/4d/6d18640d0204cacd69abbaca95ad6a34c6d7e9169e9051d0117b17b827ec/ty-0.0.64-py3-none-win_arm64.whl", hash = "sha256:82cc34c1ad9a8feb6059aef193bebcecc656e548f6fab3d518bcd8b57d198d39", size = 11899263, upload-time = "2026-07-27T18:32:43.366Z" }, ] [[package]]