From d82443d04041ef7c2d1e5ab6f665c194cb136818 Mon Sep 17 00:00:00 2001 From: Padreug Date: Sat, 6 Jun 2026 19:16:50 +0200 Subject: [PATCH] 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 --- fava_client.py | 43 +++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 43 insertions(+) diff --git a/fava_client.py b/fava_client.py index 8f60b23..578f077 100644 --- a/fava_client.py +++ b/fava_client.py @@ -80,6 +80,46 @@ class FavaClient: # Per-user locks for user-specific operations (reduces contention) 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: """ Get or create a lock for a specific user. @@ -1560,6 +1600,9 @@ class FavaClient: if target_file is None: 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 for attempt in range(max_retries):