diff --git a/backend/ai.py b/backend/ai.py
index 7401cba..0afd54d 100644
--- a/backend/ai.py
+++ b/backend/ai.py
@@ -4,12 +4,13 @@ Supports: DeepSeek, OpenRouter, Google Gemini.
Configured via environment variables.
"""
-import os
import json
import logging
+import os
from pathlib import Path
+from typing import Literal
+
import httpx
-from typing import Optional, Literal
logger = logging.getLogger("obsigate.ai")
@@ -29,7 +30,7 @@ def _read_ai_keys() -> dict:
def get_ai_key(env_name: str) -> str:
"""Get AI key: stored file first, then .env fallback."""
keys = _read_ai_keys()
- if env_name in keys and keys[env_name]:
+ if keys.get(env_name):
return keys[env_name]
return os.getenv(env_name, "").strip()
@@ -67,7 +68,7 @@ PROVIDERS = _load_provider_keys()
DEFAULT_PROVIDER: ProviderName = os.getenv("AI_DEFAULT_PROVIDER", "deepseek") # type: ignore
-def _get_provider_config(provider: Optional[ProviderName] = None) -> dict:
+def _get_provider_config(provider: ProviderName | None = None) -> dict:
"""Get provider config, falling back to default if requested provider unavailable."""
p = provider or DEFAULT_PROVIDER
if p not in PROVIDERS:
@@ -83,7 +84,7 @@ def _get_provider_config(provider: Optional[ProviderName] = None) -> dict:
return {"name": p, **cfg}
-async def _call_deepseek_openrouter(prompt: str, system: str, provider: Optional[ProviderName] = None,
+async def _call_deepseek_openrouter(prompt: str, system: str, provider: ProviderName | None = None,
temperature: float = 0.7, max_tokens: int = 2048) -> str:
"""Call OpenAI-compatible API (DeepSeek, OpenRouter)."""
cfg = _get_provider_config(provider)
@@ -133,7 +134,7 @@ async def _call_gemini(prompt: str, system: str, temperature: float = 0.7, max_t
return data["candidates"][0]["content"]["parts"][0]["text"].strip()
-async def ai_complete(prompt: str, provider: Optional[ProviderName] = None) -> str:
+async def ai_complete(prompt: str, provider: ProviderName | None = None) -> str:
"""Generic AI completion. Routes to appropriate provider."""
cfg = _get_provider_config(provider)
if cfg["name"] == "gemini":
@@ -148,7 +149,7 @@ Your responses should be direct and concise. When editing text, return ONLY the
no explanations or markdown fences."""
-async def ai_improve_writing(text: str, provider: Optional[ProviderName] = None) -> str:
+async def ai_improve_writing(text: str, provider: ProviderName | None = None) -> str:
"""Improve writing quality while preserving meaning."""
return await _call_deepseek_openrouter(
f"Improve the following text. Fix grammar, clarity, and flow. Preserve the original language and meaning.\n\n{text}",
@@ -156,7 +157,7 @@ async def ai_improve_writing(text: str, provider: Optional[ProviderName] = None)
)
-async def ai_fix_spelling(text: str, provider: Optional[ProviderName] = None) -> str:
+async def ai_fix_spelling(text: str, provider: ProviderName | None = None) -> str:
"""Fix spelling and grammar errors."""
return await _call_deepseek_openrouter(
f"Fix all spelling and grammar errors in this text. Return only the corrected text.\n\n{text}",
@@ -164,7 +165,7 @@ async def ai_fix_spelling(text: str, provider: Optional[ProviderName] = None) ->
)
-async def ai_make_shorter(text: str, provider: Optional[ProviderName] = None) -> str:
+async def ai_make_shorter(text: str, provider: ProviderName | None = None) -> str:
"""Make text more concise."""
return await _call_deepseek_openrouter(
f"Make this text shorter and more concise while preserving the key information.\n\n{text}",
@@ -172,7 +173,7 @@ async def ai_make_shorter(text: str, provider: Optional[ProviderName] = None) ->
)
-async def ai_make_longer(text: str, provider: Optional[ProviderName] = None) -> str:
+async def ai_make_longer(text: str, provider: ProviderName | None = None) -> str:
"""Expand text with more detail."""
return await _call_deepseek_openrouter(
f"Expand this text with more detail, examples, or explanation while keeping the same tone.\n\n{text}",
@@ -180,7 +181,7 @@ async def ai_make_longer(text: str, provider: Optional[ProviderName] = None) ->
)
-async def ai_simplify(text: str, provider: Optional[ProviderName] = None) -> str:
+async def ai_simplify(text: str, provider: ProviderName | None = None) -> str:
"""Simplify language."""
return await _call_deepseek_openrouter(
f"Simplify this text. Use clearer, more straightforward language. Avoid jargon.\n\n{text}",
@@ -188,7 +189,7 @@ async def ai_simplify(text: str, provider: Optional[ProviderName] = None) -> str
)
-async def ai_change_tone(text: str, tone: str, provider: Optional[ProviderName] = None) -> str:
+async def ai_change_tone(text: str, tone: str, provider: ProviderName | None = None) -> str:
"""Change the tone of the text."""
return await _call_deepseek_openrouter(
f"Rewrite this text in a {tone} tone. Preserve the original meaning.\n\n{text}",
@@ -196,7 +197,7 @@ async def ai_change_tone(text: str, tone: str, provider: Optional[ProviderName]
)
-async def ai_translate(text: str, target_lang: str, provider: Optional[ProviderName] = None) -> str:
+async def ai_translate(text: str, target_lang: str, provider: ProviderName | None = None) -> str:
"""Translate text to target language."""
# Gemini is better at translation
if provider is None and PROVIDERS["gemini"]["api_key"]:
@@ -212,7 +213,7 @@ async def ai_translate(text: str, target_lang: str, provider: Optional[ProviderN
)
-async def ai_explain(text: str, provider: Optional[ProviderName] = None) -> str:
+async def ai_explain(text: str, provider: ProviderName | None = None) -> str:
"""Explain the selected text."""
return await _call_deepseek_openrouter(
f"Explain the following text clearly and concisely:\n\n{text}",
@@ -220,7 +221,7 @@ async def ai_explain(text: str, provider: Optional[ProviderName] = None) -> str:
)
-async def ai_summarize(text: str, provider: Optional[ProviderName] = None) -> str:
+async def ai_summarize(text: str, provider: ProviderName | None = None) -> str:
"""Summarize the selected text."""
return await _call_deepseek_openrouter(
f"Summarize the following text concisely:\n\n{text}",
@@ -228,7 +229,7 @@ async def ai_summarize(text: str, provider: Optional[ProviderName] = None) -> st
)
-async def ai_continue_writing(text: str, provider: Optional[ProviderName] = None) -> str:
+async def ai_continue_writing(text: str, provider: ProviderName | None = None) -> str:
"""Continue writing from the selected text."""
return await _call_deepseek_openrouter(
f"Continue writing from where this text leaves off. Match the style and tone:\n\n{text}",
@@ -236,7 +237,7 @@ async def ai_continue_writing(text: str, provider: Optional[ProviderName] = None
)
-async def ai_custom_rewrite(text: str, instruction: str, provider: Optional[ProviderName] = None) -> str:
+async def ai_custom_rewrite(text: str, instruction: str, provider: ProviderName | None = None) -> str:
"""Rewrite text based on a custom instruction."""
return await _call_deepseek_openrouter(
f"Rewrite the following text according to this instruction: {instruction}\n\n{text}",
@@ -244,7 +245,7 @@ async def ai_custom_rewrite(text: str, instruction: str, provider: Optional[Prov
)
-async def ai_convert_to_list(text: str, provider: Optional[ProviderName] = None) -> str:
+async def ai_convert_to_list(text: str, provider: ProviderName | None = None) -> str:
"""Convert paragraph text to a markdown list."""
return await _call_deepseek_openrouter(
f"Convert this text into a well-organized markdown bullet list. Extract key points.\n\n{text}",
@@ -252,7 +253,7 @@ async def ai_convert_to_list(text: str, provider: Optional[ProviderName] = None)
)
-async def ai_convert_to_table(text: str, provider: Optional[ProviderName] = None) -> str:
+async def ai_convert_to_table(text: str, provider: ProviderName | None = None) -> str:
"""Convert text to a markdown table."""
return await _call_deepseek_openrouter(
f"Convert this information into a markdown table. Choose appropriate columns.\n\n{text}",
@@ -260,7 +261,7 @@ async def ai_convert_to_table(text: str, provider: Optional[ProviderName] = None
)
-async def ai_generate_frontmatter(text: str, provider: Optional[ProviderName] = None) -> str:
+async def ai_generate_frontmatter(text: str, provider: ProviderName | None = None) -> str:
"""Generate YAML frontmatter for a markdown document."""
return await _call_deepseek_openrouter(
f"Generate YAML frontmatter for this markdown document. Include: titre, tags (as list), catégorie, statut, date. Return ONLY the YAML between --- markers.\n\n{text}",
@@ -268,7 +269,7 @@ async def ai_generate_frontmatter(text: str, provider: Optional[ProviderName] =
)
-async def ai_inline_complete(text: str, provider: Optional[ProviderName] = None) -> str:
+async def ai_inline_complete(text: str, provider: ProviderName | None = None) -> str:
"""Inline completion — suggest continuation."""
return await _call_deepseek_openrouter(
f"Complete this text naturally. Return only the completion (just the new text, no repetition):\n\n{text}",
@@ -276,7 +277,7 @@ async def ai_inline_complete(text: str, provider: Optional[ProviderName] = None)
)
-async def ai_convert_to_canvas(text: str, provider: Optional[ProviderName] = None) -> str:
+async def ai_convert_to_canvas(text: str, provider: ProviderName | None = None) -> str:
"""Convert text to a Mermaid diagram or canvas representation."""
return await _call_deepseek_openrouter(
f"Convert this content into a Mermaid.js diagram if applicable, or a structured outline. Choose the best format.\n\n{text}",
diff --git a/backend/ai_routes.py b/backend/ai_routes.py
index 4c941dc..d3a82b9 100644
--- a/backend/ai_routes.py
+++ b/backend/ai_routes.py
@@ -1,16 +1,29 @@
"""ObsiGate AI — API routes for AI-powered editor features."""
+import logging
+
from fastapi import APIRouter, HTTPException
from pydantic import BaseModel, Field
-from typing import Optional
-import logging
from backend.ai import (
- ai_improve_writing, ai_fix_spelling, ai_make_shorter, ai_make_longer,
- ai_simplify, ai_change_tone, ai_translate, ai_explain, ai_summarize,
- ai_continue_writing, ai_custom_rewrite, ai_convert_to_list,
- ai_convert_to_table, ai_generate_frontmatter, ai_inline_complete,
- ai_convert_to_canvas, DEFAULT_PROVIDER, PROVIDERS,
+ DEFAULT_PROVIDER,
+ PROVIDERS,
+ ai_change_tone,
+ ai_continue_writing,
+ ai_convert_to_canvas,
+ ai_convert_to_list,
+ ai_convert_to_table,
+ ai_custom_rewrite,
+ ai_explain,
+ ai_fix_spelling,
+ ai_generate_frontmatter,
+ ai_improve_writing,
+ ai_inline_complete,
+ ai_make_longer,
+ ai_make_shorter,
+ ai_simplify,
+ ai_summarize,
+ ai_translate,
)
logger = logging.getLogger("obsigate.ai_routes")
@@ -81,10 +94,10 @@ async def api_status():
class AIRequest(BaseModel):
text: str = Field(..., description="Input text to process", min_length=1)
- instruction: Optional[str] = Field(None, description="Custom instruction for rewrite")
- target_lang: Optional[str] = Field(None, description="Target language for translation")
- tone: Optional[str] = Field(None, description="Target tone (professional, casual, etc.)")
- provider: Optional[str] = Field(None, description="AI provider override")
+ instruction: str | None = Field(None, description="Custom instruction for rewrite")
+ target_lang: str | None = Field(None, description="Target language for translation")
+ tone: str | None = Field(None, description="Target tone (professional, casual, etc.)")
+ provider: str | None = Field(None, description="AI provider override")
class AIResponse(BaseModel):
@@ -101,7 +114,7 @@ async def _handle(action, request: AIRequest):
raise HTTPException(status_code=400, detail=str(e))
except Exception as e:
logger.error(f"AI error: {e}")
- raise HTTPException(status_code=500, detail=f"AI service error: {str(e)}")
+ raise HTTPException(status_code=500, detail=f"AI service error: {e!s}")
@router.post("/improve", response_model=AIResponse)
diff --git a/backend/attachment_indexer.py b/backend/attachment_indexer.py
index 6953796..6c32c72 100644
--- a/backend/attachment_indexer.py
+++ b/backend/attachment_indexer.py
@@ -1,8 +1,8 @@
import asyncio
import logging
-from pathlib import Path
-from typing import Any, Dict, List, Optional
import threading
+from pathlib import Path
+from typing import Any
logger = logging.getLogger("obsigate.attachment_indexer")
@@ -10,16 +10,16 @@ logger = logging.getLogger("obsigate.attachment_indexer")
IMAGE_EXTENSIONS = {".png", ".jpg", ".jpeg", ".gif", ".svg", ".webp", ".bmp", ".ico"}
# Global attachment index: {vault_name: {filename_lower: [absolute_path, ...]}}
-attachment_index: Dict[str, Dict[str, List[Path]]] = {}
+attachment_index: dict[str, dict[str, list[Path]]] = {}
# Resolution cache: {(vault_name, filename): resolved_path}
-_resolution_cache: Dict[tuple, Optional[Path]] = {}
+_resolution_cache: dict[tuple, Path | None] = {}
# Thread-safe lock for index updates
_attachment_lock = threading.Lock()
-def clear_resolution_cache(vault_name: Optional[str] = None) -> None:
+def clear_resolution_cache(vault_name: str | None = None) -> None:
"""Clear the resolution cache for a specific vault or all vaults.
Args:
@@ -29,12 +29,12 @@ def clear_resolution_cache(vault_name: Optional[str] = None) -> None:
if vault_name is None:
_resolution_cache.clear()
else:
- keys_to_remove = [k for k in _resolution_cache.keys() if k[0] == vault_name]
+ keys_to_remove = [k for k in _resolution_cache if k[0] == vault_name]
for key in keys_to_remove:
del _resolution_cache[key]
-def _scan_vault_attachments(vault_name: str, vault_path: str, vault_cfg: dict | None = None) -> Dict[str, List[Path]]:
+def _scan_vault_attachments(vault_name: str, vault_path: str, vault_cfg: dict | None = None) -> dict[str, list[Path]]:
"""Synchronously scan a vault directory for image attachments.
Walks the vault tree and builds a filename -> absolute path mapping
@@ -49,7 +49,7 @@ def _scan_vault_attachments(vault_name: str, vault_path: str, vault_cfg: dict |
Dict mapping lowercase filenames to lists of absolute paths.
"""
vault_root = Path(vault_path)
- index: Dict[str, List[Path]] = {}
+ index: dict[str, list[Path]] = {}
if not vault_root.exists():
logger.warning(f"Vault path does not exist for attachment scan: {vault_path}")
@@ -84,7 +84,7 @@ def _scan_vault_attachments(vault_name: str, vault_path: str, vault_cfg: dict |
return index
-async def build_attachment_index(vault_config: Dict[str, Dict[str, Any]]) -> None:
+async def build_attachment_index(vault_config: dict[str, dict[str, Any]]) -> None:
"""Build the attachment index for all configured vaults.
Runs vault scans concurrently in a thread pool, then performs
@@ -101,9 +101,9 @@ async def build_attachment_index(vault_config: Dict[str, Dict[str, Any]]) -> Non
return
loop = asyncio.get_event_loop()
- new_index: Dict[str, Dict[str, List[Path]]] = {}
+ new_index: dict[str, dict[str, list[Path]]] = {}
- tasks: list[tuple[str, asyncio.Future[Dict[str, List[Path]]]]] = []
+ tasks: list[tuple[str, asyncio.Future[dict[str, list[Path]]]]] = []
for name, config in vault_config.items():
vault_path = config.get("path")
if not vault_path:
@@ -159,9 +159,9 @@ def resolve_image_path(
image_src: str,
vault_name: str,
vault_root: Path,
- current_file_path: Optional[Path] = None,
- attachments_path: Optional[str] = None
-) -> Optional[Path]:
+ current_file_path: Path | None = None,
+ attachments_path: str | None = None
+) -> Path | None:
"""Resolve an image source path using multi-strategy resolution.
Applies 7 resolution strategies in priority order:
@@ -193,8 +193,8 @@ def resolve_image_path(
resolved = None
# Strategy 1: Absolute path
- if src_path.is_absolute():
- if src_path.exists() and src_path.is_file():
+ if src_path.is_absolute() and src_path.exists() and src_path.is_file():
+
resolved = src_path
logger.debug(f"Image resolved via strategy 1 (absolute): {image_src}")
@@ -279,7 +279,7 @@ def resolve_image_path(
return resolved
-def get_attachment_stats(vault_name: Optional[str] = None) -> Dict[str, int]:
+def get_attachment_stats(vault_name: str | None = None) -> dict[str, int]:
"""Get attachment statistics for a vault or all vaults.
Args:
diff --git a/backend/audit.py b/backend/audit.py
index b3b5124..dfe9879 100644
--- a/backend/audit.py
+++ b/backend/audit.py
@@ -8,11 +8,10 @@ Format: JSON lines (one JSON object per line) for easy parsing.
"""
import json
-import os
import logging
+import os
from datetime import datetime, timezone
from pathlib import Path
-from typing import Optional
logger = logging.getLogger("obsigate.audit")
@@ -51,7 +50,7 @@ def log_file_save(
vault_name: str,
file_path: str,
size: int,
- ip: Optional[str] = None,
+ ip: str | None = None,
):
"""Log a file save (PUT) operation."""
_write_entry({
@@ -69,7 +68,7 @@ def log_file_delete(
username: str,
vault_name: str,
file_path: str,
- ip: Optional[str] = None,
+ ip: str | None = None,
):
"""Log a file delete operation."""
_write_entry({
@@ -85,7 +84,7 @@ def log_file_delete(
def log_config_change(
username: str,
changes: dict,
- ip: Optional[str] = None,
+ ip: str | None = None,
):
"""Log a configuration change."""
_write_entry({
@@ -97,7 +96,7 @@ def log_config_change(
})
-def log_vault_add(username: str, vault_name: str, vault_path: str, ip: Optional[str] = None):
+def log_vault_add(username: str, vault_name: str, vault_path: str, ip: str | None = None):
"""Log a vault addition."""
_write_entry({
"timestamp": datetime.now(timezone.utc).isoformat(),
@@ -109,7 +108,7 @@ def log_vault_add(username: str, vault_name: str, vault_path: str, ip: Optional[
})
-def log_vault_remove(username: str, vault_name: str, ip: Optional[str] = None):
+def log_vault_remove(username: str, vault_name: str, ip: str | None = None):
"""Log a vault removal."""
_write_entry({
"timestamp": datetime.now(timezone.utc).isoformat(),
@@ -120,7 +119,7 @@ def log_vault_remove(username: str, vault_name: str, ip: Optional[str] = None):
})
-def get_recent_entries(limit: int = 100, action: Optional[str] = None) -> list:
+def get_recent_entries(limit: int = 100, action: str | None = None) -> list:
"""Read the most recent audit log entries.
Args:
diff --git a/backend/auth/jwt_handler.py b/backend/auth/jwt_handler.py
index 0714d9b..65c152f 100644
--- a/backend/auth/jwt_handler.py
+++ b/backend/auth/jwt_handler.py
@@ -4,14 +4,14 @@
# Revoked token JTIs persisted to data/revoked_tokens.json.
import json
+import logging
import os
import secrets
-import uuid
import time
-import logging
+import uuid
from pathlib import Path
-from jose import jwt, JWTError
-from typing import Optional
+
+from jose import JWTError, jwt
logger = logging.getLogger("obsigate.auth.jwt")
@@ -76,7 +76,7 @@ def create_refresh_token(username: str) -> tuple:
return jwt.encode(payload, get_secret_key(), algorithm=ALGORITHM), jti
-def decode_token(token: str) -> Optional[dict]:
+def decode_token(token: str) -> dict | None:
"""Decode and validate a JWT. Returns None if invalid/expired."""
try:
return jwt.decode(token, get_secret_key(), algorithms=[ALGORITHM])
diff --git a/backend/auth/middleware.py b/backend/auth/middleware.py
index fe16422..86600d7 100644
--- a/backend/auth/middleware.py
+++ b/backend/auth/middleware.py
@@ -2,11 +2,11 @@
# FastAPI dependencies for authentication and authorization.
# Reads JWT from Authorization header OR access_token cookie.
-import os
import logging
-from fastapi import Request, HTTPException, Depends
-from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials
-from typing import Optional
+import os
+
+from fastapi import Depends, HTTPException, Request
+from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
from .jwt_handler import decode_token
from .user_store import get_user
@@ -26,8 +26,8 @@ def is_auth_enabled() -> bool:
def get_current_user(
request: Request,
- credentials: Optional[HTTPAuthorizationCredentials] = Depends(security),
-) -> Optional[dict]:
+ credentials: HTTPAuthorizationCredentials | None = Depends(security),
+) -> dict | None:
"""Extract and validate the current user from JWT.
Reads token from Authorization header first, falls back to access_token cookie.
diff --git a/backend/auth/password.py b/backend/auth/password.py
index 819f654..76e303b 100644
--- a/backend/auth/password.py
+++ b/backend/auth/password.py
@@ -3,7 +3,7 @@
# Parameters: time_cost=2, memory_cost=64MB, parallelism=2
from argon2 import PasswordHasher
-from argon2.exceptions import VerifyMismatchError, VerificationError
+from argon2.exceptions import VerificationError, VerifyMismatchError
ph = PasswordHasher(
time_cost=2,
diff --git a/backend/auth/router.py b/backend/auth/router.py
index 09ac891..0922558 100644
--- a/backend/auth/router.py
+++ b/backend/auth/router.py
@@ -2,24 +2,37 @@
# All /api/auth/* endpoints: login, logout, refresh, me, change-password,
# and admin user CRUD.
-import re
import logging
-from fastapi import APIRouter, HTTPException, Response, Request, Depends
+import re
+
+from fastapi import APIRouter, Depends, HTTPException, Request, Response
from pydantic import BaseModel, validator
-from typing import List, Optional
+
+from backend.ratelimit import is_rate_limited
+from backend.ratelimit import record_failure as rl_record_failure
+from backend.ratelimit import record_success as rl_record_success
-from .user_store import (
- get_user, get_all_users, create_user, update_user, delete_user,
- record_login_success, record_login_failure, is_locked, has_users,
-)
from .jwt_handler import (
- create_access_token, create_refresh_token, decode_token,
- revoke_token, is_token_revoked,
ACCESS_TOKEN_EXPIRE_SECONDS,
+ create_access_token,
+ create_refresh_token,
+ decode_token,
+ is_token_revoked,
+ revoke_token,
+)
+from .middleware import is_auth_enabled, require_admin, require_auth
+from .password import hash_password, verify_password
+from .user_store import (
+ create_user,
+ delete_user,
+ get_all_users,
+ get_user,
+ has_users,
+ is_locked,
+ record_login_failure,
+ record_login_success,
+ update_user,
)
-from .middleware import require_auth, require_admin, is_auth_enabled
-from .password import verify_password, hash_password
-from backend.ratelimit import is_rate_limited, record_failure as rl_record_failure, record_success as rl_record_success
logger = logging.getLogger("obsigate.auth.router")
@@ -48,9 +61,9 @@ class ChangePasswordRequest(BaseModel):
class CreateUserRequest(BaseModel):
username: str
password: str
- display_name: Optional[str] = None
+ display_name: str | None = None
role: str = "user"
- vaults: List[str] = []
+ vaults: list[str] = []
@validator("username")
def username_valid(cls, v):
@@ -66,11 +79,11 @@ class CreateUserRequest(BaseModel):
class UpdateUserRequest(BaseModel):
- display_name: Optional[str] = None
- vaults: Optional[List[str]] = None
- active: Optional[bool] = None
- password: Optional[str] = None
- role: Optional[str] = None
+ display_name: str | None = None
+ vaults: list[str] | None = None
+ active: bool | None = None
+ password: str | None = None
+ role: str | None = None
# ── Public endpoints ──────────────────────────────────────────────────
@@ -249,8 +262,8 @@ async def get_me(current_user=Depends(require_auth)):
class UpdateMeRequest(BaseModel):
"""Fields the user can update on their own profile."""
- display_name: Optional[str] = None
- language: Optional[str] = None
+ display_name: str | None = None
+ language: str | None = None
@router.patch("/me")
diff --git a/backend/auth/user_store.py b/backend/auth/user_store.py
index 8e1b7b3..12c0b94 100644
--- a/backend/auth/user_store.py
+++ b/backend/auth/user_store.py
@@ -4,12 +4,11 @@
# via tmp+rename to prevent corruption on crash.
import json
-import uuid
import logging
import shutil
+import uuid
+from datetime import datetime, timedelta, timezone
from pathlib import Path
-from datetime import datetime, timezone, timedelta
-from typing import Optional, List
from .password import hash_password
@@ -54,12 +53,12 @@ def has_users() -> bool:
return bool(_read()["users"])
-def get_user(username: str) -> Optional[dict]:
+def get_user(username: str) -> dict | None:
"""Get a user by username. Returns None if not found."""
return _read()["users"].get(username)
-def get_all_users() -> List[dict]:
+def get_all_users() -> list[dict]:
"""Get all users WITHOUT password_hash (safe for API responses)."""
users = _read()["users"]
return [
@@ -72,8 +71,8 @@ def create_user(
username: str,
password: str,
role: str = "user",
- vaults: Optional[List[str]] = None,
- display_name: Optional[str] = None,
+ vaults: list[str] | None = None,
+ display_name: str | None = None,
) -> dict:
"""Create a new user. Raises ValueError if username already taken."""
data = _read()
diff --git a/backend/create_admin.py b/backend/create_admin.py
index bb41668..e4c7c4f 100644
--- a/backend/create_admin.py
+++ b/backend/create_admin.py
@@ -11,14 +11,15 @@ Docker usage:
docker exec obsigate python backend/create_admin.py list
"""
-import sys
import argparse
+import sys
# Add parent directory to path for imports
from pathlib import Path
+
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
-from backend.auth.user_store import create_user, get_all_users, delete_user
+from backend.auth.user_store import create_user, delete_user, get_all_users
def main():
diff --git a/backend/history.py b/backend/history.py
index c6f3404..c64e083 100644
--- a/backend/history.py
+++ b/backend/history.py
@@ -1,10 +1,10 @@
# backend/history.py
import json
-import time
import logging
import shutil
+import time
from pathlib import Path
-from typing import List, Dict, Any, Optional
+from typing import Any
logger = logging.getLogger("obsigate.history")
@@ -18,7 +18,7 @@ def _get_bookmarks_file(username: str) -> Path:
HISTORY_DIR.mkdir(parents=True, exist_ok=True)
return HISTORY_DIR / f"{username}_bookmarks.json"
-def _read_data(file: Path) -> List[Dict[str, Any]]:
+def _read_data(file: Path) -> list[dict[str, Any]]:
if not file.exists():
return []
try:
@@ -27,7 +27,7 @@ def _read_data(file: Path) -> List[Dict[str, Any]]:
logger.error(f"Failed to read data from {file.name}: {e}")
return []
-def _write_data(file: Path, data: List[Dict[str, Any]]):
+def _write_data(file: Path, data: list[dict[str, Any]]):
try:
tmp = file.with_suffix(".tmp")
tmp.write_text(json.dumps(data, indent=2, ensure_ascii=False), encoding="utf-8")
@@ -58,7 +58,7 @@ def record_open(username: str, vault: str, path: str, title: str = ""):
_write_data(_get_history_file(username), history)
-def get_recent_opened(username: str, vault_filter: Optional[str] = None, limit: int = 20) -> List[Dict[str, Any]]:
+def get_recent_opened(username: str, vault_filter: str | None = None, limit: int = 20) -> list[dict[str, Any]]:
"""Get the most recently opened files for a user."""
if not username:
return []
@@ -105,7 +105,7 @@ def toggle_bookmark(username: str, vault: str, path: str, title: str = ""):
_write_data(b_file, bookmarks)
return True
-def get_bookmarks(username: str, vault_filter: Optional[str] = None) -> List[Dict[str, Any]]:
+def get_bookmarks(username: str, vault_filter: str | None = None) -> list[dict[str, Any]]:
"""Get the bookmarks for a user."""
if not username:
return []
diff --git a/backend/image_processor.py b/backend/image_processor.py
index a754423..b9b2ac2 100644
--- a/backend/image_processor.py
+++ b/backend/image_processor.py
@@ -1,8 +1,7 @@
-import re
import logging
-from pathlib import Path
-from typing import Optional
+import re
from html import escape as html_escape
+from pathlib import Path
from backend.attachment_indexer import resolve_image_path
@@ -13,8 +12,8 @@ def preprocess_images(
content: str,
vault_name: str,
vault_root: Path,
- current_file_path: Optional[Path] = None,
- attachments_path: Optional[str] = None
+ current_file_path: Path | None = None,
+ attachments_path: str | None = None
) -> str:
"""Preprocess markdown content to transform all Obsidian image syntaxes.
@@ -55,8 +54,8 @@ def _process_html_img_in_link(
content: str,
vault_name: str,
vault_root: Path,
- current_file_path: Optional[Path],
- attachments_path: Optional[str]
+ current_file_path: Path | None,
+ attachments_path: str | None
) -> str:
"""Process [
](url) syntax.
@@ -119,8 +118,8 @@ def _process_wikilink_embeds(
content: str,
vault_name: str,
vault_root: Path,
- current_file_path: Optional[Path],
- attachments_path: Optional[str]
+ current_file_path: Path | None,
+ attachments_path: str | None
) -> str:
"""Process ![[image]] and ![[path/to/image]] wiki-link embeds.
@@ -158,8 +157,8 @@ def _process_standard_images(
content: str,
vault_name: str,
vault_root: Path,
- current_file_path: Optional[Path],
- attachments_path: Optional[str]
+ current_file_path: Path | None,
+ attachments_path: str | None
) -> str:
"""Process  standard markdown images.
diff --git a/backend/indexer.py b/backend/indexer.py
index e5d00df..e493608 100644
--- a/backend/indexer.py
+++ b/backend/indexer.py
@@ -1,21 +1,22 @@
-import os
import asyncio
import logging
+import os
import re
import threading
-from pathlib import Path
+from collections.abc import Callable
from datetime import datetime, timezone
-from typing import Any, Callable, Dict, List, Optional
+from pathlib import Path
+from typing import Any
import frontmatter
logger = logging.getLogger("obsigate.indexer")
# Global in-memory index
-index: Dict[str, Dict[str, Any]] = {}
+index: dict[str, dict[str, Any]] = {}
# Vault config: {name: {path, attachmentsPath, scanAttachmentsOnStartup}}
-vault_config: Dict[str, Dict[str, Any]] = {}
+vault_config: dict[str, dict[str, Any]] = {}
# Thread-safe lock for index updates
_index_lock = threading.Lock()
@@ -41,13 +42,13 @@ def set_index_change_hook(hook):
_on_index_change = hook
# O(1) lookup table for wikilink resolution: {filename_lower: [{vault, path}, ...]}
-_file_lookup: Dict[str, List[Dict[str, str]]] = {}
+_file_lookup: dict[str, list[dict[str, str]]] = {}
# Backlink index: {vault_name: {relative_path: [{vault, path, title}, ...]}}
-_backlink_index: Dict[str, Dict[str, List[Dict[str, str]]]] = {}
+_backlink_index: dict[str, dict[str, list[dict[str, str]]]] = {}
# O(1) path index for tree filtering: {vault_name: [{path, name, type}, ...]}
-path_index: Dict[str, List[Dict[str, str]]] = {}
+path_index: dict[str, list[dict[str, str]]] = {}
# Maximum content size stored per file for in-memory search (bytes)
SEARCH_CONTENT_LIMIT = 100_000
@@ -68,10 +69,10 @@ SUPPORTED_EXTENSIONS = {
# Ignored directories (configurable via OBSIGATE_IGNORED_DIRS env var)
_DEFAULT_IGNORED = {'.obsidian', '.trash', '.git', '__pycache__', 'node_modules', '.obsigate-backup'}
_env_ignored = os.environ.get("OBSIGATE_IGNORED_DIRS", "")
-IGNORED_DIRS = set(d.strip() for d in _env_ignored.split(",") if d.strip()) if _env_ignored else _DEFAULT_IGNORED.copy()
+IGNORED_DIRS = {d.strip() for d in _env_ignored.split(",") if d.strip()} if _env_ignored else _DEFAULT_IGNORED.copy()
-def load_vault_config() -> Dict[str, Dict[str, Any]]:
+def load_vault_config() -> dict[str, dict[str, Any]]:
"""Read VAULT_N_* and DIR_N_* env vars and return vault configuration.
Scans environment variables ``VAULT_1_NAME``/``VAULT_1_PATH``,
@@ -89,7 +90,7 @@ def load_vault_config() -> Dict[str, Dict[str, Any]]:
- scanAttachmentsOnStartup: boolean (default True)
- type: "VAULT" or "DIR"
"""
- vaults: Dict[str, Dict[str, Any]] = {}
+ vaults: dict[str, dict[str, Any]] = {}
n = 1
while True:
name = os.environ.get(f"VAULT_{n}_NAME")
@@ -135,7 +136,7 @@ _CODE_BLOCK_RE = re.compile(r'```[\s\S]*?```', re.MULTILINE)
_INLINE_CODE_RE = re.compile(r'`[^`]+`')
-def _extract_tags(post: frontmatter.Post) -> List[str]:
+def _extract_tags(post: frontmatter.Post) -> list[str]:
"""Extract tags from frontmatter metadata.
@@ -158,7 +159,7 @@ def _extract_tags(post: frontmatter.Post) -> List[str]:
return tags
-def _extract_inline_tags(content: str) -> List[str]:
+def _extract_inline_tags(content: str) -> list[str]:
"""Extract inline #tag patterns from markdown content.
Strips fenced and inline code blocks before scanning to avoid
@@ -215,10 +216,10 @@ def parse_markdown_file(raw: str) -> frontmatter.Post:
match = re.match(r"^---\s*\r?\n.*?\r?\n---\s*\r?\n?", raw, flags=re.DOTALL)
if match:
content = raw[match.end():]
- return frontmatter.Post(content, **{})
+ return frontmatter.Post(content)
-def _scan_vault(vault_name: str, vault_path: str, vault_cfg: Optional[Dict[str, Any]] = None) -> Dict[str, Any]:
+def _scan_vault(vault_name: str, vault_path: str, vault_cfg: dict[str, Any] | None = None) -> dict[str, Any]:
"""Synchronously scan a single vault directory and build file index.
Walks the vault tree, reads supported files, extracts metadata
@@ -236,9 +237,9 @@ def _scan_vault(vault_name: str, vault_path: str, vault_cfg: Optional[Dict[str,
Dict with keys ``files`` (list), ``tags`` (counter dict), ``path`` (str), ``paths`` (list).
"""
vault_root = Path(vault_path)
- files: List[Dict[str, Any]] = []
- tag_counts: Dict[str, int] = {}
- paths: List[Dict[str, str]] = []
+ files: list[dict[str, Any]] = []
+ tag_counts: dict[str, int] = {}
+ paths: list[dict[str, str]] = []
if not vault_root.exists():
logger.warning(f"Vault path does not exist: {vault_path}")
@@ -283,15 +284,15 @@ def _scan_vault(vault_name: str, vault_path: str, vault_cfg: Optional[Dict[str,
# PDF handling — special path (binary, uses pdf_reader)
if ext == ".pdf":
- from backend.pdf_reader import extract_pdf_text, extract_pdf_metadata
+ from backend.pdf_reader import extract_pdf_metadata, extract_pdf_text
raw = extract_pdf_text(fpath, max_chars=100000)
pdf_meta = extract_pdf_metadata(fpath)
title = pdf_meta.get("title") or fpath.stem.replace("-", " ").replace("_", " ")
content_preview = raw[:200].strip()
- tags: List[str] = []
+ tags: list[str] = []
else:
raw = fpath.read_text(encoding="utf-8", errors="replace")
- tags: List[str] = []
+ tags: list[str] = []
title = fpath.stem.replace("-", " ").replace("_", " ")
content_preview = raw[:200].strip()
@@ -365,13 +366,13 @@ async def build_index(progress_callback=None) -> None:
loop = asyncio.get_event_loop()
- async def _process_vault(name: str, config: Dict[str, Any]):
+ async def _process_vault(name: str, config: dict[str, Any]):
vault_path = config["path"]
vault_data = await loop.run_in_executor(None, _scan_vault, name, vault_path, config)
vault_data["config"] = config
# Build lookup entries for the new vault
- new_lookup_entries: Dict[str, List[Dict[str, str]]] = {}
+ new_lookup_entries: dict[str, list[dict[str, str]]] = {}
for f in vault_data["files"]:
entry = {"vault": name, "path": f["path"]}
fname = f["path"].rsplit("/", 1)[-1].lower()
@@ -419,7 +420,7 @@ async def build_index(progress_callback=None) -> None:
await progress_callback("complete", {"total_vaults": len(vault_config), "total_files": total_files})
-async def reload_index() -> Dict[str, Any]:
+async def reload_index() -> dict[str, Any]:
"""Force a full re-index of all vaults and return per-vault statistics.
Returns:
@@ -432,7 +433,7 @@ async def reload_index() -> Dict[str, Any]:
return stats
-async def reload_single_vault(vault_name: str) -> Dict[str, Any]:
+async def reload_single_vault(vault_name: str) -> dict[str, Any]:
"""Force a re-index of a single vault and return its statistics.
Args:
@@ -464,7 +465,7 @@ async def reload_single_vault(vault_name: str) -> Dict[str, Any]:
vault_data["config"] = config
# Build lookup entries for the vault
- new_lookup_entries: Dict[str, List[Dict[str, str]]] = {}
+ new_lookup_entries: dict[str, list[dict[str, str]]] = {}
for f in vault_data["files"]:
entry = {"vault": vault_name, "path": f["path"]}
fname = f["path"].rsplit("/", 1)[-1].lower()
@@ -495,12 +496,12 @@ async def reload_single_vault(vault_name: str) -> Dict[str, Any]:
return stats
-def get_vault_names() -> List[str]:
+def get_vault_names() -> list[str]:
"""Return the list of all indexed vault names."""
return list(index.keys())
-def get_vault_data(vault_name: str) -> Optional[Dict[str, Any]]:
+def get_vault_data(vault_name: str) -> dict[str, Any] | None:
"""Return the full index data for a vault, or ``None`` if not found."""
return index.get(vault_name)
@@ -513,7 +514,7 @@ def _get_async_lock() -> asyncio.Lock:
return _async_index_lock
-def _index_single_file_sync(vault_name: str, vault_path: str, file_path: str, vault_cfg: Optional[Dict[str, Any]] = None) -> Optional[Dict[str, Any]]:
+def _index_single_file_sync(vault_name: str, vault_path: str, file_path: str, vault_cfg: dict[str, Any] | None = None) -> dict[str, Any] | None:
"""Synchronously read and parse a single file for indexing.
All files are indexed, including hidden files (starting with '.').
@@ -545,7 +546,7 @@ def _index_single_file_sync(vault_name: str, vault_path: str, file_path: str, va
modified = datetime.fromtimestamp(stat.st_mtime, tz=timezone.utc).isoformat()
raw = fpath.read_text(encoding="utf-8", errors="replace")
- tags: List[str] = []
+ tags: list[str] = []
title = fpath.stem.replace("-", " ").replace("_", " ")
content_preview = raw[:200].strip()
@@ -575,7 +576,7 @@ def _index_single_file_sync(vault_name: str, vault_path: str, file_path: str, va
return None
-def _remove_file_from_structures(vault_name: str, rel_path: str) -> Optional[Dict[str, Any]]:
+def _remove_file_from_structures(vault_name: str, rel_path: str) -> dict[str, Any] | None:
"""Remove a file from all index structures. Returns removed file info or None.
Must be called under _index_lock or _async_index_lock.
@@ -650,7 +651,7 @@ def _ensure_parent_dirs_in_path_index(vault_name: str, rel_path: str, existing:
})
-def _add_file_to_structures(vault_name: str, file_info: Dict[str, Any]):
+def _add_file_to_structures(vault_name: str, file_info: dict[str, Any]):
"""Add a file entry to all index structures.
Must be called under _index_lock or _async_index_lock.
@@ -695,7 +696,7 @@ def _add_file_to_structures(vault_name: str, file_info: Dict[str, Any]):
_on_index_change('add', vault_name, file_info["path"], file_info) # type: ignore[misc]
-async def update_single_file(vault_name: str, abs_file_path: str) -> Optional[Dict[str, Any]]:
+async def update_single_file(vault_name: str, abs_file_path: str) -> dict[str, Any] | None:
"""Re-index a single file without full rebuild.
Reads the file, removes the old entry if present, inserts the new one.
@@ -742,7 +743,7 @@ async def update_single_file(vault_name: str, abs_file_path: str) -> Optional[Di
return file_info
-async def remove_single_file(vault_name: str, abs_file_path: str) -> Optional[Dict[str, Any]]:
+async def remove_single_file(vault_name: str, abs_file_path: str) -> dict[str, Any] | None:
"""Remove a single file from the index.
Args:
@@ -774,7 +775,7 @@ async def remove_single_file(vault_name: str, abs_file_path: str) -> Optional[Di
return removed
-async def handle_file_move(vault_name: str, src_abs: str, dest_abs: str) -> Optional[Dict[str, Any]]:
+async def handle_file_move(vault_name: str, src_abs: str, dest_abs: str) -> dict[str, Any] | None:
"""Handle a file move/rename by removing old entry and indexing new location.
Args:
@@ -823,7 +824,7 @@ async def remove_vault_from_index(vault_name: str):
logger.info(f"Removed vault '{vault_name}' from index")
-async def add_vault_to_index(vault_name: str, vault_path: str) -> Dict[str, Any]:
+async def add_vault_to_index(vault_name: str, vault_path: str) -> dict[str, Any]:
"""Add a new vault to the index dynamically.
Args:
@@ -846,7 +847,7 @@ async def add_vault_to_index(vault_name: str, vault_path: str) -> Dict[str, Any]
vault_data["config"] = vault_config[vault_name]
# Build lookup entries for the new vault
- new_lookup_entries: Dict[str, List[Dict[str, str]]] = {}
+ new_lookup_entries: dict[str, list[dict[str, str]]] = {}
for f in vault_data["files"]:
entry = {"vault": vault_name, "path": f["path"]}
fname = f["path"].rsplit("/", 1)[-1].lower()
@@ -871,7 +872,7 @@ async def add_vault_to_index(vault_name: str, vault_path: str) -> Dict[str, Any]
return stats
-def find_file_in_index(link_target: str, current_vault: str) -> Optional[Dict[str, str]]:
+def find_file_in_index(link_target: str, current_vault: str) -> dict[str, str] | None:
"""Find a file matching a wikilink target using O(1) lookup table.
Searches by filename first, then by full relative path.
@@ -943,7 +944,7 @@ def _extract_wikilinks_for_backlinks(
})
-def get_backlinks(vault_name: str, file_path: str) -> List[Dict[str, str]]:
+def get_backlinks(vault_name: str, file_path: str) -> list[dict[str, str]]:
"""Get all files that link to the given file via wikilinks.
Searches across all vaults for backlinks pointing to the target file.
@@ -961,7 +962,7 @@ def get_backlinks(vault_name: str, file_path: str) -> List[Dict[str, str]]:
target_key += ".md"
results = []
- for vname, vindex in _backlink_index.items():
+ for vindex in _backlink_index.values():
bl = vindex.get(target_key, [])
results.extend(bl)
return results
diff --git a/backend/main.py b/backend/main.py
index 1748507..d810b38 100644
--- a/backend/main.py
+++ b/backend/main.py
@@ -1,10 +1,10 @@
import asyncio
-import json as _json
-import os
-import re
import html as html_mod
+import json as _json
import logging
import mimetypes
+import os
+import re
import secrets
import shutil
import string
@@ -15,43 +15,59 @@ from contextlib import asynccontextmanager
from datetime import datetime, timezone
from functools import partial
from pathlib import Path
-from typing import Optional, List, Dict, Any
+from typing import Any
import frontmatter
import mistune
-from fastapi import FastAPI, HTTPException, Query, Body, Depends
+from fastapi import Body, Depends, FastAPI, HTTPException, Query
+from fastapi.responses import FileResponse, HTMLResponse, Response, StreamingResponse
from fastapi.staticfiles import StaticFiles
-from fastapi.responses import HTMLResponse, FileResponse, Response, StreamingResponse
from pydantic import BaseModel, Field
from starlette.middleware.base import BaseHTTPMiddleware
+from backend.attachment_indexer import get_attachment_stats, rescan_vault_attachments
+from backend.history import (
+ get_bookmarks,
+ get_recent_opened,
+ is_bookmarked,
+ record_open,
+ remove_recent,
+ toggle_bookmark,
+ update_bookmarks_after_rename,
+ update_history_after_rename,
+)
+from backend.image_processor import preprocess_images
from backend.indexer import (
+ IGNORED_DIRS,
+ SUPPORTED_EXTENSIONS,
+ _extract_tags,
+ add_vault_to_index,
build_index,
- reload_index,
- index,
- path_index,
- get_vault_data,
find_file_in_index,
get_backlinks,
get_conflicts,
- parse_markdown_file,
- _extract_tags,
- SUPPORTED_EXTENSIONS,
- IGNORED_DIRS,
- update_single_file,
- remove_single_file,
+ get_vault_data,
handle_file_move,
+ index,
+ parse_markdown_file,
+ path_index,
+ reload_index,
+ remove_single_file,
remove_vault_from_index,
- add_vault_to_index,
+ update_single_file,
+)
+from backend.search import (
+ advanced_search,
+ get_all_tags,
+ init_inverted_index,
+ search,
+ suggest_tags,
+ suggest_titles,
)
-from backend.search import search, get_all_tags, advanced_search, suggest_titles, suggest_tags, init_inverted_index
-from backend.image_processor import preprocess_images
-from backend.attachment_indexer import rescan_vault_attachments, get_attachment_stats
from backend.vault_settings import (
get_vault_setting,
update_vault_setting,
)
-from backend.history import record_open, get_recent_opened, remove_recent, toggle_bookmark, get_bookmarks, is_bookmarked, update_bookmarks_after_rename, update_history_after_rename
logging.basicConfig(
level=logging.INFO,
@@ -77,16 +93,16 @@ class BrowseItem(BaseModel):
name: str = Field(description="File or directory name")
path: str = Field(description="Relative path within vault")
type: str = Field(description="'file' or 'directory'")
- children_count: Optional[int] = Field(default=None, description="Number of children (directories only)")
- size: Optional[int] = Field(default=None, description="File size in bytes")
- extension: Optional[str] = Field(default=None, description="File extension")
+ children_count: int | None = Field(default=None, description="Number of children (directories only)")
+ size: int | None = Field(default=None, description="File size in bytes")
+ extension: str | None = Field(default=None, description="File extension")
class BrowseResponse(BaseModel):
"""Paginated directory listing for a vault."""
vault: str
path: str
- items: List[BrowseItem]
+ items: list[BrowseItem]
class FileContentResponse(BaseModel):
@@ -94,14 +110,14 @@ class FileContentResponse(BaseModel):
vault: str = Field(description="Vault name")
path: str = Field(description="Relative file path within the vault")
title: str = Field(description="File title (from frontmatter or filename)")
- tags: List[str] = Field(description="Extracted tags from frontmatter and inline #tags")
- frontmatter: Dict[str, Any] = Field(description="YAML frontmatter as key-value dict")
+ tags: list[str] = Field(description="Extracted tags from frontmatter and inline #tags")
+ frontmatter: dict[str, Any] = Field(description="YAML frontmatter as key-value dict")
html: str = Field(description="Rendered HTML content")
raw_length: int = Field(description="Length of raw file content in characters")
extension: str = Field(description="File extension (e.g. .md, .txt)")
is_markdown: bool = Field(description="Whether the file is markdown")
- unsupported: Optional[bool] = Field(default=False, description="True for binary/unsupported files")
- size_bytes: Optional[int] = Field(default=None, description="File size in bytes (for unsupported files)")
+ unsupported: bool | None = Field(default=False, description="True for binary/unsupported files")
+ size_bytes: int | None = Field(default=None, description="File size in bytes (for unsupported files)")
class FileRawResponse(BaseModel):
@@ -131,7 +147,7 @@ class SearchResultItem(BaseModel):
vault: str = Field(description="Vault name")
path: str = Field(description="Relative file path")
title: str = Field(description="File title")
- tags: List[str] = Field(description="File tags")
+ tags: list[str] = Field(description="File tags")
score: int = Field(description="Relevance score")
snippet: str = Field(description="Content excerpt with highlights")
modified: str = Field(description="ISO 8601 modification timestamp")
@@ -141,18 +157,18 @@ class SearchResponse(BaseModel):
"""Full-text search response with optional pagination."""
query: str = Field(description="Original search query")
vault_filter: str = Field(description="Vault filter applied ('all' or vault name)")
- tag_filter: Optional[str] = Field(default=None, description="Tag filter applied")
+ tag_filter: str | None = Field(default=None, description="Tag filter applied")
count: int = Field(description="Number of results in this response")
total: int = Field(default=0, description="Total results before pagination")
offset: int = Field(default=0, description="Current pagination offset")
limit: int = Field(default=200, description="Page size")
- results: List[SearchResultItem] = Field(description="Search result items")
+ results: list[SearchResultItem] = Field(description="Search result items")
class TagsResponse(BaseModel):
"""Tag aggregation response."""
- vault_filter: Optional[str] = Field(default=None, description="Vault filter applied")
- tags: Dict[str, int] = Field(description="Tag name → count mapping")
+ vault_filter: str | None = Field(default=None, description="Vault filter applied")
+ tags: dict[str, int] = Field(description="Tag name → count mapping")
class TreeSearchResult(BaseModel):
@@ -168,7 +184,7 @@ class TreeSearchResponse(BaseModel):
"""Tree search response with matching paths."""
query: str = Field(description="Search query")
vault_filter: str = Field(description="Vault filter applied")
- results: List[TreeSearchResult] = Field(description="Matching files and directories")
+ results: list[TreeSearchResult] = Field(description="Matching files and directories")
class AdvancedSearchResultItem(BaseModel):
@@ -176,7 +192,7 @@ class AdvancedSearchResultItem(BaseModel):
vault: str = Field(description="Vault name")
path: str = Field(description="Relative file path")
title: str = Field(description="File title")
- tags: List[str] = Field(description="File tags")
+ tags: list[str] = Field(description="File tags")
score: float = Field(description="TF-IDF relevance score")
snippet: str = Field(description="Content excerpt with highlights")
modified: str = Field(description="ISO 8601 modification timestamp")
@@ -185,13 +201,13 @@ class AdvancedSearchResultItem(BaseModel):
class SearchFacets(BaseModel):
"""Faceted counts for search results."""
- tags: Dict[str, int] = Field(default_factory=dict)
- vaults: Dict[str, int] = Field(default_factory=dict)
+ tags: dict[str, int] = Field(default_factory=dict)
+ vaults: dict[str, int] = Field(default_factory=dict)
class AdvancedSearchResponse(BaseModel):
"""Advanced search response with TF-IDF scoring, facets, and pagination."""
- results: List[AdvancedSearchResultItem] = Field(description="Search results")
+ results: list[AdvancedSearchResultItem] = Field(description="Search results")
total: int = Field(description="Total number of matching results")
offset: int = Field(description="Current pagination offset")
limit: int = Field(description="Page size")
@@ -209,7 +225,7 @@ class TitleSuggestion(BaseModel):
class SuggestResponse(BaseModel):
"""Autocomplete suggestions for file titles."""
query: str = Field(description="Original query string")
- suggestions: List[TitleSuggestion] = Field(description="Matching file suggestions")
+ suggestions: list[TitleSuggestion] = Field(description="Matching file suggestions")
class TagSuggestion(BaseModel):
@@ -221,7 +237,7 @@ class TagSuggestion(BaseModel):
class TagSuggestResponse(BaseModel):
"""Autocomplete suggestions for tags."""
query: str = Field(description="Original query string")
- suggestions: List[TagSuggestion] = Field(description="Matching tag suggestions")
+ suggestions: list[TagSuggestion] = Field(description="Matching tag suggestions")
class GraphNode(BaseModel):
@@ -231,7 +247,7 @@ class GraphNode(BaseModel):
type: str = Field(description="'vault', 'directory', or 'file'")
path: str = Field(description="Relative path within vault")
size: int = Field(default=0, description="File size in bytes")
- tags: List[str] = Field(default_factory=list, description="Tags from frontmatter")
+ tags: list[str] = Field(default_factory=list, description="Tags from frontmatter")
incoming_count: int = Field(default=0, description="Number of incoming wikilinks")
outgoing_count: int = Field(default=0, description="Number of outgoing wikilinks")
@@ -248,14 +264,14 @@ class GraphResponse(BaseModel):
vault: str = Field(description="Vault name")
path: str = Field(description="Root path for the graph")
scope: str = Field(default="directory", description="'directory' or 'full'")
- nodes: List[GraphNode] = Field(description="Graph nodes (files and directories)")
- edges: List[GraphEdge] = Field(description="Graph edges (parent and wikilink relations)")
+ nodes: list[GraphNode] = Field(description="Graph nodes (files and directories)")
+ edges: list[GraphEdge] = Field(description="Graph edges (parent and wikilink relations)")
class ReloadResponse(BaseModel):
"""Index reload confirmation with per-vault stats."""
status: str = Field(description="Reload status ('ok' or 'error')")
- vaults: Dict[str, Any] = Field(description="Per-vault file counts after reload")
+ vaults: dict[str, Any] = Field(description="Per-vault file counts after reload")
class HealthResponse(BaseModel):
@@ -353,7 +369,7 @@ class BackupListResponse(BaseModel):
class DiffRequest(BaseModel):
"""Request parameters for generating a diff."""
version: int = Field(description="Timestamp of the backup version to compare")
- compare_with: Optional[int] = Field(default=None, description="Timestamp of another backup version. If omitted, compares with the current file.")
+ compare_with: int | None = Field(default=None, description="Timestamp of another backup version. If omitted, compares with the current file.")
class DiffResponse(BaseModel):
@@ -361,7 +377,7 @@ class DiffResponse(BaseModel):
vault: str = Field(description="Vault name")
path: str = Field(description="Relative file path")
version: int = Field(description="Backup version timestamp (left/old side)")
- compare_with: Optional[int] = Field(default=None, description="Other backup version or null for current file (right/new side)")
+ compare_with: int | None = Field(default=None, description="Other backup version or null for current file (right/new side)")
diff: str = Field(description="Unified diff (empty if no changes)")
@@ -376,7 +392,7 @@ class RestoreResponse(BaseModel):
vault: str = Field(description="Vault name")
path: str = Field(description="Relative file path")
restored_from: int = Field(description="Timestamp of the backup used")
- current_backed_up: Optional[int] = Field(default=None, description="Timestamp of the backup created from the current version before restore, if any")
+ current_backed_up: int | None = Field(default=None, description="Timestamp of the backup created from the current version before restore, if any")
# ---------------------------------------------------------------------------
@@ -388,7 +404,7 @@ class SSEManager:
"""Manages SSE client connections and broadcasts events."""
def __init__(self):
- self._clients: List[asyncio.Queue] = []
+ self._clients: list[asyncio.Queue] = []
async def connect(self) -> asyncio.Queue:
"""Register a new SSE client and return its message queue."""
@@ -406,7 +422,7 @@ class SSEManager:
async def broadcast(self, event_type: str, data: dict):
"""Send an event to all connected SSE clients."""
message = _json.dumps(data, ensure_ascii=False)
- dead: List[asyncio.Queue] = []
+ dead: list[asyncio.Queue] = []
for q in self._clients:
try:
q.put_nowait({"event": event_type, "data": message})
@@ -427,12 +443,12 @@ sse_manager = SSEManager()
# Application lifespan (replaces deprecated on_event)
# ---------------------------------------------------------------------------
-from backend.watcher import VaultWatcher # noqa: E402
+from backend.watcher import VaultWatcher
# Thread pool for offloading CPU-bound search from the event loop.
# Sized to 2 workers so concurrent searches don't starve other requests.
-_search_executor: Optional[ThreadPoolExecutor] = None
-_vault_watcher: Optional[VaultWatcher] = None
+_search_executor: ThreadPoolExecutor | None = None
+_vault_watcher: VaultWatcher | None = None
async def _on_vault_change(events: list):
@@ -493,7 +509,7 @@ def bootstrap_admin():
Only runs when auth is enabled and no users.json exists yet.
"""
from backend.auth.middleware import is_auth_enabled
- from backend.auth.user_store import has_users, create_user
+ from backend.auth.user_store import create_user, has_users
if not is_auth_enabled():
return
@@ -621,8 +637,9 @@ app = FastAPI(title="ObsiGate", version="1.4.0", lifespan=lifespan)
# GZip compression — reduces bandwidth by ~70% for text responses
# Custom wrapper: skip compression for SSE streams (/api/events)
-from fastapi.middleware.gzip import GZipMiddleware # noqa: E402
-from starlette.types import Scope, Receive, Send # noqa: E402
+from fastapi.middleware.gzip import GZipMiddleware
+from starlette.types import Receive, Scope, Send
+
class SSESafeGZipMiddleware(GZipMiddleware):
"""GZip middleware that skips SSE (Server-Sent Events) streams.
@@ -643,22 +660,40 @@ app.add_middleware(SSESafeGZipMiddleware, minimum_size=1000)
app.add_middleware(SecurityHeadersMiddleware)
# Auth router
-from backend.auth.router import router as auth_router # noqa: E402
-from backend.auth.middleware import require_auth, require_admin, check_vault_access # noqa: E402
-from backend.secret_redactor import redact_file_content # noqa: E402
-from backend.audit import log_file_save, log_file_delete # noqa: E402
+from backend.audit import log_file_delete, log_file_save
+from backend.auth.middleware import (
+ check_vault_access,
+ require_admin,
+ require_auth,
+)
+from backend.auth.router import router as auth_router
+from backend.secret_redactor import redact_file_content
+
# Lazy import: WeasyPrint PDF export (requires GTK, may not be available everywhere)
try:
- from backend.pdf_export import generate_pdf, build_pdf_html # noqa: E402
+ from backend.pdf_export import build_pdf_html, generate_pdf
except OSError:
generate_pdf = None # type: ignore[assignment]
build_pdf_html = None # type: ignore[assignment]
import logging
logging.getLogger("obsigate").warning("PDF export unavailable (WeasyPrint/GTK not found)")
-from backend.share import create_share, get_share_by_token, record_access, revoke_share, list_shares, update_shares_after_rename # noqa: E402
-from backend.webhooks import get_webhooks, create_webhook, update_webhook, delete_webhook, dispatch_webhooks # noqa: E402
-from backend.saved_searches import get_saved, save_search, delete_saved # noqa: E402
-from backend.ai_routes import router as ai_router # noqa: E402
+from backend.ai_routes import router as ai_router
+from backend.saved_searches import delete_saved, get_saved, save_search
+from backend.share import (
+ create_share,
+ get_share_by_token,
+ list_shares,
+ record_access,
+ revoke_share,
+ update_shares_after_rename,
+)
+from backend.webhooks import (
+ create_webhook,
+ delete_webhook,
+ dispatch_webhooks,
+ get_webhooks,
+ update_webhook,
+)
app.include_router(auth_router)
app.include_router(ai_router)
@@ -696,7 +731,7 @@ def _resolve_safe_path(vault_root: Path, relative_path: str) -> Path:
vault_resolved = vault_root.resolve(strict=False)
except Exception as e:
logger.error(f"Path resolution error - vault_root: {vault_root}, relative_path: {relative_path}, error: {e}")
- raise HTTPException(status_code=500, detail=f"Path resolution error: {str(e)}")
+ raise HTTPException(status_code=500, detail=f"Path resolution error: {e!s}")
# Check if resolved path is within vault using string comparison (case-insensitive on Windows)
try:
@@ -767,7 +802,7 @@ def _check_vault_writable(vault_root: Path) -> bool:
# Markdown rendering helpers (singleton renderer)
# ---------------------------------------------------------------------------
-import unicodedata # noqa: E402
+import unicodedata
def _heading_slugify(text: str) -> str:
@@ -815,7 +850,7 @@ def _add_heading_ids(html: str) -> str:
Returns:
HTML with heading IDs injected.
"""
- used_ids: Dict[str, int] = {}
+ used_ids: dict[str, int] = {}
def _replace_heading(match):
tag = match.group(1)
@@ -871,7 +906,7 @@ def _convert_wikilinks(content: str, current_vault: str) -> str:
return re.sub(pattern, _replace, content)
-def _render_markdown(raw_md: str, vault_name: str, current_file_path: Optional[Path] = None) -> str:
+def _render_markdown(raw_md: str, vault_name: str, current_file_path: Path | None = None) -> str:
"""Render a markdown string to HTML with wikilink and image support.
Uses the cached singleton mistune renderer for performance.
@@ -927,7 +962,7 @@ async def api_health():
}
-@app.get("/api/vaults", response_model=List[VaultInfo])
+@app.get("/api/vaults", response_model=list[VaultInfo])
async def api_vaults(current_user=Depends(require_auth)):
"""List configured vaults the user has access to.
@@ -962,7 +997,7 @@ def humanize_mtime(mtime: float) -> str:
@app.get("/api/recent")
-async def api_recent(limit: Optional[int] = Query(None), vault: Optional[str] = Query(None), mode: Optional[str] = Query("opened"), current_user=Depends(require_auth)):
+async def api_recent(limit: int | None = Query(None), vault: str | None = Query(None), mode: str | None = Query("opened"), current_user=Depends(require_auth)):
config = _load_config()
actual_limit = limit if limit is not None else config.get("recent_files_limit", 20)
@@ -1056,7 +1091,7 @@ async def api_recent(limit: Optional[int] = Query(None), vault: Optional[str] =
@app.get("/api/bookmarks")
-async def api_bookmarks(vault: Optional[str] = Query(None), current_user=Depends(require_auth)):
+async def api_bookmarks(vault: str | None = Query(None), current_user=Depends(require_auth)):
username = current_user.get("username")
user_vaults = current_user.get("_token_vaults") or current_user.get("vaults", [])
@@ -1101,7 +1136,7 @@ async def api_bookmarks(vault: Optional[str] = Query(None), current_user=Depends
class BookmarkToggleRequest(BaseModel):
vault: str
path: str
- title: Optional[str] = None
+ title: str | None = None
@app.post("/api/bookmarks/toggle")
async def api_toggle_bookmark(req: BookmarkToggleRequest, current_user=Depends(require_auth)):
@@ -1283,10 +1318,10 @@ async def api_file_raw(vault_name: str, path: str = Query(..., description="Rela
raw = file_path.read_bytes().decode("utf-8", errors="replace")
except Exception as e:
logger.error(f"Error reading binary raw file {path}: {e}")
- raise HTTPException(status_code=500, detail=f"Cannot read file: {str(e)}")
+ raise HTTPException(status_code=500, detail=f"Cannot read file: {e!s}")
except Exception as e:
logger.error(f"Unexpected error reading raw file {path}: {e}")
- raise HTTPException(status_code=500, detail=f"Error reading file: {str(e)}")
+ raise HTTPException(status_code=500, detail=f"Error reading file: {e!s}")
return {"vault": vault_name, "path": path, "raw": raw}
@@ -1405,7 +1440,7 @@ async def api_file_save(
raise HTTPException(status_code=403, detail="Permission denied: vault may be read-only")
except Exception as e:
logger.error(f"Error saving file {vault_name}/{path}: {e}")
- raise HTTPException(status_code=500, detail=f"Error saving file: {str(e)}")
+ raise HTTPException(status_code=500, detail=f"Error saving file: {e!s}")
@app.delete("/api/file/{vault_name}", response_model=FileDeleteResponse)
@@ -1468,7 +1503,7 @@ async def api_file_delete(vault_name: str, path: str = Query(..., description="R
raise HTTPException(status_code=403, detail="Permission denied: vault may be read-only")
except Exception as e:
logger.error(f"Error deleting file {vault_name}/{path}: {e}")
- raise HTTPException(status_code=500, detail=f"Error deleting file: {str(e)}")
+ raise HTTPException(status_code=500, detail=f"Error deleting file: {e!s}")
# ---------------------------------------------------------------------------
@@ -1516,8 +1551,8 @@ async def api_directory_create(
logger.info(f"Directory created: {vault_name}/{body.path}")
# Update path_index with the new directory
- from backend.indexer import path_index as _path_idx
from backend.indexer import _index_lock
+ from backend.indexer import path_index as _path_idx
with _index_lock:
if vault_name not in _path_idx:
_path_idx[vault_name] = []
@@ -1546,7 +1581,7 @@ async def api_directory_create(
raise HTTPException(status_code=403, detail="Permission denied: cannot create directory")
except Exception as e:
logger.error(f"Error creating directory {vault_name}/{body.path}: {e}")
- raise HTTPException(status_code=500, detail=f"Error creating directory: {str(e)}")
+ raise HTTPException(status_code=500, detail=f"Error creating directory: {e!s}")
@app.patch("/api/directory/{vault_name}", response_model=DirectoryRenameResponse)
@@ -1619,7 +1654,7 @@ async def api_directory_rename(
raise HTTPException(status_code=403, detail="Permission denied: cannot rename directory")
except Exception as e:
logger.error(f"Error renaming directory {vault_name}/{body.path}: {e}")
- raise HTTPException(status_code=500, detail=f"Error renaming directory: {str(e)}")
+ raise HTTPException(status_code=500, detail=f"Error renaming directory: {e!s}")
@app.delete("/api/directory/{vault_name}", response_model=DirectoryDeleteResponse)
@@ -1681,7 +1716,7 @@ async def api_directory_delete(
raise HTTPException(status_code=403, detail="Permission denied: cannot delete directory")
except Exception as e:
logger.error(f"Error deleting directory {vault_name}/{path}: {e}")
- raise HTTPException(status_code=500, detail=f"Error deleting directory: {str(e)}")
+ raise HTTPException(status_code=500, detail=f"Error deleting directory: {e!s}")
# ---------------------------------------------------------------------------
@@ -1751,7 +1786,7 @@ async def api_file_create(
raise HTTPException(status_code=403, detail="Permission denied: cannot create file")
except Exception as e:
logger.error(f"Error creating file {vault_name}/{body.path}: {e}")
- raise HTTPException(status_code=500, detail=f"Error creating file: {str(e)}")
+ raise HTTPException(status_code=500, detail=f"Error creating file: {e!s}")
@app.patch("/api/file/{vault_name}", response_model=FileRenameResponse)
@@ -1833,7 +1868,7 @@ async def api_file_rename(
raise HTTPException(status_code=403, detail="Permission denied: cannot rename file")
except Exception as e:
logger.error(f"Error renaming file {vault_name}/{body.path}: {e}")
- raise HTTPException(status_code=500, detail=f"Error renaming file: {str(e)}")
+ raise HTTPException(status_code=500, detail=f"Error renaming file: {e!s}")
@app.post("/api/move/{vault_name}", response_model=FileMoveResponse)
@@ -1945,7 +1980,7 @@ async def api_file_move(
raise HTTPException(status_code=403, detail="Permission denied: cannot move item")
except Exception as e:
logger.error(f"Error moving item {vault_name}/{body.source_path}: {e}")
- raise HTTPException(status_code=500, detail=f"Error moving item: {str(e)}")
+ raise HTTPException(status_code=500, detail=f"Error moving item: {e!s}")
# ---------------------------------------------------------------------------
@@ -2055,7 +2090,7 @@ async def api_file_backups(
backups = _list_backup_files(vault_name, path)
except Exception as e:
logger.error(f"Error listing backups for {vault_name}/{path}: {type(e).__name__}: {e}", exc_info=True)
- raise HTTPException(status_code=500, detail=f"Erreur lors de la lecture des backups: {str(e)}")
+ raise HTTPException(status_code=500, detail=f"Erreur lors de la lecture des backups: {e!s}")
return {"vault": vault_name, "path": path, "backups": backups}
@@ -2065,7 +2100,7 @@ async def api_file_diff(
vault_name: str,
path: str = Query(..., description="Relative path to file"),
version: int = Query(..., description="Timestamp of the backup version (left/old side)"),
- compare_with: Optional[int] = Query(default=None, description="Timestamp of another backup (right/new side). If omitted, compares with the current file."),
+ compare_with: int | None = Query(default=None, description="Timestamp of another backup (right/new side). If omitted, compares with the current file."),
current_user=Depends(require_auth),
):
"""Generate a unified diff between a backup version and another version or the current file.
@@ -2145,7 +2180,7 @@ async def api_file_diff(
raise
except Exception as e:
logger.error(f"Error generating diff for {vault_name}/{path}: {type(e).__name__}: {e}", exc_info=True)
- raise HTTPException(status_code=500, detail=f"Erreur lors de la génération du diff: {str(e)}")
+ raise HTTPException(status_code=500, detail=f"Erreur lors de la génération du diff: {e!s}")
@app.post("/api/file/{vault_name}/restore")
@@ -2227,7 +2262,7 @@ async def api_file_restore(
raise HTTPException(status_code=403, detail="Permission denied: cannot restore file")
except Exception as e:
logger.error(f"Error restoring file {vault_name}/{path}: {e}")
- raise HTTPException(status_code=500, detail=f"Error restoring file: {str(e)}")
+ raise HTTPException(status_code=500, detail=f"Error restoring file: {e!s}")
@app.get("/api/file/{vault_name}/backlinks")
@@ -2304,7 +2339,7 @@ async def api_file(vault_name: str, path: str = Query(..., description="Relative
# === PDF: special handling before read_text (binary file) ===
if ext == ".pdf":
try:
- from backend.pdf_reader import extract_pdf_text, extract_pdf_metadata
+ from backend.pdf_reader import extract_pdf_metadata, extract_pdf_text
pdf_text = extract_pdf_text(file_path, max_chars=100000)
pdf_meta = extract_pdf_metadata(file_path)
size = file_path.stat().st_size
@@ -2325,7 +2360,7 @@ async def api_file(vault_name: str, path: str = Query(..., description="Relative
}
except Exception as e:
logger.error(f"PDF read error for {path}: {e}")
- raise HTTPException(status_code=500, detail=f"Error reading PDF: {str(e)}")
+ raise HTTPException(status_code=500, detail=f"Error reading PDF: {e!s}")
try:
raw = file_path.read_text(encoding="utf-8", errors="replace")
@@ -2350,7 +2385,7 @@ async def api_file(vault_name: str, path: str = Query(..., description="Relative
}
except Exception as e:
logger.error(f"Unexpected error reading file {path}: {e}")
- raise HTTPException(status_code=500, detail=f"Error reading file: {str(e)}")
+ raise HTTPException(status_code=500, detail=f"Error reading file: {e!s}")
if ext == ".md":
post = parse_markdown_file(raw)
@@ -2414,7 +2449,7 @@ async def api_pdf_stream(vault_name: str, path: str = Query(...)):
async def api_search(
q: str = Query("", description="Search query"),
vault: str = Query("all", description="Vault filter"),
- tag: Optional[str] = Query(None, description="Tag filter"),
+ tag: str | None = Query(None, description="Tag filter"),
limit: int = Query(50, ge=1, le=200, description="Results per page"),
offset: int = Query(0, ge=0, description="Pagination offset"),
current_user=Depends(require_auth),
@@ -2451,7 +2486,7 @@ async def api_search(
@app.get("/api/tags", response_model=TagsResponse)
-async def api_tags(vault: Optional[str] = Query(None, description="Vault filter"), current_user=Depends(require_auth)):
+async def api_tags(vault: str | None = Query(None, description="Vault filter"), current_user=Depends(require_auth)):
"""Return all unique tags with occurrence counts.
Args:
@@ -2512,18 +2547,18 @@ async def api_tree_search(
async def api_advanced_search(
q: str = Query("", description="Advanced search query (supports tag:, vault:, title:, path:, ext: operators)"),
vault: str = Query("all", description="Vault filter"),
- tag: Optional[str] = Query(None, description="Comma-separated tag filter"),
+ tag: str | None = Query(None, description="Comma-separated tag filter"),
limit: int = Query(50, ge=1, le=200, description="Results per page"),
offset: int = Query(0, ge=0, description="Pagination offset"),
sort: str = Query("relevance", description="Sort by 'relevance' or 'modified'"),
case_sensitive: bool = Query(False, description="Match case"),
whole_word: bool = Query(False, description="Match whole words only"),
regex: bool = Query(False, description="Treat query as regex"),
- include_paths: Optional[str] = Query(None, description="Comma-separated glob patterns to include"),
- exclude_paths: Optional[str] = Query(None, description="Comma-separated glob patterns to exclude"),
- created: Optional[str] = Query(None, description="Created date filter (>date, bytes:
+def generate_pdf(html_content: str, title: str = "document", base_url: str | None = None) -> bytes:
"""Generate a PDF from HTML content.
Args:
diff --git a/backend/ratelimit.py b/backend/ratelimit.py
index 4437476..e023a12 100644
--- a/backend/ratelimit.py
+++ b/backend/ratelimit.py
@@ -10,11 +10,10 @@ Configuration via environment variables:
OBSIGATE_LOGIN_WINDOW_SECONDS Lockout window in seconds (default: 900 = 15min)
"""
+import logging
import os
import time
-import logging
from collections import defaultdict
-from typing import Dict, Tuple
logger = logging.getLogger("obsigate.ratelimit")
@@ -23,7 +22,7 @@ MAX_ATTEMPTS = int(os.environ.get("OBSIGATE_LOGIN_MAX_ATTEMPTS", "10"))
WINDOW_SECONDS = int(os.environ.get("OBSIGATE_LOGIN_WINDOW_SECONDS", "900")) # 15 min
# --- In-memory store: {ip: [(timestamp, success_bool), ...]} ---
-_ip_attempts: Dict[str, list] = defaultdict(list)
+_ip_attempts: dict[str, list] = defaultdict(list)
_last_cleanup = time.time()
CLEANUP_INTERVAL = 60 # seconds
@@ -45,7 +44,7 @@ def _cleanup_expired():
del _ip_attempts[ip]
-def record_failure(ip: str) -> Tuple[int, int]:
+def record_failure(ip: str) -> tuple[int, int]:
"""Record a failed login attempt from an IP.
Returns:
diff --git a/backend/saved_searches.py b/backend/saved_searches.py
index 17eee00..4892c96 100644
--- a/backend/saved_searches.py
+++ b/backend/saved_searches.py
@@ -5,11 +5,11 @@ Stored in data/saved_searches.json per user.
"""
import json
-import time
import logging
import shutil
+import time
from pathlib import Path
-from typing import List, Dict, Any
+from typing import Any
logger = logging.getLogger("obsigate.saved_searches")
@@ -21,7 +21,7 @@ def _get_file(username: str) -> Path:
return DATA_DIR / f"{username}_saved_searches.json"
-def _read(file: Path) -> List[Dict[str, Any]]:
+def _read(file: Path) -> list[dict[str, Any]]:
if not file.exists():
return []
try:
@@ -30,13 +30,13 @@ def _read(file: Path) -> List[Dict[str, Any]]:
return []
-def _write(file: Path, data: List[Dict[str, Any]]):
+def _write(file: Path, data: list[dict[str, Any]]):
tmp = file.with_suffix(".tmp")
tmp.write_text(json.dumps(data, indent=2, ensure_ascii=False), encoding="utf-8")
shutil.move(str(tmp), str(file))
-def get_saved(username: str) -> List[Dict[str, Any]]:
+def get_saved(username: str) -> list[dict[str, Any]]:
return _read(_get_file(username))
diff --git a/backend/search.py b/backend/search.py
index 9a0b085..9f8f6a9 100644
--- a/backend/search.py
+++ b/backend/search.py
@@ -1,13 +1,13 @@
-from sortedcontainers import SortedList
import logging
import math
import re
import time
import unicodedata
from collections import defaultdict
-from typing import List, Dict, Any, Optional, Tuple
+from typing import Any
from snowballstemmer import stemmer as _snowball_stemmer
+from sortedcontainers import SortedList
from backend import indexer as _indexer
from backend.indexer import index
@@ -86,7 +86,7 @@ def normalize_text(text: str) -> str:
return stripped.lower()
-def tokenize(text: str) -> List[str]:
+def tokenize(text: str) -> list[str]:
"""Split text into normalized tokens (accent-stripped, lowercased words).
Args:
@@ -101,7 +101,7 @@ def tokenize(text: str) -> List[str]:
# ---------------------------------------------------------------------------
# Tag filter helper (unchanged for backward compat)
# ---------------------------------------------------------------------------
-def _normalize_tag_filter(tag_filter: Optional[str]) -> List[str]:
+def _normalize_tag_filter(tag_filter: str | None) -> list[str]:
"""Parse a comma-separated tag filter string into a clean list.
Strips whitespace and leading ``#`` from each tag.
@@ -154,7 +154,7 @@ def _extract_snippet(content: str, query: str, context_chars: int = SNIPPET_CONT
def _extract_highlighted_snippet(
content: str,
- query_terms: List[str],
+ query_terms: list[str],
context_chars: int = SNIPPET_CONTEXT_CHARS,
max_highlights: int = MAX_SNIPPET_HIGHLIGHTS,
) -> str:
@@ -256,14 +256,13 @@ def _extract_regex_snippet(
parts.append(_escape_html(snippet[prev:m.start()]))
parts.append(f"{_escape_html(snippet[m.start():m.end()])}")
prev = m.end()
- count += 1
if prev < len(snippet):
parts.append(_escape_html(snippet[prev:]))
return prefix + "".join(parts) + suffix
-def _highlight_terms(text: str, terms: List[str], max_highlights: int) -> str:
+def _highlight_terms(text: str, terms: list[str], max_highlights: int) -> str:
"""Wrap occurrences of *terms* in *text* with ```` tags.
Uses accent-normalized comparison so diacritical variants are matched.
@@ -282,7 +281,7 @@ def _highlight_terms(text: str, terms: List[str], max_highlights: int) -> str:
norm = normalize_text(text)
# Collect (start, end) spans for all term matches
- spans: List[Tuple[int, int]] = []
+ spans: list[tuple[int, int]] = []
for term in terms:
idx = 0
while idx < len(norm):
@@ -297,7 +296,7 @@ def _highlight_terms(text: str, terms: List[str], max_highlights: int) -> str:
# Merge overlapping spans and limit count
spans.sort()
- merged: List[Tuple[int, int]] = [spans[0]]
+ merged: list[tuple[int, int]] = [spans[0]]
for s, e in spans[1:]:
if s <= merged[-1][1]:
merged[-1] = (merged[-1][0], max(merged[-1][1], e))
@@ -306,7 +305,7 @@ def _highlight_terms(text: str, terms: List[str], max_highlights: int) -> str:
merged = merged[:max_highlights]
# Build result with highlights
- parts: List[str] = []
+ parts: list[str] = []
prev = 0
for s, e in merged:
if s > prev:
@@ -348,17 +347,17 @@ class InvertedIndex:
"""
def __init__(self) -> None:
- self.word_index: Dict[str, Dict[str, int]] = defaultdict(dict)
- self.title_index: Dict[str, List[str]] = defaultdict(list)
- self.tag_norm_map: Dict[str, str] = {}
- self.tag_prefix_index: Dict[str, List[str]] = defaultdict(list)
- self.title_norm_map: Dict[str, List[Dict[str, str]]] = defaultdict(list)
+ self.word_index: dict[str, dict[str, int]] = defaultdict(dict)
+ self.title_index: dict[str, list[str]] = defaultdict(list)
+ self.tag_norm_map: dict[str, str] = {}
+ self.tag_prefix_index: dict[str, list[str]] = defaultdict(list)
+ self.title_norm_map: dict[str, list[dict[str, str]]] = defaultdict(list)
self.doc_count: int = 0
- self.doc_info: Dict[str, Dict[str, Any]] = {}
- self.doc_vault: Dict[str, str] = {}
- self.vault_docs: Dict[str, set] = defaultdict(set)
- self.tag_docs: Dict[str, set] = defaultdict(set)
- self._sorted_tokens: "SortedList" = SortedList()
+ self.doc_info: dict[str, dict[str, Any]] = {}
+ self.doc_vault: dict[str, str] = {}
+ self.vault_docs: dict[str, set] = defaultdict(set)
+ self.tag_docs: dict[str, set] = defaultdict(set)
+ self._sorted_tokens: SortedList = SortedList()
self._ready: bool = False # True after initial build
def is_stale(self) -> bool:
@@ -415,12 +414,12 @@ class InvertedIndex:
content = file_info.get("content", "")
full_text = (file_info.get("title", "") + " " + content)
tokens = tokenize(full_text)
- tf: Dict[str, int] = defaultdict(int)
+ tf: dict[str, int] = defaultdict(int)
for token in tokens:
tf[token] += 1
# Also index stemmed forms (French stemming)
# Compute stem frequencies in one pass instead of O(S×T)
- stem_freqs: Dict[str, int] = defaultdict(int)
+ stem_freqs: dict[str, int] = defaultdict(int)
for token, freq in tf.items():
stemmed = stem_token(token)
if stemmed != token: # only index stem if different
@@ -489,12 +488,12 @@ class InvertedIndex:
content = file_info.get("content", "")
full_text = title + " " + content
tokens = tokenize(full_text)
- tf: Dict[str, int] = defaultdict(int)
+ tf: dict[str, int] = defaultdict(int)
for token in tokens:
if token:
tf[token] += 1
# Also compute stems in one pass
- stem_freqs: Dict[str, int] = defaultdict(int)
+ stem_freqs: dict[str, int] = defaultdict(int)
for token, freq in tf.items():
stemmed = stem_token(token)
if stemmed != token:
@@ -615,7 +614,7 @@ class InvertedIndex:
return 0.0
return tf * self.idf(term)
- def get_prefix_tokens(self, prefix: str, max_expansions: int = 50) -> List[str]:
+ def get_prefix_tokens(self, prefix: str, max_expansions: int = 50) -> list[str]:
"""Get all tokens starting with *prefix* using binary search.
Uses a pre-sorted token list for O(log V + k) lookup instead
@@ -631,7 +630,7 @@ class InvertedIndex:
if not prefix or not self._sorted_tokens:
return []
lo = self._sorted_tokens.bisect_left(prefix)
- results: List[str] = []
+ results: list[str] = []
for i in range(lo, len(self._sorted_tokens)):
if self._sorted_tokens[i].startswith(prefix):
results.append(self._sorted_tokens[i])
@@ -688,9 +687,9 @@ def get_inverted_index() -> InvertedIndex:
def search(
query: str,
vault_filter: str = "all",
- tag_filter: Optional[str] = None,
+ tag_filter: str | None = None,
limit: int = DEFAULT_SEARCH_LIMIT,
-) -> List[Dict[str, Any]]:
+) -> list[dict[str, Any]]:
"""Full-text search across indexed vaults with relevance scoring.
Scoring heuristics (when a text query is provided):
@@ -722,7 +721,7 @@ def search(
return []
query_lower = query.lower()
- results: List[Dict[str, Any]] = []
+ results: list[dict[str, Any]] = []
for vault_name, vault_data in index.items():
if vault_filter != "all" and vault_name != vault_filter:
@@ -786,7 +785,7 @@ def search(
# ---------------------------------------------------------------------------
# Advanced search with TF-IDF scoring
# ---------------------------------------------------------------------------
-def _parse_advanced_query(raw_query: str) -> Dict[str, Any]:
+def _parse_advanced_query(raw_query: str) -> dict[str, Any]:
"""Parse an advanced query string into structured filters and free text.
Supported operators:
@@ -803,7 +802,7 @@ def _parse_advanced_query(raw_query: str) -> Dict[str, Any]:
Returns:
Dict with keys ``tags``, ``vault``, ``title``, ``path``, ``ext``, ``terms``.
"""
- parsed: Dict[str, Any] = {
+ parsed: dict[str, Any] = {
"tags": [],
"vault": None,
"title": None,
@@ -838,7 +837,7 @@ def _parse_advanced_query(raw_query: str) -> Dict[str, Any]:
return parsed
-def _split_query_tokens(raw: str) -> List[str]:
+def _split_query_tokens(raw: str) -> list[str]:
"""Split a query string respecting quoted phrases.
``tag:"my tag" hello world`` → ``['tag:my tag', 'hello', 'world']``
@@ -849,7 +848,7 @@ def _split_query_tokens(raw: str) -> List[str]:
Returns:
List of token strings.
"""
- tokens: List[str] = []
+ tokens: list[str] = []
i = 0
n = len(raw)
while i < n:
@@ -890,14 +889,14 @@ def _split_query_tokens(raw: str) -> List[str]:
def _passes_search_filters(
file_info: dict,
- query_terms: List[str],
- query_terms_raw: List[str],
+ query_terms: list[str],
+ query_terms_raw: list[str],
raw_query: str,
case_sensitive: bool,
whole_word: bool,
regex: bool,
- include_paths: Optional[str],
- exclude_paths: Optional[str],
+ include_paths: str | None,
+ exclude_paths: str | None,
) -> bool:
"""Post-filter a candidate by case-sensitive, whole-word, regex, and path filters."""
title = file_info.get("title", "")
@@ -937,7 +936,7 @@ def _passes_search_filters(
return _passes_path_filters(path, include_paths, exclude_paths)
-def _passes_path_filters(path: str, include: Optional[str], exclude: Optional[str]) -> bool:
+def _passes_path_filters(path: str, include: str | None, exclude: str | None) -> bool:
"""Check if a file path passes include/exclude glob patterns."""
import fnmatch
if include:
@@ -955,7 +954,7 @@ def _passes_path_filters(path: str, include: Optional[str], exclude: Optional[st
# Date and size filter helpers
# ---------------------------------------------------------------------------
-def _parse_date_range(raw: Optional[str]) -> Optional[tuple]:
+def _parse_date_range(raw: str | None) -> tuple | None:
"""Parse a date range filter string.
Supported formats:
@@ -1008,7 +1007,7 @@ def _parse_date_range(raw: Optional[str]) -> Optional[tuple]:
return None
-def _parse_date_to_ts(s: str) -> Optional[float]:
+def _parse_date_to_ts(s: str) -> float | None:
"""Parse a date string to Unix timestamp. Supports YYYY-MM-DD."""
s = s.strip()
for fmt in ('%Y-%m-%d', '%Y/%m/%d', '%d/%m/%Y'):
@@ -1020,14 +1019,14 @@ def _parse_date_to_ts(s: str) -> Optional[float]:
return None
-def _matches_date_range(file_ts: Optional[float], date_range: tuple) -> bool:
+def _matches_date_range(file_ts: float | None, date_range: tuple) -> bool:
"""Check if a file timestamp falls within the given range."""
if file_ts is None:
return False
return date_range[0] <= file_ts <= date_range[1]
-def _parse_size_range(raw: Optional[str]) -> Optional[tuple]:
+def _parse_size_range(raw: str | None) -> tuple | None:
"""Parse a size range filter string.
Supported formats:
@@ -1040,7 +1039,7 @@ def _parse_size_range(raw: Optional[str]) -> Optional[tuple]:
return None
raw = raw.strip()
- def _parse_size(s: str) -> Optional[int]:
+ def _parse_size(s: str) -> int | None:
s = s.strip().upper()
mult = 1
if s.endswith('GB'):
@@ -1052,10 +1051,7 @@ def _parse_size_range(raw: Optional[str]) -> Optional[tuple]:
elif s.endswith('KB'):
mult = 1024
s = s[:-2]
- elif s.endswith('B'):
- mult = 1
- s = s[:-1]
- elif s.endswith('O'):
+ elif s.endswith('B') or s.endswith('O'):
mult = 1
s = s[:-1]
try:
@@ -1094,19 +1090,19 @@ def _matches_size_range(file_size: int, size_range: tuple) -> bool:
def advanced_search(
query: str,
vault_filter: str = "all",
- tag_filter: Optional[str] = None,
+ tag_filter: str | None = None,
limit: int = ADVANCED_SEARCH_DEFAULT_LIMIT,
offset: int = 0,
sort_by: str = "relevance",
case_sensitive: bool = False,
whole_word: bool = False,
regex: bool = False,
- include_paths: Optional[str] = None,
- exclude_paths: Optional[str] = None,
- created: Optional[str] = None,
- modified: Optional[str] = None,
- size: Optional[str] = None,
-) -> Dict[str, Any]:
+ include_paths: str | None = None,
+ exclude_paths: str | None = None,
+ created: str | None = None,
+ modified: str | None = None,
+ size: str | None = None,
+) -> dict[str, Any]:
"""Advanced full-text search with TF-IDF scoring, facets, and pagination.
Uses the inverted index for O(k × postings) candidate retrieval instead
@@ -1242,12 +1238,12 @@ def advanced_search(
# ------------------------------------------------------------------
# Step 3: Score only the candidates (not all N documents)
# ------------------------------------------------------------------
- scored_results: List[Tuple[float, Dict[str, Any]]] = []
- facet_tags: Dict[str, int] = defaultdict(int)
- facet_vaults: Dict[str, int] = defaultdict(int)
+ scored_results: list[tuple[float, dict[str, Any]]] = []
+ facet_tags: dict[str, int] = defaultdict(int)
+ facet_vaults: dict[str, int] = defaultdict(int)
# Pre-compute prefix expansions once per term (avoid repeated binary search)
- prefix_expansions: Dict[str, List[str]] = {}
+ prefix_expansions: dict[str, list[str]] = {}
if has_terms:
for term in query_terms:
if len(term) >= MIN_PREFIX_LENGTH:
@@ -1357,7 +1353,7 @@ def suggest_titles(
prefix: str,
vault_filter: str = "all",
limit: int = SUGGEST_LIMIT,
-) -> List[Dict[str, str]]:
+) -> list[dict[str, str]]:
"""Suggest file titles matching a prefix (accent-insensitive).
Args:
@@ -1373,7 +1369,7 @@ def suggest_titles(
inv = get_inverted_index()
norm_prefix = normalize_text(prefix)
- results: List[Dict[str, str]] = []
+ results: list[dict[str, str]] = []
seen: set = set()
for norm_title, entries in inv.title_norm_map.items():
@@ -1395,7 +1391,7 @@ def suggest_tags(
prefix: str,
vault_filter: str = "all",
limit: int = SUGGEST_LIMIT,
-) -> List[Dict[str, Any]]:
+) -> list[dict[str, Any]]:
"""Suggest tags matching a prefix (accent-insensitive).
Args:
@@ -1413,7 +1409,7 @@ def suggest_tags(
norm_prefix = normalize_text(prefix)
all_tag_counts = get_all_tags(vault_filter)
- matches: List[Dict[str, Any]] = []
+ matches: list[dict[str, Any]] = []
for tag, count in all_tag_counts.items():
norm_tag = normalize_text(tag)
if norm_prefix in norm_tag:
@@ -1427,7 +1423,7 @@ def suggest_tags(
# ---------------------------------------------------------------------------
# Backward-compatible tag aggregation (unchanged API)
# ---------------------------------------------------------------------------
-def get_all_tags(vault_filter: Optional[str] = None) -> Dict[str, int]:
+def get_all_tags(vault_filter: str | None = None) -> dict[str, int]:
"""Aggregate tag counts across vaults, sorted by descending count.
Args:
@@ -1436,7 +1432,7 @@ def get_all_tags(vault_filter: Optional[str] = None) -> Dict[str, int]:
Returns:
Dict mapping tag names to their total occurrence count.
"""
- merged: Dict[str, int] = {}
+ merged: dict[str, int] = {}
for vault_name, vault_data in index.items():
if vault_filter and vault_filter != "all" and vault_name != vault_filter:
continue
diff --git a/backend/secret_redactor.py b/backend/secret_redactor.py
index 407c12f..d6cf56e 100644
--- a/backend/secret_redactor.py
+++ b/backend/secret_redactor.py
@@ -13,8 +13,8 @@ Patterns detected:
- Connection strings with passwords
"""
-import re
import logging
+import re
logger = logging.getLogger("obsigate.redactor")
diff --git a/backend/share.py b/backend/share.py
index 311a85e..91cefd9 100644
--- a/backend/share.py
+++ b/backend/share.py
@@ -8,11 +8,10 @@ No authentication required for public share views.
"""
import json
-import secrets
-from pathlib import Path
-from datetime import datetime, timezone, timedelta
-from typing import Optional
import logging
+import secrets
+from datetime import datetime, timedelta, timezone
+from pathlib import Path
logger = logging.getLogger("obsigate.share")
@@ -39,7 +38,7 @@ def create_share(
vault: str,
path: str,
created_by: str,
- expires_in_hours: Optional[int] = None,
+ expires_in_hours: int | None = None,
) -> dict:
"""Create a new share token for a document."""
data = _read()
@@ -66,7 +65,7 @@ def create_share(
return share
-def get_share_by_token(token: str) -> Optional[dict]:
+def get_share_by_token(token: str) -> dict | None:
"""Look up a share by token. Returns None if expired or not found."""
data = _read()
share = data["shares"].get(token)
@@ -100,7 +99,7 @@ def revoke_share(share_id: str) -> bool:
return False
-def list_shares(vault_filter: Optional[str] = None) -> list:
+def list_shares(vault_filter: str | None = None) -> list:
"""List all shares, optionally filtered by vault."""
data = _read()
shares = list(data["shares"].values())
diff --git a/backend/vault_settings.py b/backend/vault_settings.py
index 6986298..82aff0e 100644
--- a/backend/vault_settings.py
+++ b/backend/vault_settings.py
@@ -10,9 +10,9 @@ Current settings:
import json
import logging
-from pathlib import Path
-from typing import Dict, Any, Optional
import threading
+from pathlib import Path
+from typing import Any
logger = logging.getLogger("obsigate.vault_settings")
@@ -21,10 +21,10 @@ _SETTINGS_PATH = _BASE_DIR / "data" / "vault_settings.json"
_settings_lock = threading.RLock()
# In-memory cache of vault settings
-_vault_settings: Dict[str, Dict[str, Any]] = {}
+_vault_settings: dict[str, dict[str, Any]] = {}
-def load_vault_settings() -> Dict[str, Dict[str, Any]]:
+def load_vault_settings() -> dict[str, dict[str, Any]]:
"""Load vault settings from disk.
Returns:
@@ -78,7 +78,7 @@ def save_vault_settings() -> None:
raise
-def get_vault_setting(vault_name: str) -> Optional[Dict[str, Any]]:
+def get_vault_setting(vault_name: str) -> dict[str, Any] | None:
"""Get settings for a specific vault.
Args:
@@ -91,7 +91,7 @@ def get_vault_setting(vault_name: str) -> Optional[Dict[str, Any]]:
return _vault_settings.get(vault_name)
-def update_vault_setting(vault_name: str, settings: Dict[str, Any]) -> Dict[str, Any]:
+def update_vault_setting(vault_name: str, settings: dict[str, Any]) -> dict[str, Any]:
"""Update settings for a specific vault.
Args:
@@ -128,7 +128,7 @@ def delete_vault_setting(vault_name: str) -> bool:
return False
-def get_all_vault_settings() -> Dict[str, Dict[str, Any]]:
+def get_all_vault_settings() -> dict[str, dict[str, Any]]:
"""Get all vault settings.
Returns:
diff --git a/backend/watcher.py b/backend/watcher.py
index bc640c4..673f587 100644
--- a/backend/watcher.py
+++ b/backend/watcher.py
@@ -1,13 +1,14 @@
import asyncio
import logging
import time
+from collections.abc import Callable
from pathlib import Path
-from typing import Callable, Dict, List, Optional
+from watchdog.events import FileSystemEventHandler
from watchdog.observers import Observer
from watchdog.observers.polling import PollingObserver
-from watchdog.events import FileSystemEventHandler
-from backend.indexer import SUPPORTED_EXTENSIONS, IGNORED_DIRS
+
+from backend.indexer import IGNORED_DIRS, SUPPORTED_EXTENSIONS
logger = logging.getLogger("obsigate.watcher")
@@ -89,10 +90,10 @@ class VaultWatcher:
self.polling_interval = polling_interval
self.observers: dict[str, object] = {}
self.event_queue: asyncio.Queue = asyncio.Queue()
- self._processor_task: Optional[asyncio.Task] = None
+ self._processor_task: asyncio.Task | None = None
self._running = False
- async def start(self, vaults: Dict[str, str]):
+ async def start(self, vaults: dict[str, str]):
"""Démarrer la surveillance de toutes les vaults.
Args:
@@ -170,7 +171,7 @@ class VaultWatcher:
Accumule les événements pendant debounce_seconds puis appelle
le callback une seule fois avec la liste consolidée.
"""
- pending: Dict[str, dict] = {}
+ pending: dict[str, dict] = {}
while self._running:
try:
@@ -194,7 +195,7 @@ class VaultWatcher:
except Exception as e:
logger.error(f"Event processor error: {e}")
- async def _dispatch(self, events: List[dict]):
+ async def _dispatch(self, events: list[dict]):
"""Appeler le callback avec les événements consolidés."""
try:
await self.on_file_change(events)
diff --git a/backend/webhooks.py b/backend/webhooks.py
index 51561ed..4a1c831 100644
--- a/backend/webhooks.py
+++ b/backend/webhooks.py
@@ -8,19 +8,17 @@ Events: file_created, file_deleted, file_modified, file_renamed,
directory_created, directory_deleted, directory_renamed
"""
-import json
-import hmac
-import hashlib
import asyncio
+import hashlib
+import hmac
+import json
import logging
import uuid
-from pathlib import Path
from datetime import datetime, timezone
-from typing import Optional, List
+from pathlib import Path
import aiohttp
-
logger = logging.getLogger("obsigate.webhooks")
WEBHOOKS_FILE = Path("data/webhooks.json")
@@ -51,7 +49,7 @@ def get_webhooks() -> list:
return _read()
-def create_webhook(name: str, url: str, events: List[str], secret: Optional[str] = None) -> dict:
+def create_webhook(name: str, url: str, events: list[str], secret: str | None = None) -> dict:
webhooks = _read()
wh = {
"id": str(uuid.uuid4()),
@@ -69,7 +67,7 @@ def create_webhook(name: str, url: str, events: List[str], secret: Optional[str]
return wh
-def update_webhook(wh_id: str, updates: dict) -> Optional[dict]:
+def update_webhook(wh_id: str, updates: dict) -> dict | None:
webhooks = _read()
for wh in webhooks:
if wh["id"] == wh_id:
@@ -110,8 +108,7 @@ async def dispatch_webhooks(event_type: str, data: dict):
headers["X-ObsiGate-Signature"] = f"sha256={sig}"
timeout = aiohttp.ClientTimeout(total=5)
- async with aiohttp.ClientSession(timeout=timeout) as session:
- async with session.post(wh["url"], data=body, headers=headers) as resp:
+ async with aiohttp.ClientSession(timeout=timeout) as session, session.post(wh["url"], data=body, headers=headers) as resp:
if resp.status < 400:
logger.debug(f"Webhook '{wh['name']}' OK ({resp.status})")
else:
diff --git a/pyproject.toml b/pyproject.toml
new file mode 100644
index 0000000..6b23db5
--- /dev/null
+++ b/pyproject.toml
@@ -0,0 +1,28 @@
+[tool.ruff]
+# Lines too long in docstrings are OK
+line-length = 120
+
+[tool.ruff.lint]
+ignore = [
+ "B008", # FastAPI Depends() in defaults (standard pattern)
+ "BLE001", # Blind exception catch (many intentional)
+ "PLW0602", # Global declaration (intentional)
+ "TRY201", # raise without exception name
+ "RUF059", # Unused unpacked variables
+ "S110", # try-except-pass (intentional)
+ "S112", # try-except-continue (intentional)
+ "G201", # logging.exception vs .error(exc_info=True)
+ "PIE810", # Multiple statically joined strings
+ "ASYNC210",# Blocking HTTP in async context
+ "DTZ007", # Naive datetime
+ "DTZ006", # Naive datetime
+ "EXE001", # Shebang on non-executable
+]
+
+[tool.ruff.lint.per-file-ignores]
+"backend/main.py" = ["F821"] # current_user from FastAPI middleware
+
+[tool.bandit]
+skips = ["B101", "B105", "B308", "B311", "B314"]
+asserts = "B101"
+exclude_dirs = ["tests", ".venv"]