Not a member of Pastebin yet?
Sign Up,
it unlocks many cool features!
- #!/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 <accounts.csv> --out <book.gnucash> [options]
- verify <book.gnucash> <accounts.csv> [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:<NAME> 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:<NAME> 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 '<ROOT>'}'")
- 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())
Add Comment
Please, Sign In to add comment