libra/__init__.py
Padreug 4d63e08a69 fix(fava): serialize source mutations, share HTTP client, validate BQL input
Fava-client hardening cluster (CODE-REVIEW-2026-06 #7, #14, #15, #19
+ libra-#23, libra-#53):

- New FavaClient.transform_source_line does the whole
  read-checksum-modify-write under the global write lock and maps
  Fava 409/412 to ChecksumConflictError. The approve and reject
  endpoints used to do this dance with raw httpx and no lock — two
  concurrent mutations raced each other and every other ledger
  writer (libra-#23). They now route through the new method and
  translate conflicts to HTTP 409.
- update_entry_source / delete_entry raise ChecksumConflictError on
  409/412 instead of leaking raw HTTPStatusError.
- One shared httpx.AsyncClient per FavaClient (12 per-call
  instantiations removed — no more TCP handshake per request);
  closed via libra_stop. Health probes keep their 2s timeout
  per-request.
- Account names/patterns are validated against ^[A-Za-z0-9:_-]+$
  before interpolation into BQL string literals.
- The posting amount regexes are consolidated into module-level
  compiled patterns, all decimal-tolerant — the old integer-only
  SATS pattern silently dropped decimal-SATS postings (Fava's @@->@
  normalisation emits them) from balances.
- add-account no longer verifies its own write with a second
  serialized get_all_accounts round-trip (libra-#53):
  sync_single_account_from_beancount grows an assume_exists path.

New test: concurrent approve+reject must both land (was
lost-update/412 before the lock).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-12 15:42:44 +02:00

95 lines
3.1 KiB
Python

import asyncio
from fastapi import APIRouter
from loguru import logger
from .crud import db
from .tasks import wait_for_paid_invoices
from .views import libra_generic_router
from .views_api import libra_api_router
libra_ext: APIRouter = APIRouter(prefix="/libra", tags=["Libra"])
libra_ext.include_router(libra_generic_router)
libra_ext.include_router(libra_api_router)
libra_static_files = [
{
"path": "/libra/static",
"name": "libra_static",
}
]
scheduled_tasks: list[asyncio.Task] = []
def libra_stop():
"""Clean up background tasks on extension shutdown"""
for task in scheduled_tasks:
try:
task.cancel()
except Exception as ex:
logger.warning(ex)
# Close the Fava client's shared HTTP connection pool. libra_stop is
# synchronous, so schedule the close; if the loop is already gone the
# sockets die with the process anyway.
from .fava_client import _fava_client
if _fava_client is not None:
try:
asyncio.get_event_loop().create_task(_fava_client.aclose())
except Exception as ex:
logger.warning(f"Could not close Fava HTTP client: {ex}")
def libra_start():
"""Initialize Libra extension background tasks"""
from lnbits.tasks import create_permanent_unique_task
from .fava_client import init_fava_client
from .models import LibraSettings
from .tasks import wait_for_account_sync
async def _init_fava():
"""Load saved settings from DB, fall back to defaults."""
from .crud import db as libra_db
settings = None
try:
row = await libra_db.fetchone(
"SELECT * FROM extension_settings LIMIT 1",
model=LibraSettings,
)
if row:
settings = row
logger.info(f"Loaded Libra settings from DB: {settings.fava_url}/{settings.fava_ledger_slug}")
except Exception as e:
logger.warning(f"Could not load settings from DB: {e}")
if not settings:
settings = LibraSettings()
logger.info(f"Using default Libra settings: {settings.fava_url}/{settings.fava_ledger_slug}")
init_fava_client(
fava_url=settings.fava_url,
ledger_slug=settings.fava_ledger_slug,
timeout=settings.fava_timeout
)
logger.info(f"Fava client initialized: {settings.fava_url}/{settings.fava_ledger_slug}")
try:
asyncio.get_event_loop().create_task(_init_fava())
except Exception as e:
logger.error(f"Failed to initialize Fava client: {e}")
logger.warning("Libra will not function without Fava. Please configure Fava settings.")
# Start background tasks
task = create_permanent_unique_task("ext_libra", wait_for_paid_invoices)
scheduled_tasks.append(task)
# Start account sync task (runs hourly)
sync_task = create_permanent_unique_task("ext_libra_account_sync", wait_for_account_sync)
scheduled_tasks.append(sync_task)
logger.info("Libra account sync task started (runs hourly)")
__all__ = ["libra_ext", "libra_static_files", "db", "libra_start", "libra_stop"]