Skip to content

Instantly share code, notes, and snippets.

@StephenBrown2
Last active April 3, 2026 20:22
Show Gist options
  • Select an option

  • Save StephenBrown2/1d1cdf5710a917d7ef1c09314d097fe3 to your computer and use it in GitHub Desktop.

Select an option

Save StephenBrown2/1d1cdf5710a917d7ef1c09314d097fe3 to your computer and use it in GitHub Desktop.
YNAB CSV (Plan/Register) to JSON — converter aligned with YNAB API plan export shape

YNAB CSV → JSON export converter

This script turns YNAB “Export Budget Data” files (Plan.csv and Register.csv, or a .zip that contains them) into a single JSON document shaped like a full plan export. Field names and enums follow the YNAB API OpenAPI spec.

What you need

Requirement Notes
Python 3.10 or newer No third-party packages; only the standard library.
Recommended: uv Handy for running the script without touching a global Python install.

Get the files

  1. Download csv_to_ynab_json.py from this gist (Raw → Save As), or clone/copy both files from the gist.
  2. Put your YNAB export (zip or the two CSV files) somewhere on your computer.

Limitations

The CSV export does not include everything YNAB stores server-side. This tool does not recreate: real-time account balances, goals, payee GPS locations, split lines, scheduled transactions, or import metadata. Account types and on-budget flags use generic rules from the account name.


Run with Python (no extra tools)

  1. Open a terminal (Terminal on macOS, PowerShell or Command Prompt on Windows, or your Linux shell).

  2. Go to the folder where csv_to_ynab_json.py lives (and where your CSV/zip files are, or use full paths):

    cd path/to/folder
  3. Check that Python works (3.10+):

    python3 --version

    On some Windows setups, use py -3 --version instead of python3.

  4. Run the script using one of the patterns below.


Run with uv (recommended)

uv can run a script with an isolated, correct Python version.

  1. Install uv (see Installing uv).

  2. In the folder that contains csv_to_ynab_json.py:

    uv run csv_to_ynab_json.py --help

    Use uv run python csv_to_ynab_json.py if your shell does not execute the script directly.


Usage examples

Show all options:

python3 csv_to_ynab_json.py --help

Option A — Export zip (recommended if YNAB gave you a single zip):

python3 csv_to_ynab_json.py --zip "YNAB Export - My Budget as of 2026-04-03 14-38.zip"

Option B — Two CSV files:

python3 csv_to_ynab_json.py \
  --plan "My Budget as of 2026-04-03 14-38 - Plan.csv" \
  --register "My Budget as of 2026-04-03 14-38 - Register.csv"

With uv:

uv run csv_to_ynab_json.py --zip "YNAB Export - My Budget as of 2026-04-03 14-38.zip"

Output file

  • If you do not pass -o / --output, the JSON is written next to your input:

    • Zip: same name as the zip, with a .json extension (e.g. Export.zipExport.json).
    • CSV pair: based on the Plan filename, with - Plan.csv / - Register.csv removed and .json added.
  • To choose the path yourself:

    python3 csv_to_ynab_json.py --zip "export.zip" -o ~/Desktop/my-budget.json

Budget name inside the JSON

Override the budget name in the file (optional):

python3 csv_to_ynab_json.py --zip "export.zip" -n "My Budget 2026"

License

You may use and adapt this script for personal use; attribution is appreciated.

