fava_client: resolve relative target_file paths against the ledger root

Fava's /api/source endpoint rejects relative paths with HTTP 500
(NonSourceFileError: "Trying to read a non-source file at '...'"). The
include-aware `_infer_target_file` helper returns relative paths
(e.g. "accounts/users.beancount"), so add a `_resolve_target_file`
hook that prepends the ledger root directory.

The dirname is derived from a one-time GET /api/options and cached on
the FavaClient instance (which is a module-level singleton), guarded by
an asyncio.Lock so concurrent first-callers don't double-fetch.

Absolute paths pass through unchanged, so the admin endpoint that
explicitly passes target_file="accounts/chart.beancount" works the same
as one that passes "/var/lib/fava/accounts/chart.beancount".

Verified against aio-demo's live fava: relative paths now produce
HTTP 200 reads on options.beancount, accounts/chart.beancount,
accounts/users.beancount, and transactions.beancount.

Refs: aiolabs/libra#28
This commit is contained in:
Padreug 2026-06-06 19:16:50 +02:00
commit d82443d040

View file

@ -80,6 +80,46 @@ class FavaClient:
# Per-user locks for user-specific operations (reduces contention) # Per-user locks for user-specific operations (reduces contention)
self._user_locks: Dict[str, asyncio.Lock] = {} self._user_locks: Dict[str, asyncio.Lock] = {}
# Cached absolute dirname of the root ledger file, derived from
# GET /api/options on first need. Used by `_resolve_target_file` to
# turn relative include paths (e.g. "accounts/users.beancount") into
# the absolute paths fava's /api/source endpoint requires.
self._main_dir_cache: Optional[str] = None
self._main_dir_lock = asyncio.Lock()
async def _resolve_target_file(self, target_file: str) -> str:
"""
Turn a relative include path into the absolute path fava expects.
Fava's /api/source endpoint refuses relative paths with HTTP 500
(NonSourceFileError). Resolve any non-absolute target_file by
prepending the directory of the root ledger file (cached after
the first GET /api/options).
Args:
target_file: Relative (e.g. "accounts/users.beancount") or
absolute path.
Returns:
Absolute path under fava's ledger root.
"""
import os
if os.path.isabs(target_file):
return target_file
if self._main_dir_cache is None:
async with self._main_dir_lock:
if self._main_dir_cache is None:
async with httpx.AsyncClient(timeout=self.timeout) as client:
resp = await client.get(f"{self.base_url}/options")
resp.raise_for_status()
main_file = resp.json()["data"]["beancount_options"]["filename"]
self._main_dir_cache = os.path.dirname(main_file)
logger.debug(f"Cached fava ledger root dir: {self._main_dir_cache}")
return os.path.join(self._main_dir_cache, target_file)
def get_user_lock(self, user_id: str) -> asyncio.Lock: def get_user_lock(self, user_id: str) -> asyncio.Lock:
""" """
Get or create a lock for a specific user. Get or create a lock for a specific user.
@ -1560,6 +1600,9 @@ class FavaClient:
if target_file is None: if target_file is None:
target_file = _infer_target_file(account_name) target_file = _infer_target_file(account_name)
# Fava's /api/source requires absolute paths; convert if needed.
target_file = await self._resolve_target_file(target_file)
last_error = None last_error = None
for attempt in range(max_retries): for attempt in range(max_retries):