#!/usr/bin/env python3 """ GnuCash Accounts CSV → Book Builder Build a new GnuCash XML book (*.gnucash) from a “chart of accounts” (account tree) CSV. Outputs - A new book containing your full account hierarchy (a ROOT account is created automatically). - All commodities/securities referenced by the CSV (currencies, crypto-assets, precious metals, etc.). - Optional account attributes stored as slots: Placeholder, Hidden, Tax related, Notes, and Colour. Deterministic by default - Account GUIDs are stable: UUIDv5 derived from each account’s full path, so rebuilding produces the same IDs and git-friendly diffs. - Use --random-guids to generate new GUIDs on each run. Usage build --out [options] verify [options] CSV format (header must match exactly) Type, Full Account Name, Account Name, Account Code, Description, Account Colour, Notes, Symbol, Namespace, Hidden, Tax Info, Placeholder Commodity rules enforced - ISO 4217 currencies (GBP, USD, EUR, …) must use Namespace=CURRENCY. - ISO precious metal codes are treated as securities (not currencies) to avoid currency-style behaviour: XAG/XAU/XPT/XPD → METALS:SILVER/GOLD/PLATINUM/PALLADIUM - Defaults are provided for common crypto-assets and metals (names and display fractions). Override any non-currency fraction with: --commodity-fraction NAMESPACE:SYMBOL=FRACTION GnuCash compatibility notes - The internal template commodity (template:template) is created by default; disable with --no-template-commodity. - Hidden and Tax Info are stored via account slots and round-tripped so strict verify can compare results. Getting started - Begin with a small CSV: your top-level accounts plus at least one CURRENCY row (e.g. GBP/CURRENCY), then run: python gnucash_accounts_csv_book_builder.py build accounts.csv --out new_book.gnucash """ from __future__ import annotations import argparse import sys import uuid from dataclasses import dataclass from pathlib import Path from typing import Any import pandas as pd import pycountry import xml.etree.ElementTree as ET EXPECTED_COLUMNS: list[str] = [ "Type", "Full Account Name", "Account Name", "Account Code", "Description", "Account Colour", "Notes", "Symbol", "Namespace", "Hidden", "Tax Info", "Placeholder", ] GN_ACCOUNT_TYPES: set[str] = { "ASSET", "BANK", "CASH", "STOCK", "MUTUAL", "LIABILITY", "CREDIT", "EQUITY", "INCOME", "EXPENSE", "RECEIVABLE", "PAYABLE", "TRADING", } # Defaults for common securities (crypto + precious metals) precisions DEFAULT_SECURITY_FRACTIONS: dict[str, int] = { # Crypto "BTC": 100_000_000, "ETH": 100_000_000, "BNB": 100_000_000, "ADA": 1_000_000, "USDT": 1_000_000, "USDC": 1_000_000, # Precious metals (treat as normal securities, units = ounces) "SILVER": 1_000, "GOLD": 1_000, "PLATINUM": 1_000, "PALLADIUM": 1_000, } DEFAULT_SECURITY_NAMES: dict[str, str] = { "BTC": "Bitcoin", "ETH": "Ethereum", "BNB": "BNB", "ADA": "Cardano", "USDT": "Tether", "USDC": "USD Coin", "SILVER": "Silver", "GOLD": "Gold", "PLATINUM": "Platinum", "PALLADIUM": "Palladium", } # Precious metals: avoid treating ISO metal codes (XAG/XAU/XPT/XPD) as CURRENCY, # because GnuCash handles CURRENCY commodities as currencies/exchange rates, which # makes bullion "buy 1 oz for £44" style entries awkward. We normalise these to # METALS: securities instead. PRECIOUS_METALS_NAMESPACE = "METALS" PRECIOUS_METAL_ISO_TO_SYMBOL: dict[str, str] = { "XAG": "SILVER", "XAU": "GOLD", "XPT": "PLATINUM", "XPD": "PALLADIUM", } def die(msg: str, *, code: int = 2) -> "NoReturn": print(f"ERROR: {msg}", file=sys.stderr) raise SystemExit(code) def load_iso_currency_codes() -> set[str]: codes: set[str] = set() for c in pycountry.currencies: a3 = getattr(c, "alpha_3", None) if a3: codes.add(str(a3).upper()) return codes ISO_CURRENCY_CODES = load_iso_currency_codes() def _norm_bool_tf(val: Any) -> str: s = str(val).strip().upper() if s in {"T", "TRUE", "1", "YES", "Y"}: return "T" if s in {"F", "FALSE", "0", "NO", "N", ""}: return "F" die(f"Invalid boolean flag value: {val!r} (expected T/F)") def normalize_account_type(t: str) -> str: t = str(t).strip().upper() return { "CREDITCARD": "CREDIT", "CREDIT_CARD": "CREDIT", "A/RECEIVABLE": "RECEIVABLE", "A/RECEIVABLES": "RECEIVABLE", "RECEIVABLES": "RECEIVABLE", "A/PAYABLE": "PAYABLE", "A/PAYABLES": "PAYABLE", "PAYABLES": "PAYABLE", }.get(t, t) @dataclass(frozen=True) class CommodityKey: namespace: str symbol: str def __post_init__(self) -> None: ns = str(self.namespace).strip() sym = str(self.symbol).strip() # v3: preserve template commodity EXACTLY as lowercase if ns.lower() == "template" and sym.lower() == "template": ns_out, sym_out = "template", "template" else: ns_out, sym_out = ns.upper(), sym.upper() object.__setattr__(self, "namespace", ns_out) object.__setattr__(self, "symbol", sym_out) def read_accounts_csv(path: Path) -> pd.DataFrame: df = pd.read_csv(path, dtype=str, keep_default_na=False).fillna("") missing = [c for c in EXPECTED_COLUMNS if c not in df.columns] if missing: die(f"CSV is missing required columns: {missing}") df = df[EXPECTED_COLUMNS].copy() df["Type"] = df["Type"].map(normalize_account_type) for col in [ "Full Account Name", "Account Name", "Account Code", "Description", "Account Colour", "Notes", "Symbol", "Namespace", ]: df[col] = df[col].astype(str).str.strip() df["Hidden"] = df["Hidden"].map(_norm_bool_tf) df["Tax Info"] = df["Tax Info"].map(_norm_bool_tf) df["Placeholder"] = df["Placeholder"].map(_norm_bool_tf) # Normalise user-specified commodities (template commodity is internal, not from CSV) df["Namespace"] = df["Namespace"].str.upper() df["Symbol"] = df["Symbol"].str.upper() # Precious metals: normalise XAG/XAU/XPT/XPD (and any accidental CURRENCY:SILVER etc) # to METALS: securities. pm_iso = df["Symbol"].isin(PRECIOUS_METAL_ISO_TO_SYMBOL.keys()) if bool(pm_iso.any()): df.loc[pm_iso, "Symbol"] = df.loc[pm_iso, "Symbol"].map(PRECIOUS_METAL_ISO_TO_SYMBOL) df.loc[pm_iso, "Namespace"] = PRECIOUS_METALS_NAMESPACE pm_named = df["Namespace"].eq("CURRENCY") & df["Symbol"].isin(PRECIOUS_METAL_ISO_TO_SYMBOL.values()) if bool(pm_named.any()): df.loc[pm_named, "Namespace"] = PRECIOUS_METALS_NAMESPACE return df def sanity_check(df: pd.DataFrame) -> CommodityKey: if df["Full Account Name"].duplicated().any(): dups = df[df["Full Account Name"].duplicated()]["Full Account Name"].tolist()[:20] die(f"Duplicate Full Account Name values found (first 20): {dups}") bad = df[df.apply(lambda r: str(r["Full Account Name"]).split(":")[-1] != str(r["Account Name"]), axis=1)] if not bad.empty: sample = bad[["Full Account Name", "Account Name"]].head(10).to_dict(orient="records") die(f"Account Name must match the last component of Full Account Name. Examples: {sample}") bad_types = sorted(set(df["Type"]) - GN_ACCOUNT_TYPES) if bad_types: die(f"Unsupported account Type(s): {bad_types}. Allowed: {sorted(GN_ACCOUNT_TYPES)}") full_set = set(df["Full Account Name"]) parent_map: dict[str, str | None] = {} for full in df["Full Account Name"]: parts = full.split(":") parent = ":".join(parts[:-1]) if len(parts) > 1 else None parent_map[full] = parent if parent is not None and parent not in full_set: die(f"Missing parent account '{parent}' needed for '{full}'") siblings: dict[str | None, set[str]] = {} for full, name in zip(df["Full Account Name"], df["Account Name"], strict=True): parent = parent_map[full] siblings.setdefault(parent, set()) if name in siblings[parent]: die(f"Duplicate sibling account name '{name}' under parent '{parent or ''}'") siblings[parent].add(name) commodities = {CommodityKey(ns, sym) for ns, sym in zip(df["Namespace"], df["Symbol"], strict=True)} non_iso_in_currency = sorted([c.symbol for c in commodities if c.namespace == "CURRENCY" and c.symbol not in ISO_CURRENCY_CODES]) if non_iso_in_currency: die(f"Namespace=CURRENCY used with non-ISO currency code(s): {non_iso_in_currency[:50]}") iso_wrong_ns = sorted([f"{c.namespace}:{c.symbol}" for c in commodities if c.symbol in ISO_CURRENCY_CODES and c.namespace != "CURRENCY"]) if iso_wrong_ns: die("ISO currency codes must use Namespace=CURRENCY. Conflicts found (namespace:symbol): " f"{iso_wrong_ns[:50]}") by_symbol: dict[str, set[str]] = {} for c in commodities: by_symbol.setdefault(c.symbol, set()).add(c.namespace) clashes = sorted([sym for sym, nss in by_symbol.items() if sym in ISO_CURRENCY_CODES and len(nss) > 1]) if clashes: die(f"Currency/stock namespace conflict for ISO currency code(s): {clashes[:50]}") cur_rows = df[df["Namespace"] == "CURRENCY"] if cur_rows.empty: die("No CURRENCY commodities found in CSV. At least one currency (e.g. GBP/CURRENCY) is required.") home_symbol = cur_rows["Symbol"].mode().iat[0] return CommodityKey("CURRENCY", home_symbol) def qn(uri: str, local: str) -> str: return f"{{{uri}}}{local}" NS = { "gnc": "http://www.gnucash.org/XML/gnc", "act": "http://www.gnucash.org/XML/act", "book": "http://www.gnucash.org/XML/book", "cd": "http://www.gnucash.org/XML/cd", "cmdty": "http://www.gnucash.org/XML/cmdty", "slot": "http://www.gnucash.org/XML/slot", } def _indent(elem: ET.Element, level: int = 0) -> None: i = "\n" + level * " " if len(elem): if not elem.text or not elem.text.strip(): elem.text = i + " " for child in elem: _indent(child, level + 1) if not elem[-1].tail or not elem[-1].tail.strip(): elem[-1].tail = i if level and (not elem.tail or not elem.tail.strip()): elem.tail = i def make_book_slots(parent: ET.Element) -> None: slots_el = ET.SubElement(parent, qn(NS["book"], "slots")) def slot_frame(key: str, inner: list[tuple[str, str, str]]) -> None: s = ET.SubElement(slots_el, "slot") ET.SubElement(s, qn(NS["slot"], "key")).text = key v = ET.SubElement(s, qn(NS["slot"], "value")) v.set("type", "frame") for ikey, itype, itext in inner: inner_s = ET.SubElement(v, "slot") ET.SubElement(inner_s, qn(NS["slot"], "key")).text = ikey iv = ET.SubElement(inner_s, qn(NS["slot"], "value")) iv.set("type", itype) iv.text = itext def slot_string(key: str, value: str) -> None: s = ET.SubElement(slots_el, "slot") ET.SubElement(s, qn(NS["slot"], "key")).text = key v = ET.SubElement(s, qn(NS["slot"], "value")) v.set("type", "string") v.text = value counters_keys = ["gncBill", "gncCustomer", "gncEmployee", "gncExpVoucher", "gncInvoice", "gncJob", "gncOrder", "gncVendor"] slot_frame("counter_formats", [(k, "string", "") for k in counters_keys]) slot_frame("counters", [(k, "integer", "0") for k in counters_keys]) slot_string("remove-trading-splits", "true") slot_string("trading-accts", "false") def build_gnucash_xml( df: pd.DataFrame, out_path: Path, *, book_currency: CommodityKey, default_currency_scu: int = 100, default_security_fraction: int = 100_000_000, commodity_fractions: dict[CommodityKey, int] | None = None, deterministic_guids: bool = True, include_template_commodity: bool = True, ) -> None: for prefix, uri in NS.items(): ET.register_namespace(prefix, uri) root = ET.Element("gnc-v2") count_book = ET.SubElement(root, qn(NS["gnc"], "count-data")) count_book.set(qn(NS["cd"], "type"), "book") count_book.text = "1" book = ET.SubElement(root, qn(NS["gnc"], "book")) book.set("version", "2.0.0") bid = ET.SubElement(book, qn(NS["book"], "id")) bid.set("type", "guid") bid.text = uuid.uuid4().hex make_book_slots(book) commodity_fractions = commodity_fractions or {} def commodity_fraction(key: CommodityKey) -> int | None: if key.namespace == "template" and key.symbol == "template": return 1 if key.namespace == "CURRENCY": return None if key in commodity_fractions: return commodity_fractions[key] if key.symbol in DEFAULT_SECURITY_FRACTIONS: return DEFAULT_SECURITY_FRACTIONS[key.symbol] return int(default_security_fraction) def account_scu(key: CommodityKey) -> int: if key.namespace == "CURRENCY": return int(default_currency_scu) return int(commodity_fraction(key) or default_security_fraction) needed = {CommodityKey(ns, sym) for ns, sym in zip(df["Namespace"], df["Symbol"], strict=True)} if include_template_commodity: needed.add(CommodityKey("template", "template")) count_commodity = ET.SubElement(book, qn(NS["gnc"], "count-data")) count_commodity.set(qn(NS["cd"], "type"), "commodity") count_commodity.text = str(len(needed)) count_account = ET.SubElement(book, qn(NS["gnc"], "count-data")) count_account.set(qn(NS["cd"], "type"), "account") count_account.text = str(len(df) + 1) def emit_commodity(key: CommodityKey) -> None: c = ET.SubElement(book, qn(NS["gnc"], "commodity")) c.set("version", "2.0.0") ET.SubElement(c, qn(NS["cmdty"], "space")).text = key.namespace ET.SubElement(c, qn(NS["cmdty"], "id")).text = key.symbol frac = commodity_fraction(key) if key.namespace == "template" and key.symbol == "template": ET.SubElement(c, qn(NS["cmdty"], "name")).text = "template" ET.SubElement(c, qn(NS["cmdty"], "fraction")).text = "1" return if key.namespace != "CURRENCY": ET.SubElement(c, qn(NS["cmdty"], "name")).text = DEFAULT_SECURITY_NAMES.get(key.symbol, key.symbol) if frac is not None: ET.SubElement(c, qn(NS["cmdty"], "fraction")).text = str(frac) for key in sorted([k for k in needed if k.namespace == "CURRENCY"], key=lambda k: k.symbol): emit_commodity(key) for key in sorted([k for k in needed if k.namespace not in {"CURRENCY", "template"}], key=lambda k: (k.namespace, k.symbol)): emit_commodity(key) if include_template_commodity: emit_commodity(CommodityKey("template", "template")) def guid_for(seed: str) -> str: if deterministic_guids: return uuid.uuid5(uuid.NAMESPACE_URL, seed).hex return uuid.uuid4().hex root_guid = guid_for("gnucash-account:__ROOT__") root_acc = ET.SubElement(book, qn(NS["gnc"], "account")) root_acc.set("version", "2.0.0") ET.SubElement(root_acc, qn(NS["act"], "name")).text = "Root Account" rid = ET.SubElement(root_acc, qn(NS["act"], "id")) rid.set("type", "guid") rid.text = root_guid ET.SubElement(root_acc, qn(NS["act"], "type")).text = "ROOT" root_cmd = ET.SubElement(root_acc, qn(NS["act"], "commodity")) ET.SubElement(root_cmd, qn(NS["cmdty"], "space")).text = book_currency.namespace ET.SubElement(root_cmd, qn(NS["cmdty"], "id")).text = book_currency.symbol ET.SubElement(root_acc, qn(NS["act"], "commodity-scu")).text = str(account_scu(book_currency)) df2 = df.copy() df2["depth"] = df2["Full Account Name"].map(lambda s: str(s).count(":")) df2 = df2.sort_values(["depth", "Full Account Name"]).reset_index(drop=True) guid_map: dict[str, str] = { row["Full Account Name"]: guid_for(f"gnucash-account:{row['Full Account Name']}") for _, row in df2.iterrows() } def emit_account(row: pd.Series) -> None: full = str(row["Full Account Name"]) name = str(row["Account Name"]) typ = str(row["Type"]) desc = str(row["Description"]).strip() code = str(row["Account Code"]).strip() notes = str(row["Notes"]).strip() color = str(row["Account Colour"]).strip() placeholder_tf = str(row["Placeholder"]).strip().upper() hidden_tf = str(row["Hidden"]).strip().upper() tax_info_tf = str(row["Tax Info"]).strip().upper() ckey = CommodityKey(str(row["Namespace"]), str(row["Symbol"])) a = ET.SubElement(book, qn(NS["gnc"], "account")) a.set("version", "2.0.0") ET.SubElement(a, qn(NS["act"], "name")).text = name aid = ET.SubElement(a, qn(NS["act"], "id")) aid.set("type", "guid") aid.text = guid_map[full] ET.SubElement(a, qn(NS["act"], "type")).text = typ acmd = ET.SubElement(a, qn(NS["act"], "commodity")) ET.SubElement(acmd, qn(NS["cmdty"], "space")).text = ckey.namespace ET.SubElement(acmd, qn(NS["cmdty"], "id")).text = ckey.symbol ET.SubElement(a, qn(NS["act"], "commodity-scu")).text = str(account_scu(ckey)) if code: ET.SubElement(a, qn(NS["act"], "code")).text = code if desc: ET.SubElement(a, qn(NS["act"], "description")).text = desc slots_to_write: list[tuple[str, str, str | None]] = [] if placeholder_tf == "T": slots_to_write.append(("placeholder", "string", "true")) if hidden_tf == "T": slots_to_write.append(("hidden", "string", "true")) if tax_info_tf == "T": slots_to_write.append(("tax-related", "string", "true")) if notes: slots_to_write.append(("notes", "string", notes)) if color: slots_to_write.append(("color", "string", color)) if typ in {"STOCK", "MUTUAL"}: slots_to_write.append(("balance-limit", "frame", None)) if slots_to_write: slots_el = ET.SubElement(a, qn(NS["act"], "slots")) for key, vtype, vtext in slots_to_write: s = ET.SubElement(slots_el, "slot") ET.SubElement(s, qn(NS["slot"], "key")).text = key v = ET.SubElement(s, qn(NS["slot"], "value")) v.set("type", vtype) if vtype != "frame": v.text = vtext or "" parent_full = ":".join(full.split(":")[:-1]) if ":" in full else None parent_guid = guid_map[parent_full] if parent_full else root_guid ap = ET.SubElement(a, qn(NS["act"], "parent")) ap.set("type", "guid") ap.text = parent_guid for _, row in df2.iterrows(): emit_account(row) _indent(root) out_path.write_bytes(ET.tostring(root, encoding="utf-8", xml_declaration=True)) def parse_gnucash_accounts_for_verify(gnucash_path: Path) -> pd.DataFrame: tree = ET.parse(gnucash_path) root = tree.getroot() ns = {"gnc": NS["gnc"], "act": NS["act"], "cmdty": NS["cmdty"], "slot": NS["slot"]} book = root.find("gnc:book", ns) if book is None: die("Invalid .gnucash file: missing gnc:book") accs = book.findall("gnc:account", ns) data: dict[str, dict[str, Any]] = {} for a in accs: aid = a.find("act:id", ns).text # type: ignore[union-attr] name = (a.find("act:name", ns).text or "").strip() # type: ignore[union-attr] atype = (a.find("act:type", ns).text or "").strip() # type: ignore[union-attr] parent_el = a.find("act:parent", ns) parent = parent_el.text.strip() if parent_el is not None and parent_el.text else None desc_el = a.find("act:description", ns) desc = (desc_el.text or "").strip() if desc_el is not None else "" code_el = a.find("act:code", ns) code = (code_el.text or "").strip() if code_el is not None else "" com = a.find("act:commodity", ns) symbol = "" namespace = "" if com is not None: space = com.find("cmdty:space", ns) cid = com.find("cmdty:id", ns) namespace = (space.text or "").strip() if space is not None else "" # type: ignore[union-attr] symbol = (cid.text or "").strip() if cid is not None else "" # type: ignore[union-attr] slots = a.find("act:slots", ns) slot_map: dict[str, tuple[str, str]] = {} if slots is not None: for s in slots.findall("slot", ns): key = s.find("slot:key", ns) val = s.find("slot:value", ns) if key is not None and val is not None: slot_map[(key.text or "").strip()] = (val.get("type") or "", (val.text or "").strip()) def _slot_truthy(key: str) -> bool: vtype, vtext = slot_map.get(key, ("", "")) vt = (vtype or "").strip().lower() vx = (vtext or "").strip().lower() if vx in {"true", "t", "1", "yes", "y"}: return True # Tax related info is often stored as a frame when TXF metadata is set. if vt == "frame": return True return False placeholder = "T" if _slot_truthy("placeholder") else "F" hidden = "T" if _slot_truthy("hidden") else "F" tax_info = "T" if _slot_truthy("tax-related") else "F" notes = slot_map.get("notes", ("", ""))[1] color = slot_map.get("color", ("", ""))[1] data[aid] = { "id": aid, "name": name, "type": atype, "parent": parent, "description": desc, "code": code, "namespace": namespace, "symbol": symbol, "placeholder": placeholder, "hidden": hidden, "tax_info": tax_info, "notes": notes, "color": color, } def full_name(aid: str) -> str: parts: list[str] = [] cur = aid while cur and cur in data: t = data[cur]["type"] if t == "ROOT": break parts.append(data[cur]["name"]) cur = data[cur]["parent"] return ":".join(reversed(parts)) rows: list[dict[str, Any]] = [] for aid, info in data.items(): if info["type"] == "ROOT": continue rows.append({ "Type": info["type"], "Full Account Name": full_name(aid), "Account Name": info["name"], "Account Code": info["code"], "Description": info["description"], "Account Colour": info["color"], "Notes": info["notes"], "Symbol": str(info["symbol"]).upper(), "Namespace": str(info["namespace"]).upper(), "Hidden": info["hidden"], "Tax Info": info["tax_info"], "Placeholder": info["placeholder"], }) out = pd.DataFrame(rows).fillna("") out = out[EXPECTED_COLUMNS].sort_values(["Full Account Name"]).reset_index(drop=True) return out def parse_gnucash_commodities(gnucash_path: Path) -> dict[CommodityKey, dict[str, str]]: tree = ET.parse(gnucash_path) root = tree.getroot() ns = {"gnc": NS["gnc"], "cmdty": NS["cmdty"]} book = root.find("{" + NS["gnc"] + "}book") if book is None: die("Invalid .gnucash file: missing gnc:book") out: dict[CommodityKey, dict[str, str]] = {} for c in book.findall("{" + NS["gnc"] + "}commodity"): space_el = c.find("{" + NS["cmdty"] + "}space") id_el = c.find("{" + NS["cmdty"] + "}id") name_el = c.find("{" + NS["cmdty"] + "}name") frac_el = c.find("{" + NS["cmdty"] + "}fraction") space = (space_el.text or "").strip() if space_el is not None else "" cid = (id_el.text or "").strip() if id_el is not None else "" key = CommodityKey(space, cid) out[key] = { "space": space, "id": cid, "name": (name_el.text or "").strip() if name_el is not None else "", "fraction": (frac_el.text or "").strip() if frac_el is not None else "", } return out def verify( gnucash_path: Path, csv_path: Path, *, allow_missing: bool, allow_extra: bool, max_list: int, check_commodities: bool, ) -> None: df_csv = read_accounts_csv(csv_path).copy() df_gc = parse_gnucash_accounts_for_verify(gnucash_path) for df in (df_csv, df_gc): for c in EXPECTED_COLUMNS: df[c] = df[c].fillna("").astype(str).str.strip() set_csv = set(df_csv["Full Account Name"]) set_gc = set(df_gc["Full Account Name"]) missing_in_gc = sorted(set_csv - set_gc) extra_in_gc = sorted(set_gc - set_csv) if missing_in_gc: print("Missing in GnuCash (present in CSV):", file=sys.stderr) for name in missing_in_gc[:max_list]: print(f" - {name}", file=sys.stderr) if len(missing_in_gc) > max_list: print(f" ... and {len(missing_in_gc) - max_list} more", file=sys.stderr) if extra_in_gc: print("Extra in GnuCash (not in CSV):", file=sys.stderr) for name in extra_in_gc[:max_list]: print(f" + {name}", file=sys.stderr) if len(extra_in_gc) > max_list: print(f" ... and {len(extra_in_gc) - max_list} more", file=sys.stderr) if (missing_in_gc and not allow_missing) or (extra_in_gc and not allow_extra): die( f"Account set mismatch: {len(missing_in_gc)} missing account(s) in GnuCash; " f"{len(extra_in_gc)} extra account(s) in GnuCash." ) # Compare intersection if non-strict common = sorted(set_csv & set_gc) df_csv2 = df_csv[df_csv["Full Account Name"].isin(common)].sort_values(["Full Account Name"]).reset_index(drop=True) df_gc2 = df_gc[df_gc["Full Account Name"].isin(common)].sort_values(["Full Account Name"]).reset_index(drop=True) diffs: list[str] = [] for c in EXPECTED_COLUMNS: if not df_csv2[c].equals(df_gc2[c]): diffs.append(c) if diffs: for c in diffs: neq = df_csv2[c] != df_gc2[c] if bool(neq.any()): i = int(neq[neq].index[0]) print(f"First difference in column '{c}':", file=sys.stderr) print(f" Account: {df_csv2.at[i, 'Full Account Name']}", file=sys.stderr) print(f" CSV: {df_csv2.at[i, c]!r}", file=sys.stderr) print(f" GnuCash: {df_gc2.at[i, c]!r}", file=sys.stderr) break die(f"Verification failed. Columns differing: {diffs}") if check_commodities: comms = parse_gnucash_commodities(gnucash_path) # 1) template commodity must remain lowercase (if present) tmpl_key_any = None for k in comms.keys(): if k.namespace.lower() == "template" and k.symbol.lower() == "template": tmpl_key_any = k break if tmpl_key_any is not None: # Must be EXACTLY template:template with fraction 1 and name template if not (tmpl_key_any.namespace == "template" and tmpl_key_any.symbol == "template"): die(f"Template commodity regression: found {tmpl_key_any.namespace}:{tmpl_key_any.symbol} (expected template:template)") meta = comms[tmpl_key_any] if meta.get("fraction", "") not in {"1", ""}: die(f"Template commodity fraction must be 1 (found {meta.get('fraction')!r})") if meta.get("name", "") not in {"template", ""}: die(f"Template commodity name must be 'template' (found {meta.get('name')!r})") # 2) required securities (non-currency) must exist required = {CommodityKey(ns, sym) for ns, sym in zip(df_csv["Namespace"], df_csv["Symbol"], strict=True)} required = {k for k in required if k.namespace != "CURRENCY"} missing = sorted([f"{k.namespace}:{k.symbol}" for k in required if k not in comms]) if missing: die(f"Missing commodity definition(s) in book: {missing[:50]}") # 3) for known crypto, check expected fraction if present bad_frac = [] for k in required: if k.namespace != "CRYPTO": continue exp = DEFAULT_SECURITY_FRACTIONS.get(k.symbol) if exp is None: continue meta = comms.get(k, {}) frac_txt = meta.get("fraction", "") if frac_txt and frac_txt.isdigit() and int(frac_txt) != exp: bad_frac.append(f"{k.namespace}:{k.symbol} fraction={frac_txt} expected={exp}") if bad_frac: die(f"Commodity fraction mismatch(es): {bad_frac[:50]}") if allow_missing or allow_extra: print( f"OK: {gnucash_path.name} matches {csv_path.name} ({len(df_gc2)} account(s)). " f"Compared {len(common)} common account(s); missing {len(missing_in_gc)}; extra {len(extra_in_gc)}." ) else: print(f"OK: {gnucash_path.name} matches {csv_path.name} ({len(df_gc2)} account(s)).") def parse_commodity_fraction_args(items: list[str]) -> dict[CommodityKey, int]: out: dict[CommodityKey, int] = {} for item in items: try: key_part, frac_part = item.split("=", 1) ns_part, sym_part = key_part.split(":", 1) frac = int(frac_part.strip()) if frac <= 0: die(f"Invalid fraction (must be >0): {item!r}") out[CommodityKey(ns_part, sym_part)] = frac except ValueError: die(f"Invalid --commodity-fraction value {item!r}. Expected NAMESPACE:SYMBOL=FRACTION") return out def main(argv: list[str] | None = None) -> int: p = argparse.ArgumentParser(description="Generate a GnuCash XML book (*.gnucash) from an accounts tree CSV.") sub = p.add_subparsers(dest="cmd", required=True) b = sub.add_parser("build", help="Build a new .gnucash XML file from a CSV") b.add_argument("csv", type=Path, help="Input accounts tree CSV") b.add_argument("--out", type=Path, required=True, help="Output .gnucash file path") b.add_argument("--default-currency-scu", type=int, default=100, help="Default currency SCU (GBP uses 100)") b.add_argument("--default-security-fraction", type=int, default=100_000_000, help="Default fraction/SCU for non-currency commodities") b.add_argument("--commodity-fraction", action="append", default=[], help="Override fraction: NAMESPACE:SYMBOL=FRACTION (repeatable)") b.add_argument("--random-guids", action="store_true", help="Use random GUIDs (default is deterministic)") b.add_argument("--home-currency", type=str, default="", help="Override book currency (e.g. GBP). Default: most common currency in CSV") b.add_argument("--no-template-commodity", action="store_true", help="Do not emit the internal template:template commodity") v = sub.add_parser("verify", help="Verify an existing .gnucash matches a CSV (same schema)") v.add_argument("gnucash", type=Path, help="Existing .gnucash XML file") v.add_argument("csv", type=Path, help="CSV to compare against") v.add_argument("--allow-missing", action="store_true", help="Allow accounts present in CSV but missing in GnuCash") v.add_argument("--allow-extra", action="store_true", help="Allow accounts present in GnuCash but missing in CSV") v.add_argument("--max-list", type=int, default=50, help="Max missing/extra accounts to list") v.add_argument("--no-check-commodities", action="store_true", help="Skip commodity checks (template + securities)") args = p.parse_args(argv) if args.cmd == "verify": verify( args.gnucash, args.csv, allow_missing=args.allow_missing, allow_extra=args.allow_extra, max_list=args.max_list, check_commodities=not args.no_check_commodities, ) return 0 if args.cmd == "build": df = read_accounts_csv(args.csv) book_currency = sanity_check(df) if args.home_currency: sym = args.home_currency.strip().upper() if sym not in ISO_CURRENCY_CODES: die(f"--home-currency {sym!r} is not an ISO 4217 currency code") book_currency = CommodityKey("CURRENCY", sym) overrides = parse_commodity_fraction_args(args.commodity_fraction) build_gnucash_xml( df, args.out, book_currency=book_currency, default_currency_scu=args.default_currency_scu, default_security_fraction=args.default_security_fraction, commodity_fractions=overrides, deterministic_guids=not args.random_guids, include_template_commodity=not args.no_template_commodity, ) print(f"Wrote: {args.out}") return 0 die("Unknown command") return 2 if __name__ == "__main__": raise SystemExit(main())