There are a number of deployment options for persistently running rustfava on
-the Web, depending on your Web server and WSGI deployment choices. Below you
-can find some examples.
The above will make rustfava accessible at the /rustfava URL and proxy requests
-arriving there to a locally running rustfava. To make rustfava work properly in
-that context, you should run it using the --prefix command line option, like
-this:
Rustfava is a web interface for double-entry bookkeeping, powered by
+
rustfava is a web interface for double-entry bookkeeping, powered by
rustledger, a Rust-based parser for
-the Beancount file format compiled to WebAssembly for fast in-browser
-processing.
-
Rustfava is a fork of Fava that replaces
+the Beancount file format compiled to WebAssembly for fast processing.
+
rustfava is a fork of Fava that replaces
the Python Beancount parser with rustledger for improved performance. Your
existing Beancount files are fully compatible.
-
+
If you are new to rustfava or Beancount-format files, begin with the
Getting Started guide.
diff --git a/public/rustfava/docs/screenshot.png b/public/rustfava/docs/screenshot.png
new file mode 100644
index 0000000..3314105
Binary files /dev/null and b/public/rustfava/docs/screenshot.png differ
diff --git a/public/rustfava/docs/search/search_index.json b/public/rustfava/docs/search/search_index.json
index e8f197e..d7c11ab 100644
--- a/public/rustfava/docs/search/search_index.json
+++ b/public/rustfava/docs/search/search_index.json
@@ -1 +1 @@
-{"config":{"lang":["en"],"separator":"[\\s\\-]+","pipeline":["stopWordFilter"],"fields":{"title":{"boost":1000.0},"text":{"boost":1.0},"tags":{"boost":1000000.0}}},"docs":[{"location":"","title":"Welcome to rustfava!","text":"
Rustfava is a web interface for double-entry bookkeeping, powered by rustledger, a Rust-based parser for the Beancount file format compiled to WebAssembly for fast in-browser processing.
Rustfava is a fork of Fava that replaces the Python Beancount parser with rustledger for improved performance. Your existing Beancount files are fully compatible.
If you are new to rustfava or Beancount-format files, begin with the Getting Started guide.
class EntryNotFoundForHashError(RustfavaAPIError):\n \"\"\"Entry not found for hash.\"\"\"\n\n def __init__(self, entry_hash: str) -> None:\n super().__init__(f'No entry found for hash \"{entry_hash}\"')\n
class StatementNotFoundError(RustfavaAPIError):\n \"\"\"Statement not found.\"\"\"\n\n def __init__(self) -> None:\n super().__init__(\"Statement not found.\")\n
class StatementMetadataInvalidError(RustfavaAPIError):\n \"\"\"Statement metadata not found or invalid.\"\"\"\n\n def __init__(self, key: str) -> None:\n super().__init__(\n f\"Statement path at key '{key}' missing or not a string.\"\n )\n
class FilteredLedger:\n \"\"\"Filtered Beancount ledger.\"\"\"\n\n __slots__ = (\n \"__dict__\", # for the cached_property decorator\n \"_date_first\",\n \"_date_last\",\n \"_pages\",\n \"date_range\",\n \"entries\",\n \"ledger\",\n )\n _date_first: date | None\n _date_last: date | None\n\n def __init__(\n self,\n ledger: RustfavaLedger,\n *,\n account: str | None = None,\n filter: str | None = None, # noqa: A002\n time: str | None = None,\n ) -> None:\n \"\"\"Create a filtered view of a ledger.\n\n Args:\n ledger: The ledger to filter.\n account: The account filter.\n filter: The advanced filter.\n time: The time filter.\n \"\"\"\n self.ledger = ledger\n self.date_range: DateRange | None = None\n self._pages: (\n tuple[\n int,\n Literal[\"asc\", \"desc\"],\n list[Sequence[tuple[int, Directive]]],\n ]\n | None\n ) = None\n\n entries = ledger.all_entries\n if account:\n entries = AccountFilter(account).apply(entries)\n if filter and filter.strip():\n entries = AdvancedFilter(filter.strip()).apply(entries)\n if time:\n time_filter = TimeFilter(ledger.options, ledger.fava_options, time)\n entries = time_filter.apply(entries)\n self.date_range = time_filter.date_range\n self.entries = entries\n\n if self.date_range:\n self._date_first = self.date_range.begin\n self._date_last = self.date_range.end\n return\n\n self._date_first = None\n self._date_last = None\n for entry in self.entries:\n if isinstance(entry, Transaction):\n self._date_first = entry.date\n break\n for entry in reversed(self.entries):\n if isinstance(entry, (Transaction, Price)):\n self._date_last = entry.date + timedelta(1)\n break\n\n @property\n def end_date(self) -> date | None:\n \"\"\"The date to use for prices.\"\"\"\n date_range = self.date_range\n if date_range:\n return date_range.end_inclusive\n return None\n\n @cached_property\n def entries_with_all_prices(self) -> Sequence[Directive]:\n \"\"\"The filtered entries, with all prices added back in for queries.\"\"\"\n entries = [*self.entries, *self.ledger.all_entries_by_type.Price]\n entries.sort(key=_incomplete_sortkey)\n return entries\n\n @cached_property\n def entries_without_prices(self) -> Sequence[Directive]:\n \"\"\"The filtered entries, without prices for journals.\"\"\"\n return [e for e in self.entries if not isinstance(e, Price)]\n\n @cached_property\n def root_tree(self) -> Tree:\n \"\"\"A root tree.\"\"\"\n return Tree(self.entries)\n\n @cached_property\n def root_tree_closed(self) -> Tree:\n \"\"\"A root tree for the balance sheet.\"\"\"\n tree = Tree(self.entries)\n tree.cap(self.ledger.options, self.ledger.fava_options.unrealized)\n return tree\n\n def interval_ranges(self, interval: Interval) -> Sequence[DateRange]:\n \"\"\"Yield date ranges corresponding to interval boundaries.\n\n Args:\n interval: The interval to yield ranges for.\n \"\"\"\n if not self._date_first or not self._date_last:\n return []\n complete = not self.date_range\n return dateranges(\n self._date_first, self._date_last, interval, complete=complete\n )\n\n def prices(self, base: str, quote: str) -> Sequence[tuple[date, Decimal]]:\n \"\"\"List all prices for a pair of commodities.\n\n Args:\n base: The price base.\n quote: The price quote.\n \"\"\"\n all_prices = self.ledger.prices.get_all_prices((base, quote))\n if all_prices is None:\n return []\n\n date_range = self.date_range\n if date_range:\n return [\n price_point\n for price_point in all_prices\n if date_range.begin <= price_point[0] < date_range.end\n ]\n return all_prices\n\n def account_is_closed(self, account_name: str) -> bool:\n \"\"\"Check if the account is closed.\n\n Args:\n account_name: An account name.\n\n Returns:\n True if the account is closed before the end date of the current\n time filter.\n \"\"\"\n date_range = self.date_range\n close_date = self.ledger.accounts[account_name].close_date\n if close_date is None:\n return False\n return close_date < date_range.end if date_range else True\n\n def paginate_journal(\n self,\n page: int,\n per_page: int = 1000,\n order: Literal[\"asc\", \"desc\"] = \"desc\",\n ) -> JournalPage | None:\n \"\"\"Get entries for a journal page with pagination info.\n\n Args:\n page: Page number (1-indexed).\n order: Datewise order to sort in\n per_page: Number of entries per page.\n\n Returns:\n A JournalPage, containing a list of entries as (global_index,\n directive) tuples in reverse chronological order and the total\n number of pages.\n \"\"\"\n if (\n self._pages is None\n or self._pages[0] != per_page\n or self._pages[1] != order\n ):\n pages: list[Sequence[tuple[int, Directive]]] = []\n enumerated = list(enumerate(self.entries_without_prices))\n entries = (\n iter(enumerated) if order == \"asc\" else reversed(enumerated)\n )\n while batch := tuple(islice(entries, per_page)):\n pages.append(batch)\n if not pages:\n pages.append([])\n self._pages = (per_page, order, pages)\n _per_pages, _order, pages = self._pages\n total = len(pages)\n if page > total:\n return None\n return JournalPage(pages[page - 1], total)\n
Yield date ranges corresponding to interval boundaries.
Parameters:
Name Type Description Default intervalInterval
The interval to yield ranges for.
required Source code in src/rustfava/core/__init__.py
def interval_ranges(self, interval: Interval) -> Sequence[DateRange]:\n \"\"\"Yield date ranges corresponding to interval boundaries.\n\n Args:\n interval: The interval to yield ranges for.\n \"\"\"\n if not self._date_first or not self._date_last:\n return []\n complete = not self.date_range\n return dateranges(\n self._date_first, self._date_last, interval, complete=complete\n )\n
required Source code in src/rustfava/core/__init__.py
def prices(self, base: str, quote: str) -> Sequence[tuple[date, Decimal]]:\n \"\"\"List all prices for a pair of commodities.\n\n Args:\n base: The price base.\n quote: The price quote.\n \"\"\"\n all_prices = self.ledger.prices.get_all_prices((base, quote))\n if all_prices is None:\n return []\n\n date_range = self.date_range\n if date_range:\n return [\n price_point\n for price_point in all_prices\n if date_range.begin <= price_point[0] < date_range.end\n ]\n return all_prices\n
True if the account is closed before the end date of the current
bool
time filter.
Source code in src/rustfava/core/__init__.py
def account_is_closed(self, account_name: str) -> bool:\n \"\"\"Check if the account is closed.\n\n Args:\n account_name: An account name.\n\n Returns:\n True if the account is closed before the end date of the current\n time filter.\n \"\"\"\n date_range = self.date_range\n close_date = self.ledger.accounts[account_name].close_date\n if close_date is None:\n return False\n return close_date < date_range.end if date_range else True\n
Get entries for a journal page with pagination info.
Parameters:
Name Type Description Default pageint
Page number (1-indexed).
required orderLiteral['asc', 'desc']
Datewise order to sort in
'desc'per_pageint
Number of entries per page.
1000
Returns:
Type Description JournalPage | None
A JournalPage, containing a list of entries as (global_index,
JournalPage | None
directive) tuples in reverse chronological order and the total
JournalPage | None
number of pages.
Source code in src/rustfava/core/__init__.py
def paginate_journal(\n self,\n page: int,\n per_page: int = 1000,\n order: Literal[\"asc\", \"desc\"] = \"desc\",\n) -> JournalPage | None:\n \"\"\"Get entries for a journal page with pagination info.\n\n Args:\n page: Page number (1-indexed).\n order: Datewise order to sort in\n per_page: Number of entries per page.\n\n Returns:\n A JournalPage, containing a list of entries as (global_index,\n directive) tuples in reverse chronological order and the total\n number of pages.\n \"\"\"\n if (\n self._pages is None\n or self._pages[0] != per_page\n or self._pages[1] != order\n ):\n pages: list[Sequence[tuple[int, Directive]]] = []\n enumerated = list(enumerate(self.entries_without_prices))\n entries = (\n iter(enumerated) if order == \"asc\" else reversed(enumerated)\n )\n while batch := tuple(islice(entries, per_page)):\n pages.append(batch)\n if not pages:\n pages.append([])\n self._pages = (per_page, order, pages)\n _per_pages, _order, pages = self._pages\n total = len(pages)\n if page > total:\n return None\n return JournalPage(pages[page - 1], total)\n
class RustfavaLedger:\n \"\"\"Interface for a Beancount ledger.\"\"\"\n\n __slots__ = (\n \"_is_encrypted\",\n \"accounts\",\n \"accounts\",\n \"all_entries\",\n \"all_entries_by_type\",\n \"attributes\",\n \"beancount_file_path\",\n \"budgets\",\n \"charts\",\n \"commodities\",\n \"extensions\",\n \"fava_options\",\n \"fava_options_errors\",\n \"file\",\n \"format_decimal\",\n \"get_entry\",\n \"get_filtered\",\n \"ingest\",\n \"load_errors\",\n \"misc\",\n \"options\",\n \"prices\",\n \"query_shell\",\n \"watcher\",\n )\n\n #: List of all (unfiltered) entries.\n all_entries: Sequence[Directive]\n\n #: A list of all errors reported by Beancount.\n load_errors: Sequence[BeancountError]\n\n #: The Beancount options map.\n options: BeancountOptions\n\n #: A dict with all of Fava's option values.\n fava_options: RustfavaOptions\n\n #: A list of all errors from parsing the custom options.\n fava_options_errors: Sequence[BeancountError]\n\n #: The price map.\n prices: RustfavaPriceMap\n\n #: Dict of list of all (unfiltered) entries by type.\n all_entries_by_type: EntriesByType\n\n #: A :class:`.AccountDict` module - details about the accounts.\n accounts: AccountDict\n\n #: An :class:`AttributesModule` instance.\n attributes: AttributesModule\n\n #: A :class:`.BudgetModule` instance.\n budgets: BudgetModule\n\n #: A :class:`.ChartModule` instance.\n charts: ChartModule\n\n #: A :class:`.CommoditiesModule` instance.\n commodities: CommoditiesModule\n\n #: A :class:`.ExtensionModule` instance.\n extensions: ExtensionModule\n\n #: A :class:`.FileModule` instance.\n file: FileModule\n\n #: A :class:`.DecimalFormatModule` instance.\n format_decimal: DecimalFormatModule\n\n #: A :class:`.IngestModule` instance.\n ingest: IngestModule\n\n #: A :class:`.FavaMisc` instance.\n misc: FavaMisc\n\n #: A :class:`.QueryShell` instance.\n query_shell: QueryShell\n\n def __init__(self, path: str, *, poll_watcher: bool = False) -> None:\n \"\"\"Create an interface for a Beancount ledger.\n\n Arguments:\n path: Path to the main Beancount file.\n poll_watcher: Whether to use the polling file watcher.\n \"\"\"\n #: The path to the main Beancount file.\n self.beancount_file_path = path\n self._is_encrypted = is_encrypted_file(path)\n self.get_filtered = lru_cache(maxsize=16)(self._get_filtered)\n self.get_entry = lru_cache(maxsize=16)(self._get_entry)\n\n self.accounts = AccountDict(self)\n self.attributes = AttributesModule(self)\n self.budgets = BudgetModule(self)\n self.charts = ChartModule(self)\n self.commodities = CommoditiesModule(self)\n self.extensions = ExtensionModule(self)\n self.file = FileModule(self)\n self.format_decimal = DecimalFormatModule(self)\n self.ingest = IngestModule(self)\n self.misc = FavaMisc(self)\n self.query_shell = QueryShell(self)\n\n self.watcher = WatchfilesWatcher() if not poll_watcher else Watcher()\n\n self.load_file()\n\n def load_file(self) -> None:\n \"\"\"Load the main file and all included files and set attributes.\"\"\"\n self.all_entries, self.load_errors, self.options = load_uncached(\n self.beancount_file_path,\n is_encrypted=self._is_encrypted,\n )\n self.get_filtered.cache_clear()\n self.get_entry.cache_clear()\n\n self.all_entries_by_type = group_entries_by_type(self.all_entries)\n self.prices = RustfavaPriceMap(self.all_entries_by_type.Price)\n\n self.fava_options, self.fava_options_errors = parse_options(\n self.all_entries_by_type.Custom,\n )\n\n if self._is_encrypted: # pragma: no cover\n pass\n else:\n self.watcher.update(*self.paths_to_watch())\n\n # Call load_file of all modules.\n self.accounts.load_file()\n self.attributes.load_file()\n self.budgets.load_file()\n self.charts.load_file()\n self.commodities.load_file()\n self.extensions.load_file()\n self.file.load_file()\n self.format_decimal.load_file()\n self.misc.load_file()\n self.query_shell.load_file()\n self.ingest.load_file()\n\n self.extensions.after_load_file()\n\n def _get_filtered(\n self,\n account: str | None = None,\n filter: str | None = None, # noqa: A002\n time: str | None = None,\n ) -> FilteredLedger:\n \"\"\"Filter the ledger.\n\n Args:\n account: The account filter.\n filter: The advanced filter.\n time: The time filter.\n \"\"\"\n return FilteredLedger(\n ledger=self, account=account, filter=filter, time=time\n )\n\n @property\n def mtime(self) -> int:\n \"\"\"The timestamp to the latest change of the underlying files.\"\"\"\n return self.watcher.last_checked\n\n @property\n def errors(self) -> Sequence[BeancountError]:\n \"\"\"The errors that the Beancount loading plus Fava module errors.\"\"\"\n return [\n *self.load_errors,\n *self.fava_options_errors,\n *self.budgets.errors,\n *self.extensions.errors,\n *self.misc.errors,\n *self.ingest.errors,\n ]\n\n @property\n def root_accounts(self) -> tuple[str, str, str, str, str]:\n \"\"\"The five root accounts.\"\"\"\n options = self.options\n return (\n options[\"name_assets\"],\n options[\"name_liabilities\"],\n options[\"name_equity\"],\n options[\"name_income\"],\n options[\"name_expenses\"],\n )\n\n def join_path(self, *args: str) -> Path:\n \"\"\"Path relative to the directory of the ledger.\"\"\"\n return Path(self.beancount_file_path).parent.joinpath(*args).resolve()\n\n def paths_to_watch(self) -> tuple[Sequence[Path], Sequence[Path]]:\n \"\"\"Get paths to included files and document directories.\n\n Returns:\n A tuple (files, directories).\n \"\"\"\n files = [Path(i) for i in self.options[\"include\"]]\n if self.ingest.module_path:\n files.append(self.ingest.module_path)\n return (\n files,\n [\n self.join_path(path, account)\n for account in self.root_accounts\n for path in self.options[\"documents\"]\n ],\n )\n\n def changed(self) -> bool:\n \"\"\"Check if the file needs to be reloaded.\n\n Returns:\n True if a change in one of the included files or a change in a\n document folder was detected and the file has been reloaded.\n \"\"\"\n # We can't reload an encrypted file, so act like it never changes.\n if self._is_encrypted: # pragma: no cover\n return False\n changed = self.watcher.check()\n if changed:\n self.load_file()\n return changed\n\n def interval_balances(\n self,\n filtered: FilteredLedger,\n interval: Interval,\n account_name: str,\n *,\n accumulate: bool = False,\n ) -> tuple[Sequence[Tree], Sequence[DateRange]]:\n \"\"\"Balances by interval.\n\n Arguments:\n filtered: The currently filtered ledger.\n interval: An interval.\n account_name: An account name.\n accumulate: A boolean, ``True`` if the balances for an interval\n should include all entries up to the end of the interval.\n\n Returns:\n A pair of a list of Tree instances and the intervals.\n \"\"\"\n min_accounts = [\n account\n for account in self.accounts\n if account.startswith(account_name)\n ]\n\n interval_ranges = list(reversed(filtered.interval_ranges(interval)))\n interval_balances = [\n Tree(\n slice_entry_dates(\n filtered.entries,\n date.min if accumulate else date_range.begin,\n date_range.end,\n ),\n min_accounts,\n )\n for date_range in interval_ranges\n ]\n\n return interval_balances, interval_ranges\n\n @listify\n def account_journal(\n self,\n filtered: FilteredLedger,\n account_name: str,\n conversion: str | Conversion,\n *,\n with_children: bool,\n ) -> Iterable[\n tuple[int, Directive, SimpleCounterInventory, SimpleCounterInventory]\n ]:\n \"\"\"Journal for an account.\n\n Args:\n filtered: The currently filtered ledger.\n account_name: An account name.\n conversion: The conversion to use.\n with_children: Whether to include postings of subaccounts of\n the account.\n\n Yields:\n Tuples of ``(index, entry, change, balance)``.\n \"\"\"\n conv = conversion_from_str(conversion)\n relevant_account = account_tester(\n account_name, with_children=with_children\n )\n\n prices = self.prices\n balance = CounterInventory()\n for index, entry in enumerate(filtered.entries_without_prices):\n change = CounterInventory()\n entry_is_relevant = False\n postings = getattr(entry, \"postings\", None)\n if postings is not None:\n for posting in postings:\n if relevant_account(posting.account):\n entry_is_relevant = True\n balance.add_position(posting)\n change.add_position(posting)\n elif any(relevant_account(a) for a in get_entry_accounts(entry)):\n entry_is_relevant = True\n\n if entry_is_relevant:\n yield (\n index,\n entry,\n conv.apply(change, prices, entry.date),\n conv.apply(balance, prices, entry.date),\n )\n\n def _get_entry(self, entry_hash: str) -> Directive:\n \"\"\"Find an entry.\n\n Arguments:\n entry_hash: Hash of the entry.\n\n Returns:\n The entry with the given hash.\n\n Raises:\n EntryNotFoundForHashError: If there is no entry for the given hash.\n \"\"\"\n try:\n return next(\n entry\n for entry in self.all_entries\n if entry_hash == hash_entry(entry)\n )\n except StopIteration as exc:\n raise EntryNotFoundForHashError(entry_hash) from exc\n\n def context(\n self,\n entry_hash: str,\n ) -> tuple[\n Directive,\n Mapping[str, Sequence[str]] | None,\n Mapping[str, Sequence[str]] | None,\n ]:\n \"\"\"Context for an entry.\n\n Arguments:\n entry_hash: Hash of entry.\n\n Returns:\n A tuple ``(entry, before, after, source_slice, sha256sum)`` of the\n (unique) entry with the given ``entry_hash``. If the entry is a\n Balance or Transaction then ``before`` and ``after`` contain\n the balances before and after the entry of the affected accounts.\n \"\"\"\n entry = self.get_entry(entry_hash)\n\n if not isinstance(entry, (Balance, Transaction)):\n return entry, None, None\n\n entry_accounts = get_entry_accounts(entry)\n balances = {account: CounterInventory() for account in entry_accounts}\n for entry_ in takewhile(lambda e: e is not entry, self.all_entries):\n if isinstance(entry_, Transaction):\n for posting in entry_.postings:\n balance = balances.get(posting.account, None)\n if balance is not None:\n balance.add_position(posting)\n\n def visualise(inv: CounterInventory) -> Sequence[str]:\n return inv.to_strings()\n\n before = {acc: visualise(inv) for acc, inv in balances.items()}\n\n if isinstance(entry, Balance):\n return entry, before, None\n\n for posting in entry.postings:\n balances[posting.account].add_position(posting)\n after = {acc: visualise(inv) for acc, inv in balances.items()}\n return entry, before, after\n\n def commodity_pairs(self) -> Sequence[tuple[str, str]]:\n \"\"\"List pairs of commodities.\n\n Returns:\n A list of pairs of commodities. Pairs of operating currencies will\n be given in both directions not just in the one found in file.\n \"\"\"\n return self.prices.commodity_pairs(self.options[\"operating_currency\"])\n\n def statement_path(self, entry_hash: str, metadata_key: str) -> str:\n \"\"\"Get the path for a statement found in the specified entry.\n\n The entry that we look up should contain a path to a document (absolute\n or relative to the filename of the entry) or just its basename. We go\n through all documents and match on the full path or if one of the\n documents with a matching account has a matching file basename.\n\n Arguments:\n entry_hash: Hash of the entry containing the path in its metadata.\n metadata_key: The key that the path should be in.\n\n Returns:\n The filename of the matching document entry.\n\n Raises:\n StatementMetadataInvalidError: If the metadata at the given key is\n invalid.\n StatementNotFoundError: If no matching document is found.\n \"\"\"\n entry = self.get_entry(entry_hash)\n value = entry.meta.get(metadata_key, None)\n if not isinstance(value, str):\n raise StatementMetadataInvalidError(metadata_key)\n\n accounts = set(get_entry_accounts(entry))\n filename, _ = get_position(entry)\n full_path = (Path(filename).parent / value).resolve()\n for document in self.all_entries_by_type.Document:\n document_path = Path(document.filename)\n if document_path == full_path:\n return document.filename\n if document.account in accounts and document_path.name == value:\n return document.filename\n\n raise StatementNotFoundError\n\n group_entries_by_type = staticmethod(group_entries_by_type)\n
def join_path(self, *args: str) -> Path:\n \"\"\"Path relative to the directory of the ledger.\"\"\"\n return Path(self.beancount_file_path).parent.joinpath(*args).resolve()\n
Get paths to included files and document directories.
Returns:
Type Description tuple[Sequence[Path], Sequence[Path]]
A tuple (files, directories).
Source code in src/rustfava/core/__init__.py
def paths_to_watch(self) -> tuple[Sequence[Path], Sequence[Path]]:\n \"\"\"Get paths to included files and document directories.\n\n Returns:\n A tuple (files, directories).\n \"\"\"\n files = [Path(i) for i in self.options[\"include\"]]\n if self.ingest.module_path:\n files.append(self.ingest.module_path)\n return (\n files,\n [\n self.join_path(path, account)\n for account in self.root_accounts\n for path in self.options[\"documents\"]\n ],\n )\n
True if a change in one of the included files or a change in a
bool
document folder was detected and the file has been reloaded.
Source code in src/rustfava/core/__init__.py
def changed(self) -> bool:\n \"\"\"Check if the file needs to be reloaded.\n\n Returns:\n True if a change in one of the included files or a change in a\n document folder was detected and the file has been reloaded.\n \"\"\"\n # We can't reload an encrypted file, so act like it never changes.\n if self._is_encrypted: # pragma: no cover\n return False\n changed = self.watcher.check()\n if changed:\n self.load_file()\n return changed\n
Name Type Description Default filteredFilteredLedger
The currently filtered ledger.
required intervalInterval
An interval.
required account_namestr
An account name.
required accumulatebool
A boolean, True if the balances for an interval should include all entries up to the end of the interval.
False
Returns:
Type Description tuple[Sequence[Tree], Sequence[DateRange]]
A pair of a list of Tree instances and the intervals.
Source code in src/rustfava/core/__init__.py
def interval_balances(\n self,\n filtered: FilteredLedger,\n interval: Interval,\n account_name: str,\n *,\n accumulate: bool = False,\n) -> tuple[Sequence[Tree], Sequence[DateRange]]:\n \"\"\"Balances by interval.\n\n Arguments:\n filtered: The currently filtered ledger.\n interval: An interval.\n account_name: An account name.\n accumulate: A boolean, ``True`` if the balances for an interval\n should include all entries up to the end of the interval.\n\n Returns:\n A pair of a list of Tree instances and the intervals.\n \"\"\"\n min_accounts = [\n account\n for account in self.accounts\n if account.startswith(account_name)\n ]\n\n interval_ranges = list(reversed(filtered.interval_ranges(interval)))\n interval_balances = [\n Tree(\n slice_entry_dates(\n filtered.entries,\n date.min if accumulate else date_range.begin,\n date_range.end,\n ),\n min_accounts,\n )\n for date_range in interval_ranges\n ]\n\n return interval_balances, interval_ranges\n
the balances before and after the entry of the affected accounts.
Source code in src/rustfava/core/__init__.py
def context(\n self,\n entry_hash: str,\n) -> tuple[\n Directive,\n Mapping[str, Sequence[str]] | None,\n Mapping[str, Sequence[str]] | None,\n]:\n \"\"\"Context for an entry.\n\n Arguments:\n entry_hash: Hash of entry.\n\n Returns:\n A tuple ``(entry, before, after, source_slice, sha256sum)`` of the\n (unique) entry with the given ``entry_hash``. If the entry is a\n Balance or Transaction then ``before`` and ``after`` contain\n the balances before and after the entry of the affected accounts.\n \"\"\"\n entry = self.get_entry(entry_hash)\n\n if not isinstance(entry, (Balance, Transaction)):\n return entry, None, None\n\n entry_accounts = get_entry_accounts(entry)\n balances = {account: CounterInventory() for account in entry_accounts}\n for entry_ in takewhile(lambda e: e is not entry, self.all_entries):\n if isinstance(entry_, Transaction):\n for posting in entry_.postings:\n balance = balances.get(posting.account, None)\n if balance is not None:\n balance.add_position(posting)\n\n def visualise(inv: CounterInventory) -> Sequence[str]:\n return inv.to_strings()\n\n before = {acc: visualise(inv) for acc, inv in balances.items()}\n\n if isinstance(entry, Balance):\n return entry, before, None\n\n for posting in entry.postings:\n balances[posting.account].add_position(posting)\n after = {acc: visualise(inv) for acc, inv in balances.items()}\n return entry, before, after\n
A list of pairs of commodities. Pairs of operating currencies will
Sequence[tuple[str, str]]
be given in both directions not just in the one found in file.
Source code in src/rustfava/core/__init__.py
def commodity_pairs(self) -> Sequence[tuple[str, str]]:\n \"\"\"List pairs of commodities.\n\n Returns:\n A list of pairs of commodities. Pairs of operating currencies will\n be given in both directions not just in the one found in file.\n \"\"\"\n return self.prices.commodity_pairs(self.options[\"operating_currency\"])\n
Get the path for a statement found in the specified entry.
The entry that we look up should contain a path to a document (absolute or relative to the filename of the entry) or just its basename. We go through all documents and match on the full path or if one of the documents with a matching account has a matching file basename.
Parameters:
Name Type Description Default entry_hashstr
Hash of the entry containing the path in its metadata.
required metadata_keystr
The key that the path should be in.
required
Returns:
Type Description str
The filename of the matching document entry.
Raises:
Type Description StatementMetadataInvalidError
If the metadata at the given key is invalid.
StatementNotFoundError
If no matching document is found.
Source code in src/rustfava/core/__init__.py
def statement_path(self, entry_hash: str, metadata_key: str) -> str:\n \"\"\"Get the path for a statement found in the specified entry.\n\n The entry that we look up should contain a path to a document (absolute\n or relative to the filename of the entry) or just its basename. We go\n through all documents and match on the full path or if one of the\n documents with a matching account has a matching file basename.\n\n Arguments:\n entry_hash: Hash of the entry containing the path in its metadata.\n metadata_key: The key that the path should be in.\n\n Returns:\n The filename of the matching document entry.\n\n Raises:\n StatementMetadataInvalidError: If the metadata at the given key is\n invalid.\n StatementNotFoundError: If no matching document is found.\n \"\"\"\n entry = self.get_entry(entry_hash)\n value = entry.meta.get(metadata_key, None)\n if not isinstance(value, str):\n raise StatementMetadataInvalidError(metadata_key)\n\n accounts = set(get_entry_accounts(entry))\n filename, _ = get_position(entry)\n full_path = (Path(filename).parent / value).resolve()\n for document in self.all_entries_by_type.Document:\n document_path = Path(document.filename)\n if document_path == full_path:\n return document.filename\n if document.account in accounts and document_path.name == value:\n return document.filename\n\n raise StatementNotFoundError\n
@dataclass(frozen=True)\nclass LastEntry:\n \"\"\"Date and hash of the last entry for an account.\"\"\"\n\n #: The entry date.\n date: datetime.date\n\n #: The entry hash.\n entry_hash: str\n
@dataclass\nclass AccountData:\n \"\"\"Holds information about an account.\"\"\"\n\n #: The date on which this account is closed (or datetime.date.max).\n close_date: datetime.date | None = None\n\n #: The metadata of the Open entry of this account.\n meta: Meta = field(default_factory=dict)\n\n #: Uptodate status. Is only computed if the account has a\n #: \"fava-uptodate-indication\" meta attribute.\n uptodate_status: Literal[\"green\", \"yellow\", \"red\"] | None = None\n\n #: Balance directive if this account has an uptodate status.\n balance_string: str | None = None\n\n #: The last entry of the account (unless it is a close Entry)\n last_entry: LastEntry | None = None\n
class AccountDict(FavaModule, dict[str, AccountData]):\n \"\"\"Account info dictionary.\"\"\"\n\n EMPTY = AccountData()\n\n def __missing__(self, key: str) -> AccountData:\n return self.EMPTY\n\n def setdefault(\n self,\n key: str,\n _: AccountData | None = None,\n ) -> AccountData:\n \"\"\"Get the account of the given name, insert one if it is missing.\"\"\"\n if key not in self:\n self[key] = AccountData()\n return self[key]\n\n def load_file(self) -> None: # noqa: D102\n self.clear()\n entries_by_account = group_entries_by_account(self.ledger.all_entries)\n tree = Tree(self.ledger.all_entries)\n for open_entry in self.ledger.all_entries_by_type.Open:\n meta = open_entry.meta\n account_data = self.setdefault(open_entry.account)\n account_data.meta = meta\n\n txn_postings = entries_by_account[open_entry.account]\n last = get_last_entry(txn_postings)\n if last is not None and not isinstance(last, Close):\n account_data.last_entry = LastEntry(\n date=last.date,\n entry_hash=hash_entry(last),\n )\n if meta.get(\"fava-uptodate-indication\"):\n account_data.uptodate_status = uptodate_status(txn_postings)\n if account_data.uptodate_status != \"green\":\n account_data.balance_string = balance_string(\n tree.get(open_entry.account),\n )\n for close in self.ledger.all_entries_by_type.Close:\n self.setdefault(close.account).close_date = close.date\n\n def all_balance_directives(self) -> str:\n \"\"\"Balance directives for all accounts.\"\"\"\n return \"\".join(\n account_details.balance_string\n for account_details in self.values()\n if account_details.balance_string\n )\n
Get the account of the given name, insert one if it is missing.
Source code in src/rustfava/core/accounts.py
def setdefault(\n self,\n key: str,\n _: AccountData | None = None,\n) -> AccountData:\n \"\"\"Get the account of the given name, insert one if it is missing.\"\"\"\n if key not in self:\n self[key] = AccountData()\n return self[key]\n
def all_balance_directives(self) -> str:\n \"\"\"Balance directives for all accounts.\"\"\"\n return \"\".join(\n account_details.balance_string\n for account_details in self.values()\n if account_details.balance_string\n )\n
Name Type Description Default txn_postingsSequence[Directive | TransactionPosting]
The TransactionPosting for the account.
required
Returns:
Type Description Literal['green', 'yellow', 'red'] | None
A status string for the last balance or transaction of the account.
Literal['green', 'yellow', 'red'] | None
'green': A balance check that passed.
Literal['green', 'yellow', 'red'] | None
'red': A balance check that failed.
Literal['green', 'yellow', 'red'] | None
'yellow': Not a balance check.
Source code in src/rustfava/core/accounts.py
def uptodate_status(\n txn_postings: Sequence[Directive | TransactionPosting],\n) -> Literal[\"green\", \"yellow\", \"red\"] | None:\n \"\"\"Status of the last balance or transaction.\n\n Args:\n txn_postings: The TransactionPosting for the account.\n\n Returns:\n A status string for the last balance or transaction of the account.\n\n - 'green': A balance check that passed.\n - 'red': A balance check that failed.\n - 'yellow': Not a balance check.\n \"\"\"\n for txn_posting in reversed(txn_postings):\n if isinstance(txn_posting, Balance):\n return \"red\" if txn_posting.diff_amount else \"green\"\n if (\n isinstance(txn_posting, TransactionPosting)\n and txn_posting.transaction.flag != FLAG_UNREALIZED\n ):\n return \"yellow\"\n return None\n
Balance directive for the given account for today.
Source code in src/rustfava/core/accounts.py
def balance_string(tree_node: TreeNode) -> str:\n \"\"\"Balance directive for the given account for today.\"\"\"\n account = tree_node.name\n today = str(local_today())\n res = \"\"\n for currency, number in UNITS.apply(tree_node.balance).items():\n res += f\"{today} balance {account:<28} {number:>15} {currency}\\n\"\n return res\n
Some attributes of the ledger (mostly for auto-completion).
Source code in src/rustfava/core/attributes.py
class AttributesModule(FavaModule):\n \"\"\"Some attributes of the ledger (mostly for auto-completion).\"\"\"\n\n def __init__(self, ledger: RustfavaLedger) -> None:\n super().__init__(ledger)\n self.accounts: Sequence[str] = []\n self.currencies: Sequence[str] = []\n self.payees: Sequence[str] = []\n self.links: Sequence[str] = []\n self.tags: Sequence[str] = []\n self.years: Sequence[str] = []\n\n def load_file(self) -> None: # noqa: D102\n all_entries = self.ledger.all_entries\n\n all_links = set()\n all_tags = set()\n for entry in all_entries:\n links = getattr(entry, \"links\", None)\n if links is not None:\n all_links.update(links)\n tags = getattr(entry, \"tags\", None)\n if tags is not None:\n all_tags.update(tags)\n self.links = sorted(all_links)\n self.tags = sorted(all_tags)\n\n self.years = get_active_years(\n all_entries,\n self.ledger.fava_options.fiscal_year_end,\n )\n\n account_ranker = ExponentialDecayRanker(\n sorted(self.ledger.accounts.keys()),\n )\n currency_ranker = ExponentialDecayRanker()\n payee_ranker = ExponentialDecayRanker()\n\n for txn in self.ledger.all_entries_by_type.Transaction:\n if txn.payee:\n payee_ranker.update(txn.payee, txn.date)\n for posting in txn.postings:\n account_ranker.update(posting.account, txn.date)\n # Skip postings with missing units (can happen with parse errors)\n if posting.units is not None:\n currency_ranker.update(posting.units.currency, txn.date)\n if posting.cost and posting.cost.currency is not None:\n currency_ranker.update(posting.cost.currency, txn.date)\n\n self.accounts = account_ranker.sort()\n self.currencies = currency_ranker.sort()\n self.payees = payee_ranker.sort()\n\n def payee_accounts(self, payee: str) -> Sequence[str]:\n \"\"\"Rank accounts for the given payee.\"\"\"\n account_ranker = ExponentialDecayRanker(self.accounts)\n transactions = self.ledger.all_entries_by_type.Transaction\n for txn in transactions:\n if txn.payee == payee:\n for posting in txn.postings:\n account_ranker.update(posting.account, txn.date)\n return account_ranker.sort()\n\n def payee_transaction(self, payee: str) -> Transaction | None:\n \"\"\"Get the last transaction for a payee.\"\"\"\n transactions = self.ledger.all_entries_by_type.Transaction\n for txn in reversed(transactions):\n if txn.payee == payee:\n return txn\n return None\n\n def narration_transaction(self, narration: str) -> Transaction | None:\n \"\"\"Get the last transaction for a narration.\"\"\"\n transactions = self.ledger.all_entries_by_type.Transaction\n for txn in reversed(transactions):\n if txn.narration == narration:\n return txn\n return None\n\n @property\n def narrations(self) -> Sequence[str]:\n \"\"\"Get the narrations of all transactions.\"\"\"\n narration_ranker = ExponentialDecayRanker()\n for txn in self.ledger.all_entries_by_type.Transaction:\n if txn.narration:\n narration_ranker.update(txn.narration, txn.date)\n return narration_ranker.sort()\n
def payee_accounts(self, payee: str) -> Sequence[str]:\n \"\"\"Rank accounts for the given payee.\"\"\"\n account_ranker = ExponentialDecayRanker(self.accounts)\n transactions = self.ledger.all_entries_by_type.Transaction\n for txn in transactions:\n if txn.payee == payee:\n for posting in txn.postings:\n account_ranker.update(posting.account, txn.date)\n return account_ranker.sort()\n
def payee_transaction(self, payee: str) -> Transaction | None:\n \"\"\"Get the last transaction for a payee.\"\"\"\n transactions = self.ledger.all_entries_by_type.Transaction\n for txn in reversed(transactions):\n if txn.payee == payee:\n return txn\n return None\n
def narration_transaction(self, narration: str) -> Transaction | None:\n \"\"\"Get the last transaction for a narration.\"\"\"\n transactions = self.ledger.all_entries_by_type.Transaction\n for txn in reversed(transactions):\n if txn.narration == narration:\n return txn\n return None\n
Return active years, with support for fiscal years.
Parameters:
Name Type Description Default entriesSequence[Directive]
Beancount entries
required fyeFiscalYearEnd
fiscal year end
required
Returns:
Type Description list[str]
A reverse sorted list of years or fiscal years that occur in the
list[str]
entries.
Source code in src/rustfava/core/attributes.py
def get_active_years(\n entries: Sequence[Directive],\n fye: FiscalYearEnd,\n) -> list[str]:\n \"\"\"Return active years, with support for fiscal years.\n\n Args:\n entries: Beancount entries\n fye: fiscal year end\n\n Returns:\n A reverse sorted list of years or fiscal years that occur in the\n entries.\n \"\"\"\n years = []\n if fye == END_OF_YEAR:\n prev_year = None\n for entry in entries:\n year = entry.date.year\n if year != prev_year:\n prev_year = year\n years.append(year)\n return [f\"{year}\" for year in reversed(years)]\n month = fye.month\n day = fye.day\n prev_year = None\n for entry in entries:\n date = entry.date\n year = (\n entry.date.year + 1\n if date.month > month or (date.month == month and date.day > day)\n else entry.date.year\n )\n if year != prev_year:\n prev_year = year\n years.append(year)\n return [f\"FY{year}\" for year in reversed(years)]\n
A dictionary of currency to Decimal with the budget for the
Mapping[str, Decimal]
specified account and period.
Source code in src/rustfava/core/budgets.py
def calculate_budget(\n budgets: BudgetDict,\n account: str,\n date_from: datetime.date,\n date_to: datetime.date,\n) -> Mapping[str, Decimal]:\n \"\"\"Calculate budget for an account.\n\n Args:\n budgets: A list of :class:`Budget` entries.\n account: An account name.\n date_from: Starting date.\n date_to: End date (exclusive).\n\n Returns:\n A dictionary of currency to Decimal with the budget for the\n specified account and period.\n \"\"\"\n budget_list = budgets.get(account, None)\n if budget_list is None:\n return {}\n\n currency_dict: dict[str, Decimal] = defaultdict(Decimal)\n\n for day in days_in_daterange(date_from, date_to):\n matches = _matching_budgets(budget_list, day)\n for budget in matches.values():\n days_in_period = budget.period.number_of_days(day)\n currency_dict[budget.currency] += budget.number / days_in_period\n return dict(currency_dict)\n
Calculate budget for an account including budgets of its children.
Parameters:
Name Type Description Default budgetsBudgetDict
A list of :class:Budget entries.
required accountstr
An account name.
required date_fromdate
Starting date.
required date_todate
End date (exclusive).
required
Returns:
Type Description Mapping[str, Decimal]
A dictionary of currency to Decimal with the budget for the
Mapping[str, Decimal]
specified account and period.
Source code in src/rustfava/core/budgets.py
def calculate_budget_children(\n budgets: BudgetDict,\n account: str,\n date_from: datetime.date,\n date_to: datetime.date,\n) -> Mapping[str, Decimal]:\n \"\"\"Calculate budget for an account including budgets of its children.\n\n Args:\n budgets: A list of :class:`Budget` entries.\n account: An account name.\n date_from: Starting date.\n date_to: End date (exclusive).\n\n Returns:\n A dictionary of currency to Decimal with the budget for the\n specified account and period.\n \"\"\"\n currency_dict: dict[str, Decimal] = Counter() # type: ignore[assignment]\n\n for child in budgets:\n if child.startswith(account):\n currency_dict.update(\n calculate_budget(budgets, child, date_from, date_to),\n )\n return dict(currency_dict)\n
@dataclass(frozen=True)\nclass DateAndBalanceWithBudget:\n \"\"\"Balance at a date with a budget.\"\"\"\n\n date: date\n balance: SimpleCounterInventory\n account_balances: Mapping[str, SimpleCounterInventory]\n budgets: Mapping[str, Decimal]\n
class ChartModule(FavaModule):\n \"\"\"Return data for the various charts in rustfava.\"\"\"\n\n def hierarchy(\n self,\n filtered: FilteredLedger,\n account_name: str,\n conversion: Conversion,\n ) -> SerialisedTreeNode:\n \"\"\"Render an account tree.\"\"\"\n tree = filtered.root_tree\n return tree.get(account_name).serialise(\n conversion, self.ledger.prices, filtered.end_date\n )\n\n @listify\n def interval_totals(\n self,\n filtered: FilteredLedger,\n interval: Interval,\n accounts: str | tuple[str, ...],\n conversion: str | Conversion,\n *,\n invert: bool = False,\n ) -> Iterable[DateAndBalanceWithBudget]:\n \"\"\"Render totals for account (or accounts) in the intervals.\n\n Args:\n filtered: The filtered ledger.\n interval: An interval.\n accounts: A single account (str) or a tuple of accounts.\n conversion: The conversion to use.\n invert: invert all numbers.\n\n Yields:\n The balances and budgets for the intervals.\n \"\"\"\n conv = conversion_from_str(conversion)\n prices = self.ledger.prices\n\n # limit the bar charts to 100 intervals\n intervals = filtered.interval_ranges(interval)[-100:]\n\n for date_range in intervals:\n inventory = CounterInventory()\n entries = slice_entry_dates(\n filtered.entries, date_range.begin, date_range.end\n )\n account_inventories: dict[str, CounterInventory] = defaultdict(\n CounterInventory,\n )\n for entry in entries:\n for posting in getattr(entry, \"postings\", []):\n if posting.account.startswith(accounts):\n account_inventories[posting.account].add_position(\n posting,\n )\n inventory.add_position(posting)\n balance = conv.apply(\n inventory,\n prices,\n date_range.end_inclusive,\n )\n account_balances = {\n account: conv.apply(\n acct_value,\n prices,\n date_range.end_inclusive,\n )\n for account, acct_value in account_inventories.items()\n }\n budgets = (\n self.ledger.budgets.calculate_children(\n accounts,\n date_range.begin,\n date_range.end,\n )\n if isinstance(accounts, str)\n else {}\n )\n\n if invert:\n balance = -balance\n budgets = {k: -v for k, v in budgets.items()}\n account_balances = {k: -v for k, v in account_balances.items()}\n\n yield DateAndBalanceWithBudget(\n date_range.end_inclusive,\n balance,\n account_balances,\n budgets,\n )\n\n @listify\n def linechart(\n self,\n filtered: FilteredLedger,\n account_name: str,\n conversion: str | Conversion,\n ) -> Iterable[DateAndBalance]:\n \"\"\"Get the balance of an account as a line chart.\n\n Args:\n filtered: The filtered ledger.\n account_name: A string.\n conversion: The conversion to use.\n\n Yields:\n Dicts for all dates on which the balance of the given\n account has changed containing the balance (in units) of the\n account at that date.\n \"\"\"\n conv = conversion_from_str(conversion)\n\n def _balances() -> Iterable[tuple[date, CounterInventory]]:\n last_date = None\n running_balance = CounterInventory()\n is_child_account = account_tester(account_name, with_children=True)\n\n for entry in filtered.entries:\n for posting in getattr(entry, \"postings\", []):\n if is_child_account(posting.account):\n new_date = entry.date\n if last_date is not None and new_date > last_date:\n yield (last_date, running_balance)\n running_balance.add_position(posting)\n last_date = new_date\n\n if last_date is not None:\n yield (last_date, running_balance)\n\n # When the balance for a commodity just went to zero, it will be\n # missing from the 'balance' so keep track of currencies that last had\n # a balance.\n last_currencies = None\n prices = self.ledger.prices\n\n for d, running_bal in _balances():\n balance = conv.apply(running_bal, prices, d)\n currencies = set(balance.keys())\n if last_currencies:\n for currency in last_currencies - currencies:\n balance[currency] = ZERO\n last_currencies = currencies\n yield DateAndBalance(d, balance)\n\n @listify\n def net_worth(\n self,\n filtered: FilteredLedger,\n interval: Interval,\n conversion: str | Conversion,\n ) -> Iterable[DateAndBalance]:\n \"\"\"Compute net worth.\n\n Args:\n filtered: The filtered ledger.\n interval: A string for the interval.\n conversion: The conversion to use.\n\n Yields:\n Dicts for all ends of the given interval containing the\n net worth (Assets + Liabilities) separately converted to all\n operating currencies.\n \"\"\"\n conv = conversion_from_str(conversion)\n transactions = (\n entry\n for entry in filtered.entries\n if (\n isinstance(entry, Transaction)\n and entry.flag != FLAG_UNREALIZED\n )\n )\n\n types = (\n self.ledger.options[\"name_assets\"],\n self.ledger.options[\"name_liabilities\"],\n )\n\n txn = next(transactions, None)\n inventory = CounterInventory()\n\n prices = self.ledger.prices\n for date_range in filtered.interval_ranges(interval):\n while txn and txn.date < date_range.end:\n for posting in txn.postings:\n if posting.account.startswith(types):\n inventory.add_position(posting)\n txn = next(transactions, None)\n yield DateAndBalance(\n date_range.end_inclusive,\n conv.apply(\n inventory,\n prices,\n date_range.end_inclusive,\n ),\n )\n
Name Type Description Default filteredFilteredLedger
The filtered ledger.
required account_namestr
A string.
required conversionstr | Conversion
The conversion to use.
required
Yields:
Type Description Iterable[DateAndBalance]
Dicts for all dates on which the balance of the given
Iterable[DateAndBalance]
account has changed containing the balance (in units) of the
Iterable[DateAndBalance]
account at that date.
Source code in src/rustfava/core/charts.py
@listify\ndef linechart(\n self,\n filtered: FilteredLedger,\n account_name: str,\n conversion: str | Conversion,\n) -> Iterable[DateAndBalance]:\n \"\"\"Get the balance of an account as a line chart.\n\n Args:\n filtered: The filtered ledger.\n account_name: A string.\n conversion: The conversion to use.\n\n Yields:\n Dicts for all dates on which the balance of the given\n account has changed containing the balance (in units) of the\n account at that date.\n \"\"\"\n conv = conversion_from_str(conversion)\n\n def _balances() -> Iterable[tuple[date, CounterInventory]]:\n last_date = None\n running_balance = CounterInventory()\n is_child_account = account_tester(account_name, with_children=True)\n\n for entry in filtered.entries:\n for posting in getattr(entry, \"postings\", []):\n if is_child_account(posting.account):\n new_date = entry.date\n if last_date is not None and new_date > last_date:\n yield (last_date, running_balance)\n running_balance.add_position(posting)\n last_date = new_date\n\n if last_date is not None:\n yield (last_date, running_balance)\n\n # When the balance for a commodity just went to zero, it will be\n # missing from the 'balance' so keep track of currencies that last had\n # a balance.\n last_currencies = None\n prices = self.ledger.prices\n\n for d, running_bal in _balances():\n balance = conv.apply(running_bal, prices, d)\n currencies = set(balance.keys())\n if last_currencies:\n for currency in last_currencies - currencies:\n balance[currency] = ZERO\n last_currencies = currencies\n yield DateAndBalance(d, balance)\n
Name Type Description Default filteredFilteredLedger
The filtered ledger.
required intervalInterval
A string for the interval.
required conversionstr | Conversion
The conversion to use.
required
Yields:
Type Description Iterable[DateAndBalance]
Dicts for all ends of the given interval containing the
Iterable[DateAndBalance]
net worth (Assets + Liabilities) separately converted to all
Iterable[DateAndBalance]
operating currencies.
Source code in src/rustfava/core/charts.py
@listify\ndef net_worth(\n self,\n filtered: FilteredLedger,\n interval: Interval,\n conversion: str | Conversion,\n) -> Iterable[DateAndBalance]:\n \"\"\"Compute net worth.\n\n Args:\n filtered: The filtered ledger.\n interval: A string for the interval.\n conversion: The conversion to use.\n\n Yields:\n Dicts for all ends of the given interval containing the\n net worth (Assets + Liabilities) separately converted to all\n operating currencies.\n \"\"\"\n conv = conversion_from_str(conversion)\n transactions = (\n entry\n for entry in filtered.entries\n if (\n isinstance(entry, Transaction)\n and entry.flag != FLAG_UNREALIZED\n )\n )\n\n types = (\n self.ledger.options[\"name_assets\"],\n self.ledger.options[\"name_liabilities\"],\n )\n\n txn = next(transactions, None)\n inventory = CounterInventory()\n\n prices = self.ledger.prices\n for date_range in filtered.interval_ranges(interval):\n while txn and txn.date < date_range.end:\n for posting in txn.postings:\n if posting.account.startswith(types):\n inventory.add_position(posting)\n txn = next(transactions, None)\n yield DateAndBalance(\n date_range.end_inclusive,\n conv.apply(\n inventory,\n prices,\n date_range.end_inclusive,\n ),\n )\n
class CommoditiesModule(FavaModule):\n \"\"\"Details about the currencies and commodities.\"\"\"\n\n def __init__(self, ledger: RustfavaLedger) -> None:\n super().__init__(ledger)\n self.names: dict[str, str] = {}\n self.precisions: dict[str, int] = {}\n\n def load_file(self) -> None: # noqa: D102\n self.names = {}\n self.precisions = {}\n for commodity in self.ledger.all_entries_by_type.Commodity:\n name = commodity.meta.get(\"name\")\n if name:\n self.names[commodity.currency] = str(name)\n precision = commodity.meta.get(\"precision\")\n if isinstance(precision, (str, int, Decimal)):\n with suppress(ValueError):\n self.precisions[commodity.currency] = int(precision)\n\n def name(self, commodity: str) -> str:\n \"\"\"Get the name of a commodity (or the commodity itself if not set).\"\"\"\n return self.names.get(commodity, commodity)\n
Get the name of a commodity (or the commodity itself if not set).
Source code in src/rustfava/core/commodities.py
def name(self, commodity: str) -> str:\n \"\"\"Get the name of a commodity (or the commodity itself if not set).\"\"\"\n return self.names.get(commodity, commodity)\n
def get_cost(pos: Position) -> Amount:\n \"\"\"Return the total cost of a Position.\"\"\"\n cost_ = pos.cost\n return (\n _Amount(cost_.number * pos.units.number, cost_.currency)\n if cost_ is not None\n else pos.units\n )\n
This differs from the convert.get_value function in Beancount by returning the cost value if no price can be found.
Parameters:
Name Type Description Default posPosition
A Position.
required pricesRustfavaPriceMap
A rustfavaPriceMap
required datedate | None
A datetime.date instance to evaluate the value at, or None.
None
Returns:
Type Description Amount
An Amount, with value converted or if the conversion failed just the
Amount
cost value (or the units if the position has no cost).
Source code in src/rustfava/core/conversion.py
def get_market_value(\n pos: Position,\n prices: RustfavaPriceMap,\n date: datetime.date | None = None,\n) -> Amount:\n \"\"\"Get the market value of a Position.\n\n This differs from the convert.get_value function in Beancount by returning\n the cost value if no price can be found.\n\n Args:\n pos: A Position.\n prices: A rustfavaPriceMap\n date: A datetime.date instance to evaluate the value at, or None.\n\n Returns:\n An Amount, with value converted or if the conversion failed just the\n cost value (or the units if the position has no cost).\n \"\"\"\n units_ = pos.units\n cost_ = pos.cost\n\n if cost_ is not None:\n value_currency = cost_.currency\n base_quote = (units_.currency, value_currency)\n price_number = prices.get_price(base_quote, date)\n if price_number is not None:\n return _Amount(\n units_.number * price_number,\n value_currency,\n )\n return _Amount(units_.number * cost_.number, value_currency)\n return units_\n
Get the value of a Position in a particular currency.
Parameters:
Name Type Description Default posPosition
A Position.
required target_currencystr
The target currency to convert to.
required pricesRustfavaPriceMap
A rustfavaPriceMap
required datedate | None
A datetime.date instance to evaluate the value at, or None.
None
Returns:
Type Description Amount
An Amount, with value converted or if the conversion failed just the
Amount
cost value (or the units if the position has no cost).
Source code in src/rustfava/core/conversion.py
def convert_position(\n pos: Position,\n target_currency: str,\n prices: RustfavaPriceMap,\n date: datetime.date | None = None,\n) -> Amount:\n \"\"\"Get the value of a Position in a particular currency.\n\n Args:\n pos: A Position.\n target_currency: The target currency to convert to.\n prices: A rustfavaPriceMap\n date: A datetime.date instance to evaluate the value at, or None.\n\n Returns:\n An Amount, with value converted or if the conversion failed just the\n cost value (or the units if the position has no cost).\n \"\"\"\n units_ = pos.units\n\n # try the direct conversion\n base_quote = (units_.currency, target_currency)\n price_number = prices.get_price(base_quote, date)\n if price_number is not None:\n return _Amount(units_.number * price_number, target_currency)\n\n cost_ = pos.cost\n if cost_ is not None:\n cost_currency = cost_.currency\n if cost_currency != target_currency:\n base_quote1 = (units_.currency, cost_currency)\n rate1 = prices.get_price(base_quote1, date)\n if rate1 is not None:\n base_quote2 = (cost_currency, target_currency)\n rate2 = prices.get_price(base_quote2, date)\n if rate2 is not None:\n return _Amount(\n units_.number * rate1 * rate2,\n target_currency,\n )\n return units_\n
def conversion_from_str(value: str | Conversion) -> Conversion:\n \"\"\"Parse a conversion string.\"\"\"\n if not isinstance(value, str):\n return value\n if value == \"at_cost\":\n return AT_COST\n if value == \"at_value\":\n return AT_VALUE\n if value == \"units\":\n return UNITS\n\n return _CurrencyConversion(value)\n
Check whether the filename is a document or in an import directory.
This is a security validation function that prevents path traversal.
Parameters:
Name Type Description Default filenamestr
The filename to check.
required ledgerRustfavaLedger
The RustfavaLedger.
required
Returns:
Type Description bool
Whether this is one of the documents or a path in an import dir.
Source code in src/rustfava/core/documents.py
def is_document_or_import_file(filename: str, ledger: RustfavaLedger) -> bool:\n \"\"\"Check whether the filename is a document or in an import directory.\n\n This is a security validation function that prevents path traversal.\n\n Args:\n filename: The filename to check.\n ledger: The RustfavaLedger.\n\n Returns:\n Whether this is one of the documents or a path in an import dir.\n \"\"\"\n # Check if it's an exact match for a known document\n if any(\n filename == d.filename for d in ledger.all_entries_by_type.Document\n ):\n return True\n # Check if resolved path is within an import directory (prevents path traversal)\n file_path = Path(filename).resolve()\n for import_dir in ledger.fava_options.import_dirs:\n resolved_dir = ledger.join_path(import_dir).resolve()\n if file_path.is_relative_to(resolved_dir):\n return True\n return False\n
File path for a document in the folder for an account.
Parameters:
Name Type Description Default documents_folderstr
The documents folder.
required accountstr
The account to choose the subfolder for.
required filenamestr
The filename of the document.
required ledgerRustfavaLedger
The RustfavaLedger.
required
Returns:
Type Description Path
The path that the document should be saved at.
Source code in src/rustfava/core/documents.py
def filepath_in_document_folder(\n documents_folder: str,\n account: str,\n filename: str,\n ledger: RustfavaLedger,\n) -> Path:\n \"\"\"File path for a document in the folder for an account.\n\n Args:\n documents_folder: The documents folder.\n account: The account to choose the subfolder for.\n filename: The filename of the document.\n ledger: The RustfavaLedger.\n\n Returns:\n The path that the document should be saved at.\n \"\"\"\n if documents_folder not in ledger.options[\"documents\"]:\n raise NotADocumentsFolderError(documents_folder)\n\n if account not in ledger.attributes.accounts:\n raise NotAValidAccountError(account)\n\n filename = filename.replace(sep, \" \")\n if altsep: # pragma: no cover\n filename = filename.replace(altsep, \" \")\n\n return ledger.join_path(\n documents_folder,\n *account.split(\":\"),\n filename,\n )\n
The information about an extension that is needed for the frontend.
Source code in src/rustfava/core/extensions.py
@dataclass\nclass ExtensionDetails:\n \"\"\"The information about an extension that is needed for the frontend.\"\"\"\n\n name: str\n report_title: str | None\n has_js_module: bool\n
def get_extension(self, name: str) -> RustfavaExtensionBase | None:\n \"\"\"Get the extension with the given name.\"\"\"\n return self._instances.get(name, None)\n
def after_insert_entry(self, entry: Directive) -> None:\n \"\"\"Run all `after_insert_entry` hooks.\"\"\"\n for ext in self._exts: # pragma: no cover\n ext.after_insert_entry(entry)\n
def after_delete_entry(self, entry: Directive) -> None:\n \"\"\"Run all `after_delete_entry` hooks.\"\"\"\n for ext in self._exts: # pragma: no cover\n ext.after_delete_entry(entry)\n
Options for rustfava can be specified through Custom entries in the Beancount file. This module contains a list of possible options, the defaults and the code for parsing the options.
An option that determines where entries for matching accounts should be inserted.
Source code in src/rustfava/core/fava_options.py
@dataclass(frozen=True)\nclass InsertEntryOption:\n \"\"\"Insert option.\n\n An option that determines where entries for matching accounts should be\n inserted.\n \"\"\"\n\n date: datetime.date\n re: Pattern[str]\n filename: str\n lineno: int\n
def set_import_dirs(self, value: str) -> None:\n \"\"\"Add an import directory.\"\"\"\n # It's typed as Sequence so that it's not externally mutated\n self.import_dirs.append(value) # type: ignore[attr-defined]\n
Name Type Description Default custom_entriesSequence[Custom]
A list of Custom entries.
required
Returns:
Type Description RustfavaOptions
A tuple (options, errors) where options is a dictionary of all options
list[OptionError]
to values, and errors contains possible parsing errors.
Source code in src/rustfava/core/fava_options.py
def parse_options(\n custom_entries: Sequence[Custom],\n) -> tuple[RustfavaOptions, list[OptionError]]:\n \"\"\"Parse custom entries for rustfava options.\n\n The format for option entries is the following::\n\n 2016-04-01 custom \"fava-option\" \"[name]\" \"[value]\"\n\n Args:\n custom_entries: A list of Custom entries.\n\n Returns:\n A tuple (options, errors) where options is a dictionary of all options\n to values, and errors contains possible parsing errors.\n \"\"\"\n options = RustfavaOptions()\n errors = []\n\n for entry in (e for e in custom_entries if e.type == \"fava-option\"):\n try:\n if not entry.values:\n raise MissingOptionError\n parse_option_custom_entry(entry, options)\n except (IndexError, TypeError, ValueError) as err:\n msg = f\"Failed to parse fava-option entry: {err!s}\"\n errors.append(OptionError(entry.meta, msg, entry))\n\n return options, errors\n
class NonSourceFileError(RustfavaAPIError):\n \"\"\"Trying to read a non-source file.\"\"\"\n\n def __init__(self, path: Path) -> None:\n super().__init__(f\"Trying to read a non-source file at '{path}'\")\n
class GeneratedEntryError(RustfavaAPIError):\n \"\"\"The entry is generated and cannot be edited.\"\"\"\n\n def __init__(self) -> None:\n super().__init__(\"The entry is generated and cannot be edited.\")\n
Functions related to reading/writing to Beancount files.
Source code in src/rustfava/core/file.py
class FileModule(FavaModule):\n \"\"\"Functions related to reading/writing to Beancount files.\"\"\"\n\n def __init__(self, ledger: RustfavaLedger) -> None:\n super().__init__(ledger)\n self._lock = threading.Lock()\n\n def get_source(self, path: Path) -> tuple[str, str]:\n \"\"\"Get source files.\n\n Args:\n path: The path of the file.\n\n Returns:\n A string with the file contents and the `sha256sum` of the file.\n\n Raises:\n NonSourceFileError: If the file is not one of the source files.\n InvalidUnicodeError: If the file contains invalid unicode.\n \"\"\"\n if str(path) not in self.ledger.options[\"include\"]:\n raise NonSourceFileError(path)\n\n try:\n source = path.read_text(\"utf-8\")\n except UnicodeDecodeError as exc:\n raise InvalidUnicodeError(str(exc)) from exc\n\n return source, _sha256_str(source)\n\n def set_source(self, path: Path, source: str, sha256sum: str) -> str:\n \"\"\"Write to source file.\n\n Args:\n path: The path of the file.\n source: A string with the file contents.\n sha256sum: Hash of the file.\n\n Returns:\n The `sha256sum` of the updated file.\n\n Raises:\n NonSourceFileError: If the file is not one of the source files.\n InvalidUnicodeError: If the file contains invalid unicode.\n ExternallyChangedError: If the file was changed externally.\n \"\"\"\n with self._lock:\n _, original_sha256sum = self.get_source(path)\n if original_sha256sum != sha256sum:\n raise ExternallyChangedError(path)\n\n newline = _file_newline_character(path)\n with path.open(\"w\", encoding=\"utf-8\", newline=newline) as file:\n file.write(source)\n self.ledger.watcher.notify(path)\n\n self.ledger.extensions.after_write_source(str(path), source)\n self.ledger.load_file()\n\n return _sha256_str(source)\n\n def insert_metadata(\n self,\n entry_hash: str,\n basekey: str,\n value: str,\n ) -> None:\n \"\"\"Insert metadata into a file at lineno.\n\n Also, prevent duplicate keys.\n\n Args:\n entry_hash: Hash of an entry.\n basekey: Key to insert metadata for.\n value: Metadate value to insert.\n \"\"\"\n with self._lock:\n self.ledger.changed()\n entry = self.ledger.get_entry(entry_hash)\n key = next_key(basekey, entry.meta)\n indent = self.ledger.fava_options.indent\n path, lineno = _get_position(entry)\n insert_metadata_in_file(path, lineno, indent, key, value)\n self.ledger.watcher.notify(path)\n self.ledger.extensions.after_insert_metadata(entry, key, value)\n\n def save_entry_slice(\n self,\n entry_hash: str,\n source_slice: str,\n sha256sum: str,\n ) -> str:\n \"\"\"Save slice of the source file for an entry.\n\n Args:\n entry_hash: Hash of an entry.\n source_slice: The lines that the entry should be replaced with.\n sha256sum: The sha256sum of the current lines of the entry.\n\n Returns:\n The `sha256sum` of the new lines of the entry.\n\n Raises:\n RustfavaAPIError: If the entry is not found or the file changed.\n \"\"\"\n with self._lock:\n entry = self.ledger.get_entry(entry_hash)\n new_sha256sum = save_entry_slice(entry, source_slice, sha256sum)\n self.ledger.watcher.notify(Path(get_position(entry)[0]))\n self.ledger.extensions.after_entry_modified(entry, source_slice)\n return new_sha256sum\n\n def delete_entry_slice(self, entry_hash: str, sha256sum: str) -> None:\n \"\"\"Delete slice of the source file for an entry.\n\n Args:\n entry_hash: Hash of an entry.\n sha256sum: The sha256sum of the current lines of the entry.\n\n Raises:\n RustfavaAPIError: If the entry is not found or the file changed.\n \"\"\"\n with self._lock:\n entry = self.ledger.get_entry(entry_hash)\n delete_entry_slice(entry, sha256sum)\n self.ledger.watcher.notify(Path(get_position(entry)[0]))\n self.ledger.extensions.after_delete_entry(entry)\n\n def insert_entries(self, entries: Sequence[Directive]) -> None:\n \"\"\"Insert entries.\n\n Args:\n entries: A list of entries.\n \"\"\"\n with self._lock:\n self.ledger.changed()\n fava_options = self.ledger.fava_options\n for entry in sorted(entries, key=_incomplete_sortkey):\n path, updated_insert_options = insert_entry(\n entry,\n (\n self.ledger.fava_options.default_file\n or self.ledger.beancount_file_path\n ),\n insert_options=fava_options.insert_entry,\n currency_column=fava_options.currency_column,\n indent=fava_options.indent,\n )\n self.ledger.watcher.notify(path)\n self.ledger.fava_options.insert_entry = updated_insert_options\n self.ledger.extensions.after_insert_entry(entry)\n\n def render_entries(self, entries: Sequence[Directive]) -> Iterable[Markup]:\n \"\"\"Return entries in Beancount format.\n\n Only renders :class:`.Balance` and :class:`.Transaction`.\n\n Args:\n entries: A list of entries.\n\n Yields:\n The entries rendered in Beancount format.\n \"\"\"\n indent = self.ledger.fava_options.indent\n for entry in entries:\n if isinstance(entry, (Balance, Transaction)):\n if (\n isinstance(entry, Transaction)\n and entry.flag in _EXCL_FLAGS\n ):\n continue\n try:\n yield Markup(get_entry_slice(entry)[0] + \"\\n\") # noqa: S704\n except (KeyError, FileNotFoundError):\n yield Markup( # noqa: S704\n to_string(\n entry,\n self.ledger.fava_options.currency_column,\n indent,\n ),\n )\n
A string with the file contents and the sha256sum of the file.
Raises:
Type Description NonSourceFileError
If the file is not one of the source files.
InvalidUnicodeError
If the file contains invalid unicode.
Source code in src/rustfava/core/file.py
def get_source(self, path: Path) -> tuple[str, str]:\n \"\"\"Get source files.\n\n Args:\n path: The path of the file.\n\n Returns:\n A string with the file contents and the `sha256sum` of the file.\n\n Raises:\n NonSourceFileError: If the file is not one of the source files.\n InvalidUnicodeError: If the file contains invalid unicode.\n \"\"\"\n if str(path) not in self.ledger.options[\"include\"]:\n raise NonSourceFileError(path)\n\n try:\n source = path.read_text(\"utf-8\")\n except UnicodeDecodeError as exc:\n raise InvalidUnicodeError(str(exc)) from exc\n\n return source, _sha256_str(source)\n
def set_source(self, path: Path, source: str, sha256sum: str) -> str:\n \"\"\"Write to source file.\n\n Args:\n path: The path of the file.\n source: A string with the file contents.\n sha256sum: Hash of the file.\n\n Returns:\n The `sha256sum` of the updated file.\n\n Raises:\n NonSourceFileError: If the file is not one of the source files.\n InvalidUnicodeError: If the file contains invalid unicode.\n ExternallyChangedError: If the file was changed externally.\n \"\"\"\n with self._lock:\n _, original_sha256sum = self.get_source(path)\n if original_sha256sum != sha256sum:\n raise ExternallyChangedError(path)\n\n newline = _file_newline_character(path)\n with path.open(\"w\", encoding=\"utf-8\", newline=newline) as file:\n file.write(source)\n self.ledger.watcher.notify(path)\n\n self.ledger.extensions.after_write_source(str(path), source)\n self.ledger.load_file()\n\n return _sha256_str(source)\n
def save_entry_slice(\n self,\n entry_hash: str,\n source_slice: str,\n sha256sum: str,\n) -> str:\n \"\"\"Save slice of the source file for an entry.\n\n Args:\n entry_hash: Hash of an entry.\n source_slice: The lines that the entry should be replaced with.\n sha256sum: The sha256sum of the current lines of the entry.\n\n Returns:\n The `sha256sum` of the new lines of the entry.\n\n Raises:\n RustfavaAPIError: If the entry is not found or the file changed.\n \"\"\"\n with self._lock:\n entry = self.ledger.get_entry(entry_hash)\n new_sha256sum = save_entry_slice(entry, source_slice, sha256sum)\n self.ledger.watcher.notify(Path(get_position(entry)[0]))\n self.ledger.extensions.after_entry_modified(entry, source_slice)\n return new_sha256sum\n
def delete_entry_slice(self, entry_hash: str, sha256sum: str) -> None:\n \"\"\"Delete slice of the source file for an entry.\n\n Args:\n entry_hash: Hash of an entry.\n sha256sum: The sha256sum of the current lines of the entry.\n\n Raises:\n RustfavaAPIError: If the entry is not found or the file changed.\n \"\"\"\n with self._lock:\n entry = self.ledger.get_entry(entry_hash)\n delete_entry_slice(entry, sha256sum)\n self.ledger.watcher.notify(Path(get_position(entry)[0]))\n self.ledger.extensions.after_delete_entry(entry)\n
Insert the specified metadata in the file below lineno.
Takes the whitespace in front of the line that lineno into account.
Source code in src/rustfava/core/file.py
def insert_metadata_in_file(\n path: Path,\n lineno: int,\n indent: int,\n key: str,\n value: str,\n) -> None:\n \"\"\"Insert the specified metadata in the file below lineno.\n\n Takes the whitespace in front of the line that lineno into account.\n \"\"\"\n with path.open(encoding=\"utf-8\") as file:\n contents = file.readlines()\n\n contents.insert(lineno, f'{\" \" * indent}{key}: \"{value}\"\\n')\n newline = _file_newline_character(path)\n with path.open(\"w\", encoding=\"utf-8\", newline=newline) as file:\n file.write(\"\".join(contents))\n
def find_entry_lines(lines: Sequence[str], lineno: int) -> Sequence[str]:\n \"\"\"Lines of entry starting at lineno.\n\n Args:\n lines: A list of lines.\n lineno: The 0-based line-index to start at.\n \"\"\"\n entry_lines = [lines[lineno]]\n while True:\n lineno += 1\n try:\n line = lines[lineno]\n except IndexError:\n return entry_lines\n if not line.strip() or re.match(r\"\\S\", line[0]):\n return entry_lines\n entry_lines.append(line)\n
A string containing the lines of the entry and the sha256sum of
str
these lines.
Raises:
Type Description GeneratedEntryError
If the entry is generated and cannot be edited.
Source code in src/rustfava/core/file.py
def get_entry_slice(entry: Directive) -> tuple[str, str]:\n \"\"\"Get slice of the source file for an entry.\n\n Args:\n entry: An entry.\n\n Returns:\n A string containing the lines of the entry and the `sha256sum` of\n these lines.\n\n Raises:\n GeneratedEntryError: If the entry is generated and cannot be edited.\n \"\"\"\n path, lineno = _get_position(entry)\n with path.open(encoding=\"utf-8\") as file:\n lines = file.readlines()\n\n entry_lines = find_entry_lines(lines, lineno - 1)\n entry_source = \"\".join(entry_lines).rstrip(\"\\n\")\n\n return entry_source, _sha256_str(entry_source)\n
def save_entry_slice(\n entry: Directive,\n source_slice: str,\n sha256sum: str,\n) -> str:\n \"\"\"Save slice of the source file for an entry.\n\n Args:\n entry: An entry.\n source_slice: The lines that the entry should be replaced with.\n sha256sum: The sha256sum of the current lines of the entry.\n\n Returns:\n The `sha256sum` of the new lines of the entry.\n\n Raises:\n ExternallyChangedError: If the file was changed externally.\n GeneratedEntryError: If the entry is generated and cannot be edited.\n \"\"\"\n path, lineno = _get_position(entry)\n with path.open(encoding=\"utf-8\") as file:\n lines = file.readlines()\n\n first_entry_line = lineno - 1\n entry_lines = find_entry_lines(lines, first_entry_line)\n entry_source = \"\".join(entry_lines).rstrip(\"\\n\")\n if _sha256_str(entry_source) != sha256sum:\n raise ExternallyChangedError(path)\n\n lines = [\n *lines[:first_entry_line],\n source_slice + \"\\n\",\n *lines[first_entry_line + len(entry_lines) :],\n ]\n newline = _file_newline_character(path)\n with path.open(\"w\", encoding=\"utf-8\", newline=newline) as file:\n file.writelines(lines)\n\n return _sha256_str(source_slice)\n
def delete_entry_slice(\n entry: Directive,\n sha256sum: str,\n) -> None:\n \"\"\"Delete slice of the source file for an entry.\n\n Args:\n entry: An entry.\n sha256sum: The sha256sum of the current lines of the entry.\n\n Raises:\n ExternallyChangedError: If the file was changed externally.\n GeneratedEntryError: If the entry is generated and cannot be edited.\n \"\"\"\n path, lineno = _get_position(entry)\n with path.open(encoding=\"utf-8\") as file:\n lines = file.readlines()\n\n first_entry_line = lineno - 1\n entry_lines = find_entry_lines(lines, first_entry_line)\n entry_source = \"\".join(entry_lines).rstrip(\"\\n\")\n if _sha256_str(entry_source) != sha256sum:\n raise ExternallyChangedError(path)\n\n # Also delete the whitespace following this entry\n last_entry_line = first_entry_line + len(entry_lines)\n while True:\n try:\n line = lines[last_entry_line]\n except IndexError:\n break\n if line.strip(): # pragma: no cover\n break\n last_entry_line += 1 # pragma: no cover\n lines = lines[:first_entry_line] + lines[last_entry_line:]\n newline = _file_newline_character(path)\n with path.open(\"w\", encoding=\"utf-8\", newline=newline) as file:\n file.writelines(lines)\n
The default file to insert into if no option matches.
required
Returns:
Type Description tuple[str, int | None]
A tuple of the filename and the line number.
Source code in src/rustfava/core/file.py
def find_insert_position(\n entry: Directive,\n insert_options: Sequence[InsertEntryOption],\n default_filename: str,\n) -> tuple[str, int | None]:\n \"\"\"Find insert position for an entry.\n\n Args:\n entry: An entry.\n insert_options: A list of InsertOption.\n default_filename: The default file to insert into if no option matches.\n\n Returns:\n A tuple of the filename and the line number.\n \"\"\"\n # Get the list of accounts that should be considered for the entry.\n # For transactions, we want the reversed list of posting accounts.\n accounts = get_entry_accounts(entry)\n\n # Make no assumptions about the order of insert_options entries and instead\n # sort them ourselves (by descending dates)\n insert_options = sorted(\n insert_options,\n key=attrgetter(\"date\"),\n reverse=True,\n )\n\n for account in accounts:\n for insert_option in insert_options:\n # Only consider InsertOptions before the entry date.\n if insert_option.date >= entry.date:\n continue\n if insert_option.re.match(account):\n return (insert_option.filename, insert_option.lineno - 1)\n\n return (default_filename, None)\n
The lexer attribute only exists since PLY writes to it in case of a parser error.
Source code in src/rustfava/core/filters.py
class Token:\n \"\"\"A token having a certain type and value.\n\n The lexer attribute only exists since PLY writes to it in case of a parser\n error.\n \"\"\"\n\n __slots__ = (\"lexer\", \"type\", \"value\")\n\n def __init__(self, type_: str, value: str) -> None:\n self.type = type_\n self.value = value\n\n def __repr__(self) -> str: # pragma: no cover\n return f\"Token({self.type}, {self.value})\"\n
class MatchAmount:\n \"\"\"Matches an amount.\"\"\"\n\n __slots__ = (\"match\",)\n\n match: Callable[[Decimal], bool]\n\n def __init__(self, op: str, value: Decimal) -> None:\n if op == \"=\":\n self.match = lambda x: x == value\n elif op == \">=\":\n self.match = lambda x: x >= value\n elif op == \"<=\":\n self.match = lambda x: x <= value\n elif op == \">\":\n self.match = lambda x: x > value\n else: # op == \"<\":\n self.match = lambda x: x < value\n\n def __call__(self, obj: Any) -> bool:\n # Compare to the absolute value to simplify this filter.\n number = getattr(obj, \"number\", None)\n return self.match(abs(number)) if number is not None else False\n
"},{"location":"api/#rustfava.core.filters.FilterSyntaxParser","title":"FilterSyntaxParser","text":"Source code in src/rustfava/core/filters.py
class EntryFilter(ABC):\n \"\"\"Filters a list of entries.\"\"\"\n\n @abstractmethod\n def apply(self, entries: Sequence[Directive]) -> Sequence[Directive]:\n \"\"\"Filter a list of directives.\"\"\"\n
The filter string can either be a regular expression or a parent account.
Source code in src/rustfava/core/filters.py
class AccountFilter(EntryFilter):\n \"\"\"Filter by account.\n\n The filter string can either be a regular expression or a parent account.\n \"\"\"\n\n __slots__ = (\"_match\", \"_value\")\n\n def __init__(self, value: str) -> None:\n self._value = value\n self._match = Match(value)\n\n def apply(self, entries: Sequence[Directive]) -> Sequence[Directive]:\n value = self._value\n if not value:\n return entries\n match = self._match\n return [\n entry\n for entry in entries\n if any(\n _has_component(name, value) or match(name)\n for name in get_entry_accounts(entry)\n )\n ]\n
Name Type Description Default entriesSequence[Directive]
A list of entries.
required
Returns:
Type Description Mapping[str, Sequence[Directive | TransactionPosting]]
A dict mapping account names to their entries.
Source code in src/rustfava/core/group_entries.py
def group_entries_by_account(\n entries: Sequence[abc.Directive],\n) -> Mapping[str, Sequence[abc.Directive | TransactionPosting]]:\n \"\"\"Group entries by account.\n\n Arguments:\n entries: A list of entries.\n\n Returns:\n A dict mapping account names to their entries.\n \"\"\"\n res: dict[str, list[abc.Directive | TransactionPosting]] = defaultdict(\n list,\n )\n\n for entry in entries:\n if isinstance(entry, abc.Transaction):\n for posting in entry.postings:\n res[posting.account].append(TransactionPosting(entry, posting))\n else:\n for account in get_entry_accounts(entry):\n res[account].append(entry)\n\n return dict(sorted(res.items()))\n
class ImporterMethodCallError(RustfavaAPIError):\n \"\"\"Error calling one of the importer methods.\"\"\"\n\n def __init__(self) -> None:\n super().__init__(\n f\"Error calling method on importer:\\n\\n{traceback.format_exc()}\"\n )\n
One of the importer methods returned an unexpected type.
Source code in src/rustfava/core/ingest.py
class ImporterInvalidTypeError(RustfavaAPIError):\n \"\"\"One of the importer methods returned an unexpected type.\"\"\"\n\n def __init__(self, attr: str, expected: type[Any], actual: Any) -> None:\n super().__init__(\n f\"Got unexpected type from importer as {attr}:\"\n f\" expected {expected!s}, got {type(actual)!s}:\"\n )\n
class MissingImporterDirsError(RustfavaAPIError):\n \"\"\"You need to set at least one imports-dir.\"\"\"\n\n def __init__(self) -> None:\n super().__init__(\"You need to set at least one imports-dir.\")\n
Identify files and importers that can be imported.
Returns:
Type Description list[FileImporters]
A list of :class:.FileImportInfo.
Source code in src/rustfava/core/ingest.py
def import_data(self) -> list[FileImporters]:\n \"\"\"Identify files and importers that can be imported.\n\n Returns:\n A list of :class:`.FileImportInfo`.\n \"\"\"\n if not self.importers:\n return []\n\n importers = list(self.importers.values())\n\n ret: list[FileImporters] = []\n for directory in self.ledger.fava_options.import_dirs:\n full_path = self.ledger.join_path(directory)\n ret.extend(find_imports(importers, full_path))\n\n return ret\n
Extract entries from filename with the specified importer.
Parameters:
Name Type Description Default filenamestr
The full path to a file.
required importer_namestr
The name of an importer that matched the file.
required
Returns:
Type Description list[Directive]
A list of new imported entries.
Source code in src/rustfava/core/ingest.py
def extract(self, filename: str, importer_name: str) -> list[Directive]:\n \"\"\"Extract entries from filename with the specified importer.\n\n Args:\n filename: The full path to a file.\n importer_name: The name of an importer that matched the file.\n\n Returns:\n A list of new imported entries.\n \"\"\"\n if not self.module_path:\n raise MissingImporterConfigError\n\n # reload (if changed)\n self.load_file()\n\n try:\n path = Path(filename)\n importer = self.importers[importer_name]\n new_entries = extract_from_file(\n importer,\n path,\n existing_entries=self.ledger.all_entries,\n )\n except Exception as exc:\n raise ImporterExtractError from exc\n\n for hook_fn in self.hooks:\n annotations = get_annotations(hook_fn)\n if any(\"Importer\" in a for a in annotations.values()):\n importer_info = importer.file_import_info(path)\n new_entries_list: HookOutput = [\n (\n filename,\n new_entries,\n importer_info.account,\n importer.importer,\n )\n ]\n else:\n new_entries_list = [(filename, new_entries)]\n\n new_entries_list = hook_fn(\n new_entries_list,\n self.ledger.all_entries,\n )\n\n new_entries = new_entries_list[0][1]\n\n return new_entries\n
Ignores common dot-directories like .git, .cache. .venv, see IGNORE_DIRS.
Parameters:
Name Type Description Default directoryPath
The directory to start in.
required
Yields:
Type Description Iterable[Path]
All full paths under directory, ignoring some directories.
Source code in src/rustfava/core/ingest.py
def walk_dir(directory: Path) -> Iterable[Path]:\n \"\"\"Walk through all files in dir.\n\n Ignores common dot-directories like .git, .cache. .venv, see IGNORE_DIRS.\n\n Args:\n directory: The directory to start in.\n\n Yields:\n All full paths under directory, ignoring some directories.\n \"\"\"\n for root, dirs, filenames in os.walk(directory):\n dirs[:] = sorted(d for d in dirs if d not in IGNORE_DIRS)\n root_path = Path(root)\n for filename in sorted(filenames):\n yield root_path / filename\n
For each file in directory, a pair of its filename and the matching
Iterable[FileImporters]
importers.
Source code in src/rustfava/core/ingest.py
def find_imports(\n config: Sequence[WrappedImporter], directory: Path\n) -> Iterable[FileImporters]:\n \"\"\"Pair files and matching importers.\n\n Yields:\n For each file in directory, a pair of its filename and the matching\n importers.\n \"\"\"\n for path in walk_dir(directory):\n stat = path.stat()\n if stat.st_size > _FILE_TOO_LARGE_THRESHOLD: # pragma: no cover\n continue\n\n importers = [\n importer.file_import_info(path)\n for importer in config\n if importer.identify(path)\n ]\n yield FileImporters(\n name=str(path), basename=path.name, importers=importers\n )\n
Load the given import config and extract importers and hooks.
Parameters:
Name Type Description Default module_pathPath
Path to the import config.
required
Returns:
Type Description tuple[Mapping[str, WrappedImporter], Hooks]
A pair of the importers (by name) and the list of hooks.
Source code in src/rustfava/core/ingest.py
def load_import_config(\n module_path: Path,\n) -> tuple[Mapping[str, WrappedImporter], Hooks]:\n \"\"\"Load the given import config and extract importers and hooks.\n\n Args:\n module_path: Path to the import config.\n\n Returns:\n A pair of the importers (by name) and the list of hooks.\n \"\"\"\n try:\n mod = run_path(str(module_path))\n except Exception as error: # pragma: no cover\n message = traceback.format_exc()\n raise ImportConfigLoadError(message) from error\n\n if \"CONFIG\" not in mod:\n msg = \"CONFIG is missing\"\n raise ImportConfigLoadError(msg)\n if not isinstance(mod[\"CONFIG\"], list): # pragma: no cover\n msg = \"CONFIG is not a list\"\n raise ImportConfigLoadError(msg)\n\n config = mod[\"CONFIG\"]\n hooks = DEFAULT_HOOKS\n if \"HOOKS\" in mod: # pragma: no cover\n hooks = mod[\"HOOKS\"]\n if not isinstance(hooks, list) or not all(\n callable(fn) for fn in hooks\n ):\n msg = \"HOOKS is not a list of callables\"\n raise ImportConfigLoadError(msg)\n importers = {}\n for importer in config:\n if not isinstance(\n importer, (BeanImporterProtocol, Importer)\n ): # pragma: no cover\n name = importer.__class__.__name__\n msg = (\n f\"Importer class '{name}' in '{module_path}' does \"\n \"not satisfy importer protocol\"\n )\n raise ImportConfigLoadError(msg)\n wrapped_importer = WrappedImporter(importer)\n if wrapped_importer.name in importers:\n msg = f\"Duplicate importer name found: {wrapped_importer.name}\"\n raise ImportConfigLoadError(msg)\n importers[wrapped_importer.name] = wrapped_importer\n return importers, hooks\n
File path for a document to upload to the primary import folder.
Parameters:
Name Type Description Default filenamestr
The filename of the document.
required ledgerRustfavaLedger
The RustfavaLedger.
required
Returns:
Type Description Path
The path that the document should be saved at.
Source code in src/rustfava/core/ingest.py
def filepath_in_primary_imports_folder(\n filename: str,\n ledger: RustfavaLedger,\n) -> Path:\n \"\"\"File path for a document to upload to the primary import folder.\n\n Args:\n filename: The filename of the document.\n ledger: The RustfavaLedger.\n\n Returns:\n The path that the document should be saved at.\n \"\"\"\n primary_imports_folder = next(iter(ledger.fava_options.import_dirs), None)\n if primary_imports_folder is None:\n raise MissingImporterDirsError\n\n filename = filename.replace(sep, \" \")\n if altsep: # pragma: no cover\n filename = filename.replace(altsep, \" \")\n\n return ledger.join_path(primary_imports_folder, filename)\n
This is intended as a faster alternative to Beancount's Inventory class. Due to not using a list, for inventories with a lot of different positions, inserting is much faster.
The keys should be tuples (currency, cost).
Source code in src/rustfava/core/inventory.py
class CounterInventory(dict[InventoryKey, Decimal]):\n \"\"\"A lightweight inventory.\n\n This is intended as a faster alternative to Beancount's Inventory class.\n Due to not using a list, for inventories with a lot of different positions,\n inserting is much faster.\n\n The keys should be tuples ``(currency, cost)``.\n \"\"\"\n\n def is_empty(self) -> bool:\n \"\"\"Check if the inventory is empty.\"\"\"\n return not bool(self)\n\n def add(self, key: InventoryKey, number: Decimal) -> None:\n \"\"\"Add a number to key.\"\"\"\n new_num = number + self.get(key, ZERO)\n if new_num == ZERO:\n self.pop(key, None)\n else:\n self[key] = new_num\n\n def __iter__(self) -> Iterator[InventoryKey]:\n raise NotImplementedError\n\n def to_strings(self) -> list[str]:\n \"\"\"Print as a list of strings (e.g. for snapshot tests).\"\"\"\n strings = []\n for (currency, cost), number in self.items():\n if cost is None:\n strings.append(f\"{number} {currency}\")\n else:\n cost_str = cost_to_string(cost)\n strings.append(f\"{number} {currency} {{{cost_str}}}\")\n return strings\n\n def reduce(\n self,\n reducer: Callable[Concatenate[Position, P], Amount],\n *args: P.args,\n **_kwargs: P.kwargs,\n ) -> SimpleCounterInventory:\n \"\"\"Reduce inventory.\n\n Note that this returns a simple :class:`CounterInventory` with just\n currencies as keys.\n \"\"\"\n counter = SimpleCounterInventory()\n for (currency, cost), number in self.items():\n pos = _Position(_Amount(number, currency), cost)\n amount = reducer(pos, *args) # type: ignore[call-arg]\n counter.add(amount.currency, amount.number)\n return counter\n\n def add_amount(self, amount: Amount, cost: Cost | None = None) -> None:\n \"\"\"Add an Amount to the inventory.\"\"\"\n key = (amount.currency, cost)\n self.add(key, amount.number)\n\n def add_position(self, pos: Position) -> None:\n \"\"\"Add a Position or Posting to the inventory.\"\"\"\n # Skip positions with missing units (can happen with parse errors)\n if pos.units is None:\n return\n self.add_amount(pos.units, pos.cost)\n\n def __neg__(self) -> CounterInventory:\n return CounterInventory({key: -num for key, num in self.items()})\n\n def __add__(self, other: CounterInventory) -> CounterInventory:\n counter = CounterInventory(self)\n counter.add_inventory(other)\n return counter\n\n def add_inventory(self, counter: CounterInventory) -> None:\n \"\"\"Add another :class:`CounterInventory`.\"\"\"\n if not self:\n self.update(counter)\n else:\n self_get = self.get\n for key, num in counter.items():\n new_num = num + self_get(key, ZERO)\n if new_num == ZERO:\n self.pop(key, None)\n else:\n self[key] = new_num\n
Print as a list of strings (e.g. for snapshot tests).
Source code in src/rustfava/core/inventory.py
def to_strings(self) -> list[str]:\n \"\"\"Print as a list of strings (e.g. for snapshot tests).\"\"\"\n strings = []\n for (currency, cost), number in self.items():\n if cost is None:\n strings.append(f\"{number} {currency}\")\n else:\n cost_str = cost_to_string(cost)\n strings.append(f\"{number} {currency} {{{cost_str}}}\")\n return strings\n
def add_position(self, pos: Position) -> None:\n \"\"\"Add a Position or Posting to the inventory.\"\"\"\n # Skip positions with missing units (can happen with parse errors)\n if pos.units is None:\n return\n self.add_amount(pos.units, pos.cost)\n
def add_inventory(self, counter: CounterInventory) -> None:\n \"\"\"Add another :class:`CounterInventory`.\"\"\"\n if not self:\n self.update(counter)\n else:\n self_get = self.get\n for key, num in counter.items():\n new_num = num + self_get(key, ZERO)\n if new_num == ZERO:\n self.pop(key, None)\n else:\n self[key] = new_num\n
def sidebar_links(custom_entries: Sequence[Custom]) -> SidebarLinks:\n \"\"\"Parse custom entries for links.\n\n They have the following format:\n\n 2016-04-01 custom \"fava-sidebar-link\" \"2014\" \"/income_statement/?time=2014\"\n \"\"\"\n sidebar_link_entries = [\n entry for entry in custom_entries if entry.type == \"fava-sidebar-link\"\n ]\n return [\n (entry.values[0].value, entry.values[1].value)\n for entry in sidebar_link_entries\n ]\n
Name Type Description Default eventsSequence[Event]
A list of events.
required max_deltaint
Number of days that should be considered.
required
Returns:
Type Description Sequence[Event]
A list of the Events in entries that are less than max_delta days
Sequence[Event]
away.
Source code in src/rustfava/core/misc.py
def upcoming_events(\n events: Sequence[Event], max_delta: int\n) -> Sequence[Event]:\n \"\"\"Parse entries for upcoming events.\n\n Args:\n events: A list of events.\n max_delta: Number of days that should be considered.\n\n Returns:\n A list of the Events in entries that are less than `max_delta` days\n away.\n \"\"\"\n today = local_today()\n upcoming = []\n\n for event in events:\n delta = event.date - today\n if delta.days >= 0 and delta.days < max_delta:\n upcoming.append(event)\n\n return upcoming\n
class FavaModule:\n \"\"\"Base class for the \"modules\" of rustfavaLedger.\"\"\"\n\n def __init__(self, ledger: RustfavaLedger) -> None:\n self.ledger = ledger\n\n def load_file(self) -> None:\n \"\"\"Run when the file has been (re)loaded.\"\"\"\n
Format a decimal to the right number of decimal digits with locale.
Parameters:
Name Type Description Default valueDecimal
A decimal number.
required currencystr | None
A currency string or None.
None
Returns:
Type Description str
A string, the formatted decimal.
Source code in src/rustfava/core/number.py
def __call__(self, value: Decimal, currency: str | None = None) -> str:\n \"\"\"Format a decimal to the right number of decimal digits with locale.\n\n Arguments:\n value: A decimal number.\n currency: A currency string or None.\n\n Returns:\n A string, the formatted decimal.\n \"\"\"\n if currency is None:\n return self._default_pattern(value)\n return self._formatters.get(currency, self._default_pattern)(value)\n
Obtain formatting pattern for the given locale and precision.
Parameters:
Name Type Description Default localeLocale | None
An optional locale.
required precisionint
The precision.
required
Returns:
Type Description Formatter
A function that renders Decimals to strings as desired.
Source code in src/rustfava/core/number.py
def get_locale_format(locale: Locale | None, precision: int) -> Formatter:\n \"\"\"Obtain formatting pattern for the given locale and precision.\n\n Arguments:\n locale: An optional locale.\n precision: The precision.\n\n Returns:\n A function that renders Decimals to strings as desired.\n \"\"\"\n # Set a maximum precision of 14, half the default precision of Decimal\n precision = min(precision, 14)\n if locale is None:\n fmt_string = \"{:.\" + str(precision) + \"f}\"\n\n def fmt(num: Decimal) -> str:\n return fmt_string.format(num)\n\n return fmt\n\n pattern = copy.copy(locale.decimal_formats.get(None))\n if not pattern: # pragma: no cover\n msg = \"Expected Locale to have a decimal format pattern\"\n raise ValueError(msg)\n pattern.frac_prec = (precision, precision)\n\n def locale_fmt(num: Decimal) -> str:\n return pattern.apply(num, locale) # type: ignore[no-any-return]\n\n return locale_fmt\n
@staticmethod\ndef serialise(\n val: QueryRowValue,\n) -> SerialisedQueryRowValue:\n \"\"\"Serialiseable version of the column value.\"\"\"\n return val # type: ignore[no-any-return]\n
@dataclass(frozen=True)\nclass InventoryColumn(BaseColumn):\n \"\"\"An inventory query column.\"\"\"\n\n dtype: str = \"Inventory\"\n\n @staticmethod\n def serialise(\n val: dict[str, Decimal] | None,\n ) -> SimpleCounterInventory | None:\n \"\"\"Serialise an inventory.\n\n Rustledger returns inventory as a dict of currency -> Decimal.\n \"\"\"\n if val is None:\n return None\n # Rustledger already converts to {currency: Decimal} format\n if isinstance(val, dict):\n from rustfava.core.inventory import SimpleCounterInventory\n return SimpleCounterInventory(val)\n # Fallback for beancount Inventory type (for backwards compat)\n return UNITS.apply_inventory(val) if val is not None else None\n
Rustledger returns inventory as a dict of currency -> Decimal.
Source code in src/rustfava/core/query.py
@staticmethod\ndef serialise(\n val: dict[str, Decimal] | None,\n) -> SimpleCounterInventory | None:\n \"\"\"Serialise an inventory.\n\n Rustledger returns inventory as a dict of currency -> Decimal.\n \"\"\"\n if val is None:\n return None\n # Rustledger already converts to {currency: Decimal} format\n if isinstance(val, dict):\n from rustfava.core.inventory import SimpleCounterInventory\n return SimpleCounterInventory(val)\n # Fallback for beancount Inventory type (for backwards compat)\n return UNITS.apply_inventory(val) if val is not None else None\n
class TooManyRunArgsError(FavaShellError):\n \"\"\"Too many args to run: '{args}'.\"\"\"\n\n def __init__(self, args: str) -> None:\n super().__init__(f\"Too many args to run: '{args}'.\")\n
Only queries that return a table can be printed to a file.
Source code in src/rustfava/core/query_shell.py
class NonExportableQueryError(FavaShellError):\n \"\"\"Only queries that return a table can be printed to a file.\"\"\"\n\n def __init__(self) -> None:\n super().__init__(\n \"Only queries that return a table can be printed to a file.\"\n )\n
class FavaQueryRunner:\n \"\"\"Runs BQL queries using rustledger.\"\"\"\n\n def __init__(self, ledger: RustfavaLedger) -> None:\n self.ledger = ledger\n\n def run(\n self, entries: Sequence[Directive], query: str\n ) -> RLCursor | str:\n \"\"\"Run a query, returning cursor or text result.\"\"\"\n # Get the source from the ledger for queries\n source = getattr(self.ledger, \"_source\", None)\n\n # Create connection\n conn = connect(\n \"rustledger:\",\n entries=entries,\n errors=self.ledger.errors,\n options=self.ledger.options,\n )\n\n if source:\n conn.set_source(source)\n\n # Parse the query to handle special commands\n query = query.strip()\n query_lower = query.lower()\n\n # Handle noop commands (return fixed text)\n noop_doc = \"Doesn't do anything in rustfava's query shell.\"\n if query_lower in (\".exit\", \".quit\", \"exit\", \"quit\"):\n return noop_doc\n\n # Handle .run or run command\n if query_lower.startswith((\".run\", \"run\")):\n # Check if it's just \"run\" or \".run\" (list queries) or \"run name\"\n if query_lower in (\"run\", \".run\") or query_lower.startswith((\"run \", \".run \")):\n return self._handle_run(query, conn)\n\n # Handle help commands - return text\n if query_lower.startswith((\".help\", \"help\")):\n # \".help exit\" or \".help <command>\" returns noop doc\n if \" \" in query_lower:\n return noop_doc\n return self._help_text()\n\n # Handle .explain - return placeholder\n if query_lower.startswith((\".explain\", \"explain\")):\n return f\"EXPLAIN: {query}\"\n\n # Handle SELECT/BALANCES/JOURNAL queries\n try:\n return conn.execute(query)\n except ParseError as exc:\n raise QueryParseError(exc) from exc\n except CompilationError as exc:\n raise QueryCompilationError(exc) from exc\n\n def _handle_run(self, query: str, conn: RLConnection) -> RLCursor | str:\n \"\"\"Handle .run command to execute stored queries.\"\"\"\n queries = self.ledger.all_entries_by_type.Query\n\n # Parse the run command\n parts = shlex.split(query)\n if len(parts) == 1:\n # Just \"run\" - list available queries\n return \"\\n\".join(q.name for q in queries)\n\n if len(parts) > 2:\n raise TooManyRunArgsError(query)\n\n name = parts[1].rstrip(\";\")\n query_obj = next((q for q in queries if q.name == name), None)\n if query_obj is None:\n raise QueryNotFoundError(name)\n\n try:\n return conn.execute(query_obj.query_string)\n except ParseError as exc:\n raise QueryParseError(exc) from exc\n except CompilationError as exc:\n raise QueryCompilationError(exc) from exc\n\n def _help_text(self) -> str:\n \"\"\"Return help text for the query shell.\"\"\"\n return \"\"\"Fava Query Shell\n\nCommands:\n SELECT ... Run a BQL SELECT query\n run <name> Run a stored query by name\n run List all stored queries\n help Show this help message\n\nExample queries:\n SELECT account, sum(position) GROUP BY account\n SELECT date, narration, position WHERE account ~ \"Expenses\"\n\"\"\"\n
class QueryShell(FavaModule):\n \"\"\"A Fava module to run BQL queries.\"\"\"\n\n def __init__(self, ledger: RustfavaLedger) -> None:\n super().__init__(ledger)\n self.runner = FavaQueryRunner(ledger)\n\n def execute_query_serialised(\n self, entries: Sequence[Directive], query: str\n ) -> QueryResultTable | QueryResultText:\n \"\"\"Run a query and returns its serialised result.\n\n Arguments:\n entries: The entries to run the query on.\n query: A query string.\n\n Returns:\n Either a table or a text result (depending on the query).\n\n Raises:\n RustfavaAPIError: If the query response is an error.\n \"\"\"\n res = self.runner.run(entries, query)\n return (\n QueryResultText(res) if isinstance(res, str) else _serialise(res)\n )\n\n def query_to_file(\n self,\n entries: Sequence[Directive],\n query_string: str,\n result_format: str,\n ) -> tuple[str, io.BytesIO]:\n \"\"\"Get query result as file.\n\n Arguments:\n entries: The entries to run the query on.\n query_string: A string, the query to run.\n result_format: The file format to save to.\n\n Returns:\n A tuple (name, data), where name is either 'query_result' or the\n name of a custom query if the query string is 'run name_of_query'.\n ``data`` contains the file contents.\n\n Raises:\n RustfavaAPIError: If the result format is not supported or the\n query failed.\n \"\"\"\n name = \"query_result\"\n\n if query_string.lower().startswith((\".run\", \"run \")):\n parts = shlex.split(query_string)\n if len(parts) > 2:\n raise TooManyRunArgsError(query_string)\n if len(parts) == 2:\n name = parts[1].rstrip(\";\")\n queries = self.ledger.all_entries_by_type.Query\n query_obj = next((q for q in queries if q.name == name), None)\n if query_obj is None:\n raise QueryNotFoundError(name)\n query_string = query_obj.query_string\n\n res = self.runner.run(entries, query_string)\n if isinstance(res, str):\n raise NonExportableQueryError\n\n rrows = res.fetchall()\n rtypes = res.description\n\n # Convert rows to exportable format\n rows = _numberify_rows(rrows, rtypes)\n\n if result_format == \"csv\":\n data = to_csv(list(rtypes), rows)\n else:\n if not HAVE_EXCEL: # pragma: no cover\n msg = \"Result format not supported.\"\n raise RustfavaAPIError(msg)\n data = to_excel(list(rtypes), rows, result_format, query_string)\n return name, data\n
Name Type Description Default entriesSequence[Directive]
The entries to run the query on.
required querystr
A query string.
required
Returns:
Type Description QueryResultTable | QueryResultText
Either a table or a text result (depending on the query).
Raises:
Type Description RustfavaAPIError
If the query response is an error.
Source code in src/rustfava/core/query_shell.py
def execute_query_serialised(\n self, entries: Sequence[Directive], query: str\n) -> QueryResultTable | QueryResultText:\n \"\"\"Run a query and returns its serialised result.\n\n Arguments:\n entries: The entries to run the query on.\n query: A query string.\n\n Returns:\n Either a table or a text result (depending on the query).\n\n Raises:\n RustfavaAPIError: If the query response is an error.\n \"\"\"\n res = self.runner.run(entries, query)\n return (\n QueryResultText(res) if isinstance(res, str) else _serialise(res)\n )\n
Name Type Description Default entriesSequence[Directive]
The entries to run the query on.
required query_stringstr
A string, the query to run.
required result_formatstr
The file format to save to.
required
Returns:
Type Description str
A tuple (name, data), where name is either 'query_result' or the
BytesIO
name of a custom query if the query string is 'run name_of_query'.
tuple[str, BytesIO]
data contains the file contents.
Raises:
Type Description RustfavaAPIError
If the result format is not supported or the
Source code in src/rustfava/core/query_shell.py
def query_to_file(\n self,\n entries: Sequence[Directive],\n query_string: str,\n result_format: str,\n) -> tuple[str, io.BytesIO]:\n \"\"\"Get query result as file.\n\n Arguments:\n entries: The entries to run the query on.\n query_string: A string, the query to run.\n result_format: The file format to save to.\n\n Returns:\n A tuple (name, data), where name is either 'query_result' or the\n name of a custom query if the query string is 'run name_of_query'.\n ``data`` contains the file contents.\n\n Raises:\n RustfavaAPIError: If the result format is not supported or the\n query failed.\n \"\"\"\n name = \"query_result\"\n\n if query_string.lower().startswith((\".run\", \"run \")):\n parts = shlex.split(query_string)\n if len(parts) > 2:\n raise TooManyRunArgsError(query_string)\n if len(parts) == 2:\n name = parts[1].rstrip(\";\")\n queries = self.ledger.all_entries_by_type.Query\n query_obj = next((q for q in queries if q.name == name), None)\n if query_obj is None:\n raise QueryNotFoundError(name)\n query_string = query_obj.query_string\n\n res = self.runner.run(entries, query_string)\n if isinstance(res, str):\n raise NonExportableQueryError\n\n rrows = res.fetchall()\n rtypes = res.description\n\n # Convert rows to exportable format\n rows = _numberify_rows(rrows, rtypes)\n\n if result_format == \"csv\":\n data = to_csv(list(rtypes), rows)\n else:\n if not HAVE_EXCEL: # pragma: no cover\n msg = \"Result format not supported.\"\n raise RustfavaAPIError(msg)\n data = to_excel(list(rtypes), rows, result_format, query_string)\n return name, data\n
Name Type Description Default entriesIterable[Directive | Directive] | None
A list of entries to compute balances from.
Nonecreate_accountslist[str] | None
A list of accounts that the tree should contain.
None Source code in src/rustfava/core/tree.py
class Tree(dict[str, TreeNode]):\n \"\"\"Account tree.\n\n Args:\n entries: A list of entries to compute balances from.\n create_accounts: A list of accounts that the tree should contain.\n \"\"\"\n\n def __init__(\n self,\n entries: Iterable[Directive | data.Directive] | None = None,\n create_accounts: list[str] | None = None,\n ) -> None:\n super().__init__(self)\n self.get(\"\", insert=True)\n if create_accounts:\n for account in create_accounts:\n self.get(account, insert=True)\n if entries:\n account_balances: dict[str, CounterInventory]\n account_balances = defaultdict(CounterInventory)\n for entry in entries:\n if isinstance(entry, Open):\n self.get(entry.account, insert=True)\n for posting in getattr(entry, \"postings\", []):\n account_balances[posting.account].add_position(posting)\n\n for name, balance in sorted(account_balances.items()):\n self.insert(name, balance)\n\n @property\n def accounts(self) -> list[str]:\n \"\"\"The accounts in this tree.\"\"\"\n return sorted(self.keys())\n\n def ancestors(self, name: str) -> Iterable[TreeNode]:\n \"\"\"Ancestors of an account.\n\n Args:\n name: An account name.\n\n Yields:\n The ancestors of the given account from the bottom up.\n \"\"\"\n while name:\n name = account_parent(name) or \"\"\n yield self.get(name)\n\n def insert(self, name: str, balance: CounterInventory) -> None:\n \"\"\"Insert account with a balance.\n\n Insert account and update its balance and the balances of its\n ancestors.\n\n Args:\n name: An account name.\n balance: The balance of the account.\n \"\"\"\n node = self.get(name, insert=True)\n node.balance.add_inventory(balance)\n node.balance_children.add_inventory(balance)\n node.has_txns = True\n for parent_node in self.ancestors(name):\n parent_node.balance_children.add_inventory(balance)\n\n def get( # type: ignore[override]\n self,\n name: str,\n *,\n insert: bool = False,\n ) -> TreeNode:\n \"\"\"Get an account.\n\n Args:\n name: An account name.\n insert: If True, insert the name into the tree if it does not\n exist.\n\n Returns:\n TreeNode: The account of that name or an empty account if the\n account is not in the tree.\n \"\"\"\n try:\n return self[name]\n except KeyError:\n node = TreeNode(name)\n if insert:\n if name:\n parent = self.get(account_parent(name) or \"\", insert=True)\n parent.children.append(node)\n self[name] = node\n return node\n\n def net_profit(\n self,\n options: BeancountOptions,\n account_name: str,\n ) -> TreeNode:\n \"\"\"Calculate the net profit.\n\n Args:\n options: The Beancount options.\n account_name: The name to use for the account containing the net\n profit.\n \"\"\"\n income = self.get(options[\"name_income\"])\n expenses = self.get(options[\"name_expenses\"])\n\n net_profit = Tree()\n net_profit.insert(\n account_name,\n income.balance_children + expenses.balance_children,\n )\n\n return net_profit.get(account_name)\n\n def cap(self, options: BeancountOptions, unrealized_account: str) -> None:\n \"\"\"Transfer Income and Expenses, add conversions and unrealized gains.\n\n Args:\n options: The Beancount options.\n unrealized_account: The name of the account to post unrealized\n gains to (as a subaccount of Equity).\n \"\"\"\n equity = options[\"name_equity\"]\n conversions = CounterInventory(\n {\n (currency, None): -number\n for currency, number in AT_COST.apply(\n self.get(\"\").balance_children\n ).items()\n },\n )\n\n # Add conversions\n self.insert(\n equity + \":\" + options[\"account_current_conversions\"],\n conversions,\n )\n\n # Insert unrealized gains.\n self.insert(\n equity + \":\" + unrealized_account,\n -self.get(\"\").balance_children,\n )\n\n # Transfer Income and Expenses\n self.insert(\n equity + \":\" + options[\"account_current_earnings\"],\n self.get(options[\"name_income\"]).balance_children,\n )\n self.insert(\n equity + \":\" + options[\"account_current_earnings\"],\n self.get(options[\"name_expenses\"]).balance_children,\n )\n
The ancestors of the given account from the bottom up.
Source code in src/rustfava/core/tree.py
def ancestors(self, name: str) -> Iterable[TreeNode]:\n \"\"\"Ancestors of an account.\n\n Args:\n name: An account name.\n\n Yields:\n The ancestors of the given account from the bottom up.\n \"\"\"\n while name:\n name = account_parent(name) or \"\"\n yield self.get(name)\n
Insert account and update its balance and the balances of its ancestors.
Parameters:
Name Type Description Default namestr
An account name.
required balanceCounterInventory
The balance of the account.
required Source code in src/rustfava/core/tree.py
def insert(self, name: str, balance: CounterInventory) -> None:\n \"\"\"Insert account with a balance.\n\n Insert account and update its balance and the balances of its\n ancestors.\n\n Args:\n name: An account name.\n balance: The balance of the account.\n \"\"\"\n node = self.get(name, insert=True)\n node.balance.add_inventory(balance)\n node.balance_children.add_inventory(balance)\n node.has_txns = True\n for parent_node in self.ancestors(name):\n parent_node.balance_children.add_inventory(balance)\n
If True, insert the name into the tree if it does not exist.
False
Returns:
Name Type Description TreeNodeTreeNode
The account of that name or an empty account if the
TreeNode
account is not in the tree.
Source code in src/rustfava/core/tree.py
def get( # type: ignore[override]\n self,\n name: str,\n *,\n insert: bool = False,\n) -> TreeNode:\n \"\"\"Get an account.\n\n Args:\n name: An account name.\n insert: If True, insert the name into the tree if it does not\n exist.\n\n Returns:\n TreeNode: The account of that name or an empty account if the\n account is not in the tree.\n \"\"\"\n try:\n return self[name]\n except KeyError:\n node = TreeNode(name)\n if insert:\n if name:\n parent = self.get(account_parent(name) or \"\", insert=True)\n parent.children.append(node)\n self[name] = node\n return node\n
Name Type Description Default optionsBeancountOptions
The Beancount options.
required account_namestr
The name to use for the account containing the net profit.
required Source code in src/rustfava/core/tree.py
def net_profit(\n self,\n options: BeancountOptions,\n account_name: str,\n) -> TreeNode:\n \"\"\"Calculate the net profit.\n\n Args:\n options: The Beancount options.\n account_name: The name to use for the account containing the net\n profit.\n \"\"\"\n income = self.get(options[\"name_income\"])\n expenses = self.get(options[\"name_expenses\"])\n\n net_profit = Tree()\n net_profit.insert(\n account_name,\n income.balance_children + expenses.balance_children,\n )\n\n return net_profit.get(account_name)\n
class WatcherBase(abc.ABC):\n \"\"\"ABC for rustfava ledger file watchers.\"\"\"\n\n last_checked: int\n \"\"\"Timestamp of the latest change noticed by the file watcher.\"\"\"\n\n last_notified: int\n \"\"\"Timestamp of the latest change that the watcher was notified of.\"\"\"\n\n @abc.abstractmethod\n def update(self, files: Iterable[Path], folders: Iterable[Path]) -> None:\n \"\"\"Update the folders/files to watch.\n\n Args:\n files: A list of file paths.\n folders: A list of paths to folders.\n \"\"\"\n\n def check(self) -> bool:\n \"\"\"Check for changes.\n\n Returns:\n `True` if there was a file change in one of the files or folders,\n `False` otherwise.\n \"\"\"\n latest_mtime = max(self._get_latest_mtime(), self.last_notified)\n has_higher_mtime = latest_mtime > self.last_checked\n if has_higher_mtime:\n self.last_checked = latest_mtime\n return has_higher_mtime\n\n def notify(self, path: Path) -> None:\n \"\"\"Notify the watcher of a change to a path.\"\"\"\n try:\n change_mtime = Path(path).stat().st_mtime_ns\n except FileNotFoundError:\n change_mtime = max(self.last_notified, self.last_checked) + 1\n self.last_notified = max(self.last_notified, change_mtime)\n\n @abc.abstractmethod\n def _get_latest_mtime(self) -> int:\n \"\"\"Get the latest change mtime.\"\"\"\n
required Source code in src/rustfava/core/watcher.py
@abc.abstractmethod\ndef update(self, files: Iterable[Path], folders: Iterable[Path]) -> None:\n \"\"\"Update the folders/files to watch.\n\n Args:\n files: A list of file paths.\n folders: A list of paths to folders.\n \"\"\"\n
True if there was a file change in one of the files or folders,
bool
False otherwise.
Source code in src/rustfava/core/watcher.py
def check(self) -> bool:\n \"\"\"Check for changes.\n\n Returns:\n `True` if there was a file change in one of the files or folders,\n `False` otherwise.\n \"\"\"\n latest_mtime = max(self._get_latest_mtime(), self.last_notified)\n has_higher_mtime = latest_mtime > self.last_checked\n if has_higher_mtime:\n self.last_checked = latest_mtime\n return has_higher_mtime\n
def update(self, files: Iterable[Path], folders: Iterable[Path]) -> None:\n \"\"\"Update the folders/files to watch.\"\"\"\n files_set = {p.absolute() for p in files if p.exists()}\n folders_set = {p.absolute() for p in folders if p.is_dir()}\n new_paths = (files_set, folders_set)\n if self._watchers and new_paths == self._paths:\n self.check()\n return\n self._paths = new_paths\n if self._watchers:\n self._watchers[0].stop()\n self._watchers[1].stop()\n self._watchers = (\n _FilesWatchfilesThread(files_set, self.last_checked),\n _WatchfilesThread(folders_set, self.last_checked, recursive=True),\n )\n self._watchers[0].start()\n self._watchers[1].start()\n self.check()\n
For folders, only checks mtime of the folder and all subdirectories. So a file change won't be noticed, but only new/deleted files.
Source code in src/rustfava/core/watcher.py
class Watcher(WatcherBase):\n \"\"\"A simple file and folder watcher.\n\n For folders, only checks mtime of the folder and all subdirectories.\n So a file change won't be noticed, but only new/deleted files.\n \"\"\"\n\n def __init__(self) -> None:\n self.last_checked = 0\n self.last_notified = 0\n self._files: Sequence[Path] = []\n self._folders: Sequence[Path] = []\n\n def update(self, files: Iterable[Path], folders: Iterable[Path]) -> None:\n \"\"\"Update the folders/files to watch.\"\"\"\n self._files = list(files)\n self._folders = list(folders)\n self.check()\n\n def _mtimes(self) -> Iterable[int]:\n for path in self._files:\n try:\n yield path.stat().st_mtime_ns\n except FileNotFoundError:\n yield max(self.last_notified, self.last_checked) + 1\n for path in self._folders:\n for dirpath, _, _ in walk(path):\n yield Path(dirpath).stat().st_mtime_ns\n\n def _get_latest_mtime(self) -> int:\n return max(self._mtimes())\n
def translations() -> dict[str, str]:\n \"\"\"Get translations catalog.\"\"\"\n catalog = get_translations()._catalog # noqa: SLF001\n return {k: v for k, v in catalog.items() if isinstance(k, str) and k}\n
If the BEANCOUNT_FILE environment variable is set, Rustfava will use the files (delimited by ';' on Windows and ':' on POSIX) given there in addition to FILENAMES.
Note you can also specify command-line options via environment variables with the RUSTFAVA_ prefix. For example, --host=0.0.0.0 is equivalent to setting the environment variable RUSTFAVA_HOST=0.0.0.0.
Source code in src/rustfava/cli.py
@click.command(context_settings={\"auto_envvar_prefix\": \"RUSTFAVA\"})\n@click.argument(\n \"filenames\",\n nargs=-1,\n type=click.Path(exists=True, dir_okay=False, resolve_path=True),\n)\n@click.option(\n \"-p\",\n \"--port\",\n type=int,\n default=5000,\n show_default=True,\n metavar=\"<port>\",\n help=\"The port to listen on.\",\n)\n@click.option(\n \"-H\",\n \"--host\",\n type=str,\n default=\"localhost\",\n show_default=True,\n metavar=\"<host>\",\n help=\"The host to listen on.\",\n)\n@click.option(\"--prefix\", type=str, help=\"Set an URL prefix.\")\n@click.option(\n \"--incognito\",\n is_flag=True,\n help=\"Run in incognito mode and obscure all numbers.\",\n)\n@click.option(\n \"--read-only\",\n is_flag=True,\n help=\"Run in read-only mode, disable any change through rustfava.\",\n)\n@click.option(\"-d\", \"--debug\", is_flag=True, help=\"Turn on debugging.\")\n@click.option(\n \"--profile\",\n is_flag=True,\n help=\"Turn on profiling. Implies --debug.\",\n)\n@click.option(\n \"--profile-dir\",\n type=click.Path(),\n help=\"Output directory for profiling data.\",\n)\n@click.option(\n \"--poll-watcher\", is_flag=True, help=\"Use old polling-based watcher.\"\n)\n@click.version_option(package_name=\"rustfava\")\ndef main( # noqa: PLR0913\n *,\n filenames: tuple[str, ...] = (),\n port: int = 5000,\n host: str = \"localhost\",\n prefix: str | None = None,\n incognito: bool = False,\n read_only: bool = False,\n debug: bool = False,\n profile: bool = False,\n profile_dir: str | None = None,\n poll_watcher: bool = False,\n) -> None: # pragma: no cover\n \"\"\"Start Rustfava for FILENAMES on http://<host>:<port>.\n\n If the `BEANCOUNT_FILE` environment variable is set, Rustfava will use the\n files (delimited by ';' on Windows and ':' on POSIX) given there in\n addition to FILENAMES.\n\n Note you can also specify command-line options via environment variables\n with the `RUSTFAVA_` prefix. For example, `--host=0.0.0.0` is equivalent to\n setting the environment variable `RUSTFAVA_HOST=0.0.0.0`.\n \"\"\"\n all_filenames = _add_env_filenames(filenames)\n\n if not all_filenames:\n raise NoFileSpecifiedError\n\n from rustfava.application import create_app\n\n app = create_app(\n all_filenames,\n incognito=incognito,\n read_only=read_only,\n poll_watcher=poll_watcher,\n )\n\n if prefix:\n from werkzeug.middleware.dispatcher import DispatcherMiddleware\n\n from rustfava.util import simple_wsgi\n\n app.wsgi_app = DispatcherMiddleware( # type: ignore[method-assign]\n simple_wsgi,\n {prefix: app.wsgi_app},\n )\n\n # ensure that cheroot does not use IP6 for localhost\n host = \"127.0.0.1\" if host == \"localhost\" else host\n # Debug mode if profiling is active\n debug = debug or profile\n\n click.secho(f\"Starting Fava on http://{host}:{port}\", fg=\"green\")\n if not debug:\n from cheroot.wsgi import Server\n\n server = Server((host, port), app)\n try:\n server.start()\n except KeyboardInterrupt:\n click.echo(\"Keyboard interrupt received: stopping Fava\", err=True)\n server.stop()\n except OSError as error:\n if \"No socket could be created\" in str(error):\n raise AddressInUse(port) from error\n raise click.Abort from error\n else:\n from werkzeug.middleware.profiler import ProfilerMiddleware\n\n from rustfava.util import setup_debug_logging\n\n setup_debug_logging()\n if profile:\n app.wsgi_app = ProfilerMiddleware( # type: ignore[method-assign]\n app.wsgi_app,\n restrictions=(30,),\n profile_dir=profile_dir or None,\n )\n\n app.jinja_env.auto_reload = True\n try:\n app.run(host, port, debug)\n except OSError as error:\n if error.errno == errno.EADDRINUSE:\n raise AddressInUse(port) from error\n raise\n
This is a fork of Fava that replaces the Python Beancount parser and beanquery with rustledger, a Rust-based implementation of the Beancount format compiled to WebAssembly.
Key changes from Fava:
No Beancount dependency: Rustfava uses rustledger for parsing, so you don't need to install Python's beancount package. Your existing Beancount files are fully compatible.
Query support: BQL queries are now handled by rustledger's built-in query engine instead of beanquery. The query syntax remains largely compatible.
Optional beancount compatibility: If you need to use Beancount plugins or the import system, install with uv pip install rustfava[beancount-compat].
With this release, query results are now rendered in the frontend. The templates for HTML rendering are still available but extension authors are encouraged to switch, see the statistics report for an example how this can be done. This release adds CSS styles for dark-mode. Numerical comparisons on the units, price or cost are now possible in rustfava filters. As the watchfiles based watcher might not work correctly in some setups with network file systems, you can switch to the (slower) polling based watcher as well. The default-file option, if set, is now considered instead of the \"main\" file when inserting an entry.
This release accumulates a couple of minor fixes and improvements. Under the hood, the file change detection is now powered by watchfiles instead of polling, which is more performant.
It is now possible to convert to a sequence of currencies. Posting metadata is now supported in the entry forms. The editor should now be a bit more performant as the previous parses will be reused better. For compatibility with extensions using them, the Javascript and CSS for the \"old\" account trees has been re-added.
This release brings various improvements to the charts, like allowing the toggling of currencies by clicking on their names in the chart legend. The account balance trees in rustfava are now rendered in the frontend, fixing some minor bugs in the process and easing maintenance. rustfava extensions can now also provide their own endpoints.
With this release, extensions can now ship Javascript code to run in the frontend. The editor in rustfava now uses a tree-sitter grammar to obtain a full parsed syntax tree, which makes editor functionality more maintainable and should improve the autocompletion. The Flask WSGI app is now created using the application factory pattern - users who use the rustfava WSGI app directly should switch from rustfava.application.app to the create_app function in rustfava.application. This release also drops support for Python 3.7 and contains a couple of minor fixes and changes, in particular various styling fixes.
With this release, the rendering of some report like the documents report has been moved completely to the frontend, which should be slightly more perfomant and easier to maintain. This release also contains a couple of minor fixes and changes.
This release brings stacked bar charts, which are a great way to visualise income broken down per account per month for example. The inferred display precision for currencies is now also used in the frontend and can be overwritten with commodity metadata.
The journal-show, journal-show-document, and journal-show-transaction rustfava-options have been removed. The types of entries that to show in the journal are now automatically stored in the browser of the user (in localStorage).
As usual, this release also includes a couple of bug fixes and minor improvements. To avoid some race conditions and improve perfomance, the per-file Ledger class is not filtered anymore in-place but rather the filtered data is generated per request - some extensions might have to adjust for this and use g.filtered instead of ledger for some attributes.
In this release, the document page now shows counts in the account tree and allows collapsing of accounts in the tree. Parts of the charts in the future are now desaturated. This release contains a couple of bug fixes as usual.
The conversion and interval options have been removed. Their functionality can be achieved with the new default-page option. The editor components have been completely reworked, include autocompletion in more places and are now based on version 6 of CodeMirror. An option invert-income-liabilities-equity has been added to invert the numbers of those accounts on the income statement and the balance sheet. This release also adds a Bulgarian translation and features various smaller improvements and fixes as usual.
This release brings area charts as an alternative option to view the various line charts in rustfava and a Catalan translation for rustfava. There is also now an option to set the indentation of inserted Beancount entries. As usual this release also includes various minor fixes and improvements.
This is mainly a bugfix release to fix compatibility with one of the main dependencies (werkzeug). Also, a default-conversion option was added, which allows setting a default conversion.
Rustfava can now display charts for BQL queries - if they have exactly two columns with the first being a date or string and the second an inventory, then a line chart or treemap chart is shown on the query page.
Apart from plenty of bug fixes, this release mainly contains improvements to the forms to add transactions: postings can now be dragged and the full cost syntax of Beancount should supported.
The import page of rustfava has been reworked - it now supports moving files to the documents folder and the import process should be a bit more interactive. This release also contains various fixes and a new collapse-pattern option to collapse accounts in account trees based on regular expressions (and replaces the use of the rustfava-collapse-account metadata entry).
Other changes:
Command line flags can be specified by setting environment variables.
In this release, the click behaviour has been updated to allow filtering for payees. The entry input forms now allow inputting prices and costs. As always, bugs have been fixed.
The journal design has been updated and should now have a clearer structure. Starting with this version, there will not be any more GUI releases of rustfava. The GUI broke frequently and does not seem to worth the maintenance burden.
Other changes:
When downloading documents, the original filename will be used.
any() and all() functions have been added to the filter syntax to allow filtering entries by properties of their postings.
The entry filters have been reworked in this release and should now support for more flexible filtering of the entries. See the help page on how the new syntax works. Also, when completing the payee in the transaction form, the postings of the last transaction for this payee will be auto-filled.
Other changes:
The rustfava-option to hide the charts has been removed. This is now tracked in the page URL.
This is a release with various small changes and mainly some speed improvements to the Balance Sheet and the net worth calculation. Also, if 'At Value' is selected, the current unrealized gain is shown in parentheses in the Balance Sheet.
Other changes:
The currently filtered entries can now be exported from the Journal page.
Rustfava now has an interface to edit single entries. Clicking on the entry date in the Journal will open an overlay that shows the entry context and allows editing just the lines of that entry.
Other changes:
The source editor now has a menu that gives access to editor commands like \"fold all\".
Entries with matching tags or links can now be excluded with -#tag.
The keyboard shortcuts are now displayed in-place.
The incognito option has been removed and replaced with a --incognito command line switch.
Rustfava now provides an interface for Beancount's import system that allows you to import transactions from your bank for example.
Rustfava can now show your balances at market value or convert them to a single currency if your file contains the necessary price information.
We now also provide a compiled GUI version of rustfava for Linux and macOS. This version might still be a bit buggy so any feedback/help on it is very welcome.
Other changes:
The insert-entry option can be used to control where transactions are inserted.
The transaction form now accepts tags and links in the narration field.
Budgets are now accumulated over all children where appropriate.
The translations of rustfava are on POEditor.com, which has helped us get translations in five more languages: Chinese (simplified), Dutch, French, Portuguese, and Spanish. A big thank you to the new translators!
The transaction form has been improved, it now supports adding metadata and the suggestions will be ranked by how often and recently they occur (using exponential decay).
The Query page supports all commands of the bean-query shell and shares its history of recently used queries.
Rustfava has gained a basic extension mechanism. Extensions allow you to run hooks at various points, e.g., after adding a transaction. They are specified using the extensions option and for an example, see the rustfava.ext.auto_commit extension.
Other changes:
The default sort order in journals has been reversed so that the most recent entries come first.
The new incognito option can be used to obscure all numbers.
You can now add transactions from within rustfava. The form supports autocompletion for most fields.
Rustfava will now show a little bubble in the sidebar for the number of events in the next week. This can be configured with the upcoming-events option.
Other changes:
The payee filter can filter by regular expression.
The tag filter can filter for links, too.
There's a nice spinning indicator during asynchronous page loads.
You can now upload documents by dropping them onto transactions, which will also add the file path as statement metadata to the transaction. rustfava also ships with a plugin to link these transactions with the generated documents. See the help pages for details.
This is the first release for which we provide compiled binaries (for macOS and Linux). These do not have any dependencies and can simply be executed from the terminal.
Other changes:
The bar charts on account pages now also show budgets.
The Journal can now be sorted by date, flag and narration.
This is a major new release that includes too many improvements and changes to list. Some highlights:
The layout has been tweaked and we use some nicer fonts.
Rustfava looks and works much better on smaller screens.
Rustfava loads most pages asynchronously, so navigating rustfava is much faster and responsive.
rustfava's configuration is not read from a configuration file anymore but can rather be specified using custom entries in the Beancount file. Some options have also been removed or renamed, so check rustfava's help page on the available options when upgrading from v0.3.0.
There have been many changes under the hood to improve rustfava's codebase and a lot of bugs have been squashed.
There are now more interval options available for charts and the account balances report. The interval can be selected from a dropdown next to the charts.
Show metadata for postings in the Journal.
The editor now supports org-mode style folding.
Show colored dots for all the postings of a transaction in the Journal report. This way flagged postings can be quickly spotted.
The uptodate-indicator is now shown everywhere by default, but only enabled for accounts that have the metadata rustfava-uptodate-indication: \"True\" set on their open-directives.
Speedier Journal rendering.
Only basenames will be shown for documents in the Journal.
It was not possible to install any of the earlier versions only using pip and you may consult the git log for earlier changes. The first commit in the git repository was on December 4th, 2015.
There are a number of deployment options for persistently running rustfava on the Web, depending on your Web server and WSGI deployment choices. Below you can find some examples.
"},{"location":"deployment/#apache-with-reverse-proxy","title":"Apache with reverse proxy","text":"
The above will make rustfava accessible at the /rustfava URL and proxy requests arriving there to a locally running rustfava. To make rustfava work properly in that context, you should run it using the --prefix command line option, like this:
To have rustfava run automatically at boot and manageable as a system service you might want to define a systemd unit file for it, for example:
[Unit]\nDescription=Rustfava Web UI for Beancount\n\n[Service]\nType=simple\nExecStart=/usr/bin/rustfava --host localhost --port 5000 --prefix /rustfava /path/to/your/main.beancount\nUser=your-user\n
"},{"location":"development/","title":"Development","text":""},{"location":"development/#setting-up-a-development-environment","title":"Setting up a development environment","text":"
If you want to hack on rustfava or run the latest development version, make sure you have recent enough versions of the following installed (ideally with your system package manager):
Python 3.13+ - as rustfava is written in Python
Bun - to build the frontend
just - to run various build / lint / test targets
uv - to install the development environment and run scripts
Then this will get you up and running:
git clone https://github.com/rustledger/rustfava.git\ncd rustfava\n# setup a virtual environment (at .venv) and install rustfava and development\n# dependencies into it:\njust dev\n
You can start rustfava in the virtual environment as usual by running rustfava. Running in debug mode with rustfava --debug is useful for development.
You can run the tests with just test and the linters by running just lint. Run just --list to see all available recipes. After any changes to the Javascript code, you will need to re-build the frontend, which you can do by running just frontend. If you are working on the frontend code, running bun run dev in the frontend folder will watch for file changes and rebuild the Javascript bundle continuously.
Contributions are very welcome, just open a PR on GitHub.
If you're new to Beancount-format files or double-entry accounting in general, we recommend Command-line Accounting in Context, a motivational document written by Martin Blais, the creator of the Beancount format.
To learn how to create your ledger file, refer to Getting Started with Beancount guide. There is extensive documentation for the Beancount file format at the Beancount Documentation page.
Rustfava runs on macOS, Linux, and Windows. You will need Python 3 and uv.
Then you can install rustfava or update your existing installation by running:
uv pip install --upgrade rustfava\n
Rustfava uses rustledger, a Rust-based parser compiled to WebAssembly, to parse your Beancount files. No separate Beancount installation is required.
If you want to export query results to Microsoft Excel or LibreOffice Calc, use the following command to install the optional dependencies for this feature:
After installing rustfava, you can start it by running:
rustfava ledger.beancount\n
pointing it to your Beancount file -- and visit the web interface at http://localhost:5000.
There are some command-line options available, run rustfava --help for an overview.
For more information on rustfava's features, refer to the help pages that are available through rustfava's web-interface. Rustfava comes with Gmail-style keyboard shortcuts; press ? to show an overview.
"}]}
\ No newline at end of file
+{"config":{"lang":["en"],"separator":"[\\s\\-]+","pipeline":["stopWordFilter"],"fields":{"title":{"boost":1000.0},"text":{"boost":1.0},"tags":{"boost":1000000.0}}},"docs":[{"location":"","title":"Welcome to rustfava!","text":"
rustfava is a web interface for double-entry bookkeeping, powered by rustledger, a Rust-based parser for the Beancount file format compiled to WebAssembly for fast processing.
rustfava is a fork of Fava that replaces the Python Beancount parser with rustledger for improved performance. Your existing Beancount files are fully compatible.
If you are new to rustfava or Beancount-format files, begin with the Getting Started guide.
class EntryNotFoundForHashError(RustfavaAPIError):\n \"\"\"Entry not found for hash.\"\"\"\n\n def __init__(self, entry_hash: str) -> None:\n super().__init__(f'No entry found for hash \"{entry_hash}\"')\n
class StatementNotFoundError(RustfavaAPIError):\n \"\"\"Statement not found.\"\"\"\n\n def __init__(self) -> None:\n super().__init__(\"Statement not found.\")\n
class StatementMetadataInvalidError(RustfavaAPIError):\n \"\"\"Statement metadata not found or invalid.\"\"\"\n\n def __init__(self, key: str) -> None:\n super().__init__(\n f\"Statement path at key '{key}' missing or not a string.\"\n )\n
class FilteredLedger:\n \"\"\"Filtered Beancount ledger.\"\"\"\n\n __slots__ = (\n \"__dict__\", # for the cached_property decorator\n \"_date_first\",\n \"_date_last\",\n \"_pages\",\n \"date_range\",\n \"entries\",\n \"ledger\",\n )\n _date_first: date | None\n _date_last: date | None\n\n def __init__(\n self,\n ledger: RustfavaLedger,\n *,\n account: str | None = None,\n filter: str | None = None, # noqa: A002\n time: str | None = None,\n ) -> None:\n \"\"\"Create a filtered view of a ledger.\n\n Args:\n ledger: The ledger to filter.\n account: The account filter.\n filter: The advanced filter.\n time: The time filter.\n \"\"\"\n self.ledger = ledger\n self.date_range: DateRange | None = None\n self._pages: (\n tuple[\n int,\n Literal[\"asc\", \"desc\"],\n list[Sequence[tuple[int, Directive]]],\n ]\n | None\n ) = None\n\n entries = ledger.all_entries\n if account:\n entries = AccountFilter(account).apply(entries)\n if filter and filter.strip():\n entries = AdvancedFilter(filter.strip()).apply(entries)\n if time:\n time_filter = TimeFilter(ledger.options, ledger.fava_options, time)\n entries = time_filter.apply(entries)\n self.date_range = time_filter.date_range\n self.entries = entries\n\n if self.date_range:\n self._date_first = self.date_range.begin\n self._date_last = self.date_range.end\n return\n\n self._date_first = None\n self._date_last = None\n for entry in self.entries:\n if isinstance(entry, Transaction):\n self._date_first = entry.date\n break\n for entry in reversed(self.entries):\n if isinstance(entry, (Transaction, Price)):\n self._date_last = entry.date + timedelta(1)\n break\n\n @property\n def end_date(self) -> date | None:\n \"\"\"The date to use for prices.\"\"\"\n date_range = self.date_range\n if date_range:\n return date_range.end_inclusive\n return None\n\n @cached_property\n def entries_with_all_prices(self) -> Sequence[Directive]:\n \"\"\"The filtered entries, with all prices added back in for queries.\"\"\"\n entries = [*self.entries, *self.ledger.all_entries_by_type.Price]\n entries.sort(key=_incomplete_sortkey)\n return entries\n\n @cached_property\n def entries_without_prices(self) -> Sequence[Directive]:\n \"\"\"The filtered entries, without prices for journals.\"\"\"\n return [e for e in self.entries if not isinstance(e, Price)]\n\n @cached_property\n def root_tree(self) -> Tree:\n \"\"\"A root tree.\"\"\"\n return Tree(self.entries)\n\n @cached_property\n def root_tree_closed(self) -> Tree:\n \"\"\"A root tree for the balance sheet.\"\"\"\n tree = Tree(self.entries)\n tree.cap(self.ledger.options, self.ledger.fava_options.unrealized)\n return tree\n\n def interval_ranges(self, interval: Interval) -> Sequence[DateRange]:\n \"\"\"Yield date ranges corresponding to interval boundaries.\n\n Args:\n interval: The interval to yield ranges for.\n \"\"\"\n if not self._date_first or not self._date_last:\n return []\n complete = not self.date_range\n return dateranges(\n self._date_first, self._date_last, interval, complete=complete\n )\n\n def prices(self, base: str, quote: str) -> Sequence[tuple[date, Decimal]]:\n \"\"\"List all prices for a pair of commodities.\n\n Args:\n base: The price base.\n quote: The price quote.\n \"\"\"\n all_prices = self.ledger.prices.get_all_prices((base, quote))\n if all_prices is None:\n return []\n\n date_range = self.date_range\n if date_range:\n return [\n price_point\n for price_point in all_prices\n if date_range.begin <= price_point[0] < date_range.end\n ]\n return all_prices\n\n def account_is_closed(self, account_name: str) -> bool:\n \"\"\"Check if the account is closed.\n\n Args:\n account_name: An account name.\n\n Returns:\n True if the account is closed before the end date of the current\n time filter.\n \"\"\"\n date_range = self.date_range\n close_date = self.ledger.accounts[account_name].close_date\n if close_date is None:\n return False\n return close_date < date_range.end if date_range else True\n\n def paginate_journal(\n self,\n page: int,\n per_page: int = 1000,\n order: Literal[\"asc\", \"desc\"] = \"desc\",\n ) -> JournalPage | None:\n \"\"\"Get entries for a journal page with pagination info.\n\n Args:\n page: Page number (1-indexed).\n order: Datewise order to sort in\n per_page: Number of entries per page.\n\n Returns:\n A JournalPage, containing a list of entries as (global_index,\n directive) tuples in reverse chronological order and the total\n number of pages.\n \"\"\"\n if (\n self._pages is None\n or self._pages[0] != per_page\n or self._pages[1] != order\n ):\n pages: list[Sequence[tuple[int, Directive]]] = []\n enumerated = list(enumerate(self.entries_without_prices))\n entries = (\n iter(enumerated) if order == \"asc\" else reversed(enumerated)\n )\n while batch := tuple(islice(entries, per_page)):\n pages.append(batch)\n if not pages:\n pages.append([])\n self._pages = (per_page, order, pages)\n _per_pages, _order, pages = self._pages\n total = len(pages)\n if page > total:\n return None\n return JournalPage(pages[page - 1], total)\n
Yield date ranges corresponding to interval boundaries.
Parameters:
Name Type Description Default intervalInterval
The interval to yield ranges for.
required Source code in src/rustfava/core/__init__.py
def interval_ranges(self, interval: Interval) -> Sequence[DateRange]:\n \"\"\"Yield date ranges corresponding to interval boundaries.\n\n Args:\n interval: The interval to yield ranges for.\n \"\"\"\n if not self._date_first or not self._date_last:\n return []\n complete = not self.date_range\n return dateranges(\n self._date_first, self._date_last, interval, complete=complete\n )\n
required Source code in src/rustfava/core/__init__.py
def prices(self, base: str, quote: str) -> Sequence[tuple[date, Decimal]]:\n \"\"\"List all prices for a pair of commodities.\n\n Args:\n base: The price base.\n quote: The price quote.\n \"\"\"\n all_prices = self.ledger.prices.get_all_prices((base, quote))\n if all_prices is None:\n return []\n\n date_range = self.date_range\n if date_range:\n return [\n price_point\n for price_point in all_prices\n if date_range.begin <= price_point[0] < date_range.end\n ]\n return all_prices\n
True if the account is closed before the end date of the current
bool
time filter.
Source code in src/rustfava/core/__init__.py
def account_is_closed(self, account_name: str) -> bool:\n \"\"\"Check if the account is closed.\n\n Args:\n account_name: An account name.\n\n Returns:\n True if the account is closed before the end date of the current\n time filter.\n \"\"\"\n date_range = self.date_range\n close_date = self.ledger.accounts[account_name].close_date\n if close_date is None:\n return False\n return close_date < date_range.end if date_range else True\n
Get entries for a journal page with pagination info.
Parameters:
Name Type Description Default pageint
Page number (1-indexed).
required orderLiteral['asc', 'desc']
Datewise order to sort in
'desc'per_pageint
Number of entries per page.
1000
Returns:
Type Description JournalPage | None
A JournalPage, containing a list of entries as (global_index,
JournalPage | None
directive) tuples in reverse chronological order and the total
JournalPage | None
number of pages.
Source code in src/rustfava/core/__init__.py
def paginate_journal(\n self,\n page: int,\n per_page: int = 1000,\n order: Literal[\"asc\", \"desc\"] = \"desc\",\n) -> JournalPage | None:\n \"\"\"Get entries for a journal page with pagination info.\n\n Args:\n page: Page number (1-indexed).\n order: Datewise order to sort in\n per_page: Number of entries per page.\n\n Returns:\n A JournalPage, containing a list of entries as (global_index,\n directive) tuples in reverse chronological order and the total\n number of pages.\n \"\"\"\n if (\n self._pages is None\n or self._pages[0] != per_page\n or self._pages[1] != order\n ):\n pages: list[Sequence[tuple[int, Directive]]] = []\n enumerated = list(enumerate(self.entries_without_prices))\n entries = (\n iter(enumerated) if order == \"asc\" else reversed(enumerated)\n )\n while batch := tuple(islice(entries, per_page)):\n pages.append(batch)\n if not pages:\n pages.append([])\n self._pages = (per_page, order, pages)\n _per_pages, _order, pages = self._pages\n total = len(pages)\n if page > total:\n return None\n return JournalPage(pages[page - 1], total)\n
class RustfavaLedger:\n \"\"\"Interface for a Beancount ledger.\"\"\"\n\n __slots__ = (\n \"_is_encrypted\",\n \"accounts\",\n \"accounts\",\n \"all_entries\",\n \"all_entries_by_type\",\n \"attributes\",\n \"beancount_file_path\",\n \"budgets\",\n \"charts\",\n \"commodities\",\n \"extensions\",\n \"fava_options\",\n \"fava_options_errors\",\n \"file\",\n \"format_decimal\",\n \"get_entry\",\n \"get_filtered\",\n \"ingest\",\n \"load_errors\",\n \"misc\",\n \"options\",\n \"prices\",\n \"query_shell\",\n \"watcher\",\n )\n\n #: List of all (unfiltered) entries.\n all_entries: Sequence[Directive]\n\n #: A list of all errors reported by Beancount.\n load_errors: Sequence[BeancountError]\n\n #: The Beancount options map.\n options: BeancountOptions\n\n #: A dict with all of Fava's option values.\n fava_options: RustfavaOptions\n\n #: A list of all errors from parsing the custom options.\n fava_options_errors: Sequence[BeancountError]\n\n #: The price map.\n prices: RustfavaPriceMap\n\n #: Dict of list of all (unfiltered) entries by type.\n all_entries_by_type: EntriesByType\n\n #: A :class:`.AccountDict` module - details about the accounts.\n accounts: AccountDict\n\n #: An :class:`AttributesModule` instance.\n attributes: AttributesModule\n\n #: A :class:`.BudgetModule` instance.\n budgets: BudgetModule\n\n #: A :class:`.ChartModule` instance.\n charts: ChartModule\n\n #: A :class:`.CommoditiesModule` instance.\n commodities: CommoditiesModule\n\n #: A :class:`.ExtensionModule` instance.\n extensions: ExtensionModule\n\n #: A :class:`.FileModule` instance.\n file: FileModule\n\n #: A :class:`.DecimalFormatModule` instance.\n format_decimal: DecimalFormatModule\n\n #: A :class:`.IngestModule` instance.\n ingest: IngestModule\n\n #: A :class:`.FavaMisc` instance.\n misc: FavaMisc\n\n #: A :class:`.QueryShell` instance.\n query_shell: QueryShell\n\n def __init__(self, path: str, *, poll_watcher: bool = False) -> None:\n \"\"\"Create an interface for a Beancount ledger.\n\n Arguments:\n path: Path to the main Beancount file.\n poll_watcher: Whether to use the polling file watcher.\n \"\"\"\n #: The path to the main Beancount file.\n self.beancount_file_path = path\n self._is_encrypted = is_encrypted_file(path)\n self.get_filtered = lru_cache(maxsize=16)(self._get_filtered)\n self.get_entry = lru_cache(maxsize=16)(self._get_entry)\n\n self.accounts = AccountDict(self)\n self.attributes = AttributesModule(self)\n self.budgets = BudgetModule(self)\n self.charts = ChartModule(self)\n self.commodities = CommoditiesModule(self)\n self.extensions = ExtensionModule(self)\n self.file = FileModule(self)\n self.format_decimal = DecimalFormatModule(self)\n self.ingest = IngestModule(self)\n self.misc = FavaMisc(self)\n self.query_shell = QueryShell(self)\n\n self.watcher = WatchfilesWatcher() if not poll_watcher else Watcher()\n\n self.load_file()\n\n def load_file(self) -> None:\n \"\"\"Load the main file and all included files and set attributes.\"\"\"\n self.all_entries, self.load_errors, self.options = load_uncached(\n self.beancount_file_path,\n is_encrypted=self._is_encrypted,\n )\n self.get_filtered.cache_clear()\n self.get_entry.cache_clear()\n\n self.all_entries_by_type = group_entries_by_type(self.all_entries)\n self.prices = RustfavaPriceMap(self.all_entries_by_type.Price)\n\n self.fava_options, self.fava_options_errors = parse_options(\n self.all_entries_by_type.Custom,\n )\n\n if self._is_encrypted: # pragma: no cover\n pass\n else:\n self.watcher.update(*self.paths_to_watch())\n\n # Call load_file of all modules.\n self.accounts.load_file()\n self.attributes.load_file()\n self.budgets.load_file()\n self.charts.load_file()\n self.commodities.load_file()\n self.extensions.load_file()\n self.file.load_file()\n self.format_decimal.load_file()\n self.misc.load_file()\n self.query_shell.load_file()\n self.ingest.load_file()\n\n self.extensions.after_load_file()\n\n def _get_filtered(\n self,\n account: str | None = None,\n filter: str | None = None, # noqa: A002\n time: str | None = None,\n ) -> FilteredLedger:\n \"\"\"Filter the ledger.\n\n Args:\n account: The account filter.\n filter: The advanced filter.\n time: The time filter.\n \"\"\"\n return FilteredLedger(\n ledger=self, account=account, filter=filter, time=time\n )\n\n @property\n def mtime(self) -> int:\n \"\"\"The timestamp to the latest change of the underlying files.\"\"\"\n return self.watcher.last_checked\n\n @property\n def errors(self) -> Sequence[BeancountError]:\n \"\"\"The errors that the Beancount loading plus Fava module errors.\"\"\"\n return [\n *self.load_errors,\n *self.fava_options_errors,\n *self.budgets.errors,\n *self.extensions.errors,\n *self.misc.errors,\n *self.ingest.errors,\n ]\n\n @property\n def root_accounts(self) -> tuple[str, str, str, str, str]:\n \"\"\"The five root accounts.\"\"\"\n options = self.options\n return (\n options[\"name_assets\"],\n options[\"name_liabilities\"],\n options[\"name_equity\"],\n options[\"name_income\"],\n options[\"name_expenses\"],\n )\n\n def join_path(self, *args: str) -> Path:\n \"\"\"Path relative to the directory of the ledger.\"\"\"\n return Path(self.beancount_file_path).parent.joinpath(*args).resolve()\n\n def paths_to_watch(self) -> tuple[Sequence[Path], Sequence[Path]]:\n \"\"\"Get paths to included files and document directories.\n\n Returns:\n A tuple (files, directories).\n \"\"\"\n files = [Path(i) for i in self.options[\"include\"]]\n if self.ingest.module_path:\n files.append(self.ingest.module_path)\n return (\n files,\n [\n self.join_path(path, account)\n for account in self.root_accounts\n for path in self.options[\"documents\"]\n ],\n )\n\n def changed(self) -> bool:\n \"\"\"Check if the file needs to be reloaded.\n\n Returns:\n True if a change in one of the included files or a change in a\n document folder was detected and the file has been reloaded.\n \"\"\"\n # We can't reload an encrypted file, so act like it never changes.\n if self._is_encrypted: # pragma: no cover\n return False\n changed = self.watcher.check()\n if changed:\n self.load_file()\n return changed\n\n def interval_balances(\n self,\n filtered: FilteredLedger,\n interval: Interval,\n account_name: str,\n *,\n accumulate: bool = False,\n ) -> tuple[Sequence[Tree], Sequence[DateRange]]:\n \"\"\"Balances by interval.\n\n Arguments:\n filtered: The currently filtered ledger.\n interval: An interval.\n account_name: An account name.\n accumulate: A boolean, ``True`` if the balances for an interval\n should include all entries up to the end of the interval.\n\n Returns:\n A pair of a list of Tree instances and the intervals.\n \"\"\"\n min_accounts = [\n account\n for account in self.accounts\n if account.startswith(account_name)\n ]\n\n interval_ranges = list(reversed(filtered.interval_ranges(interval)))\n interval_balances = [\n Tree(\n slice_entry_dates(\n filtered.entries,\n date.min if accumulate else date_range.begin,\n date_range.end,\n ),\n min_accounts,\n )\n for date_range in interval_ranges\n ]\n\n return interval_balances, interval_ranges\n\n @listify\n def account_journal(\n self,\n filtered: FilteredLedger,\n account_name: str,\n conversion: str | Conversion,\n *,\n with_children: bool,\n ) -> Iterable[\n tuple[int, Directive, SimpleCounterInventory, SimpleCounterInventory]\n ]:\n \"\"\"Journal for an account.\n\n Args:\n filtered: The currently filtered ledger.\n account_name: An account name.\n conversion: The conversion to use.\n with_children: Whether to include postings of subaccounts of\n the account.\n\n Yields:\n Tuples of ``(index, entry, change, balance)``.\n \"\"\"\n conv = conversion_from_str(conversion)\n relevant_account = account_tester(\n account_name, with_children=with_children\n )\n\n prices = self.prices\n balance = CounterInventory()\n for index, entry in enumerate(filtered.entries_without_prices):\n change = CounterInventory()\n entry_is_relevant = False\n postings = getattr(entry, \"postings\", None)\n if postings is not None:\n for posting in postings:\n if relevant_account(posting.account):\n entry_is_relevant = True\n balance.add_position(posting)\n change.add_position(posting)\n elif any(relevant_account(a) for a in get_entry_accounts(entry)):\n entry_is_relevant = True\n\n if entry_is_relevant:\n yield (\n index,\n entry,\n conv.apply(change, prices, entry.date),\n conv.apply(balance, prices, entry.date),\n )\n\n def _get_entry(self, entry_hash: str) -> Directive:\n \"\"\"Find an entry.\n\n Arguments:\n entry_hash: Hash of the entry.\n\n Returns:\n The entry with the given hash.\n\n Raises:\n EntryNotFoundForHashError: If there is no entry for the given hash.\n \"\"\"\n try:\n return next(\n entry\n for entry in self.all_entries\n if entry_hash == hash_entry(entry)\n )\n except StopIteration as exc:\n raise EntryNotFoundForHashError(entry_hash) from exc\n\n def context(\n self,\n entry_hash: str,\n ) -> tuple[\n Directive,\n Mapping[str, Sequence[str]] | None,\n Mapping[str, Sequence[str]] | None,\n ]:\n \"\"\"Context for an entry.\n\n Arguments:\n entry_hash: Hash of entry.\n\n Returns:\n A tuple ``(entry, before, after, source_slice, sha256sum)`` of the\n (unique) entry with the given ``entry_hash``. If the entry is a\n Balance or Transaction then ``before`` and ``after`` contain\n the balances before and after the entry of the affected accounts.\n \"\"\"\n entry = self.get_entry(entry_hash)\n\n if not isinstance(entry, (Balance, Transaction)):\n return entry, None, None\n\n entry_accounts = get_entry_accounts(entry)\n balances = {account: CounterInventory() for account in entry_accounts}\n for entry_ in takewhile(lambda e: e is not entry, self.all_entries):\n if isinstance(entry_, Transaction):\n for posting in entry_.postings:\n balance = balances.get(posting.account, None)\n if balance is not None:\n balance.add_position(posting)\n\n def visualise(inv: CounterInventory) -> Sequence[str]:\n return inv.to_strings()\n\n before = {acc: visualise(inv) for acc, inv in balances.items()}\n\n if isinstance(entry, Balance):\n return entry, before, None\n\n for posting in entry.postings:\n balances[posting.account].add_position(posting)\n after = {acc: visualise(inv) for acc, inv in balances.items()}\n return entry, before, after\n\n def commodity_pairs(self) -> Sequence[tuple[str, str]]:\n \"\"\"List pairs of commodities.\n\n Returns:\n A list of pairs of commodities. Pairs of operating currencies will\n be given in both directions not just in the one found in file.\n \"\"\"\n return self.prices.commodity_pairs(self.options[\"operating_currency\"])\n\n def statement_path(self, entry_hash: str, metadata_key: str) -> str:\n \"\"\"Get the path for a statement found in the specified entry.\n\n The entry that we look up should contain a path to a document (absolute\n or relative to the filename of the entry) or just its basename. We go\n through all documents and match on the full path or if one of the\n documents with a matching account has a matching file basename.\n\n Arguments:\n entry_hash: Hash of the entry containing the path in its metadata.\n metadata_key: The key that the path should be in.\n\n Returns:\n The filename of the matching document entry.\n\n Raises:\n StatementMetadataInvalidError: If the metadata at the given key is\n invalid.\n StatementNotFoundError: If no matching document is found.\n \"\"\"\n entry = self.get_entry(entry_hash)\n value = entry.meta.get(metadata_key, None)\n if not isinstance(value, str):\n raise StatementMetadataInvalidError(metadata_key)\n\n accounts = set(get_entry_accounts(entry))\n filename, _ = get_position(entry)\n full_path = (Path(filename).parent / value).resolve()\n for document in self.all_entries_by_type.Document:\n document_path = Path(document.filename)\n if document_path == full_path:\n return document.filename\n if document.account in accounts and document_path.name == value:\n return document.filename\n\n raise StatementNotFoundError\n\n group_entries_by_type = staticmethod(group_entries_by_type)\n
def join_path(self, *args: str) -> Path:\n \"\"\"Path relative to the directory of the ledger.\"\"\"\n return Path(self.beancount_file_path).parent.joinpath(*args).resolve()\n
Get paths to included files and document directories.
Returns:
Type Description tuple[Sequence[Path], Sequence[Path]]
A tuple (files, directories).
Source code in src/rustfava/core/__init__.py
def paths_to_watch(self) -> tuple[Sequence[Path], Sequence[Path]]:\n \"\"\"Get paths to included files and document directories.\n\n Returns:\n A tuple (files, directories).\n \"\"\"\n files = [Path(i) for i in self.options[\"include\"]]\n if self.ingest.module_path:\n files.append(self.ingest.module_path)\n return (\n files,\n [\n self.join_path(path, account)\n for account in self.root_accounts\n for path in self.options[\"documents\"]\n ],\n )\n
True if a change in one of the included files or a change in a
bool
document folder was detected and the file has been reloaded.
Source code in src/rustfava/core/__init__.py
def changed(self) -> bool:\n \"\"\"Check if the file needs to be reloaded.\n\n Returns:\n True if a change in one of the included files or a change in a\n document folder was detected and the file has been reloaded.\n \"\"\"\n # We can't reload an encrypted file, so act like it never changes.\n if self._is_encrypted: # pragma: no cover\n return False\n changed = self.watcher.check()\n if changed:\n self.load_file()\n return changed\n
Name Type Description Default filteredFilteredLedger
The currently filtered ledger.
required intervalInterval
An interval.
required account_namestr
An account name.
required accumulatebool
A boolean, True if the balances for an interval should include all entries up to the end of the interval.
False
Returns:
Type Description tuple[Sequence[Tree], Sequence[DateRange]]
A pair of a list of Tree instances and the intervals.
Source code in src/rustfava/core/__init__.py
def interval_balances(\n self,\n filtered: FilteredLedger,\n interval: Interval,\n account_name: str,\n *,\n accumulate: bool = False,\n) -> tuple[Sequence[Tree], Sequence[DateRange]]:\n \"\"\"Balances by interval.\n\n Arguments:\n filtered: The currently filtered ledger.\n interval: An interval.\n account_name: An account name.\n accumulate: A boolean, ``True`` if the balances for an interval\n should include all entries up to the end of the interval.\n\n Returns:\n A pair of a list of Tree instances and the intervals.\n \"\"\"\n min_accounts = [\n account\n for account in self.accounts\n if account.startswith(account_name)\n ]\n\n interval_ranges = list(reversed(filtered.interval_ranges(interval)))\n interval_balances = [\n Tree(\n slice_entry_dates(\n filtered.entries,\n date.min if accumulate else date_range.begin,\n date_range.end,\n ),\n min_accounts,\n )\n for date_range in interval_ranges\n ]\n\n return interval_balances, interval_ranges\n
the balances before and after the entry of the affected accounts.
Source code in src/rustfava/core/__init__.py
def context(\n self,\n entry_hash: str,\n) -> tuple[\n Directive,\n Mapping[str, Sequence[str]] | None,\n Mapping[str, Sequence[str]] | None,\n]:\n \"\"\"Context for an entry.\n\n Arguments:\n entry_hash: Hash of entry.\n\n Returns:\n A tuple ``(entry, before, after, source_slice, sha256sum)`` of the\n (unique) entry with the given ``entry_hash``. If the entry is a\n Balance or Transaction then ``before`` and ``after`` contain\n the balances before and after the entry of the affected accounts.\n \"\"\"\n entry = self.get_entry(entry_hash)\n\n if not isinstance(entry, (Balance, Transaction)):\n return entry, None, None\n\n entry_accounts = get_entry_accounts(entry)\n balances = {account: CounterInventory() for account in entry_accounts}\n for entry_ in takewhile(lambda e: e is not entry, self.all_entries):\n if isinstance(entry_, Transaction):\n for posting in entry_.postings:\n balance = balances.get(posting.account, None)\n if balance is not None:\n balance.add_position(posting)\n\n def visualise(inv: CounterInventory) -> Sequence[str]:\n return inv.to_strings()\n\n before = {acc: visualise(inv) for acc, inv in balances.items()}\n\n if isinstance(entry, Balance):\n return entry, before, None\n\n for posting in entry.postings:\n balances[posting.account].add_position(posting)\n after = {acc: visualise(inv) for acc, inv in balances.items()}\n return entry, before, after\n
A list of pairs of commodities. Pairs of operating currencies will
Sequence[tuple[str, str]]
be given in both directions not just in the one found in file.
Source code in src/rustfava/core/__init__.py
def commodity_pairs(self) -> Sequence[tuple[str, str]]:\n \"\"\"List pairs of commodities.\n\n Returns:\n A list of pairs of commodities. Pairs of operating currencies will\n be given in both directions not just in the one found in file.\n \"\"\"\n return self.prices.commodity_pairs(self.options[\"operating_currency\"])\n
Get the path for a statement found in the specified entry.
The entry that we look up should contain a path to a document (absolute or relative to the filename of the entry) or just its basename. We go through all documents and match on the full path or if one of the documents with a matching account has a matching file basename.
Parameters:
Name Type Description Default entry_hashstr
Hash of the entry containing the path in its metadata.
required metadata_keystr
The key that the path should be in.
required
Returns:
Type Description str
The filename of the matching document entry.
Raises:
Type Description StatementMetadataInvalidError
If the metadata at the given key is invalid.
StatementNotFoundError
If no matching document is found.
Source code in src/rustfava/core/__init__.py
def statement_path(self, entry_hash: str, metadata_key: str) -> str:\n \"\"\"Get the path for a statement found in the specified entry.\n\n The entry that we look up should contain a path to a document (absolute\n or relative to the filename of the entry) or just its basename. We go\n through all documents and match on the full path or if one of the\n documents with a matching account has a matching file basename.\n\n Arguments:\n entry_hash: Hash of the entry containing the path in its metadata.\n metadata_key: The key that the path should be in.\n\n Returns:\n The filename of the matching document entry.\n\n Raises:\n StatementMetadataInvalidError: If the metadata at the given key is\n invalid.\n StatementNotFoundError: If no matching document is found.\n \"\"\"\n entry = self.get_entry(entry_hash)\n value = entry.meta.get(metadata_key, None)\n if not isinstance(value, str):\n raise StatementMetadataInvalidError(metadata_key)\n\n accounts = set(get_entry_accounts(entry))\n filename, _ = get_position(entry)\n full_path = (Path(filename).parent / value).resolve()\n for document in self.all_entries_by_type.Document:\n document_path = Path(document.filename)\n if document_path == full_path:\n return document.filename\n if document.account in accounts and document_path.name == value:\n return document.filename\n\n raise StatementNotFoundError\n
@dataclass(frozen=True)\nclass LastEntry:\n \"\"\"Date and hash of the last entry for an account.\"\"\"\n\n #: The entry date.\n date: datetime.date\n\n #: The entry hash.\n entry_hash: str\n
@dataclass\nclass AccountData:\n \"\"\"Holds information about an account.\"\"\"\n\n #: The date on which this account is closed (or datetime.date.max).\n close_date: datetime.date | None = None\n\n #: The metadata of the Open entry of this account.\n meta: Meta = field(default_factory=dict)\n\n #: Uptodate status. Is only computed if the account has a\n #: \"fava-uptodate-indication\" meta attribute.\n uptodate_status: Literal[\"green\", \"yellow\", \"red\"] | None = None\n\n #: Balance directive if this account has an uptodate status.\n balance_string: str | None = None\n\n #: The last entry of the account (unless it is a close Entry)\n last_entry: LastEntry | None = None\n
class AccountDict(FavaModule, dict[str, AccountData]):\n \"\"\"Account info dictionary.\"\"\"\n\n EMPTY = AccountData()\n\n def __missing__(self, key: str) -> AccountData:\n return self.EMPTY\n\n def setdefault(\n self,\n key: str,\n _: AccountData | None = None,\n ) -> AccountData:\n \"\"\"Get the account of the given name, insert one if it is missing.\"\"\"\n if key not in self:\n self[key] = AccountData()\n return self[key]\n\n def load_file(self) -> None: # noqa: D102\n self.clear()\n entries_by_account = group_entries_by_account(self.ledger.all_entries)\n tree = Tree(self.ledger.all_entries)\n for open_entry in self.ledger.all_entries_by_type.Open:\n meta = open_entry.meta\n account_data = self.setdefault(open_entry.account)\n account_data.meta = meta\n\n txn_postings = entries_by_account[open_entry.account]\n last = get_last_entry(txn_postings)\n if last is not None and not isinstance(last, Close):\n account_data.last_entry = LastEntry(\n date=last.date,\n entry_hash=hash_entry(last),\n )\n if meta.get(\"fava-uptodate-indication\"):\n account_data.uptodate_status = uptodate_status(txn_postings)\n if account_data.uptodate_status != \"green\":\n account_data.balance_string = balance_string(\n tree.get(open_entry.account),\n )\n for close in self.ledger.all_entries_by_type.Close:\n self.setdefault(close.account).close_date = close.date\n\n def all_balance_directives(self) -> str:\n \"\"\"Balance directives for all accounts.\"\"\"\n return \"\".join(\n account_details.balance_string\n for account_details in self.values()\n if account_details.balance_string\n )\n
Get the account of the given name, insert one if it is missing.
Source code in src/rustfava/core/accounts.py
def setdefault(\n self,\n key: str,\n _: AccountData | None = None,\n) -> AccountData:\n \"\"\"Get the account of the given name, insert one if it is missing.\"\"\"\n if key not in self:\n self[key] = AccountData()\n return self[key]\n
def all_balance_directives(self) -> str:\n \"\"\"Balance directives for all accounts.\"\"\"\n return \"\".join(\n account_details.balance_string\n for account_details in self.values()\n if account_details.balance_string\n )\n
Name Type Description Default txn_postingsSequence[Directive | TransactionPosting]
The TransactionPosting for the account.
required
Returns:
Type Description Literal['green', 'yellow', 'red'] | None
A status string for the last balance or transaction of the account.
Literal['green', 'yellow', 'red'] | None
'green': A balance check that passed.
Literal['green', 'yellow', 'red'] | None
'red': A balance check that failed.
Literal['green', 'yellow', 'red'] | None
'yellow': Not a balance check.
Source code in src/rustfava/core/accounts.py
def uptodate_status(\n txn_postings: Sequence[Directive | TransactionPosting],\n) -> Literal[\"green\", \"yellow\", \"red\"] | None:\n \"\"\"Status of the last balance or transaction.\n\n Args:\n txn_postings: The TransactionPosting for the account.\n\n Returns:\n A status string for the last balance or transaction of the account.\n\n - 'green': A balance check that passed.\n - 'red': A balance check that failed.\n - 'yellow': Not a balance check.\n \"\"\"\n for txn_posting in reversed(txn_postings):\n if isinstance(txn_posting, Balance):\n return \"red\" if txn_posting.diff_amount else \"green\"\n if (\n isinstance(txn_posting, TransactionPosting)\n and txn_posting.transaction.flag != FLAG_UNREALIZED\n ):\n return \"yellow\"\n return None\n
Balance directive for the given account for today.
Source code in src/rustfava/core/accounts.py
def balance_string(tree_node: TreeNode) -> str:\n \"\"\"Balance directive for the given account for today.\"\"\"\n account = tree_node.name\n today = str(local_today())\n res = \"\"\n for currency, number in UNITS.apply(tree_node.balance).items():\n res += f\"{today} balance {account:<28} {number:>15} {currency}\\n\"\n return res\n
Some attributes of the ledger (mostly for auto-completion).
Source code in src/rustfava/core/attributes.py
class AttributesModule(FavaModule):\n \"\"\"Some attributes of the ledger (mostly for auto-completion).\"\"\"\n\n def __init__(self, ledger: RustfavaLedger) -> None:\n super().__init__(ledger)\n self.accounts: Sequence[str] = []\n self.currencies: Sequence[str] = []\n self.payees: Sequence[str] = []\n self.links: Sequence[str] = []\n self.tags: Sequence[str] = []\n self.years: Sequence[str] = []\n\n def load_file(self) -> None: # noqa: D102\n all_entries = self.ledger.all_entries\n\n all_links = set()\n all_tags = set()\n for entry in all_entries:\n links = getattr(entry, \"links\", None)\n if links is not None:\n all_links.update(links)\n tags = getattr(entry, \"tags\", None)\n if tags is not None:\n all_tags.update(tags)\n self.links = sorted(all_links)\n self.tags = sorted(all_tags)\n\n self.years = get_active_years(\n all_entries,\n self.ledger.fava_options.fiscal_year_end,\n )\n\n account_ranker = ExponentialDecayRanker(\n sorted(self.ledger.accounts.keys()),\n )\n currency_ranker = ExponentialDecayRanker()\n payee_ranker = ExponentialDecayRanker()\n\n for txn in self.ledger.all_entries_by_type.Transaction:\n if txn.payee:\n payee_ranker.update(txn.payee, txn.date)\n for posting in txn.postings:\n account_ranker.update(posting.account, txn.date)\n # Skip postings with missing units (can happen with parse errors)\n if posting.units is not None:\n currency_ranker.update(posting.units.currency, txn.date)\n if posting.cost and posting.cost.currency is not None:\n currency_ranker.update(posting.cost.currency, txn.date)\n\n self.accounts = account_ranker.sort()\n self.currencies = currency_ranker.sort()\n self.payees = payee_ranker.sort()\n\n def payee_accounts(self, payee: str) -> Sequence[str]:\n \"\"\"Rank accounts for the given payee.\"\"\"\n account_ranker = ExponentialDecayRanker(self.accounts)\n transactions = self.ledger.all_entries_by_type.Transaction\n for txn in transactions:\n if txn.payee == payee:\n for posting in txn.postings:\n account_ranker.update(posting.account, txn.date)\n return account_ranker.sort()\n\n def payee_transaction(self, payee: str) -> Transaction | None:\n \"\"\"Get the last transaction for a payee.\"\"\"\n transactions = self.ledger.all_entries_by_type.Transaction\n for txn in reversed(transactions):\n if txn.payee == payee:\n return txn\n return None\n\n def narration_transaction(self, narration: str) -> Transaction | None:\n \"\"\"Get the last transaction for a narration.\"\"\"\n transactions = self.ledger.all_entries_by_type.Transaction\n for txn in reversed(transactions):\n if txn.narration == narration:\n return txn\n return None\n\n @property\n def narrations(self) -> Sequence[str]:\n \"\"\"Get the narrations of all transactions.\"\"\"\n narration_ranker = ExponentialDecayRanker()\n for txn in self.ledger.all_entries_by_type.Transaction:\n if txn.narration:\n narration_ranker.update(txn.narration, txn.date)\n return narration_ranker.sort()\n
def payee_accounts(self, payee: str) -> Sequence[str]:\n \"\"\"Rank accounts for the given payee.\"\"\"\n account_ranker = ExponentialDecayRanker(self.accounts)\n transactions = self.ledger.all_entries_by_type.Transaction\n for txn in transactions:\n if txn.payee == payee:\n for posting in txn.postings:\n account_ranker.update(posting.account, txn.date)\n return account_ranker.sort()\n
def payee_transaction(self, payee: str) -> Transaction | None:\n \"\"\"Get the last transaction for a payee.\"\"\"\n transactions = self.ledger.all_entries_by_type.Transaction\n for txn in reversed(transactions):\n if txn.payee == payee:\n return txn\n return None\n
def narration_transaction(self, narration: str) -> Transaction | None:\n \"\"\"Get the last transaction for a narration.\"\"\"\n transactions = self.ledger.all_entries_by_type.Transaction\n for txn in reversed(transactions):\n if txn.narration == narration:\n return txn\n return None\n
Return active years, with support for fiscal years.
Parameters:
Name Type Description Default entriesSequence[Directive]
Beancount entries
required fyeFiscalYearEnd
fiscal year end
required
Returns:
Type Description list[str]
A reverse sorted list of years or fiscal years that occur in the
list[str]
entries.
Source code in src/rustfava/core/attributes.py
def get_active_years(\n entries: Sequence[Directive],\n fye: FiscalYearEnd,\n) -> list[str]:\n \"\"\"Return active years, with support for fiscal years.\n\n Args:\n entries: Beancount entries\n fye: fiscal year end\n\n Returns:\n A reverse sorted list of years or fiscal years that occur in the\n entries.\n \"\"\"\n years = []\n if fye == END_OF_YEAR:\n prev_year = None\n for entry in entries:\n year = entry.date.year\n if year != prev_year:\n prev_year = year\n years.append(year)\n return [f\"{year}\" for year in reversed(years)]\n month = fye.month\n day = fye.day\n prev_year = None\n for entry in entries:\n date = entry.date\n year = (\n entry.date.year + 1\n if date.month > month or (date.month == month and date.day > day)\n else entry.date.year\n )\n if year != prev_year:\n prev_year = year\n years.append(year)\n return [f\"FY{year}\" for year in reversed(years)]\n
A dictionary of currency to Decimal with the budget for the
Mapping[str, Decimal]
specified account and period.
Source code in src/rustfava/core/budgets.py
def calculate_budget(\n budgets: BudgetDict,\n account: str,\n date_from: datetime.date,\n date_to: datetime.date,\n) -> Mapping[str, Decimal]:\n \"\"\"Calculate budget for an account.\n\n Args:\n budgets: A list of :class:`Budget` entries.\n account: An account name.\n date_from: Starting date.\n date_to: End date (exclusive).\n\n Returns:\n A dictionary of currency to Decimal with the budget for the\n specified account and period.\n \"\"\"\n budget_list = budgets.get(account, None)\n if budget_list is None:\n return {}\n\n currency_dict: dict[str, Decimal] = defaultdict(Decimal)\n\n for day in days_in_daterange(date_from, date_to):\n matches = _matching_budgets(budget_list, day)\n for budget in matches.values():\n days_in_period = budget.period.number_of_days(day)\n currency_dict[budget.currency] += budget.number / days_in_period\n return dict(currency_dict)\n
Calculate budget for an account including budgets of its children.
Parameters:
Name Type Description Default budgetsBudgetDict
A list of :class:Budget entries.
required accountstr
An account name.
required date_fromdate
Starting date.
required date_todate
End date (exclusive).
required
Returns:
Type Description Mapping[str, Decimal]
A dictionary of currency to Decimal with the budget for the
Mapping[str, Decimal]
specified account and period.
Source code in src/rustfava/core/budgets.py
def calculate_budget_children(\n budgets: BudgetDict,\n account: str,\n date_from: datetime.date,\n date_to: datetime.date,\n) -> Mapping[str, Decimal]:\n \"\"\"Calculate budget for an account including budgets of its children.\n\n Args:\n budgets: A list of :class:`Budget` entries.\n account: An account name.\n date_from: Starting date.\n date_to: End date (exclusive).\n\n Returns:\n A dictionary of currency to Decimal with the budget for the\n specified account and period.\n \"\"\"\n currency_dict: dict[str, Decimal] = Counter() # type: ignore[assignment]\n\n for child in budgets:\n if child.startswith(account):\n currency_dict.update(\n calculate_budget(budgets, child, date_from, date_to),\n )\n return dict(currency_dict)\n
@dataclass(frozen=True)\nclass DateAndBalanceWithBudget:\n \"\"\"Balance at a date with a budget.\"\"\"\n\n date: date\n balance: SimpleCounterInventory\n account_balances: Mapping[str, SimpleCounterInventory]\n budgets: Mapping[str, Decimal]\n
class ChartModule(FavaModule):\n \"\"\"Return data for the various charts in rustfava.\"\"\"\n\n def hierarchy(\n self,\n filtered: FilteredLedger,\n account_name: str,\n conversion: Conversion,\n ) -> SerialisedTreeNode:\n \"\"\"Render an account tree.\"\"\"\n tree = filtered.root_tree\n return tree.get(account_name).serialise(\n conversion, self.ledger.prices, filtered.end_date\n )\n\n @listify\n def interval_totals(\n self,\n filtered: FilteredLedger,\n interval: Interval,\n accounts: str | tuple[str, ...],\n conversion: str | Conversion,\n *,\n invert: bool = False,\n ) -> Iterable[DateAndBalanceWithBudget]:\n \"\"\"Render totals for account (or accounts) in the intervals.\n\n Args:\n filtered: The filtered ledger.\n interval: An interval.\n accounts: A single account (str) or a tuple of accounts.\n conversion: The conversion to use.\n invert: invert all numbers.\n\n Yields:\n The balances and budgets for the intervals.\n \"\"\"\n conv = conversion_from_str(conversion)\n prices = self.ledger.prices\n\n # limit the bar charts to 100 intervals\n intervals = filtered.interval_ranges(interval)[-100:]\n\n for date_range in intervals:\n inventory = CounterInventory()\n entries = slice_entry_dates(\n filtered.entries, date_range.begin, date_range.end\n )\n account_inventories: dict[str, CounterInventory] = defaultdict(\n CounterInventory,\n )\n for entry in entries:\n for posting in getattr(entry, \"postings\", []):\n if posting.account.startswith(accounts):\n account_inventories[posting.account].add_position(\n posting,\n )\n inventory.add_position(posting)\n balance = conv.apply(\n inventory,\n prices,\n date_range.end_inclusive,\n )\n account_balances = {\n account: conv.apply(\n acct_value,\n prices,\n date_range.end_inclusive,\n )\n for account, acct_value in account_inventories.items()\n }\n budgets = (\n self.ledger.budgets.calculate_children(\n accounts,\n date_range.begin,\n date_range.end,\n )\n if isinstance(accounts, str)\n else {}\n )\n\n if invert:\n balance = -balance\n budgets = {k: -v for k, v in budgets.items()}\n account_balances = {k: -v for k, v in account_balances.items()}\n\n yield DateAndBalanceWithBudget(\n date_range.end_inclusive,\n balance,\n account_balances,\n budgets,\n )\n\n @listify\n def linechart(\n self,\n filtered: FilteredLedger,\n account_name: str,\n conversion: str | Conversion,\n ) -> Iterable[DateAndBalance]:\n \"\"\"Get the balance of an account as a line chart.\n\n Args:\n filtered: The filtered ledger.\n account_name: A string.\n conversion: The conversion to use.\n\n Yields:\n Dicts for all dates on which the balance of the given\n account has changed containing the balance (in units) of the\n account at that date.\n \"\"\"\n conv = conversion_from_str(conversion)\n\n def _balances() -> Iterable[tuple[date, CounterInventory]]:\n last_date = None\n running_balance = CounterInventory()\n is_child_account = account_tester(account_name, with_children=True)\n\n for entry in filtered.entries:\n for posting in getattr(entry, \"postings\", []):\n if is_child_account(posting.account):\n new_date = entry.date\n if last_date is not None and new_date > last_date:\n yield (last_date, running_balance)\n running_balance.add_position(posting)\n last_date = new_date\n\n if last_date is not None:\n yield (last_date, running_balance)\n\n # When the balance for a commodity just went to zero, it will be\n # missing from the 'balance' so keep track of currencies that last had\n # a balance.\n last_currencies = None\n prices = self.ledger.prices\n\n for d, running_bal in _balances():\n balance = conv.apply(running_bal, prices, d)\n currencies = set(balance.keys())\n if last_currencies:\n for currency in last_currencies - currencies:\n balance[currency] = ZERO\n last_currencies = currencies\n yield DateAndBalance(d, balance)\n\n @listify\n def net_worth(\n self,\n filtered: FilteredLedger,\n interval: Interval,\n conversion: str | Conversion,\n ) -> Iterable[DateAndBalance]:\n \"\"\"Compute net worth.\n\n Args:\n filtered: The filtered ledger.\n interval: A string for the interval.\n conversion: The conversion to use.\n\n Yields:\n Dicts for all ends of the given interval containing the\n net worth (Assets + Liabilities) separately converted to all\n operating currencies.\n \"\"\"\n conv = conversion_from_str(conversion)\n transactions = (\n entry\n for entry in filtered.entries\n if (\n isinstance(entry, Transaction)\n and entry.flag != FLAG_UNREALIZED\n )\n )\n\n types = (\n self.ledger.options[\"name_assets\"],\n self.ledger.options[\"name_liabilities\"],\n )\n\n txn = next(transactions, None)\n inventory = CounterInventory()\n\n prices = self.ledger.prices\n for date_range in filtered.interval_ranges(interval):\n while txn and txn.date < date_range.end:\n for posting in txn.postings:\n if posting.account.startswith(types):\n inventory.add_position(posting)\n txn = next(transactions, None)\n yield DateAndBalance(\n date_range.end_inclusive,\n conv.apply(\n inventory,\n prices,\n date_range.end_inclusive,\n ),\n )\n
Name Type Description Default filteredFilteredLedger
The filtered ledger.
required account_namestr
A string.
required conversionstr | Conversion
The conversion to use.
required
Yields:
Type Description Iterable[DateAndBalance]
Dicts for all dates on which the balance of the given
Iterable[DateAndBalance]
account has changed containing the balance (in units) of the
Iterable[DateAndBalance]
account at that date.
Source code in src/rustfava/core/charts.py
@listify\ndef linechart(\n self,\n filtered: FilteredLedger,\n account_name: str,\n conversion: str | Conversion,\n) -> Iterable[DateAndBalance]:\n \"\"\"Get the balance of an account as a line chart.\n\n Args:\n filtered: The filtered ledger.\n account_name: A string.\n conversion: The conversion to use.\n\n Yields:\n Dicts for all dates on which the balance of the given\n account has changed containing the balance (in units) of the\n account at that date.\n \"\"\"\n conv = conversion_from_str(conversion)\n\n def _balances() -> Iterable[tuple[date, CounterInventory]]:\n last_date = None\n running_balance = CounterInventory()\n is_child_account = account_tester(account_name, with_children=True)\n\n for entry in filtered.entries:\n for posting in getattr(entry, \"postings\", []):\n if is_child_account(posting.account):\n new_date = entry.date\n if last_date is not None and new_date > last_date:\n yield (last_date, running_balance)\n running_balance.add_position(posting)\n last_date = new_date\n\n if last_date is not None:\n yield (last_date, running_balance)\n\n # When the balance for a commodity just went to zero, it will be\n # missing from the 'balance' so keep track of currencies that last had\n # a balance.\n last_currencies = None\n prices = self.ledger.prices\n\n for d, running_bal in _balances():\n balance = conv.apply(running_bal, prices, d)\n currencies = set(balance.keys())\n if last_currencies:\n for currency in last_currencies - currencies:\n balance[currency] = ZERO\n last_currencies = currencies\n yield DateAndBalance(d, balance)\n
Name Type Description Default filteredFilteredLedger
The filtered ledger.
required intervalInterval
A string for the interval.
required conversionstr | Conversion
The conversion to use.
required
Yields:
Type Description Iterable[DateAndBalance]
Dicts for all ends of the given interval containing the
Iterable[DateAndBalance]
net worth (Assets + Liabilities) separately converted to all
Iterable[DateAndBalance]
operating currencies.
Source code in src/rustfava/core/charts.py
@listify\ndef net_worth(\n self,\n filtered: FilteredLedger,\n interval: Interval,\n conversion: str | Conversion,\n) -> Iterable[DateAndBalance]:\n \"\"\"Compute net worth.\n\n Args:\n filtered: The filtered ledger.\n interval: A string for the interval.\n conversion: The conversion to use.\n\n Yields:\n Dicts for all ends of the given interval containing the\n net worth (Assets + Liabilities) separately converted to all\n operating currencies.\n \"\"\"\n conv = conversion_from_str(conversion)\n transactions = (\n entry\n for entry in filtered.entries\n if (\n isinstance(entry, Transaction)\n and entry.flag != FLAG_UNREALIZED\n )\n )\n\n types = (\n self.ledger.options[\"name_assets\"],\n self.ledger.options[\"name_liabilities\"],\n )\n\n txn = next(transactions, None)\n inventory = CounterInventory()\n\n prices = self.ledger.prices\n for date_range in filtered.interval_ranges(interval):\n while txn and txn.date < date_range.end:\n for posting in txn.postings:\n if posting.account.startswith(types):\n inventory.add_position(posting)\n txn = next(transactions, None)\n yield DateAndBalance(\n date_range.end_inclusive,\n conv.apply(\n inventory,\n prices,\n date_range.end_inclusive,\n ),\n )\n
class CommoditiesModule(FavaModule):\n \"\"\"Details about the currencies and commodities.\"\"\"\n\n def __init__(self, ledger: RustfavaLedger) -> None:\n super().__init__(ledger)\n self.names: dict[str, str] = {}\n self.precisions: dict[str, int] = {}\n\n def load_file(self) -> None: # noqa: D102\n self.names = {}\n self.precisions = {}\n for commodity in self.ledger.all_entries_by_type.Commodity:\n name = commodity.meta.get(\"name\")\n if name:\n self.names[commodity.currency] = str(name)\n precision = commodity.meta.get(\"precision\")\n if isinstance(precision, (str, int, Decimal)):\n with suppress(ValueError):\n self.precisions[commodity.currency] = int(precision)\n\n def name(self, commodity: str) -> str:\n \"\"\"Get the name of a commodity (or the commodity itself if not set).\"\"\"\n return self.names.get(commodity, commodity)\n
Get the name of a commodity (or the commodity itself if not set).
Source code in src/rustfava/core/commodities.py
def name(self, commodity: str) -> str:\n \"\"\"Get the name of a commodity (or the commodity itself if not set).\"\"\"\n return self.names.get(commodity, commodity)\n
def get_cost(pos: Position) -> Amount:\n \"\"\"Return the total cost of a Position.\"\"\"\n cost_ = pos.cost\n return (\n _Amount(cost_.number * pos.units.number, cost_.currency)\n if cost_ is not None\n else pos.units\n )\n
This differs from the convert.get_value function in Beancount by returning the cost value if no price can be found.
Parameters:
Name Type Description Default posPosition
A Position.
required pricesRustfavaPriceMap
A rustfavaPriceMap
required datedate | None
A datetime.date instance to evaluate the value at, or None.
None
Returns:
Type Description Amount
An Amount, with value converted or if the conversion failed just the
Amount
cost value (or the units if the position has no cost).
Source code in src/rustfava/core/conversion.py
def get_market_value(\n pos: Position,\n prices: RustfavaPriceMap,\n date: datetime.date | None = None,\n) -> Amount:\n \"\"\"Get the market value of a Position.\n\n This differs from the convert.get_value function in Beancount by returning\n the cost value if no price can be found.\n\n Args:\n pos: A Position.\n prices: A rustfavaPriceMap\n date: A datetime.date instance to evaluate the value at, or None.\n\n Returns:\n An Amount, with value converted or if the conversion failed just the\n cost value (or the units if the position has no cost).\n \"\"\"\n units_ = pos.units\n cost_ = pos.cost\n\n if cost_ is not None:\n value_currency = cost_.currency\n base_quote = (units_.currency, value_currency)\n price_number = prices.get_price(base_quote, date)\n if price_number is not None:\n return _Amount(\n units_.number * price_number,\n value_currency,\n )\n return _Amount(units_.number * cost_.number, value_currency)\n return units_\n
Get the value of a Position in a particular currency.
Parameters:
Name Type Description Default posPosition
A Position.
required target_currencystr
The target currency to convert to.
required pricesRustfavaPriceMap
A rustfavaPriceMap
required datedate | None
A datetime.date instance to evaluate the value at, or None.
None
Returns:
Type Description Amount
An Amount, with value converted or if the conversion failed just the
Amount
cost value (or the units if the position has no cost).
Source code in src/rustfava/core/conversion.py
def convert_position(\n pos: Position,\n target_currency: str,\n prices: RustfavaPriceMap,\n date: datetime.date | None = None,\n) -> Amount:\n \"\"\"Get the value of a Position in a particular currency.\n\n Args:\n pos: A Position.\n target_currency: The target currency to convert to.\n prices: A rustfavaPriceMap\n date: A datetime.date instance to evaluate the value at, or None.\n\n Returns:\n An Amount, with value converted or if the conversion failed just the\n cost value (or the units if the position has no cost).\n \"\"\"\n units_ = pos.units\n\n # try the direct conversion\n base_quote = (units_.currency, target_currency)\n price_number = prices.get_price(base_quote, date)\n if price_number is not None:\n return _Amount(units_.number * price_number, target_currency)\n\n cost_ = pos.cost\n if cost_ is not None:\n cost_currency = cost_.currency\n if cost_currency != target_currency:\n base_quote1 = (units_.currency, cost_currency)\n rate1 = prices.get_price(base_quote1, date)\n if rate1 is not None:\n base_quote2 = (cost_currency, target_currency)\n rate2 = prices.get_price(base_quote2, date)\n if rate2 is not None:\n return _Amount(\n units_.number * rate1 * rate2,\n target_currency,\n )\n return units_\n
def conversion_from_str(value: str | Conversion) -> Conversion:\n \"\"\"Parse a conversion string.\"\"\"\n if not isinstance(value, str):\n return value\n if value == \"at_cost\":\n return AT_COST\n if value == \"at_value\":\n return AT_VALUE\n if value == \"units\":\n return UNITS\n\n return _CurrencyConversion(value)\n
Check whether the filename is a document or in an import directory.
This is a security validation function that prevents path traversal.
Parameters:
Name Type Description Default filenamestr
The filename to check.
required ledgerRustfavaLedger
The RustfavaLedger.
required
Returns:
Type Description bool
Whether this is one of the documents or a path in an import dir.
Source code in src/rustfava/core/documents.py
def is_document_or_import_file(filename: str, ledger: RustfavaLedger) -> bool:\n \"\"\"Check whether the filename is a document or in an import directory.\n\n This is a security validation function that prevents path traversal.\n\n Args:\n filename: The filename to check.\n ledger: The RustfavaLedger.\n\n Returns:\n Whether this is one of the documents or a path in an import dir.\n \"\"\"\n # Check if it's an exact match for a known document\n if any(\n filename == d.filename for d in ledger.all_entries_by_type.Document\n ):\n return True\n # Check if resolved path is within an import directory (prevents path traversal)\n file_path = Path(filename).resolve()\n for import_dir in ledger.fava_options.import_dirs:\n resolved_dir = ledger.join_path(import_dir).resolve()\n if file_path.is_relative_to(resolved_dir):\n return True\n return False\n
File path for a document in the folder for an account.
Parameters:
Name Type Description Default documents_folderstr
The documents folder.
required accountstr
The account to choose the subfolder for.
required filenamestr
The filename of the document.
required ledgerRustfavaLedger
The RustfavaLedger.
required
Returns:
Type Description Path
The path that the document should be saved at.
Source code in src/rustfava/core/documents.py
def filepath_in_document_folder(\n documents_folder: str,\n account: str,\n filename: str,\n ledger: RustfavaLedger,\n) -> Path:\n \"\"\"File path for a document in the folder for an account.\n\n Args:\n documents_folder: The documents folder.\n account: The account to choose the subfolder for.\n filename: The filename of the document.\n ledger: The RustfavaLedger.\n\n Returns:\n The path that the document should be saved at.\n \"\"\"\n if documents_folder not in ledger.options[\"documents\"]:\n raise NotADocumentsFolderError(documents_folder)\n\n if account not in ledger.attributes.accounts:\n raise NotAValidAccountError(account)\n\n filename = filename.replace(sep, \" \")\n if altsep: # pragma: no cover\n filename = filename.replace(altsep, \" \")\n\n return ledger.join_path(\n documents_folder,\n *account.split(\":\"),\n filename,\n )\n
The information about an extension that is needed for the frontend.
Source code in src/rustfava/core/extensions.py
@dataclass\nclass ExtensionDetails:\n \"\"\"The information about an extension that is needed for the frontend.\"\"\"\n\n name: str\n report_title: str | None\n has_js_module: bool\n
def get_extension(self, name: str) -> RustfavaExtensionBase | None:\n \"\"\"Get the extension with the given name.\"\"\"\n return self._instances.get(name, None)\n
def after_insert_entry(self, entry: Directive) -> None:\n \"\"\"Run all `after_insert_entry` hooks.\"\"\"\n for ext in self._exts: # pragma: no cover\n ext.after_insert_entry(entry)\n
def after_delete_entry(self, entry: Directive) -> None:\n \"\"\"Run all `after_delete_entry` hooks.\"\"\"\n for ext in self._exts: # pragma: no cover\n ext.after_delete_entry(entry)\n
Options for rustfava can be specified through Custom entries in the Beancount file. This module contains a list of possible options, the defaults and the code for parsing the options.
An option that determines where entries for matching accounts should be inserted.
Source code in src/rustfava/core/fava_options.py
@dataclass(frozen=True)\nclass InsertEntryOption:\n \"\"\"Insert option.\n\n An option that determines where entries for matching accounts should be\n inserted.\n \"\"\"\n\n date: datetime.date\n re: Pattern[str]\n filename: str\n lineno: int\n
def set_import_dirs(self, value: str) -> None:\n \"\"\"Add an import directory.\"\"\"\n # It's typed as Sequence so that it's not externally mutated\n self.import_dirs.append(value) # type: ignore[attr-defined]\n
Name Type Description Default custom_entriesSequence[Custom]
A list of Custom entries.
required
Returns:
Type Description RustfavaOptions
A tuple (options, errors) where options is a dictionary of all options
list[OptionError]
to values, and errors contains possible parsing errors.
Source code in src/rustfava/core/fava_options.py
def parse_options(\n custom_entries: Sequence[Custom],\n) -> tuple[RustfavaOptions, list[OptionError]]:\n \"\"\"Parse custom entries for rustfava options.\n\n The format for option entries is the following::\n\n 2016-04-01 custom \"fava-option\" \"[name]\" \"[value]\"\n\n Args:\n custom_entries: A list of Custom entries.\n\n Returns:\n A tuple (options, errors) where options is a dictionary of all options\n to values, and errors contains possible parsing errors.\n \"\"\"\n options = RustfavaOptions()\n errors = []\n\n for entry in (e for e in custom_entries if e.type == \"fava-option\"):\n try:\n if not entry.values:\n raise MissingOptionError\n parse_option_custom_entry(entry, options)\n except (IndexError, TypeError, ValueError) as err:\n msg = f\"Failed to parse fava-option entry: {err!s}\"\n errors.append(OptionError(entry.meta, msg, entry))\n\n return options, errors\n
class NonSourceFileError(RustfavaAPIError):\n \"\"\"Trying to read a non-source file.\"\"\"\n\n def __init__(self, path: Path) -> None:\n super().__init__(f\"Trying to read a non-source file at '{path}'\")\n
class GeneratedEntryError(RustfavaAPIError):\n \"\"\"The entry is generated and cannot be edited.\"\"\"\n\n def __init__(self) -> None:\n super().__init__(\"The entry is generated and cannot be edited.\")\n
Functions related to reading/writing to Beancount files.
Source code in src/rustfava/core/file.py
class FileModule(FavaModule):\n \"\"\"Functions related to reading/writing to Beancount files.\"\"\"\n\n def __init__(self, ledger: RustfavaLedger) -> None:\n super().__init__(ledger)\n self._lock = threading.Lock()\n\n def get_source(self, path: Path) -> tuple[str, str]:\n \"\"\"Get source files.\n\n Args:\n path: The path of the file.\n\n Returns:\n A string with the file contents and the `sha256sum` of the file.\n\n Raises:\n NonSourceFileError: If the file is not one of the source files.\n InvalidUnicodeError: If the file contains invalid unicode.\n \"\"\"\n if str(path) not in self.ledger.options[\"include\"]:\n raise NonSourceFileError(path)\n\n try:\n source = path.read_text(\"utf-8\")\n except UnicodeDecodeError as exc:\n raise InvalidUnicodeError(str(exc)) from exc\n\n return source, _sha256_str(source)\n\n def set_source(self, path: Path, source: str, sha256sum: str) -> str:\n \"\"\"Write to source file.\n\n Args:\n path: The path of the file.\n source: A string with the file contents.\n sha256sum: Hash of the file.\n\n Returns:\n The `sha256sum` of the updated file.\n\n Raises:\n NonSourceFileError: If the file is not one of the source files.\n InvalidUnicodeError: If the file contains invalid unicode.\n ExternallyChangedError: If the file was changed externally.\n \"\"\"\n with self._lock:\n _, original_sha256sum = self.get_source(path)\n if original_sha256sum != sha256sum:\n raise ExternallyChangedError(path)\n\n newline = _file_newline_character(path)\n with path.open(\"w\", encoding=\"utf-8\", newline=newline) as file:\n file.write(source)\n self.ledger.watcher.notify(path)\n\n self.ledger.extensions.after_write_source(str(path), source)\n self.ledger.load_file()\n\n return _sha256_str(source)\n\n def insert_metadata(\n self,\n entry_hash: str,\n basekey: str,\n value: str,\n ) -> None:\n \"\"\"Insert metadata into a file at lineno.\n\n Also, prevent duplicate keys.\n\n Args:\n entry_hash: Hash of an entry.\n basekey: Key to insert metadata for.\n value: Metadate value to insert.\n \"\"\"\n with self._lock:\n self.ledger.changed()\n entry = self.ledger.get_entry(entry_hash)\n key = next_key(basekey, entry.meta)\n indent = self.ledger.fava_options.indent\n path, lineno = _get_position(entry)\n insert_metadata_in_file(path, lineno, indent, key, value)\n self.ledger.watcher.notify(path)\n self.ledger.extensions.after_insert_metadata(entry, key, value)\n\n def save_entry_slice(\n self,\n entry_hash: str,\n source_slice: str,\n sha256sum: str,\n ) -> str:\n \"\"\"Save slice of the source file for an entry.\n\n Args:\n entry_hash: Hash of an entry.\n source_slice: The lines that the entry should be replaced with.\n sha256sum: The sha256sum of the current lines of the entry.\n\n Returns:\n The `sha256sum` of the new lines of the entry.\n\n Raises:\n RustfavaAPIError: If the entry is not found or the file changed.\n \"\"\"\n with self._lock:\n entry = self.ledger.get_entry(entry_hash)\n new_sha256sum = save_entry_slice(entry, source_slice, sha256sum)\n self.ledger.watcher.notify(Path(get_position(entry)[0]))\n self.ledger.extensions.after_entry_modified(entry, source_slice)\n return new_sha256sum\n\n def delete_entry_slice(self, entry_hash: str, sha256sum: str) -> None:\n \"\"\"Delete slice of the source file for an entry.\n\n Args:\n entry_hash: Hash of an entry.\n sha256sum: The sha256sum of the current lines of the entry.\n\n Raises:\n RustfavaAPIError: If the entry is not found or the file changed.\n \"\"\"\n with self._lock:\n entry = self.ledger.get_entry(entry_hash)\n delete_entry_slice(entry, sha256sum)\n self.ledger.watcher.notify(Path(get_position(entry)[0]))\n self.ledger.extensions.after_delete_entry(entry)\n\n def insert_entries(self, entries: Sequence[Directive]) -> None:\n \"\"\"Insert entries.\n\n Args:\n entries: A list of entries.\n \"\"\"\n with self._lock:\n self.ledger.changed()\n fava_options = self.ledger.fava_options\n for entry in sorted(entries, key=_incomplete_sortkey):\n path, updated_insert_options = insert_entry(\n entry,\n (\n self.ledger.fava_options.default_file\n or self.ledger.beancount_file_path\n ),\n insert_options=fava_options.insert_entry,\n currency_column=fava_options.currency_column,\n indent=fava_options.indent,\n )\n self.ledger.watcher.notify(path)\n self.ledger.fava_options.insert_entry = updated_insert_options\n self.ledger.extensions.after_insert_entry(entry)\n\n def render_entries(self, entries: Sequence[Directive]) -> Iterable[Markup]:\n \"\"\"Return entries in Beancount format.\n\n Only renders :class:`.Balance` and :class:`.Transaction`.\n\n Args:\n entries: A list of entries.\n\n Yields:\n The entries rendered in Beancount format.\n \"\"\"\n indent = self.ledger.fava_options.indent\n for entry in entries:\n if isinstance(entry, (Balance, Transaction)):\n if (\n isinstance(entry, Transaction)\n and entry.flag in _EXCL_FLAGS\n ):\n continue\n try:\n yield Markup(get_entry_slice(entry)[0] + \"\\n\") # noqa: S704\n except (KeyError, FileNotFoundError):\n yield Markup( # noqa: S704\n to_string(\n entry,\n self.ledger.fava_options.currency_column,\n indent,\n ),\n )\n
A string with the file contents and the sha256sum of the file.
Raises:
Type Description NonSourceFileError
If the file is not one of the source files.
InvalidUnicodeError
If the file contains invalid unicode.
Source code in src/rustfava/core/file.py
def get_source(self, path: Path) -> tuple[str, str]:\n \"\"\"Get source files.\n\n Args:\n path: The path of the file.\n\n Returns:\n A string with the file contents and the `sha256sum` of the file.\n\n Raises:\n NonSourceFileError: If the file is not one of the source files.\n InvalidUnicodeError: If the file contains invalid unicode.\n \"\"\"\n if str(path) not in self.ledger.options[\"include\"]:\n raise NonSourceFileError(path)\n\n try:\n source = path.read_text(\"utf-8\")\n except UnicodeDecodeError as exc:\n raise InvalidUnicodeError(str(exc)) from exc\n\n return source, _sha256_str(source)\n
def set_source(self, path: Path, source: str, sha256sum: str) -> str:\n \"\"\"Write to source file.\n\n Args:\n path: The path of the file.\n source: A string with the file contents.\n sha256sum: Hash of the file.\n\n Returns:\n The `sha256sum` of the updated file.\n\n Raises:\n NonSourceFileError: If the file is not one of the source files.\n InvalidUnicodeError: If the file contains invalid unicode.\n ExternallyChangedError: If the file was changed externally.\n \"\"\"\n with self._lock:\n _, original_sha256sum = self.get_source(path)\n if original_sha256sum != sha256sum:\n raise ExternallyChangedError(path)\n\n newline = _file_newline_character(path)\n with path.open(\"w\", encoding=\"utf-8\", newline=newline) as file:\n file.write(source)\n self.ledger.watcher.notify(path)\n\n self.ledger.extensions.after_write_source(str(path), source)\n self.ledger.load_file()\n\n return _sha256_str(source)\n
def save_entry_slice(\n self,\n entry_hash: str,\n source_slice: str,\n sha256sum: str,\n) -> str:\n \"\"\"Save slice of the source file for an entry.\n\n Args:\n entry_hash: Hash of an entry.\n source_slice: The lines that the entry should be replaced with.\n sha256sum: The sha256sum of the current lines of the entry.\n\n Returns:\n The `sha256sum` of the new lines of the entry.\n\n Raises:\n RustfavaAPIError: If the entry is not found or the file changed.\n \"\"\"\n with self._lock:\n entry = self.ledger.get_entry(entry_hash)\n new_sha256sum = save_entry_slice(entry, source_slice, sha256sum)\n self.ledger.watcher.notify(Path(get_position(entry)[0]))\n self.ledger.extensions.after_entry_modified(entry, source_slice)\n return new_sha256sum\n
def delete_entry_slice(self, entry_hash: str, sha256sum: str) -> None:\n \"\"\"Delete slice of the source file for an entry.\n\n Args:\n entry_hash: Hash of an entry.\n sha256sum: The sha256sum of the current lines of the entry.\n\n Raises:\n RustfavaAPIError: If the entry is not found or the file changed.\n \"\"\"\n with self._lock:\n entry = self.ledger.get_entry(entry_hash)\n delete_entry_slice(entry, sha256sum)\n self.ledger.watcher.notify(Path(get_position(entry)[0]))\n self.ledger.extensions.after_delete_entry(entry)\n
Insert the specified metadata in the file below lineno.
Takes the whitespace in front of the line that lineno into account.
Source code in src/rustfava/core/file.py
def insert_metadata_in_file(\n path: Path,\n lineno: int,\n indent: int,\n key: str,\n value: str,\n) -> None:\n \"\"\"Insert the specified metadata in the file below lineno.\n\n Takes the whitespace in front of the line that lineno into account.\n \"\"\"\n with path.open(encoding=\"utf-8\") as file:\n contents = file.readlines()\n\n contents.insert(lineno, f'{\" \" * indent}{key}: \"{value}\"\\n')\n newline = _file_newline_character(path)\n with path.open(\"w\", encoding=\"utf-8\", newline=newline) as file:\n file.write(\"\".join(contents))\n
def find_entry_lines(lines: Sequence[str], lineno: int) -> Sequence[str]:\n \"\"\"Lines of entry starting at lineno.\n\n Args:\n lines: A list of lines.\n lineno: The 0-based line-index to start at.\n \"\"\"\n entry_lines = [lines[lineno]]\n while True:\n lineno += 1\n try:\n line = lines[lineno]\n except IndexError:\n return entry_lines\n if not line.strip() or re.match(r\"\\S\", line[0]):\n return entry_lines\n entry_lines.append(line)\n
A string containing the lines of the entry and the sha256sum of
str
these lines.
Raises:
Type Description GeneratedEntryError
If the entry is generated and cannot be edited.
Source code in src/rustfava/core/file.py
def get_entry_slice(entry: Directive) -> tuple[str, str]:\n \"\"\"Get slice of the source file for an entry.\n\n Args:\n entry: An entry.\n\n Returns:\n A string containing the lines of the entry and the `sha256sum` of\n these lines.\n\n Raises:\n GeneratedEntryError: If the entry is generated and cannot be edited.\n \"\"\"\n path, lineno = _get_position(entry)\n with path.open(encoding=\"utf-8\") as file:\n lines = file.readlines()\n\n entry_lines = find_entry_lines(lines, lineno - 1)\n entry_source = \"\".join(entry_lines).rstrip(\"\\n\")\n\n return entry_source, _sha256_str(entry_source)\n
def save_entry_slice(\n entry: Directive,\n source_slice: str,\n sha256sum: str,\n) -> str:\n \"\"\"Save slice of the source file for an entry.\n\n Args:\n entry: An entry.\n source_slice: The lines that the entry should be replaced with.\n sha256sum: The sha256sum of the current lines of the entry.\n\n Returns:\n The `sha256sum` of the new lines of the entry.\n\n Raises:\n ExternallyChangedError: If the file was changed externally.\n GeneratedEntryError: If the entry is generated and cannot be edited.\n \"\"\"\n path, lineno = _get_position(entry)\n with path.open(encoding=\"utf-8\") as file:\n lines = file.readlines()\n\n first_entry_line = lineno - 1\n entry_lines = find_entry_lines(lines, first_entry_line)\n entry_source = \"\".join(entry_lines).rstrip(\"\\n\")\n if _sha256_str(entry_source) != sha256sum:\n raise ExternallyChangedError(path)\n\n lines = [\n *lines[:first_entry_line],\n source_slice + \"\\n\",\n *lines[first_entry_line + len(entry_lines) :],\n ]\n newline = _file_newline_character(path)\n with path.open(\"w\", encoding=\"utf-8\", newline=newline) as file:\n file.writelines(lines)\n\n return _sha256_str(source_slice)\n
def delete_entry_slice(\n entry: Directive,\n sha256sum: str,\n) -> None:\n \"\"\"Delete slice of the source file for an entry.\n\n Args:\n entry: An entry.\n sha256sum: The sha256sum of the current lines of the entry.\n\n Raises:\n ExternallyChangedError: If the file was changed externally.\n GeneratedEntryError: If the entry is generated and cannot be edited.\n \"\"\"\n path, lineno = _get_position(entry)\n with path.open(encoding=\"utf-8\") as file:\n lines = file.readlines()\n\n first_entry_line = lineno - 1\n entry_lines = find_entry_lines(lines, first_entry_line)\n entry_source = \"\".join(entry_lines).rstrip(\"\\n\")\n if _sha256_str(entry_source) != sha256sum:\n raise ExternallyChangedError(path)\n\n # Also delete the whitespace following this entry\n last_entry_line = first_entry_line + len(entry_lines)\n while True:\n try:\n line = lines[last_entry_line]\n except IndexError:\n break\n if line.strip(): # pragma: no cover\n break\n last_entry_line += 1 # pragma: no cover\n lines = lines[:first_entry_line] + lines[last_entry_line:]\n newline = _file_newline_character(path)\n with path.open(\"w\", encoding=\"utf-8\", newline=newline) as file:\n file.writelines(lines)\n
The default file to insert into if no option matches.
required
Returns:
Type Description tuple[str, int | None]
A tuple of the filename and the line number.
Source code in src/rustfava/core/file.py
def find_insert_position(\n entry: Directive,\n insert_options: Sequence[InsertEntryOption],\n default_filename: str,\n) -> tuple[str, int | None]:\n \"\"\"Find insert position for an entry.\n\n Args:\n entry: An entry.\n insert_options: A list of InsertOption.\n default_filename: The default file to insert into if no option matches.\n\n Returns:\n A tuple of the filename and the line number.\n \"\"\"\n # Get the list of accounts that should be considered for the entry.\n # For transactions, we want the reversed list of posting accounts.\n accounts = get_entry_accounts(entry)\n\n # Make no assumptions about the order of insert_options entries and instead\n # sort them ourselves (by descending dates)\n insert_options = sorted(\n insert_options,\n key=attrgetter(\"date\"),\n reverse=True,\n )\n\n for account in accounts:\n for insert_option in insert_options:\n # Only consider InsertOptions before the entry date.\n if insert_option.date >= entry.date:\n continue\n if insert_option.re.match(account):\n return (insert_option.filename, insert_option.lineno - 1)\n\n return (default_filename, None)\n
The lexer attribute only exists since PLY writes to it in case of a parser error.
Source code in src/rustfava/core/filters.py
class Token:\n \"\"\"A token having a certain type and value.\n\n The lexer attribute only exists since PLY writes to it in case of a parser\n error.\n \"\"\"\n\n __slots__ = (\"lexer\", \"type\", \"value\")\n\n def __init__(self, type_: str, value: str) -> None:\n self.type = type_\n self.value = value\n\n def __repr__(self) -> str: # pragma: no cover\n return f\"Token({self.type}, {self.value})\"\n
class MatchAmount:\n \"\"\"Matches an amount.\"\"\"\n\n __slots__ = (\"match\",)\n\n match: Callable[[Decimal], bool]\n\n def __init__(self, op: str, value: Decimal) -> None:\n if op == \"=\":\n self.match = lambda x: x == value\n elif op == \">=\":\n self.match = lambda x: x >= value\n elif op == \"<=\":\n self.match = lambda x: x <= value\n elif op == \">\":\n self.match = lambda x: x > value\n else: # op == \"<\":\n self.match = lambda x: x < value\n\n def __call__(self, obj: Any) -> bool:\n # Compare to the absolute value to simplify this filter.\n number = getattr(obj, \"number\", None)\n return self.match(abs(number)) if number is not None else False\n
"},{"location":"api/#rustfava.core.filters.FilterSyntaxParser","title":"FilterSyntaxParser","text":"Source code in src/rustfava/core/filters.py
class EntryFilter(ABC):\n \"\"\"Filters a list of entries.\"\"\"\n\n @abstractmethod\n def apply(self, entries: Sequence[Directive]) -> Sequence[Directive]:\n \"\"\"Filter a list of directives.\"\"\"\n
The filter string can either be a regular expression or a parent account.
Source code in src/rustfava/core/filters.py
class AccountFilter(EntryFilter):\n \"\"\"Filter by account.\n\n The filter string can either be a regular expression or a parent account.\n \"\"\"\n\n __slots__ = (\"_match\", \"_value\")\n\n def __init__(self, value: str) -> None:\n self._value = value\n self._match = Match(value)\n\n def apply(self, entries: Sequence[Directive]) -> Sequence[Directive]:\n value = self._value\n if not value:\n return entries\n match = self._match\n return [\n entry\n for entry in entries\n if any(\n _has_component(name, value) or match(name)\n for name in get_entry_accounts(entry)\n )\n ]\n
Name Type Description Default entriesSequence[Directive]
A list of entries.
required
Returns:
Type Description Mapping[str, Sequence[Directive | TransactionPosting]]
A dict mapping account names to their entries.
Source code in src/rustfava/core/group_entries.py
def group_entries_by_account(\n entries: Sequence[abc.Directive],\n) -> Mapping[str, Sequence[abc.Directive | TransactionPosting]]:\n \"\"\"Group entries by account.\n\n Arguments:\n entries: A list of entries.\n\n Returns:\n A dict mapping account names to their entries.\n \"\"\"\n res: dict[str, list[abc.Directive | TransactionPosting]] = defaultdict(\n list,\n )\n\n for entry in entries:\n if isinstance(entry, abc.Transaction):\n for posting in entry.postings:\n res[posting.account].append(TransactionPosting(entry, posting))\n else:\n for account in get_entry_accounts(entry):\n res[account].append(entry)\n\n return dict(sorted(res.items()))\n
class ImporterMethodCallError(RustfavaAPIError):\n \"\"\"Error calling one of the importer methods.\"\"\"\n\n def __init__(self) -> None:\n super().__init__(\n f\"Error calling method on importer:\\n\\n{traceback.format_exc()}\"\n )\n
One of the importer methods returned an unexpected type.
Source code in src/rustfava/core/ingest.py
class ImporterInvalidTypeError(RustfavaAPIError):\n \"\"\"One of the importer methods returned an unexpected type.\"\"\"\n\n def __init__(self, attr: str, expected: type[Any], actual: Any) -> None:\n super().__init__(\n f\"Got unexpected type from importer as {attr}:\"\n f\" expected {expected!s}, got {type(actual)!s}:\"\n )\n
class MissingImporterDirsError(RustfavaAPIError):\n \"\"\"You need to set at least one imports-dir.\"\"\"\n\n def __init__(self) -> None:\n super().__init__(\"You need to set at least one imports-dir.\")\n
Identify files and importers that can be imported.
Returns:
Type Description list[FileImporters]
A list of :class:.FileImportInfo.
Source code in src/rustfava/core/ingest.py
def import_data(self) -> list[FileImporters]:\n \"\"\"Identify files and importers that can be imported.\n\n Returns:\n A list of :class:`.FileImportInfo`.\n \"\"\"\n if not self.importers:\n return []\n\n importers = list(self.importers.values())\n\n ret: list[FileImporters] = []\n for directory in self.ledger.fava_options.import_dirs:\n full_path = self.ledger.join_path(directory)\n ret.extend(find_imports(importers, full_path))\n\n return ret\n
Extract entries from filename with the specified importer.
Parameters:
Name Type Description Default filenamestr
The full path to a file.
required importer_namestr
The name of an importer that matched the file.
required
Returns:
Type Description list[Directive]
A list of new imported entries.
Source code in src/rustfava/core/ingest.py
def extract(self, filename: str, importer_name: str) -> list[Directive]:\n \"\"\"Extract entries from filename with the specified importer.\n\n Args:\n filename: The full path to a file.\n importer_name: The name of an importer that matched the file.\n\n Returns:\n A list of new imported entries.\n \"\"\"\n if not self.module_path:\n raise MissingImporterConfigError\n\n # reload (if changed)\n self.load_file()\n\n try:\n path = Path(filename)\n importer = self.importers[importer_name]\n new_entries = extract_from_file(\n importer,\n path,\n existing_entries=self.ledger.all_entries,\n )\n except Exception as exc:\n raise ImporterExtractError from exc\n\n for hook_fn in self.hooks:\n annotations = get_annotations(hook_fn)\n if any(\"Importer\" in a for a in annotations.values()):\n importer_info = importer.file_import_info(path)\n new_entries_list: HookOutput = [\n (\n filename,\n new_entries,\n importer_info.account,\n importer.importer,\n )\n ]\n else:\n new_entries_list = [(filename, new_entries)]\n\n new_entries_list = hook_fn(\n new_entries_list,\n self.ledger.all_entries,\n )\n\n new_entries = new_entries_list[0][1]\n\n return new_entries\n
Ignores common dot-directories like .git, .cache. .venv, see IGNORE_DIRS.
Parameters:
Name Type Description Default directoryPath
The directory to start in.
required
Yields:
Type Description Iterable[Path]
All full paths under directory, ignoring some directories.
Source code in src/rustfava/core/ingest.py
def walk_dir(directory: Path) -> Iterable[Path]:\n \"\"\"Walk through all files in dir.\n\n Ignores common dot-directories like .git, .cache. .venv, see IGNORE_DIRS.\n\n Args:\n directory: The directory to start in.\n\n Yields:\n All full paths under directory, ignoring some directories.\n \"\"\"\n for root, dirs, filenames in os.walk(directory):\n dirs[:] = sorted(d for d in dirs if d not in IGNORE_DIRS)\n root_path = Path(root)\n for filename in sorted(filenames):\n yield root_path / filename\n
For each file in directory, a pair of its filename and the matching
Iterable[FileImporters]
importers.
Source code in src/rustfava/core/ingest.py
def find_imports(\n config: Sequence[WrappedImporter], directory: Path\n) -> Iterable[FileImporters]:\n \"\"\"Pair files and matching importers.\n\n Yields:\n For each file in directory, a pair of its filename and the matching\n importers.\n \"\"\"\n for path in walk_dir(directory):\n stat = path.stat()\n if stat.st_size > _FILE_TOO_LARGE_THRESHOLD: # pragma: no cover\n continue\n\n importers = [\n importer.file_import_info(path)\n for importer in config\n if importer.identify(path)\n ]\n yield FileImporters(\n name=str(path), basename=path.name, importers=importers\n )\n
Load the given import config and extract importers and hooks.
Parameters:
Name Type Description Default module_pathPath
Path to the import config.
required
Returns:
Type Description tuple[Mapping[str, WrappedImporter], Hooks]
A pair of the importers (by name) and the list of hooks.
Source code in src/rustfava/core/ingest.py
def load_import_config(\n module_path: Path,\n) -> tuple[Mapping[str, WrappedImporter], Hooks]:\n \"\"\"Load the given import config and extract importers and hooks.\n\n Args:\n module_path: Path to the import config.\n\n Returns:\n A pair of the importers (by name) and the list of hooks.\n \"\"\"\n try:\n mod = run_path(str(module_path))\n except Exception as error: # pragma: no cover\n message = traceback.format_exc()\n raise ImportConfigLoadError(message) from error\n\n if \"CONFIG\" not in mod:\n msg = \"CONFIG is missing\"\n raise ImportConfigLoadError(msg)\n if not isinstance(mod[\"CONFIG\"], list): # pragma: no cover\n msg = \"CONFIG is not a list\"\n raise ImportConfigLoadError(msg)\n\n config = mod[\"CONFIG\"]\n hooks = DEFAULT_HOOKS\n if \"HOOKS\" in mod: # pragma: no cover\n hooks = mod[\"HOOKS\"]\n if not isinstance(hooks, list) or not all(\n callable(fn) for fn in hooks\n ):\n msg = \"HOOKS is not a list of callables\"\n raise ImportConfigLoadError(msg)\n importers = {}\n for importer in config:\n if not isinstance(\n importer, (BeanImporterProtocol, Importer)\n ): # pragma: no cover\n name = importer.__class__.__name__\n msg = (\n f\"Importer class '{name}' in '{module_path}' does \"\n \"not satisfy importer protocol\"\n )\n raise ImportConfigLoadError(msg)\n wrapped_importer = WrappedImporter(importer)\n if wrapped_importer.name in importers:\n msg = f\"Duplicate importer name found: {wrapped_importer.name}\"\n raise ImportConfigLoadError(msg)\n importers[wrapped_importer.name] = wrapped_importer\n return importers, hooks\n
File path for a document to upload to the primary import folder.
Parameters:
Name Type Description Default filenamestr
The filename of the document.
required ledgerRustfavaLedger
The RustfavaLedger.
required
Returns:
Type Description Path
The path that the document should be saved at.
Source code in src/rustfava/core/ingest.py
def filepath_in_primary_imports_folder(\n filename: str,\n ledger: RustfavaLedger,\n) -> Path:\n \"\"\"File path for a document to upload to the primary import folder.\n\n Args:\n filename: The filename of the document.\n ledger: The RustfavaLedger.\n\n Returns:\n The path that the document should be saved at.\n \"\"\"\n primary_imports_folder = next(iter(ledger.fava_options.import_dirs), None)\n if primary_imports_folder is None:\n raise MissingImporterDirsError\n\n filename = filename.replace(sep, \" \")\n if altsep: # pragma: no cover\n filename = filename.replace(altsep, \" \")\n\n return ledger.join_path(primary_imports_folder, filename)\n
This is intended as a faster alternative to Beancount's Inventory class. Due to not using a list, for inventories with a lot of different positions, inserting is much faster.
The keys should be tuples (currency, cost).
Source code in src/rustfava/core/inventory.py
class CounterInventory(dict[InventoryKey, Decimal]):\n \"\"\"A lightweight inventory.\n\n This is intended as a faster alternative to Beancount's Inventory class.\n Due to not using a list, for inventories with a lot of different positions,\n inserting is much faster.\n\n The keys should be tuples ``(currency, cost)``.\n \"\"\"\n\n def is_empty(self) -> bool:\n \"\"\"Check if the inventory is empty.\"\"\"\n return not bool(self)\n\n def add(self, key: InventoryKey, number: Decimal) -> None:\n \"\"\"Add a number to key.\"\"\"\n new_num = number + self.get(key, ZERO)\n if new_num == ZERO:\n self.pop(key, None)\n else:\n self[key] = new_num\n\n def __iter__(self) -> Iterator[InventoryKey]:\n raise NotImplementedError\n\n def to_strings(self) -> list[str]:\n \"\"\"Print as a list of strings (e.g. for snapshot tests).\"\"\"\n strings = []\n for (currency, cost), number in self.items():\n if cost is None:\n strings.append(f\"{number} {currency}\")\n else:\n cost_str = cost_to_string(cost)\n strings.append(f\"{number} {currency} {{{cost_str}}}\")\n return strings\n\n def reduce(\n self,\n reducer: Callable[Concatenate[Position, P], Amount],\n *args: P.args,\n **_kwargs: P.kwargs,\n ) -> SimpleCounterInventory:\n \"\"\"Reduce inventory.\n\n Note that this returns a simple :class:`CounterInventory` with just\n currencies as keys.\n \"\"\"\n counter = SimpleCounterInventory()\n for (currency, cost), number in self.items():\n pos = _Position(_Amount(number, currency), cost)\n amount = reducer(pos, *args) # type: ignore[call-arg]\n counter.add(amount.currency, amount.number)\n return counter\n\n def add_amount(self, amount: Amount, cost: Cost | None = None) -> None:\n \"\"\"Add an Amount to the inventory.\"\"\"\n key = (amount.currency, cost)\n self.add(key, amount.number)\n\n def add_position(self, pos: Position) -> None:\n \"\"\"Add a Position or Posting to the inventory.\"\"\"\n # Skip positions with missing units (can happen with parse errors)\n if pos.units is None:\n return\n self.add_amount(pos.units, pos.cost)\n\n def __neg__(self) -> CounterInventory:\n return CounterInventory({key: -num for key, num in self.items()})\n\n def __add__(self, other: CounterInventory) -> CounterInventory:\n counter = CounterInventory(self)\n counter.add_inventory(other)\n return counter\n\n def add_inventory(self, counter: CounterInventory) -> None:\n \"\"\"Add another :class:`CounterInventory`.\"\"\"\n if not self:\n self.update(counter)\n else:\n self_get = self.get\n for key, num in counter.items():\n new_num = num + self_get(key, ZERO)\n if new_num == ZERO:\n self.pop(key, None)\n else:\n self[key] = new_num\n
Print as a list of strings (e.g. for snapshot tests).
Source code in src/rustfava/core/inventory.py
def to_strings(self) -> list[str]:\n \"\"\"Print as a list of strings (e.g. for snapshot tests).\"\"\"\n strings = []\n for (currency, cost), number in self.items():\n if cost is None:\n strings.append(f\"{number} {currency}\")\n else:\n cost_str = cost_to_string(cost)\n strings.append(f\"{number} {currency} {{{cost_str}}}\")\n return strings\n
def add_position(self, pos: Position) -> None:\n \"\"\"Add a Position or Posting to the inventory.\"\"\"\n # Skip positions with missing units (can happen with parse errors)\n if pos.units is None:\n return\n self.add_amount(pos.units, pos.cost)\n
def add_inventory(self, counter: CounterInventory) -> None:\n \"\"\"Add another :class:`CounterInventory`.\"\"\"\n if not self:\n self.update(counter)\n else:\n self_get = self.get\n for key, num in counter.items():\n new_num = num + self_get(key, ZERO)\n if new_num == ZERO:\n self.pop(key, None)\n else:\n self[key] = new_num\n
def sidebar_links(custom_entries: Sequence[Custom]) -> SidebarLinks:\n \"\"\"Parse custom entries for links.\n\n They have the following format:\n\n 2016-04-01 custom \"fava-sidebar-link\" \"2014\" \"/income_statement/?time=2014\"\n \"\"\"\n sidebar_link_entries = [\n entry for entry in custom_entries if entry.type == \"fava-sidebar-link\"\n ]\n return [\n (entry.values[0].value, entry.values[1].value)\n for entry in sidebar_link_entries\n ]\n
Name Type Description Default eventsSequence[Event]
A list of events.
required max_deltaint
Number of days that should be considered.
required
Returns:
Type Description Sequence[Event]
A list of the Events in entries that are less than max_delta days
Sequence[Event]
away.
Source code in src/rustfava/core/misc.py
def upcoming_events(\n events: Sequence[Event], max_delta: int\n) -> Sequence[Event]:\n \"\"\"Parse entries for upcoming events.\n\n Args:\n events: A list of events.\n max_delta: Number of days that should be considered.\n\n Returns:\n A list of the Events in entries that are less than `max_delta` days\n away.\n \"\"\"\n today = local_today()\n upcoming = []\n\n for event in events:\n delta = event.date - today\n if delta.days >= 0 and delta.days < max_delta:\n upcoming.append(event)\n\n return upcoming\n
class FavaModule:\n \"\"\"Base class for the \"modules\" of rustfavaLedger.\"\"\"\n\n def __init__(self, ledger: RustfavaLedger) -> None:\n self.ledger = ledger\n\n def load_file(self) -> None:\n \"\"\"Run when the file has been (re)loaded.\"\"\"\n
Format a decimal to the right number of decimal digits with locale.
Parameters:
Name Type Description Default valueDecimal
A decimal number.
required currencystr | None
A currency string or None.
None
Returns:
Type Description str
A string, the formatted decimal.
Source code in src/rustfava/core/number.py
def __call__(self, value: Decimal, currency: str | None = None) -> str:\n \"\"\"Format a decimal to the right number of decimal digits with locale.\n\n Arguments:\n value: A decimal number.\n currency: A currency string or None.\n\n Returns:\n A string, the formatted decimal.\n \"\"\"\n if currency is None:\n return self._default_pattern(value)\n return self._formatters.get(currency, self._default_pattern)(value)\n
Obtain formatting pattern for the given locale and precision.
Parameters:
Name Type Description Default localeLocale | None
An optional locale.
required precisionint
The precision.
required
Returns:
Type Description Formatter
A function that renders Decimals to strings as desired.
Source code in src/rustfava/core/number.py
def get_locale_format(locale: Locale | None, precision: int) -> Formatter:\n \"\"\"Obtain formatting pattern for the given locale and precision.\n\n Arguments:\n locale: An optional locale.\n precision: The precision.\n\n Returns:\n A function that renders Decimals to strings as desired.\n \"\"\"\n # Set a maximum precision of 14, half the default precision of Decimal\n precision = min(precision, 14)\n if locale is None:\n fmt_string = \"{:.\" + str(precision) + \"f}\"\n\n def fmt(num: Decimal) -> str:\n return fmt_string.format(num)\n\n return fmt\n\n pattern = copy.copy(locale.decimal_formats.get(None))\n if not pattern: # pragma: no cover\n msg = \"Expected Locale to have a decimal format pattern\"\n raise ValueError(msg)\n pattern.frac_prec = (precision, precision)\n\n def locale_fmt(num: Decimal) -> str:\n return pattern.apply(num, locale) # type: ignore[no-any-return]\n\n return locale_fmt\n
@staticmethod\ndef serialise(\n val: QueryRowValue,\n) -> SerialisedQueryRowValue:\n \"\"\"Serialiseable version of the column value.\"\"\"\n return val # type: ignore[no-any-return]\n
@dataclass(frozen=True)\nclass InventoryColumn(BaseColumn):\n \"\"\"An inventory query column.\"\"\"\n\n dtype: str = \"Inventory\"\n\n @staticmethod\n def serialise(\n val: dict[str, Decimal] | None,\n ) -> SimpleCounterInventory | None:\n \"\"\"Serialise an inventory.\n\n Rustledger returns inventory as a dict of currency -> Decimal.\n \"\"\"\n if val is None:\n return None\n # Rustledger already converts to {currency: Decimal} format\n if isinstance(val, dict):\n from rustfava.core.inventory import SimpleCounterInventory\n return SimpleCounterInventory(val)\n # Fallback for beancount Inventory type (for backwards compat)\n return UNITS.apply_inventory(val) if val is not None else None\n
Rustledger returns inventory as a dict of currency -> Decimal.
Source code in src/rustfava/core/query.py
@staticmethod\ndef serialise(\n val: dict[str, Decimal] | None,\n) -> SimpleCounterInventory | None:\n \"\"\"Serialise an inventory.\n\n Rustledger returns inventory as a dict of currency -> Decimal.\n \"\"\"\n if val is None:\n return None\n # Rustledger already converts to {currency: Decimal} format\n if isinstance(val, dict):\n from rustfava.core.inventory import SimpleCounterInventory\n return SimpleCounterInventory(val)\n # Fallback for beancount Inventory type (for backwards compat)\n return UNITS.apply_inventory(val) if val is not None else None\n
class TooManyRunArgsError(FavaShellError):\n \"\"\"Too many args to run: '{args}'.\"\"\"\n\n def __init__(self, args: str) -> None:\n super().__init__(f\"Too many args to run: '{args}'.\")\n
Only queries that return a table can be printed to a file.
Source code in src/rustfava/core/query_shell.py
class NonExportableQueryError(FavaShellError):\n \"\"\"Only queries that return a table can be printed to a file.\"\"\"\n\n def __init__(self) -> None:\n super().__init__(\n \"Only queries that return a table can be printed to a file.\"\n )\n
class FavaQueryRunner:\n \"\"\"Runs BQL queries using rustledger.\"\"\"\n\n def __init__(self, ledger: RustfavaLedger) -> None:\n self.ledger = ledger\n\n def run(\n self, entries: Sequence[Directive], query: str\n ) -> RLCursor | str:\n \"\"\"Run a query, returning cursor or text result.\"\"\"\n # Get the source from the ledger for queries\n source = getattr(self.ledger, \"_source\", None)\n\n # Create connection\n conn = connect(\n \"rustledger:\",\n entries=entries,\n errors=self.ledger.errors,\n options=self.ledger.options,\n )\n\n if source:\n conn.set_source(source)\n\n # Parse the query to handle special commands\n query = query.strip()\n query_lower = query.lower()\n\n # Handle noop commands (return fixed text)\n noop_doc = \"Doesn't do anything in rustfava's query shell.\"\n if query_lower in (\".exit\", \".quit\", \"exit\", \"quit\"):\n return noop_doc\n\n # Handle .run or run command\n if query_lower.startswith((\".run\", \"run\")):\n # Check if it's just \"run\" or \".run\" (list queries) or \"run name\"\n if query_lower in (\"run\", \".run\") or query_lower.startswith((\"run \", \".run \")):\n return self._handle_run(query, conn)\n\n # Handle help commands - return text\n if query_lower.startswith((\".help\", \"help\")):\n # \".help exit\" or \".help <command>\" returns noop doc\n if \" \" in query_lower:\n return noop_doc\n return self._help_text()\n\n # Handle .explain - return placeholder\n if query_lower.startswith((\".explain\", \"explain\")):\n return f\"EXPLAIN: {query}\"\n\n # Handle SELECT/BALANCES/JOURNAL queries\n try:\n return conn.execute(query)\n except ParseError as exc:\n raise QueryParseError(exc) from exc\n except CompilationError as exc:\n raise QueryCompilationError(exc) from exc\n\n def _handle_run(self, query: str, conn: RLConnection) -> RLCursor | str:\n \"\"\"Handle .run command to execute stored queries.\"\"\"\n queries = self.ledger.all_entries_by_type.Query\n\n # Parse the run command\n parts = shlex.split(query)\n if len(parts) == 1:\n # Just \"run\" - list available queries\n return \"\\n\".join(q.name for q in queries)\n\n if len(parts) > 2:\n raise TooManyRunArgsError(query)\n\n name = parts[1].rstrip(\";\")\n query_obj = next((q for q in queries if q.name == name), None)\n if query_obj is None:\n raise QueryNotFoundError(name)\n\n try:\n return conn.execute(query_obj.query_string)\n except ParseError as exc:\n raise QueryParseError(exc) from exc\n except CompilationError as exc:\n raise QueryCompilationError(exc) from exc\n\n def _help_text(self) -> str:\n \"\"\"Return help text for the query shell.\"\"\"\n return \"\"\"Fava Query Shell\n\nCommands:\n SELECT ... Run a BQL SELECT query\n run <name> Run a stored query by name\n run List all stored queries\n help Show this help message\n\nExample queries:\n SELECT account, sum(position) GROUP BY account\n SELECT date, narration, position WHERE account ~ \"Expenses\"\n\"\"\"\n
class QueryShell(FavaModule):\n \"\"\"A Fava module to run BQL queries.\"\"\"\n\n def __init__(self, ledger: RustfavaLedger) -> None:\n super().__init__(ledger)\n self.runner = FavaQueryRunner(ledger)\n\n def execute_query_serialised(\n self, entries: Sequence[Directive], query: str\n ) -> QueryResultTable | QueryResultText:\n \"\"\"Run a query and returns its serialised result.\n\n Arguments:\n entries: The entries to run the query on.\n query: A query string.\n\n Returns:\n Either a table or a text result (depending on the query).\n\n Raises:\n RustfavaAPIError: If the query response is an error.\n \"\"\"\n res = self.runner.run(entries, query)\n return (\n QueryResultText(res) if isinstance(res, str) else _serialise(res)\n )\n\n def query_to_file(\n self,\n entries: Sequence[Directive],\n query_string: str,\n result_format: str,\n ) -> tuple[str, io.BytesIO]:\n \"\"\"Get query result as file.\n\n Arguments:\n entries: The entries to run the query on.\n query_string: A string, the query to run.\n result_format: The file format to save to.\n\n Returns:\n A tuple (name, data), where name is either 'query_result' or the\n name of a custom query if the query string is 'run name_of_query'.\n ``data`` contains the file contents.\n\n Raises:\n RustfavaAPIError: If the result format is not supported or the\n query failed.\n \"\"\"\n name = \"query_result\"\n\n if query_string.lower().startswith((\".run\", \"run \")):\n parts = shlex.split(query_string)\n if len(parts) > 2:\n raise TooManyRunArgsError(query_string)\n if len(parts) == 2:\n name = parts[1].rstrip(\";\")\n queries = self.ledger.all_entries_by_type.Query\n query_obj = next((q for q in queries if q.name == name), None)\n if query_obj is None:\n raise QueryNotFoundError(name)\n query_string = query_obj.query_string\n\n res = self.runner.run(entries, query_string)\n if isinstance(res, str):\n raise NonExportableQueryError\n\n rrows = res.fetchall()\n rtypes = res.description\n\n # Convert rows to exportable format\n rows = _numberify_rows(rrows, rtypes)\n\n if result_format == \"csv\":\n data = to_csv(list(rtypes), rows)\n else:\n if not HAVE_EXCEL: # pragma: no cover\n msg = \"Result format not supported.\"\n raise RustfavaAPIError(msg)\n data = to_excel(list(rtypes), rows, result_format, query_string)\n return name, data\n
Name Type Description Default entriesSequence[Directive]
The entries to run the query on.
required querystr
A query string.
required
Returns:
Type Description QueryResultTable | QueryResultText
Either a table or a text result (depending on the query).
Raises:
Type Description RustfavaAPIError
If the query response is an error.
Source code in src/rustfava/core/query_shell.py
def execute_query_serialised(\n self, entries: Sequence[Directive], query: str\n) -> QueryResultTable | QueryResultText:\n \"\"\"Run a query and returns its serialised result.\n\n Arguments:\n entries: The entries to run the query on.\n query: A query string.\n\n Returns:\n Either a table or a text result (depending on the query).\n\n Raises:\n RustfavaAPIError: If the query response is an error.\n \"\"\"\n res = self.runner.run(entries, query)\n return (\n QueryResultText(res) if isinstance(res, str) else _serialise(res)\n )\n
Name Type Description Default entriesSequence[Directive]
The entries to run the query on.
required query_stringstr
A string, the query to run.
required result_formatstr
The file format to save to.
required
Returns:
Type Description str
A tuple (name, data), where name is either 'query_result' or the
BytesIO
name of a custom query if the query string is 'run name_of_query'.
tuple[str, BytesIO]
data contains the file contents.
Raises:
Type Description RustfavaAPIError
If the result format is not supported or the
Source code in src/rustfava/core/query_shell.py
def query_to_file(\n self,\n entries: Sequence[Directive],\n query_string: str,\n result_format: str,\n) -> tuple[str, io.BytesIO]:\n \"\"\"Get query result as file.\n\n Arguments:\n entries: The entries to run the query on.\n query_string: A string, the query to run.\n result_format: The file format to save to.\n\n Returns:\n A tuple (name, data), where name is either 'query_result' or the\n name of a custom query if the query string is 'run name_of_query'.\n ``data`` contains the file contents.\n\n Raises:\n RustfavaAPIError: If the result format is not supported or the\n query failed.\n \"\"\"\n name = \"query_result\"\n\n if query_string.lower().startswith((\".run\", \"run \")):\n parts = shlex.split(query_string)\n if len(parts) > 2:\n raise TooManyRunArgsError(query_string)\n if len(parts) == 2:\n name = parts[1].rstrip(\";\")\n queries = self.ledger.all_entries_by_type.Query\n query_obj = next((q for q in queries if q.name == name), None)\n if query_obj is None:\n raise QueryNotFoundError(name)\n query_string = query_obj.query_string\n\n res = self.runner.run(entries, query_string)\n if isinstance(res, str):\n raise NonExportableQueryError\n\n rrows = res.fetchall()\n rtypes = res.description\n\n # Convert rows to exportable format\n rows = _numberify_rows(rrows, rtypes)\n\n if result_format == \"csv\":\n data = to_csv(list(rtypes), rows)\n else:\n if not HAVE_EXCEL: # pragma: no cover\n msg = \"Result format not supported.\"\n raise RustfavaAPIError(msg)\n data = to_excel(list(rtypes), rows, result_format, query_string)\n return name, data\n
Name Type Description Default entriesIterable[Directive | Directive] | None
A list of entries to compute balances from.
Nonecreate_accountslist[str] | None
A list of accounts that the tree should contain.
None Source code in src/rustfava/core/tree.py
class Tree(dict[str, TreeNode]):\n \"\"\"Account tree.\n\n Args:\n entries: A list of entries to compute balances from.\n create_accounts: A list of accounts that the tree should contain.\n \"\"\"\n\n def __init__(\n self,\n entries: Iterable[Directive | data.Directive] | None = None,\n create_accounts: list[str] | None = None,\n ) -> None:\n super().__init__(self)\n self.get(\"\", insert=True)\n if create_accounts:\n for account in create_accounts:\n self.get(account, insert=True)\n if entries:\n account_balances: dict[str, CounterInventory]\n account_balances = defaultdict(CounterInventory)\n for entry in entries:\n if isinstance(entry, Open):\n self.get(entry.account, insert=True)\n for posting in getattr(entry, \"postings\", []):\n account_balances[posting.account].add_position(posting)\n\n for name, balance in sorted(account_balances.items()):\n self.insert(name, balance)\n\n @property\n def accounts(self) -> list[str]:\n \"\"\"The accounts in this tree.\"\"\"\n return sorted(self.keys())\n\n def ancestors(self, name: str) -> Iterable[TreeNode]:\n \"\"\"Ancestors of an account.\n\n Args:\n name: An account name.\n\n Yields:\n The ancestors of the given account from the bottom up.\n \"\"\"\n while name:\n name = account_parent(name) or \"\"\n yield self.get(name)\n\n def insert(self, name: str, balance: CounterInventory) -> None:\n \"\"\"Insert account with a balance.\n\n Insert account and update its balance and the balances of its\n ancestors.\n\n Args:\n name: An account name.\n balance: The balance of the account.\n \"\"\"\n node = self.get(name, insert=True)\n node.balance.add_inventory(balance)\n node.balance_children.add_inventory(balance)\n node.has_txns = True\n for parent_node in self.ancestors(name):\n parent_node.balance_children.add_inventory(balance)\n\n def get( # type: ignore[override]\n self,\n name: str,\n *,\n insert: bool = False,\n ) -> TreeNode:\n \"\"\"Get an account.\n\n Args:\n name: An account name.\n insert: If True, insert the name into the tree if it does not\n exist.\n\n Returns:\n TreeNode: The account of that name or an empty account if the\n account is not in the tree.\n \"\"\"\n try:\n return self[name]\n except KeyError:\n node = TreeNode(name)\n if insert:\n if name:\n parent = self.get(account_parent(name) or \"\", insert=True)\n parent.children.append(node)\n self[name] = node\n return node\n\n def net_profit(\n self,\n options: BeancountOptions,\n account_name: str,\n ) -> TreeNode:\n \"\"\"Calculate the net profit.\n\n Args:\n options: The Beancount options.\n account_name: The name to use for the account containing the net\n profit.\n \"\"\"\n income = self.get(options[\"name_income\"])\n expenses = self.get(options[\"name_expenses\"])\n\n net_profit = Tree()\n net_profit.insert(\n account_name,\n income.balance_children + expenses.balance_children,\n )\n\n return net_profit.get(account_name)\n\n def cap(self, options: BeancountOptions, unrealized_account: str) -> None:\n \"\"\"Transfer Income and Expenses, add conversions and unrealized gains.\n\n Args:\n options: The Beancount options.\n unrealized_account: The name of the account to post unrealized\n gains to (as a subaccount of Equity).\n \"\"\"\n equity = options[\"name_equity\"]\n conversions = CounterInventory(\n {\n (currency, None): -number\n for currency, number in AT_COST.apply(\n self.get(\"\").balance_children\n ).items()\n },\n )\n\n # Add conversions\n self.insert(\n equity + \":\" + options[\"account_current_conversions\"],\n conversions,\n )\n\n # Insert unrealized gains.\n self.insert(\n equity + \":\" + unrealized_account,\n -self.get(\"\").balance_children,\n )\n\n # Transfer Income and Expenses\n self.insert(\n equity + \":\" + options[\"account_current_earnings\"],\n self.get(options[\"name_income\"]).balance_children,\n )\n self.insert(\n equity + \":\" + options[\"account_current_earnings\"],\n self.get(options[\"name_expenses\"]).balance_children,\n )\n
The ancestors of the given account from the bottom up.
Source code in src/rustfava/core/tree.py
def ancestors(self, name: str) -> Iterable[TreeNode]:\n \"\"\"Ancestors of an account.\n\n Args:\n name: An account name.\n\n Yields:\n The ancestors of the given account from the bottom up.\n \"\"\"\n while name:\n name = account_parent(name) or \"\"\n yield self.get(name)\n
Insert account and update its balance and the balances of its ancestors.
Parameters:
Name Type Description Default namestr
An account name.
required balanceCounterInventory
The balance of the account.
required Source code in src/rustfava/core/tree.py
def insert(self, name: str, balance: CounterInventory) -> None:\n \"\"\"Insert account with a balance.\n\n Insert account and update its balance and the balances of its\n ancestors.\n\n Args:\n name: An account name.\n balance: The balance of the account.\n \"\"\"\n node = self.get(name, insert=True)\n node.balance.add_inventory(balance)\n node.balance_children.add_inventory(balance)\n node.has_txns = True\n for parent_node in self.ancestors(name):\n parent_node.balance_children.add_inventory(balance)\n
If True, insert the name into the tree if it does not exist.
False
Returns:
Name Type Description TreeNodeTreeNode
The account of that name or an empty account if the
TreeNode
account is not in the tree.
Source code in src/rustfava/core/tree.py
def get( # type: ignore[override]\n self,\n name: str,\n *,\n insert: bool = False,\n) -> TreeNode:\n \"\"\"Get an account.\n\n Args:\n name: An account name.\n insert: If True, insert the name into the tree if it does not\n exist.\n\n Returns:\n TreeNode: The account of that name or an empty account if the\n account is not in the tree.\n \"\"\"\n try:\n return self[name]\n except KeyError:\n node = TreeNode(name)\n if insert:\n if name:\n parent = self.get(account_parent(name) or \"\", insert=True)\n parent.children.append(node)\n self[name] = node\n return node\n
Name Type Description Default optionsBeancountOptions
The Beancount options.
required account_namestr
The name to use for the account containing the net profit.
required Source code in src/rustfava/core/tree.py
def net_profit(\n self,\n options: BeancountOptions,\n account_name: str,\n) -> TreeNode:\n \"\"\"Calculate the net profit.\n\n Args:\n options: The Beancount options.\n account_name: The name to use for the account containing the net\n profit.\n \"\"\"\n income = self.get(options[\"name_income\"])\n expenses = self.get(options[\"name_expenses\"])\n\n net_profit = Tree()\n net_profit.insert(\n account_name,\n income.balance_children + expenses.balance_children,\n )\n\n return net_profit.get(account_name)\n
class WatcherBase(abc.ABC):\n \"\"\"ABC for rustfava ledger file watchers.\"\"\"\n\n last_checked: int\n \"\"\"Timestamp of the latest change noticed by the file watcher.\"\"\"\n\n last_notified: int\n \"\"\"Timestamp of the latest change that the watcher was notified of.\"\"\"\n\n @abc.abstractmethod\n def update(self, files: Iterable[Path], folders: Iterable[Path]) -> None:\n \"\"\"Update the folders/files to watch.\n\n Args:\n files: A list of file paths.\n folders: A list of paths to folders.\n \"\"\"\n\n def check(self) -> bool:\n \"\"\"Check for changes.\n\n Returns:\n `True` if there was a file change in one of the files or folders,\n `False` otherwise.\n \"\"\"\n latest_mtime = max(self._get_latest_mtime(), self.last_notified)\n has_higher_mtime = latest_mtime > self.last_checked\n if has_higher_mtime:\n self.last_checked = latest_mtime\n return has_higher_mtime\n\n def notify(self, path: Path) -> None:\n \"\"\"Notify the watcher of a change to a path.\"\"\"\n try:\n change_mtime = Path(path).stat().st_mtime_ns\n except FileNotFoundError:\n change_mtime = max(self.last_notified, self.last_checked) + 1\n self.last_notified = max(self.last_notified, change_mtime)\n\n @abc.abstractmethod\n def _get_latest_mtime(self) -> int:\n \"\"\"Get the latest change mtime.\"\"\"\n
required Source code in src/rustfava/core/watcher.py
@abc.abstractmethod\ndef update(self, files: Iterable[Path], folders: Iterable[Path]) -> None:\n \"\"\"Update the folders/files to watch.\n\n Args:\n files: A list of file paths.\n folders: A list of paths to folders.\n \"\"\"\n
True if there was a file change in one of the files or folders,
bool
False otherwise.
Source code in src/rustfava/core/watcher.py
def check(self) -> bool:\n \"\"\"Check for changes.\n\n Returns:\n `True` if there was a file change in one of the files or folders,\n `False` otherwise.\n \"\"\"\n latest_mtime = max(self._get_latest_mtime(), self.last_notified)\n has_higher_mtime = latest_mtime > self.last_checked\n if has_higher_mtime:\n self.last_checked = latest_mtime\n return has_higher_mtime\n
def update(self, files: Iterable[Path], folders: Iterable[Path]) -> None:\n \"\"\"Update the folders/files to watch.\"\"\"\n files_set = {p.absolute() for p in files if p.exists()}\n folders_set = {p.absolute() for p in folders if p.is_dir()}\n new_paths = (files_set, folders_set)\n if self._watchers and new_paths == self._paths:\n self.check()\n return\n self._paths = new_paths\n if self._watchers:\n self._watchers[0].stop()\n self._watchers[1].stop()\n self._watchers = (\n _FilesWatchfilesThread(files_set, self.last_checked),\n _WatchfilesThread(folders_set, self.last_checked, recursive=True),\n )\n self._watchers[0].start()\n self._watchers[1].start()\n self.check()\n
For folders, only checks mtime of the folder and all subdirectories. So a file change won't be noticed, but only new/deleted files.
Source code in src/rustfava/core/watcher.py
class Watcher(WatcherBase):\n \"\"\"A simple file and folder watcher.\n\n For folders, only checks mtime of the folder and all subdirectories.\n So a file change won't be noticed, but only new/deleted files.\n \"\"\"\n\n def __init__(self) -> None:\n self.last_checked = 0\n self.last_notified = 0\n self._files: Sequence[Path] = []\n self._folders: Sequence[Path] = []\n\n def update(self, files: Iterable[Path], folders: Iterable[Path]) -> None:\n \"\"\"Update the folders/files to watch.\"\"\"\n self._files = list(files)\n self._folders = list(folders)\n self.check()\n\n def _mtimes(self) -> Iterable[int]:\n for path in self._files:\n try:\n yield path.stat().st_mtime_ns\n except FileNotFoundError:\n yield max(self.last_notified, self.last_checked) + 1\n for path in self._folders:\n for dirpath, _, _ in walk(path):\n yield Path(dirpath).stat().st_mtime_ns\n\n def _get_latest_mtime(self) -> int:\n return max(self._mtimes())\n
def translations() -> dict[str, str]:\n \"\"\"Get translations catalog.\"\"\"\n catalog = get_translations()._catalog # noqa: SLF001\n return {k: v for k, v in catalog.items() if isinstance(k, str) and k}\n
If the BEANCOUNT_FILE environment variable is set, Rustfava will use the files (delimited by ';' on Windows and ':' on POSIX) given there in addition to FILENAMES.
Note you can also specify command-line options via environment variables with the RUSTFAVA_ prefix. For example, --host=0.0.0.0 is equivalent to setting the environment variable RUSTFAVA_HOST=0.0.0.0.
Source code in src/rustfava/cli.py
@click.command(context_settings={\"auto_envvar_prefix\": \"RUSTFAVA\"})\n@click.argument(\n \"filenames\",\n nargs=-1,\n type=click.Path(exists=True, dir_okay=False, resolve_path=True),\n)\n@click.option(\n \"-p\",\n \"--port\",\n type=int,\n default=5000,\n show_default=True,\n metavar=\"<port>\",\n help=\"The port to listen on.\",\n)\n@click.option(\n \"-H\",\n \"--host\",\n type=str,\n default=\"localhost\",\n show_default=True,\n metavar=\"<host>\",\n help=\"The host to listen on.\",\n)\n@click.option(\"--prefix\", type=str, help=\"Set an URL prefix.\")\n@click.option(\n \"--incognito\",\n is_flag=True,\n help=\"Run in incognito mode and obscure all numbers.\",\n)\n@click.option(\n \"--read-only\",\n is_flag=True,\n help=\"Run in read-only mode, disable any change through rustfava.\",\n)\n@click.option(\"-d\", \"--debug\", is_flag=True, help=\"Turn on debugging.\")\n@click.option(\n \"--profile\",\n is_flag=True,\n help=\"Turn on profiling. Implies --debug.\",\n)\n@click.option(\n \"--profile-dir\",\n type=click.Path(),\n help=\"Output directory for profiling data.\",\n)\n@click.option(\n \"--poll-watcher\", is_flag=True, help=\"Use old polling-based watcher.\"\n)\n@click.version_option(package_name=\"rustfava\")\ndef main( # noqa: PLR0913\n *,\n filenames: tuple[str, ...] = (),\n port: int = 5000,\n host: str = \"localhost\",\n prefix: str | None = None,\n incognito: bool = False,\n read_only: bool = False,\n debug: bool = False,\n profile: bool = False,\n profile_dir: str | None = None,\n poll_watcher: bool = False,\n) -> None: # pragma: no cover\n \"\"\"Start Rustfava for FILENAMES on http://<host>:<port>.\n\n If the `BEANCOUNT_FILE` environment variable is set, Rustfava will use the\n files (delimited by ';' on Windows and ':' on POSIX) given there in\n addition to FILENAMES.\n\n Note you can also specify command-line options via environment variables\n with the `RUSTFAVA_` prefix. For example, `--host=0.0.0.0` is equivalent to\n setting the environment variable `RUSTFAVA_HOST=0.0.0.0`.\n \"\"\"\n all_filenames = _add_env_filenames(filenames)\n\n if not all_filenames:\n raise NoFileSpecifiedError\n\n from rustfava.application import create_app\n\n app = create_app(\n all_filenames,\n incognito=incognito,\n read_only=read_only,\n poll_watcher=poll_watcher,\n )\n\n if prefix:\n from werkzeug.middleware.dispatcher import DispatcherMiddleware\n\n from rustfava.util import simple_wsgi\n\n app.wsgi_app = DispatcherMiddleware( # type: ignore[method-assign]\n simple_wsgi,\n {prefix: app.wsgi_app},\n )\n\n # ensure that cheroot does not use IP6 for localhost\n host = \"127.0.0.1\" if host == \"localhost\" else host\n # Debug mode if profiling is active\n debug = debug or profile\n\n click.secho(f\"Starting Fava on http://{host}:{port}\", fg=\"green\")\n if not debug:\n from cheroot.wsgi import Server\n\n server = Server((host, port), app)\n try:\n server.start()\n except KeyboardInterrupt:\n click.echo(\"Keyboard interrupt received: stopping Fava\", err=True)\n server.stop()\n except OSError as error:\n if \"No socket could be created\" in str(error):\n raise AddressInUse(port) from error\n raise click.Abort from error\n else:\n from werkzeug.middleware.profiler import ProfilerMiddleware\n\n from rustfava.util import setup_debug_logging\n\n setup_debug_logging()\n if profile:\n app.wsgi_app = ProfilerMiddleware( # type: ignore[method-assign]\n app.wsgi_app,\n restrictions=(30,),\n profile_dir=profile_dir or None,\n )\n\n app.jinja_env.auto_reload = True\n try:\n app.run(host, port, debug)\n except OSError as error:\n if error.errno == errno.EADDRINUSE:\n raise AddressInUse(port) from error\n raise\n
This is a fork of Fava that replaces the Python Beancount parser and beanquery with rustledger, a Rust-based implementation of the Beancount format compiled to WebAssembly.
Key changes from Fava:
No Beancount dependency: Rustfava uses rustledger for parsing, so you don't need to install Python's beancount package. Your existing Beancount files are fully compatible.
Query support: BQL queries are now handled by rustledger's built-in query engine instead of beanquery. The query syntax remains largely compatible.
Optional beancount compatibility: If you need to use Beancount plugins or the import system, install with uv pip install rustfava[beancount-compat].
With this release, query results are now rendered in the frontend. The templates for HTML rendering are still available but extension authors are encouraged to switch, see the statistics report for an example how this can be done. This release adds CSS styles for dark-mode. Numerical comparisons on the units, price or cost are now possible in rustfava filters. As the watchfiles based watcher might not work correctly in some setups with network file systems, you can switch to the (slower) polling based watcher as well. The default-file option, if set, is now considered instead of the \"main\" file when inserting an entry.
This release accumulates a couple of minor fixes and improvements. Under the hood, the file change detection is now powered by watchfiles instead of polling, which is more performant.
It is now possible to convert to a sequence of currencies. Posting metadata is now supported in the entry forms. The editor should now be a bit more performant as the previous parses will be reused better. For compatibility with extensions using them, the Javascript and CSS for the \"old\" account trees has been re-added.
This release brings various improvements to the charts, like allowing the toggling of currencies by clicking on their names in the chart legend. The account balance trees in rustfava are now rendered in the frontend, fixing some minor bugs in the process and easing maintenance. rustfava extensions can now also provide their own endpoints.
With this release, extensions can now ship Javascript code to run in the frontend. The editor in rustfava now uses a tree-sitter grammar to obtain a full parsed syntax tree, which makes editor functionality more maintainable and should improve the autocompletion. The Flask WSGI app is now created using the application factory pattern - users who use the rustfava WSGI app directly should switch from rustfava.application.app to the create_app function in rustfava.application. This release also drops support for Python 3.7 and contains a couple of minor fixes and changes, in particular various styling fixes.
With this release, the rendering of some report like the documents report has been moved completely to the frontend, which should be slightly more perfomant and easier to maintain. This release also contains a couple of minor fixes and changes.
This release brings stacked bar charts, which are a great way to visualise income broken down per account per month for example. The inferred display precision for currencies is now also used in the frontend and can be overwritten with commodity metadata.
The journal-show, journal-show-document, and journal-show-transaction rustfava-options have been removed. The types of entries that to show in the journal are now automatically stored in the browser of the user (in localStorage).
As usual, this release also includes a couple of bug fixes and minor improvements. To avoid some race conditions and improve perfomance, the per-file Ledger class is not filtered anymore in-place but rather the filtered data is generated per request - some extensions might have to adjust for this and use g.filtered instead of ledger for some attributes.
In this release, the document page now shows counts in the account tree and allows collapsing of accounts in the tree. Parts of the charts in the future are now desaturated. This release contains a couple of bug fixes as usual.
The conversion and interval options have been removed. Their functionality can be achieved with the new default-page option. The editor components have been completely reworked, include autocompletion in more places and are now based on version 6 of CodeMirror. An option invert-income-liabilities-equity has been added to invert the numbers of those accounts on the income statement and the balance sheet. This release also adds a Bulgarian translation and features various smaller improvements and fixes as usual.
This release brings area charts as an alternative option to view the various line charts in rustfava and a Catalan translation for rustfava. There is also now an option to set the indentation of inserted Beancount entries. As usual this release also includes various minor fixes and improvements.
This is mainly a bugfix release to fix compatibility with one of the main dependencies (werkzeug). Also, a default-conversion option was added, which allows setting a default conversion.
Rustfava can now display charts for BQL queries - if they have exactly two columns with the first being a date or string and the second an inventory, then a line chart or treemap chart is shown on the query page.
Apart from plenty of bug fixes, this release mainly contains improvements to the forms to add transactions: postings can now be dragged and the full cost syntax of Beancount should supported.
The import page of rustfava has been reworked - it now supports moving files to the documents folder and the import process should be a bit more interactive. This release also contains various fixes and a new collapse-pattern option to collapse accounts in account trees based on regular expressions (and replaces the use of the rustfava-collapse-account metadata entry).
Other changes:
Command line flags can be specified by setting environment variables.
In this release, the click behaviour has been updated to allow filtering for payees. The entry input forms now allow inputting prices and costs. As always, bugs have been fixed.
The journal design has been updated and should now have a clearer structure. Starting with this version, there will not be any more GUI releases of rustfava. The GUI broke frequently and does not seem to worth the maintenance burden.
Other changes:
When downloading documents, the original filename will be used.
any() and all() functions have been added to the filter syntax to allow filtering entries by properties of their postings.
The entry filters have been reworked in this release and should now support for more flexible filtering of the entries. See the help page on how the new syntax works. Also, when completing the payee in the transaction form, the postings of the last transaction for this payee will be auto-filled.
Other changes:
The rustfava-option to hide the charts has been removed. This is now tracked in the page URL.
This is a release with various small changes and mainly some speed improvements to the Balance Sheet and the net worth calculation. Also, if 'At Value' is selected, the current unrealized gain is shown in parentheses in the Balance Sheet.
Other changes:
The currently filtered entries can now be exported from the Journal page.
Rustfava now has an interface to edit single entries. Clicking on the entry date in the Journal will open an overlay that shows the entry context and allows editing just the lines of that entry.
Other changes:
The source editor now has a menu that gives access to editor commands like \"fold all\".
Entries with matching tags or links can now be excluded with -#tag.
The keyboard shortcuts are now displayed in-place.
The incognito option has been removed and replaced with a --incognito command line switch.
Rustfava now provides an interface for Beancount's import system that allows you to import transactions from your bank for example.
Rustfava can now show your balances at market value or convert them to a single currency if your file contains the necessary price information.
We now also provide a compiled GUI version of rustfava for Linux and macOS. This version might still be a bit buggy so any feedback/help on it is very welcome.
Other changes:
The insert-entry option can be used to control where transactions are inserted.
The transaction form now accepts tags and links in the narration field.
Budgets are now accumulated over all children where appropriate.
The translations of rustfava are on POEditor.com, which has helped us get translations in five more languages: Chinese (simplified), Dutch, French, Portuguese, and Spanish. A big thank you to the new translators!
The transaction form has been improved, it now supports adding metadata and the suggestions will be ranked by how often and recently they occur (using exponential decay).
The Query page supports all commands of the bean-query shell and shares its history of recently used queries.
Rustfava has gained a basic extension mechanism. Extensions allow you to run hooks at various points, e.g., after adding a transaction. They are specified using the extensions option and for an example, see the rustfava.ext.auto_commit extension.
Other changes:
The default sort order in journals has been reversed so that the most recent entries come first.
The new incognito option can be used to obscure all numbers.
You can now add transactions from within rustfava. The form supports autocompletion for most fields.
Rustfava will now show a little bubble in the sidebar for the number of events in the next week. This can be configured with the upcoming-events option.
Other changes:
The payee filter can filter by regular expression.
The tag filter can filter for links, too.
There's a nice spinning indicator during asynchronous page loads.
You can now upload documents by dropping them onto transactions, which will also add the file path as statement metadata to the transaction. rustfava also ships with a plugin to link these transactions with the generated documents. See the help pages for details.
This is the first release for which we provide compiled binaries (for macOS and Linux). These do not have any dependencies and can simply be executed from the terminal.
Other changes:
The bar charts on account pages now also show budgets.
The Journal can now be sorted by date, flag and narration.
This is a major new release that includes too many improvements and changes to list. Some highlights:
The layout has been tweaked and we use some nicer fonts.
Rustfava looks and works much better on smaller screens.
Rustfava loads most pages asynchronously, so navigating rustfava is much faster and responsive.
rustfava's configuration is not read from a configuration file anymore but can rather be specified using custom entries in the Beancount file. Some options have also been removed or renamed, so check rustfava's help page on the available options when upgrading from v0.3.0.
There have been many changes under the hood to improve rustfava's codebase and a lot of bugs have been squashed.
There are now more interval options available for charts and the account balances report. The interval can be selected from a dropdown next to the charts.
Show metadata for postings in the Journal.
The editor now supports org-mode style folding.
Show colored dots for all the postings of a transaction in the Journal report. This way flagged postings can be quickly spotted.
The uptodate-indicator is now shown everywhere by default, but only enabled for accounts that have the metadata rustfava-uptodate-indication: \"True\" set on their open-directives.
Speedier Journal rendering.
Only basenames will be shown for documents in the Journal.
It was not possible to install any of the earlier versions only using pip and you may consult the git log for earlier changes. The first commit in the git repository was on December 4th, 2015.
Use HTTPS - Never expose plain HTTP to the public internet
Add authentication - Use a reverse proxy with OAuth2 or basic auth
Restrict access - Use firewall rules to limit access to trusted IPs
Keep updated - Regularly update rustfava for security patches
See SECURITY.md for more security best practices.
"},{"location":"development/","title":"Development","text":""},{"location":"development/#setting-up-a-development-environment","title":"Setting up a development environment","text":"
If you want to hack on rustfava or run the latest development version, make sure you have recent enough versions of the following installed (ideally with your system package manager):
Python 3.13+ - as rustfava is written in Python
Bun - to build the frontend
just - to run various build / lint / test targets
uv - to install the development environment and run scripts
Then this will get you up and running:
git clone https://github.com/rustledger/rustfava.git\ncd rustfava\n# setup a virtual environment (at .venv) and install rustfava and development\n# dependencies into it:\njust dev\n
You can start rustfava in the virtual environment as usual by running rustfava. Running in debug mode with rustfava --debug is useful for development.
You can run the tests with just test and the linters by running just lint. Run just --list to see all available recipes. After any changes to the Javascript code, you will need to re-build the frontend, which you can do by running just frontend. If you are working on the frontend code, running bun run dev in the frontend folder will watch for file changes and rebuild the Javascript bundle continuously.
Contributions are very welcome, just open a PR on GitHub.
Rustfava is released under the MIT License.
"},{"location":"upstream-sync/","title":"Syncing with Upstream Fava","text":"
rustfava is a fork of Fava that replaces the beancount parser with rustledger. This document describes how to sync relevant changes from upstream.
The frontend is largely unchanged and can usually accept upstream patches.
"},{"location":"upstream-sync/#checking-for-upstream-changes","title":"Checking for Upstream Changes","text":"
# Fetch upstream\ngit fetch upstream\n\n# Find the common ancestor (fork point)\ngit merge-base main upstream/main\n\n# Count commits since fork\ngit rev-list --count $(git merge-base main upstream/main)..upstream/main\n\n# List upstream commits since fork\ngit log --oneline $(git merge-base main upstream/main)..upstream/main\n
Upstream references src/fava/ but rustfava uses src/rustfava/. The cherry-pick usually handles this via directory mapping, but manual fixes may be needed.
If you're new to Beancount-format files or double-entry accounting in general, we recommend Command-line Accounting in Context, a motivational document written by Martin Blais, the creator of the Beancount format.
To learn how to create your ledger file, refer to Getting Started with Beancount guide. There is extensive documentation for the Beancount file format at the Beancount Documentation page.
For more information on rustfava's features, refer to the help pages available through rustfava's web interface. rustfava comes with Gmail-style keyboard shortcuts; press ? to show an overview.
Upstream references src/fava/ but rustfava uses src/rustfava/. The cherry-pick usually handles this via directory mapping, but manual fixes may be needed.
rustfava uses rustledger, a
Rust-based parser compiled to WebAssembly, to parse your Beancount files. No
separate Beancount installation is required.
-
If you want to export query results to Microsoft Excel or LibreOffice Calc, use
-the following command to install the optional dependencies for this feature:
-
uvpipinstall--upgraderustfava[excel]
+
To export query results to Microsoft Excel or LibreOffice Calc:
pointing it to your Beancount file -- and visit the web interface at
-http://localhost:5000.
-
There are some command-line options available, run rustfava --help for an
-overview.
-
For more information on rustfava's features, refer to the help pages that are
-available through rustfava's web-interface. Rustfava comes with Gmail-style
-keyboard shortcuts; press ? to show an overview.
For more information on rustfava's features, refer to the help pages available
+through rustfava's web interface. rustfava comes with Gmail-style keyboard
+shortcuts; press ? to show an overview.