Add ledger management service and API integration
- Introduced `LedgerService` to handle ledger file operations, queries, and snapshots. - Added API routes for journal entry retrieval, account balances, and custom queries using FastAPI. - Updated project dependencies to include `ruff` for linting, along with its configuration. - Integrated `lifespan` for managing the lifecycle of `LedgerService`.
This commit is contained in:
0
services/__init__.py
Normal file
0
services/__init__.py
Normal file
333
services/ledger.py
Normal file
333
services/ledger.py
Normal file
@@ -0,0 +1,333 @@
|
||||
import asyncio
|
||||
import datetime
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
from typing import Any, Iterable
|
||||
|
||||
from beancount import loader
|
||||
from beancount.core import amount, convert, data, inventory
|
||||
from beancount.ops import summarize
|
||||
from beanquery import query as bql
|
||||
|
||||
|
||||
def _amount_to_dict(amt: amount.Amount) -> dict[str, Any]:
|
||||
return {"number": str(amt.number), "currency": amt.currency}
|
||||
|
||||
|
||||
def _inventory_to_list(inv: inventory.Inventory) -> list[dict[str, str]]:
|
||||
units_inv = inv.reduce(convert.get_units)
|
||||
return [
|
||||
_amount_to_dict(position.units)
|
||||
for position in sorted(units_inv, key=lambda p: p.units.currency)
|
||||
]
|
||||
|
||||
|
||||
def _date_to_str(value: datetime.date | None = None) -> str | None:
|
||||
return value.isoformat() if value else None
|
||||
|
||||
|
||||
def _entry_base(entry: data.Directive) -> dict[str, Any]:
|
||||
return {
|
||||
"type": entry.__class__.__name__,
|
||||
"date": _date_to_str(entry.date),
|
||||
"meta": dict(entry.meta or {}),
|
||||
}
|
||||
|
||||
|
||||
def _posting_to_dict(posting: data.Posting) -> dict[str, Any]:
|
||||
cost = None
|
||||
|
||||
if posting.cost:
|
||||
cost = {
|
||||
"number": str(posting.cost.number),
|
||||
"currency": posting.cost.currency,
|
||||
"date": _date_to_str(posting.cost.date),
|
||||
"label": posting.cost.label,
|
||||
}
|
||||
|
||||
return {
|
||||
"account": posting.account,
|
||||
"units": _amount_to_dict(posting.units) if posting.units else None,
|
||||
"cost": cost,
|
||||
"price": _amount_to_dict(posting.price) if posting.price else None,
|
||||
"flag": posting.flag,
|
||||
"meta": dict(posting.meta or {}),
|
||||
}
|
||||
|
||||
|
||||
def entry_to_dict(entry: data.Directive) -> dict[str, Any]:
|
||||
"""
|
||||
Converts a financial entry object to its dictionary representation. This function
|
||||
handles various types of entries (e.g., Transaction, Balance, Open, Close, Note,
|
||||
Document, Price, and Event) by extracting relevant attributes and formatting them
|
||||
appropriately. The dictionary returned serves as a structured representation of the
|
||||
entry.
|
||||
|
||||
:param entry: The financial entry object to be converted. It can be an instance of
|
||||
the following types:
|
||||
- data.Transaction: Represents a financial transaction with attributes like flag,
|
||||
payee, narration, tags, links, and postings.
|
||||
- data.Balance: Represents a balance directive with attributes like account and
|
||||
amount.
|
||||
- data.Open: Represents an account opening directive with attributes such as
|
||||
account, currencies, and booking.
|
||||
- data.Close: Represents an account closing directive with attributes like account.
|
||||
- data.Note: Represents an account note with attributes like account and comment.
|
||||
- data.Document: Represents a document associated with an account, including
|
||||
account and filename attributes.
|
||||
- data.Price: Represents a price directive with attributes such as currency and
|
||||
amount.
|
||||
- data.Event: Represents an event directive with attributes like type and
|
||||
description.
|
||||
|
||||
:type entry: data.Directive
|
||||
|
||||
:return: A dictionary containing the extracted and formatted attributes of the input
|
||||
entry, structured according to its type.
|
||||
:rtype: dict[str, Any]
|
||||
"""
|
||||
base = _entry_base(entry)
|
||||
|
||||
if isinstance(entry, data.Transaction):
|
||||
base.update(
|
||||
{
|
||||
"flag": entry.flag,
|
||||
"payee": entry.payee,
|
||||
"narration": entry.narration,
|
||||
"tags": entry.tags,
|
||||
"links": entry.links,
|
||||
"postings": [_posting_to_dict(posting) for posting in entry.postings],
|
||||
}
|
||||
)
|
||||
|
||||
elif isinstance(entry, data.Balance):
|
||||
base.update({"account": entry.account, "amount": _amount_to_dict(entry.amount)})
|
||||
|
||||
elif isinstance(entry, data.Open):
|
||||
base.update(
|
||||
{
|
||||
"account": entry.account,
|
||||
"currencies": sorted(entry.currencies or []),
|
||||
"booking": str(entry.booking) if entry.booking else None,
|
||||
}
|
||||
)
|
||||
|
||||
elif isinstance(entry, data.Close):
|
||||
base.update({"account": entry.account})
|
||||
|
||||
elif isinstance(entry, data.Note):
|
||||
base.update({"account": entry.account, "comment": entry.comment})
|
||||
|
||||
elif isinstance(entry, data.Document):
|
||||
base.update({"account": entry.account, "filename": entry.filename})
|
||||
|
||||
elif isinstance(entry, data.Price):
|
||||
base.update(
|
||||
{"currency": entry.currency, "amount": _amount_to_dict(entry.amount)}
|
||||
)
|
||||
|
||||
elif isinstance(entry, data.Event):
|
||||
base.update({"type": entry.type, "description": entry.description})
|
||||
|
||||
return base
|
||||
|
||||
|
||||
def query_to_dict(
|
||||
types: Iterable[Any], rows: Iterable[Iterable[Any]]
|
||||
) -> dict[str, Any]:
|
||||
"""
|
||||
Converts the given types and rows into a dictionary with column names and their corresponding
|
||||
row data. This function takes types to determine the column names and rows to populate the
|
||||
data for each column. Conversion is applied for specific data types if identified.
|
||||
|
||||
:param types: Iterable of column type descriptors. Each descriptor could be a tuple where
|
||||
the first element represents the column name or a string representing the column name directly.
|
||||
:param rows: Iterable of rows, where each row is itself an iterable of values. Each row corresponds
|
||||
to the data for one record, with each value mapped to a corresponding column name.
|
||||
:return: A dictionary with two keys: "columns" and "rows".
|
||||
- The "columns" key maps to an iterable of column names as strings.
|
||||
- The "rows" key maps to a list of dictionaries, where each dictionary represents a row of values,
|
||||
with keys corresponding to column names.
|
||||
:rtype: dict[str, Any]
|
||||
"""
|
||||
column_names = []
|
||||
|
||||
for entry in types:
|
||||
if isinstance(entry, tuple) and entry:
|
||||
column_names.append(entry[0])
|
||||
else:
|
||||
column_names.append(str(entry))
|
||||
|
||||
result_rows = []
|
||||
for row in rows:
|
||||
row_dict = {}
|
||||
for name, value in zip(column_names, row):
|
||||
if isinstance(value, amount.Amount):
|
||||
row_dict[name] = _amount_to_dict(value)
|
||||
elif isinstance(value, inventory.Inventory):
|
||||
row_dict[name] = _inventory_to_list(value)
|
||||
elif isinstance(value, datetime.date):
|
||||
row_dict[name] = value.isoformat()
|
||||
else:
|
||||
row_dict[name] = value
|
||||
|
||||
result_rows.append(row_dict)
|
||||
|
||||
return {"columns": column_names, "rows": result_rows}
|
||||
|
||||
|
||||
@dataclass
|
||||
class LedgerSnapshot:
|
||||
entries: list[data.Directive]
|
||||
options_map: dict[str, Any]
|
||||
errors: list[data.BeancountError]
|
||||
last_loaded_at: datetime.datetime
|
||||
mtime: float
|
||||
|
||||
|
||||
class LedgerService:
|
||||
def __init__(self, ledger_path: Path, reload_interval: float = 2.0) -> None:
|
||||
self.ledger_path = ledger_path
|
||||
self.reload_interval = reload_interval
|
||||
self._lock = asyncio.Lock()
|
||||
self._snapshot: LedgerSnapshot | None = None
|
||||
self._stop_event = asyncio.Event()
|
||||
|
||||
async def start(self) -> None:
|
||||
await self.refresh(force=True)
|
||||
asyncio.create_task(self._refresh_loop())
|
||||
|
||||
async def stop(self) -> None:
|
||||
self._stop_event.set()
|
||||
|
||||
async def _refresh_loop(self) -> None:
|
||||
while not self._stop_event.is_set():
|
||||
await self.refresh(force=False)
|
||||
|
||||
try:
|
||||
await asyncio.wait_for(
|
||||
self._stop_event.wait(), timeout=self.reload_interval
|
||||
)
|
||||
except asyncio.TimeoutError:
|
||||
continue
|
||||
|
||||
async def refresh(self, force: bool = False) -> None:
|
||||
"""
|
||||
Asynchronously refreshes the ledger data by checking for updates on the file's last
|
||||
modified time and reloading the file data if necessary. A force-refresh option is
|
||||
available to reload the data irrespective of modifications to the file.
|
||||
|
||||
:param force: A flag indicating whether to force a refresh, even if no updates are
|
||||
present in the file. Defaults to False.
|
||||
:return: None
|
||||
"""
|
||||
async with self._lock:
|
||||
mtime = self._get_mtime()
|
||||
if not force and self._snapshot and mtime == self._snapshot.mtime:
|
||||
return
|
||||
|
||||
entries, errors, options_map = await asyncio.to_thread(
|
||||
loader.load_file, str(self.ledger_path)
|
||||
)
|
||||
self._snapshot = LedgerSnapshot(
|
||||
entries,
|
||||
options_map,
|
||||
errors,
|
||||
last_loaded_at=datetime.datetime.now(datetime.UTC),
|
||||
mtime=mtime,
|
||||
)
|
||||
|
||||
async def snapshot(self) -> LedgerSnapshot:
|
||||
"""
|
||||
Captures the current state of the ledger as a snapshot. The snapshot
|
||||
is created after refreshing the ledger to ensure the data is up-to-date.
|
||||
If the snapshot is not available or has not been loaded,
|
||||
an exception will be raised.
|
||||
|
||||
:raises RuntimeError: If the ledger snapshot is not loaded yet.
|
||||
:return: A ledger snapshot representing the current state.
|
||||
:rtype: LedgerSnapshot
|
||||
"""
|
||||
await self.refresh()
|
||||
|
||||
if not self._snapshot:
|
||||
raise RuntimeError("Ledger snapshot not loaded yet")
|
||||
return self._snapshot
|
||||
|
||||
async def run_query(self, query: str) -> dict[str, Any]:
|
||||
"""
|
||||
Executes a Beancount Query Language (BQL) query asynchronously and returns the results in a
|
||||
dictionary format.
|
||||
|
||||
:param query: The BQL query to be executed.
|
||||
:type query: str
|
||||
:return: A dictionary containing the query's results, with column names mapped to
|
||||
their respective values.
|
||||
:rtype: dict[str, Any]
|
||||
"""
|
||||
snapshot = await self.snapshot()
|
||||
types, rows = await asyncio.to_thread(
|
||||
bql.run_query, snapshot.entries, snapshot.options_map, query
|
||||
)
|
||||
return query_to_dict(types, rows)
|
||||
|
||||
async def account_balances(
|
||||
self, on_date: datetime.date | None = None
|
||||
) -> list[dict[str, Any]]:
|
||||
"""
|
||||
Get the account balances as of a specific date or the latest snapshot if no date is provided.
|
||||
|
||||
This method retrieves a summary of account balances, either on a specified date or
|
||||
based on the latest available snapshot. The data is organized and returned in the form
|
||||
of a list of dictionaries, each representing an account and its corresponding balance.
|
||||
|
||||
:param on_date: The date for which to compute account balances. If None, the latest
|
||||
snapshot is used for computation of balances.
|
||||
:type on_date: datetime.date or None
|
||||
:return: A list of dictionaries, where each dictionary contains an account name and its
|
||||
associated balance information.
|
||||
:rtype: list[dict[str, Any]]
|
||||
"""
|
||||
snapshot = await self.snapshot()
|
||||
balances, _ = summarize.balance_by_account(snapshot.entries, on_date)
|
||||
results = []
|
||||
|
||||
for account_name in sorted(balances.keys()):
|
||||
results.append(
|
||||
{
|
||||
"account": account_name,
|
||||
"balance": _inventory_to_list(balances[account_name]),
|
||||
}
|
||||
)
|
||||
|
||||
return results
|
||||
|
||||
async def journal_entries(self, limit: int | None = None) -> list[dict[str, Any]]:
|
||||
"""
|
||||
Fetches a list of journal entries in dictionary format, with an optional limit on the number
|
||||
of recent transactions to return. This function retrieves all journal entries from the
|
||||
snapshot, filters transactions specifically of type `Transaction`, and then converts each
|
||||
entry into a dictionary representation.
|
||||
|
||||
:param limit: An optional integer specifying the maximum number of recent transactions to
|
||||
include in the returned list. If not provided, all transactions will be returned.
|
||||
:return: A list of dictionaries where each dictionary represents a journal entry of type
|
||||
`Transaction`.
|
||||
:rtype: list[dict[str, Any]]
|
||||
"""
|
||||
snapshot = await self.snapshot()
|
||||
transactions = [
|
||||
entry for entry in snapshot.entries if isinstance(entry, data.Transaction)
|
||||
]
|
||||
|
||||
if limit:
|
||||
transactions = transactions[-limit:]
|
||||
|
||||
return [entry_to_dict(entry) for entry in transactions]
|
||||
|
||||
def _get_mtime(self) -> float:
|
||||
if not self.ledger_path.exists():
|
||||
self.ledger_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
self.ledger_path.touch()
|
||||
|
||||
return self.ledger_path.stat().st_mtime
|
||||
Reference in New Issue
Block a user