from __future__ import annotations import gzip from dataclasses import dataclass from datetime import date, datetime from decimal import Decimal, InvalidOperation from pathlib import Path from xml.etree import ElementTree as ET NS = { "gnc": "http://www.gnucash.org/XML/gnc", "act": "http://www.gnucash.org/XML/act", "trn": "http://www.gnucash.org/XML/trn", "split": "http://www.gnucash.org/XML/split", "ts": "http://www.gnucash.org/XML/ts", } CENT = Decimal("0.01") class GnuCashImportError(RuntimeError): pass @dataclass(frozen=True, slots=True) class GnuCashAccount: guid: str name: str full_name: str account_type: str @dataclass(frozen=True, slots=True) class GnuCashTransaction: guid: str date: date description: str memo: str amount: Decimal def _read_root(path: Path) -> ET.Element: try: raw = path.read_bytes() except OSError as exc: raise GnuCashImportError(f"GnuCash-Datei konnte nicht gelesen werden: {exc}") from exc if raw[:2] == b"\x1f\x8b": try: raw = gzip.decompress(raw) except OSError as exc: raise GnuCashImportError(f"GnuCash-Datei konnte nicht entpackt werden: {exc}") from exc try: return ET.fromstring(raw) except ET.ParseError as exc: raise GnuCashImportError(f"GnuCash-Datei ist kein gültiges XML: {exc}") from exc def _book(root: ET.Element) -> ET.Element: book = root.find("gnc:book", NS) if book is None: raise GnuCashImportError("Keine GnuCash-Buchführung (gnc:book) in der Datei gefunden.") return book def _text(element: ET.Element, path: str) -> str: return (element.findtext(path, default="", namespaces=NS) or "").strip() def list_accounts(path: str | Path) -> list[GnuCashAccount]: root = _read_root(Path(path)) book = _book(root) by_guid: dict[str, ET.Element] = {} parent_of: dict[str, str] = {} for account_el in book.findall("gnc:account", NS): guid = _text(account_el, "act:id") if not guid: continue by_guid[guid] = account_el parent_of[guid] = _text(account_el, "act:parent") if not by_guid: raise GnuCashImportError("Die Datei enthält keine Konten.") def full_name(guid: str, seen: frozenset[str]) -> str: account_el = by_guid.get(guid) if account_el is None or guid in seen or _text(account_el, "act:type") == "ROOT": return "" name = _text(account_el, "act:name") parent = parent_of.get(guid, "") if not parent: return name parent_name = full_name(parent, seen | {guid}) return f"{parent_name}:{name}" if parent_name else name accounts = [] for guid, account_el in by_guid.items(): account_type = _text(account_el, "act:type") if account_type in {"ROOT", ""}: continue accounts.append( GnuCashAccount( guid=guid, name=_text(account_el, "act:name"), full_name=full_name(guid, frozenset()) or _text(account_el, "act:name"), account_type=account_type, ) ) accounts.sort(key=lambda item: item.full_name.casefold()) return accounts def _parse_fraction(text: str) -> Decimal | None: text = text.strip() if "/" not in text: return None numerator, _, denominator = text.partition("/") try: denom = Decimal(denominator) if denom == 0: return None return (Decimal(numerator) / denom).quantize(CENT) except InvalidOperation: return None def _parse_posted_date(text: str) -> date | None: text = text.strip() if not text: return None for fmt in ("%Y-%m-%d %H:%M:%S %z", "%Y-%m-%d"): try: return datetime.strptime(text, fmt).date() except ValueError: continue return None def list_transactions(path: str | Path, account_guid: str) -> list[GnuCashTransaction]: """Return every booking that has a split on the given account, one row per matching split (a transaction with two splits on the same account, e.g. a transfer to itself, yields two rows -- rare in practice, harmless here).""" root = _read_root(Path(path)) book = _book(root) results: list[GnuCashTransaction] = [] for transaction_el in book.findall("gnc:transaction", NS): guid = _text(transaction_el, "trn:id") posted_date = _parse_posted_date(_text(transaction_el, "trn:date-posted/ts:date")) if not guid or posted_date is None: continue description = _text(transaction_el, "trn:description") splits_el = transaction_el.find("trn:splits", NS) if splits_el is None: continue for split_el in splits_el.findall("trn:split", NS): if _text(split_el, "split:account") != account_guid: continue amount = _parse_fraction(_text(split_el, "split:value")) if amount is None: continue results.append( GnuCashTransaction( guid=guid, date=posted_date, description=description, memo=_text(split_el, "split:memo"), amount=amount, ) ) results.sort(key=lambda item: (item.date, item.description.casefold())) return results