Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 9 additions & 15 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand All @@ -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",
Expand All @@ -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"



Expand Down
20 changes: 16 additions & 4 deletions src/basic_memory/api/template_loader.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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."""
Expand Down Expand Up @@ -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,
Expand All @@ -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:
Expand Down
10 changes: 7 additions & 3 deletions src/basic_memory/api/v2/routers/importer_router.py
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@
from basic_memory.schemas.importer import (
ChatImportResult,
EntityImportResult,
ImportResult,
ProjectImportResult,
)

Expand Down Expand Up @@ -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:
Expand Down
11 changes: 6 additions & 5 deletions src/basic_memory/api/v2/routers/schema_router.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"])
Expand Down Expand Up @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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]

Expand Down Expand Up @@ -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,
Expand Down
4 changes: 2 additions & 2 deletions src/basic_memory/cli/analytics.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@
import os
import threading
import urllib.request
from typing import Optional
from typing import Any, Optional

import basic_memory

Expand Down Expand Up @@ -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
Expand Down
16 changes: 8 additions & 8 deletions src/basic_memory/cli/auth.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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"

Expand Down Expand Up @@ -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"]
Expand All @@ -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"

Expand Down Expand Up @@ -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"],
Expand All @@ -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
Expand All @@ -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"

Expand Down
11 changes: 7 additions & 4 deletions src/basic_memory/cli/commands/cloud/api_client.py
Original file line number Diff line number Diff line change
@@ -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

Expand All @@ -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
Expand Down Expand Up @@ -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,
Expand Down
2 changes: 1 addition & 1 deletion src/basic_memory/cli/commands/cloud/rclone_installer.py
Original file line number Diff line number Diff line change
Expand Up @@ -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]")
Expand Down
8 changes: 4 additions & 4 deletions src/basic_memory/cli/commands/cloud/shares.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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]")
Expand Down Expand Up @@ -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,
}
Expand Down Expand Up @@ -416,7 +416,7 @@ async def _update():
)
raise typer.Exit(1)

payload: dict = {}
payload: dict[str, Any] = {}
if enable:
payload["enabled"] = True
if disable:
Expand Down
18 changes: 16 additions & 2 deletions src/basic_memory/cli/commands/cloud/upload.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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,
Expand All @@ -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.
Expand Down
Loading
Loading