#!/usr/bin/env python3
# /// script
# requires-python = ">=3.10"
# ///
#
# (PEP 723 inline metadata — `uv run csv_to_ynab_json.py` uses this Python bound;
# no third-party packages; stdlib only.)
"""
Convert YNAB CSV exports (Plan + Register) into JSON shaped like a full plan
export. Field meanings and enums follow the YNAB API (see OpenAPI spec).
Spec: https://api.ynab.com/papi/open_api_spec.yaml
Limitations (CSV does not contain):
- Server-issued UUIDs, goals, category notes, payee locations, splits,
scheduled transactions, real account balances, direct-import flags.
Dependencies: Python 3.10+ standard library only.
"""
from __future__ import annotations
import argparse
import csv
import json
import re
import tempfile
import uuid
import zipfile
from collections import defaultdict
from datetime import UTC, datetime
from pathlib import Path
from typing import Any
# Deterministic IDs across runs (same inputs -> same UUIDs)
_NS = uuid.UUID("6ba7b810-9dad-11d1-80b4-00c04fd430c8")
# YNAB export / API labels for the Ready to Assign inflow category (not user payees).
# See MonthSummaryBase.income in the OpenAPI spec.
_INFLOW_GROUP = "Inflow"
_RTA_CATEGORY = "Ready to Assign"
def _uid(kind: str, *parts: str) -> str:
return str(uuid.uuid5(_NS, f"{kind}|" + "|".join(parts)))
def parse_money(s: str) -> int:
"""CSV currency string -> YNAB milliunits (1000 = $1.00)."""
if not s or not str(s).strip():
return 0
s = str(s).strip().replace("$", "").replace(",", "")
neg = False
if s.startswith("-"):
neg = True
s = s[1:].strip()
if s.startswith("(") and s.endswith(")"):
neg = True
s = s[1:-1].strip()
try:
v = float(s)
except ValueError:
return 0
if neg:
v = -v
return int(round(v * 1000))
def parse_month_label(label: str) -> str | None:
"""'Mar 2024' -> '2024-03-01'."""
label = label.strip()
try:
dt = datetime.strptime(label, "%b %Y")
except ValueError:
try:
dt = datetime.strptime(label, "%B %Y")
except ValueError:
return None
return f"{dt.year:04d}-{dt.month:02d}-01"
def guess_account_type(name: str) -> str:
"""
Map account name to AccountType enum (OpenAPI components/schemas/AccountType).
Uses only generic English keywords, not institution or person names.
"""
n = name.lower()
if "mortgage" in n or "home loan" in n:
return "mortgage"
if "heloc" in n or "home equity" in n and "loan" not in n:
return "lineOfCredit"
if "line of credit" in n or re.search(r"\bloc\b", n):
return "lineOfCredit"
if "student loan" in n:
return "studentLoan"
if "auto loan" in n or "car loan" in n:
return "autoLoan"
if "medical" in n and "debt" in n:
return "medicalDebt"
if "credit card" in n:
return "creditCard"
if re.search(r"\b(visa|mastercard|amex|discover)\b", n):
return "creditCard"
if n.endswith(" card") or n.endswith(" card "):
return "creditCard"
if "store card" in n:
return "creditCard"
if "savings" in n:
return "savings"
if re.search(r"\bcash\b", n) or "wallet" in n:
return "cash"
if any(x in n for x in ("ira", "401k", "401(k)", "brokerage", "investment")):
return "otherAsset"
if any(x in n for x in ("equity", "retirement")) and "line" not in n:
return "otherAsset"
if any(x in n for x in ("crypto", "bitcoin", "precious metal")):
return "otherAsset"
if "loan" in n:
return "otherDebt"
if "liability" in n:
return "otherLiability"
return "checking"
def guess_on_budget(acct_type: str) -> bool:
"""Tracking / debt accounts are off-budget; everyday accounts on-budget."""
off_budget = frozenset(
{
"mortgage",
"autoLoan",
"studentLoan",
"personalLoan",
"medicalDebt",
"otherDebt",
"otherAsset",
"otherLiability",
}
)
return acct_type not in off_budget
def cleared_status(raw: str) -> str:
"""TransactionClearedStatus: cleared | uncleared | reconciled."""
m = raw.strip().lower()
if m == "reconciled":
return "reconciled"
if m == "uncleared":
return "uncleared"
return "cleared"
def transfer_target_name(payee: str) -> str | None:
"""
If payee is a transfer payee (\"Transfer : …\"), return the payee name with
the \"Transfer : \" prefix removed; otherwise None.
The remainder is typically the destination account display name as exported
by YNAB (e.g. matching another account's name).
"""
p = payee.strip()
prefix = "Transfer : "
if not p.startswith(prefix):
return None
rest = p[len(prefix) :].strip()
return rest if rest else None
def empty_category_template(
cid: str,
gid: str,
name: str,
hidden: bool,
budgeted: int,
activity: int,
balance: int,
) -> dict[str, Any]:
return {
"id": cid,
"category_group_id": gid,
"name": name,
"hidden": hidden,
"original_category_group_id": None,
"note": None,
"budgeted": budgeted,
"activity": activity,
"balance": balance,
"goal_type": None,
"goal_needs_whole_amount": None,
"goal_day": None,
"goal_cadence": None,
"goal_cadence_frequency": None,
"goal_creation_month": None,
"goal_target": 0,
"goal_target_month": None,
"goal_percentage_complete": None,
"goal_months_to_budget": None,
"goal_under_funded": None,
"goal_overall_funded": None,
"goal_overall_left": None,
"goal_snoozed_at": None,
"deleted": False,
}
def load_plan(path: str | Path) -> tuple[dict[tuple[str, str], str], dict[str, dict[tuple[str, str], dict[str, int]]]]:
by_month: dict[str, dict[tuple[str, str], dict[str, int]]] = defaultdict(
lambda: defaultdict(lambda: {"budgeted": 0, "activity": 0, "balance": 0})
)
month_for_key: dict[tuple[str, str], str] = {}
with open(path, newline="", encoding="utf-8-sig") as f:
reader = csv.DictReader(f)
for row in reader:
g = (row.get("Category Group") or "").strip()
c = (row.get("Category") or "").strip()
if not g and not c:
continue
mlabel = (row.get("Month") or "").strip()
month_iso = parse_month_label(mlabel)
if not month_iso:
continue
key = (g, c)
month_for_key[key] = month_iso
by_month[month_iso][key]["budgeted"] = parse_money(row.get("Assigned", ""))
by_month[month_iso][key]["activity"] = parse_money(row.get("Activity", ""))
by_month[month_iso][key]["balance"] = parse_money(row.get("Available", ""))
return month_for_key, dict(by_month)
def collect_category_keys_from_register(path: str | Path) -> set[tuple[str, str]]:
keys: set[tuple[str, str]] = set()
with open(path, newline="", encoding="utf-8-sig") as f:
reader = csv.DictReader(f)
for row in reader:
g = (row.get("Category Group") or "").strip()
c = (row.get("Category") or "").strip()
if g or c:
keys.add((g, c))
return keys
def _is_rta_row(row: dict[str, str]) -> bool:
return (
(row.get("Category Group") or "").strip() == _INFLOW_GROUP
and (row.get("Category") or "").strip() == _RTA_CATEGORY
)
def _register_row_amount(row: dict[str, str]) -> int:
return parse_money(row.get("Inflow", "")) - parse_money(row.get("Outflow", ""))
def _scan_register_metrics(path: str | Path) -> tuple[dict[str, int], dict[str, int]]:
"""
Per YNAB API MonthSummaryBase:
- income: total transaction amounts in 'Inflow: Ready to Assign' for the month
- activity_month: total transaction amounts excluding RTA inflow category
"""
income_by_ym: dict[str, int] = defaultdict(int)
activity_ex_rta_by_ym: dict[str, int] = defaultdict(int)
with open(path, newline="", encoding="utf-8-sig") as f:
reader = csv.DictReader(f)
for row in reader:
ds = (row.get("Date") or "").strip()
if len(ds) < 7:
continue
ym = ds[:7]
amt = _register_row_amount(row)
if _is_rta_row(row):
income_by_ym[ym] += amt
else:
activity_ex_rta_by_ym[ym] += amt
return dict(income_by_ym), dict(activity_ex_rta_by_ym)
def find_plan_and_register_csvs_in_zip(zip_path: Path) -> tuple[Path, Path]:
"""Locate Plan.csv and Register.csv inside an export zip (any folder)."""
with zipfile.ZipFile(zip_path) as zf:
names = [n for n in zf.namelist() if not n.endswith("/")]
def is_plan(n: str) -> bool:
return bool(re.search(r"Plan\.csv$", n, re.I))
def is_register(n: str) -> bool:
return bool(re.search(r"Register\.csv$", n, re.I))
plans = [n for n in names if is_plan(n)]
regs = [n for n in names if is_register(n)]
if not plans:
raise SystemExit(f"No *Plan.csv file found in {zip_path}")
if not regs:
raise SystemExit(f"No *Register.csv file found in {zip_path}")
if len(plans) == 1 and len(regs) == 1:
return Path(plans[0]), Path(regs[0])
def stem_base(path_str: str, kind: str) -> str:
base = Path(path_str).name
suf = f" - {kind}.csv"
if base.lower().endswith(suf.lower()):
return base[: -len(suf)].strip()
base = re.sub(r"(?i)\s*-\s*" + re.escape(kind) + r"\.csv\s*$", "", base)
return base.strip()
pb = {stem_base(p, "Plan"): p for p in plans}
rb = {stem_base(r, "Register"): r for r in regs}
common = set(pb.keys()) & set(rb.keys())
if len(common) == 1:
k = next(iter(common))
return Path(pb[k]), Path(rb[k])
raise SystemExit(
f"Could not pair Plan/Register CSVs in {zip_path}. Found plans={plans!r} registers={regs!r}"
)
def extract_zip(zip_path: Path) -> tuple[Path, Path, tempfile.TemporaryDirectory[str]]:
"""Extract zip to a temp directory; return (plan_path, register_path, tempdir)."""
tmp = tempfile.TemporaryDirectory(prefix="ynab_csv_export_")
root = Path(tmp.name)
plan_member, reg_member = find_plan_and_register_csvs_in_zip(zip_path)
with zipfile.ZipFile(zip_path) as zf:
zf.extractall(root)
plan_p = root / plan_member
reg_p = root / reg_member
if not plan_p.is_file():
raise SystemExit(f"Extracted Plan path missing: {plan_p}")
if not reg_p.is_file():
raise SystemExit(f"Extracted Register path missing: {reg_p}")
return plan_p, reg_p, tmp
def build_budget(
plan_path: str | Path,
register_path: str | Path,
budget_name: str,
) -> dict[str, Any]:
plan_path = Path(plan_path)
register_path = Path(register_path)
month_for_key, plan_by_month = load_plan(plan_path)
reg_cats = collect_category_keys_from_register(register_path)
all_keys: set[tuple[str, str]] = set(month_for_key.keys()) | reg_cats
all_keys = {(g, c) for g, c in all_keys if g and c}
group_names = sorted({g for g, _ in all_keys})
group_meta: dict[str, dict[str, Any]] = {}
for g in group_names:
gid = _uid("category_group", g)
group_meta[g] = {"id": gid, "name": g, "hidden": False, "deleted": False}
cat_ids: dict[tuple[str, str], str] = {}
for g, c in sorted(all_keys):
cat_ids[(g, c)] = _uid("category", g, c)
account_names: list[str] = []
seen_acc: set[str] = set()
with open(register_path, newline="", encoding="utf-8-sig") as f:
reader = csv.DictReader(f)
for row in reader:
name = (row.get("Account") or "").strip()
if name and name not in seen_acc:
seen_acc.add(name)
account_names.append(name)
accounts: list[dict[str, Any]] = []
account_by_name: dict[str, dict[str, Any]] = {}
for name in account_names:
aid = _uid("account", name)
tid = _uid("transfer_payee", name)
atype = guess_account_type(name)
ob = guess_on_budget(atype)
acct = {
"id": aid,
"name": name,
"type": atype,
"on_budget": ob,
"closed": False,
"note": None,
"balance": 0,
"cleared_balance": 0,
"uncleared_balance": 0,
"transfer_payee_id": tid,
"direct_import_linked": False,
"direct_import_in_error": False,
"last_reconciled_at": None,
"debt_original_balance": None,
"debt_interest_rates": {},
"debt_minimum_payments": {},
"debt_escrow_amounts": {},
"deleted": False,
}
accounts.append(acct)
account_by_name[name] = acct
payees: list[dict[str, Any]] = []
payee_by_name: dict[str, str] = {}
for name in account_names:
acct = account_by_name[name]
pid = acct["transfer_payee_id"]
payees.append(
{
"id": pid,
"name": f"Transfer : {name}",
"transfer_account_id": acct["id"],
"deleted": False,
}
)
payee_by_name[f"Transfer : {name}"] = pid
with open(register_path, newline="", encoding="utf-8-sig") as f:
reader = csv.DictReader(f)
payee_names: set[str] = set()
for row in reader:
p = (row.get("Payee") or "").strip()
if not p:
continue
if transfer_target_name(p) is not None:
continue
payee_names.add(p)
for pname in sorted(payee_names):
pid = _uid("payee", pname)
payees.append({"id": pid, "name": pname, "transfer_account_id": None, "deleted": False})
payee_by_name[pname] = pid
all_months = sorted(plan_by_month.keys())
if not all_months:
first_m = last_m = datetime.now(UTC).strftime("%Y-%m-01")
else:
first_m = all_months[0]
last_m = all_months[-1]
budget_id = _uid("budget", budget_name)
last_plan = plan_by_month.get(last_m, {})
root_categories: list[dict[str, Any]] = []
for (g, c), cid in sorted(cat_ids.items(), key=lambda x: (x[0][0], x[0][1])):
gid = group_meta[g]["id"]
cat_hidden = g == "Hidden Categories"
nums = last_plan.get((g, c), {"budgeted": 0, "activity": 0, "balance": 0})
root_categories.append(
empty_category_template(
cid, gid, c, cat_hidden, nums["budgeted"], nums["activity"], nums["balance"]
)
)
income_by_month, activity_register_by_month = _scan_register_metrics(register_path)
months_out: list[dict[str, Any]] = []
for month_iso in sorted(plan_by_month.keys(), reverse=True):
pdata = plan_by_month[month_iso]
cats_layer: list[dict[str, Any]] = []
sum_budgeted = 0
for (g, c), cid in sorted(cat_ids.items(), key=lambda x: (x[0][0], x[0][1])):
gid = group_meta[g]["id"]
cat_hidden = g == "Hidden Categories"
nums = pdata.get((g, c), {"budgeted": 0, "activity": 0, "balance": 0})
sum_budgeted += nums["budgeted"]
cats_layer.append(
empty_category_template(
cid, gid, c, cat_hidden, nums["budgeted"], nums["activity"], nums["balance"]
)
)
ym = month_iso[:7]
income_mu = income_by_month.get(ym, 0)
# API: month.activity = transaction totals excluding RTA inflow (not Plan rollups).
activity_month = activity_register_by_month.get(ym, 0)
months_out.append(
{
"month": month_iso,
"note": "",
"income": income_mu,
"budgeted": sum_budgeted,
"activity": activity_month,
"to_be_budgeted": 0,
"age_of_money": None,
"deleted": False,
"categories": cats_layer,
}
)
raw_rows: list[dict[str, Any]] = []
with open(register_path, newline="", encoding="utf-8-sig") as f:
reader = csv.DictReader(f)
for i, row in enumerate(reader):
raw_rows.append({"row_index": i, **row})
pending: dict[tuple[Any, ...], list[dict[str, Any]]] = defaultdict(list)
for row in raw_rows:
acc = (row.get("Account") or "").strip()
payee = (row.get("Payee") or "").strip()
tgt = transfer_target_name(payee)
if tgt is None:
continue
amt = parse_money(row.get("Outflow", "")) - parse_money(row.get("Inflow", ""))
if amt == 0:
continue
d = (row.get("Date") or "").strip()
key = (d, abs(amt), tuple(sorted([acc, tgt])))
pending[key].append(row)
pair_id: dict[int, tuple[str, str]] = {}
for key, rows in pending.items():
if len(rows) != 2:
continue
r1, r2 = rows
a1 = (r1.get("Account") or "").strip()
a2 = (r2.get("Account") or "").strip()
t1 = transfer_target_name((r1.get("Payee") or "").strip())
t2 = transfer_target_name((r2.get("Payee") or "").strip())
if t1 == a2 and t2 == a1:
base = _uid("xfer", key[0], str(key[1]), key[2][0], key[2][1])
d = key[0]
id_a = f"{base}_{d}"
id_b = f"{base}_t_{d}"
amt1 = parse_money(r1.get("Outflow", "")) - parse_money(r1.get("Inflow", ""))
if amt1 < 0:
pair_id[r1["row_index"]] = (id_a, id_b)
pair_id[r2["row_index"]] = (id_b, id_a)
else:
pair_id[r1["row_index"]] = (id_b, id_a)
pair_id[r2["row_index"]] = (id_a, id_b)
transactions: list[dict[str, Any]] = []
for row in raw_rows:
acc_name = (row.get("Account") or "").strip()
acct = account_by_name.get(acc_name)
if not acct:
continue
outf = parse_money(row.get("Outflow", ""))
inf = parse_money(row.get("Inflow", ""))
amount = inf - outf
payee = (row.get("Payee") or "").strip()
memo = (row.get("Memo") or "").strip() or None
date_s = (row.get("Date") or "").strip()
cat_id: str | None = None
g = (row.get("Category Group") or "").strip()
c = (row.get("Category") or "").strip()
if g and c and (g, c) in cat_ids:
cat_id = cat_ids[(g, c)]
xfer_account: str | None = None
payee_id: str | None = None
tt = transfer_target_name(payee)
if tt is not None:
target = account_by_name.get(tt)
if target:
payee_id = target["transfer_payee_id"]
xfer_account = target["id"]
else:
pname = payee
payee_id = payee_by_name.get(pname) or _uid("payee", pname)
if pname not in payee_by_name:
payees.append(
{"id": payee_id, "name": pname, "transfer_account_id": None, "deleted": False}
)
payee_by_name[pname] = payee_id
else:
payee_id = payee_by_name.get(payee)
if not payee_id:
payee_id = _uid("payee", payee)
payees.append({"id": payee_id, "name": payee, "transfer_account_id": None, "deleted": False})
payee_by_name[payee] = payee_id
if tt is not None:
cat_id = None
transfer_transaction_id: str | None = None
tx_id: str
if row["row_index"] in pair_id:
my_id, other_id = pair_id[row["row_index"]]
tx_id = my_id
transfer_transaction_id = other_id
else:
tx_id = f"{_uid('tx', acc_name, date_s, str(amount), str(row['row_index']))}_{date_s}"
flag = (row.get("Flag") or "").strip().lower() or None
if flag not in {"red", "orange", "yellow", "green", "blue", "purple"}:
flag = None
transactions.append(
{
"id": tx_id,
"date": date_s,
"amount": amount,
"memo": memo,
"cleared": cleared_status(row.get("Cleared", "")),
"approved": True,
"flag_color": flag,
"account_id": acct["id"],
"payee_id": payee_id,
"category_id": cat_id,
"transfer_account_id": xfer_account,
"transfer_transaction_id": transfer_transaction_id,
"matched_transaction_id": None,
"import_id": None,
"import_payee_name": None,
"import_payee_name_original": None,
"debt_transaction_type": None,
"deleted": False,
}
)
transactions.sort(key=lambda t: (t["date"], t["id"]), reverse=True)
now = datetime.now(UTC).strftime("%Y-%m-%dT%H:%M:%SZ")
budget: dict[str, Any] = {
"id": budget_id,
"name": budget_name,
"last_modified_on": now,
"date_format": {"format": "YYYY-MM-DD"},
"currency_format": {
"iso_code": "USD",
"example_format": "123,456.78",
"decimal_digits": 2,
"decimal_separator": ".",
"symbol_first": True,
"group_separator": ",",
"currency_symbol": "$",
"display_symbol": True,
},
"first_month": first_m,
"last_month": last_m,
"accounts": accounts,
"payees": payees,
"payee_locations": [],
"category_groups": [group_meta[g] for g in group_names],
"categories": root_categories,
"months": months_out,
"transactions": transactions,
"subtransactions": [],
"scheduled_transactions": [],
"scheduled_subtransactions": [],
}
return {"data": {"budget": budget, "server_knowledge": 0}}
def _budget_name_from_plan_path(plan_path: Path) -> str:
base = plan_path.name
name = re.sub(r"\s+as of\s+.*$", "", base, flags=re.I)
name = re.sub(r"\s*-\s*Plan\.csv$", "", name, flags=re.I).strip()
return name or "Budget"
def default_output_path(*, plan_path: Path | None, zip_path: Path | None) -> Path:
"""
Default JSON path beside the input: same directory and basename pattern as
the zip, or the Plan/Register CSV with ' - Plan.csv' / ' - Register.csv' removed.
"""
if zip_path is not None:
return zip_path.with_suffix(".json")
if plan_path is None:
raise ValueError("default_output_path requires zip_path or plan_path")
stem = plan_path.name
stem = re.sub(r"(?i)\s*-\s*Plan\.csv\s*$", "", stem)
stem = re.sub(r"(?i)\s*-\s*Register\.csv\s*$", "", stem).strip()
if not stem:
stem = plan_path.stem
return plan_path.with_name(f"{stem}.json")
def _budget_name_from_zip_path(zip_path: Path) -> str:
base = zip_path.stem
base = re.sub(r"^\s*YNAB\s+Export\s*-\s*", "", base, flags=re.I).strip()
base = re.sub(r"\s+as of\s+.*$", "", base, flags=re.I).strip()
return base or "Budget"
def main() -> None:
ap = argparse.ArgumentParser(
description="Convert YNAB Plan.csv + Register.csv to JSON aligned with the YNAB API plan export shape."
)
ap.add_argument(
"-z",
"--zip",
type=Path,
help="Zip export containing *Plan.csv and *Register.csv (e.g. YNAB Export - ….zip)",
)
ap.add_argument(
"-p",
"--plan",
type=Path,
help="Path to Plan.csv (requires --register; mutually exclusive with --zip)",
)
ap.add_argument(
"-r",
"--register",
type=Path,
help="Path to Register.csv (requires --plan; mutually exclusive with --zip)",
)
ap.add_argument(
"-o",
"--output",
type=Path,
default=None,
help="Output .json path (default: derived from Plan filename or zip name)",
)
ap.add_argument(
"-n",
"--budget-name",
default=None,
help="Budget name in JSON (default: derived from Plan filename or zip name)",
)
args = ap.parse_args()
use_zip = args.zip is not None
use_csv = args.plan is not None or args.register is not None
if use_zip and use_csv:
ap.error("Use either --zip or --plan/--register together, not both.")
if not use_zip and not (args.plan is not None and args.register is not None):
ap.error("Provide --zip, or both --plan and --register.")
out_path = args.output
if out_path is None:
out_path = default_output_path(
plan_path=args.plan if not use_zip else None,
zip_path=args.zip if use_zip else None,
)
if use_zip:
if not args.zip.is_file():
ap.error(f"Not a file: {args.zip}")
plan_path, reg_path, tmp = extract_zip(args.zip)
try:
name = args.budget_name or _budget_name_from_zip_path(args.zip)
out = build_budget(plan_path, reg_path, name)
finally:
tmp.cleanup()
else:
if not args.plan.is_file():
ap.error(f"Not a file: {args.plan}")
if not args.register.is_file():
ap.error(f"Not a file: {args.register}")
name = args.budget_name or _budget_name_from_plan_path(args.plan)
out = build_budget(args.plan, args.register, name)
out_path.parent.mkdir(parents=True, exist_ok=True)
with open(out_path, "w", encoding="utf-8") as f:
json.dump(out, f, indent=2, ensure_ascii=False)
f.write("\n")
if __name__ == "__main__":
main()
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment