diff options
Diffstat (limited to 'firefly_cli')
| -rw-r--r-- | firefly_cli/__init__.py | 2 | ||||
| -rw-r--r-- | firefly_cli/cli.py | 15 | ||||
| -rw-r--r-- | firefly_cli/commands/account.py | 38 | ||||
| -rw-r--r-- | firefly_cli/commands/auth.py | 4 | ||||
| -rw-r--r-- | firefly_cli/commands/category.py | 2 | ||||
| -rw-r--r-- | firefly_cli/commands/tag.py | 2 | ||||
| -rw-r--r-- | firefly_cli/commands/transaction.py | 8 | ||||
| -rw-r--r-- | firefly_cli/output.py | 100 |
8 files changed, 153 insertions, 18 deletions
diff --git a/firefly_cli/__init__.py b/firefly_cli/__init__.py index f50a029..6173b2b 100644 --- a/firefly_cli/__init__.py +++ b/firefly_cli/__init__.py @@ -2,4 +2,4 @@ # Copyright (C) 2026 Danilo M. <danix@danix.xyz> # Licensed under the GNU General Public License v2.0 only. -__version__ = "0.1.0" +__version__ = "0.2.2" diff --git a/firefly_cli/cli.py b/firefly_cli/cli.py index d547455..ad54504 100644 --- a/firefly_cli/cli.py +++ b/firefly_cli/cli.py @@ -11,6 +11,16 @@ import firefly_cli.commands # noqa: F401 triggers registration # Commands that must work without a configured client. _NO_CLIENT = {"auth set"} +# Short blurb per command group, shown in `firefly --help` and as the +# description in `firefly <group> --help`. +_GROUP_HELP = { + "auth": "configure and verify the Firefly III connection (url + token)", + "account": "list, create, and inspect accounts (asset, expense, revenue)", + "category": "list categories (categories auto-create when used on a tx)", + "tag": "list tags (tags auto-create when attached to a tx)", + "tx": "record, list, and search transactions", +} + def _build_parser(): parser = argparse.ArgumentParser(prog="firefly", description="CLI for Firefly III") @@ -25,9 +35,10 @@ def _build_parser(): for cmd in registry.all_commands(): group, _, leaf = cmd.name.partition(" ") if group not in groups: - gp = sub.add_parser(group) + blurb = _GROUP_HELP.get(group) + gp = sub.add_parser(group, help=blurb, description=blurb) groups[group] = gp.add_subparsers(dest="_leaf", required=True) - lp = groups[group].add_parser(leaf, help=cmd.help) + lp = groups[group].add_parser(leaf, help=cmd.help, description=cmd.help) if cmd.args: cmd.args(lp) lp.set_defaults(_handler=cmd.handler, _cmdname=cmd.name) diff --git a/firefly_cli/commands/account.py b/firefly_cli/commands/account.py index fa2dc65..9dbfab6 100644 --- a/firefly_cli/commands/account.py +++ b/firefly_cli/commands/account.py @@ -1,26 +1,58 @@ # Copyright (C) 2026 Danilo M. <danix@danix.xyz> GPL-2.0-only from firefly_cli import registry, output +from firefly_cli.errors import FireflyError + +# v1 scope: the everyday types. Liabilities need extra required fields +# (liability_type/direction/amount); add when needed. +_CREATE_TYPES = ("asset", "expense", "revenue") def _list_args(p): p.add_argument("--type", help="filter: asset, expense, revenue, liability, ...") -@registry.command("account list", help="list accounts", args=_list_args) +@registry.command("account list", help="list accounts; optionally filter by --type", args=_list_args) def cmd_list(args, ctx): params = {"type": args.type} if getattr(args, "type", None) else None resp = ctx.client.request("GET", "/api/v1/accounts", params=params) output.emit(output.unwrap(resp), human=ctx.human) return 0 +def _create_args(p): + p.add_argument("name", help="account name (must be unique)") + p.add_argument("--type", required=True, + help="asset, expense, or revenue") + p.add_argument("--opening-balance", dest="opening_balance", default=None, + help="initial balance (asset accounts); dated today") + p.add_argument("--currency", default=None, help="currency code, e.g. EUR") + +@registry.command("account create", help="create an asset, expense, or revenue account", args=_create_args) +def cmd_create(args, ctx): + if args.type not in _CREATE_TYPES: + raise FireflyError( + f'Unsupported account type "{args.type}". ' + f'Use one of: {", ".join(_CREATE_TYPES)}.') + body = {"name": args.name, "type": args.type} + if args.type == "asset": + body["account_role"] = "defaultAsset" + if args.opening_balance is not None: + from datetime import date as _date + body["opening_balance"] = str(args.opening_balance) + body["opening_balance_date"] = _date.today().isoformat() + if args.currency: + body["currency_code"] = args.currency + resp = ctx.client.request("POST", "/api/v1/accounts", body=body) + output.emit(output.unwrap(resp), human=ctx.human) + return 0 + def _name_arg(p): p.add_argument("account", help="account name or id") -@registry.command("account get", help="show one account", args=_name_arg) +@registry.command("account get", help="show full details for one account (name or id)", args=_name_arg) def cmd_get(args, ctx): acc = ctx.resolver.account(args.account) output.emit(acc, human=ctx.human) return 0 -@registry.command("account balance", help="show account balance", args=_name_arg) +@registry.command("account balance", help="show current balance for one account (name or id)", args=_name_arg) def cmd_balance(args, ctx): acc = ctx.resolver.account(args.account) output.emit({"id": acc["id"], "name": acc.get("name"), diff --git a/firefly_cli/commands/auth.py b/firefly_cli/commands/auth.py index d2319e9..99e79c9 100644 --- a/firefly_cli/commands/auth.py +++ b/firefly_cli/commands/auth.py @@ -6,7 +6,7 @@ def _set_args(p): p.add_argument("--url", help="Firefly III base URL") p.add_argument("--token", help="Personal Access Token") -@registry.command("auth set", help="write url+token to config", args=_set_args) +@registry.command("auth set", help="save url + token to the config file (no API call)", args=_set_args) def cmd_set(args, ctx): url = args.url or input("Firefly III URL: ").strip() token = args.token or getpass.getpass("Personal Access Token: ").strip() @@ -14,7 +14,7 @@ def cmd_set(args, ctx): output.emit({"written": str(path)}, human=ctx.human) return 0 -@registry.command("auth test", help="verify connectivity and token") +@registry.command("auth test", help="check the configured url + token reach Firefly") def cmd_test(args, ctx): resp = ctx.client.request("GET", "/api/v1/about") output.emit(resp.get("data", resp), human=ctx.human) diff --git a/firefly_cli/commands/category.py b/firefly_cli/commands/category.py index a7ddbe8..d4bad93 100644 --- a/firefly_cli/commands/category.py +++ b/firefly_cli/commands/category.py @@ -1,7 +1,7 @@ # Copyright (C) 2026 Danilo M. <danix@danix.xyz> GPL-2.0-only from firefly_cli import registry, output -@registry.command("category list", help="list categories") +@registry.command("category list", help="list all categories known to Firefly") def cmd_list(args, ctx): resp = ctx.client.request("GET", "/api/v1/categories") output.emit(output.unwrap(resp), human=ctx.human) diff --git a/firefly_cli/commands/tag.py b/firefly_cli/commands/tag.py index 668c4ae..beed56c 100644 --- a/firefly_cli/commands/tag.py +++ b/firefly_cli/commands/tag.py @@ -1,7 +1,7 @@ # Copyright (C) 2026 Danilo M. <danix@danix.xyz> GPL-2.0-only from firefly_cli import registry, output -@registry.command("tag list", help="list tags") +@registry.command("tag list", help="list all tags known to Firefly") def cmd_list(args, ctx): resp = ctx.client.request("GET", "/api/v1/tags") output.emit(output.unwrap(resp), human=ctx.human) diff --git a/firefly_cli/commands/transaction.py b/firefly_cli/commands/transaction.py index be8d281..67d8b71 100644 --- a/firefly_cli/commands/transaction.py +++ b/firefly_cli/commands/transaction.py @@ -30,7 +30,7 @@ def _add_args(p): p.add_argument("--type", default=None, help="withdrawal|deposit|transfer (overrides inference)") -@registry.command("tx add", help="record a transaction", args=_add_args) +@registry.command("tx add", help="record a transaction; source/destination resolve to accounts, category/tags auto-create", args=_add_args) def cmd_add(args, ctx): src = ctx.resolver.account(args.source) dst = ctx.resolver.account(args.dest) @@ -60,7 +60,7 @@ def _list_args(p): p.add_argument("--account", default=None, help="filter by account name") p.add_argument("--limit", type=int, default=20) -@registry.command("tx list", help="list transactions", args=_list_args) +@registry.command("tx list", help="list recent transactions (newest first)", args=_list_args) def cmd_list(args, ctx): if args.account: acc = ctx.resolver.account(args.account) @@ -79,7 +79,7 @@ def cmd_list(args, ctx): def _id_arg(p): p.add_argument("id") -@registry.command("tx get", help="show one transaction", args=_id_arg) +@registry.command("tx get", help="show full details for one transaction by id", args=_id_arg) def cmd_get(args, ctx): resp = ctx.client.request("GET", f"/api/v1/transactions/{args.id}") output.emit(output.unwrap(resp), human=ctx.human) @@ -88,7 +88,7 @@ def cmd_get(args, ctx): def _query_arg(p): p.add_argument("query") -@registry.command("tx search", help="search transactions", args=_query_arg) +@registry.command("tx search", help="search transactions by Firefly query string", args=_query_arg) def cmd_search(args, ctx): resp = ctx.client.request("GET", "/api/v1/search/transactions", params={"query": args.query}) diff --git a/firefly_cli/output.py b/firefly_cli/output.py index 8da2b1f..166e1bf 100644 --- a/firefly_cli/output.py +++ b/firefly_cli/output.py @@ -15,6 +15,81 @@ def unwrap(resp): return flat(data) return data +# Columns shown for transactions in --human mode, in order. The useful data +# lives in each tx's nested `transactions` split list, not the top level. +_TX_COLS = ("id", "date", "type", "amount", "currency", + "description", "from", "to", "category") + +def _it_date(d): + """Firefly date 'YYYY-MM-DD...' -> Italian 'DD/MM/YYYY'. Raw on mismatch.""" + s = (d or "")[:10] + parts = s.split("-") + if len(parts) == 3 and all(parts): + y, m, day = parts + return f"{day}/{m}/{y}" + return s + +def _trim_amount(a): + """Firefly sends amounts as 12-decimal strings; show a tidy 2-dp value. + Falls back to the raw value if it is not a plain number.""" + try: + return f"{float(a):.2f}" + except (TypeError, ValueError): + return a + +def _tx_rows(rows): + """Explode Firefly transaction objects into flat per-split display rows. + + Each object has a `transactions` list (one entry per split). We surface the + fields a human actually reads; the raw JSON output is unaffected. + """ + out = [] + for r in rows: + for s in r.get("transactions", [{}]): + out.append({ + "id": r.get("id"), + "date": _it_date(s.get("date")), + "type": s.get("type"), + "amount": _trim_amount(s.get("amount")), + "currency": s.get("currency_code"), + "description": s.get("description"), + "from": s.get("source_name"), + "to": s.get("destination_name"), + "category": s.get("category_name"), + }) + return out + +def _is_tx(rows): + return bool(rows) and isinstance(rows[0], dict) and "transactions" in rows[0] + +# Per-resource column whitelists for --human. Firefly returns ~50 fields per +# row; only a handful are worth a table. A row is matched by a signature key. +# (signature, columns) -- first match wins; unmatched rows use a generic table. +_VIEWS = [ + ("account_role", ("id", "name", "type", "current_balance", + "currency_code", "active")), + ("tag", ("id", "tag", "description")), + ("current_balance", ("id", "name", "current_balance")), # `account balance` + ("name", ("id", "name")), # category, etc. +] + +def _cols_for(row): + for sig, cols in _VIEWS: + if sig in row: + return list(cols) + return None + +def _cell(v): + # Tables are one row per line; collapse any embedded newlines (e.g. notes). + return str(v if v is not None else "").replace("\n", " ").replace("\r", "") + +_RESET = "\033[0m" +_TYPE_COLOR = { # by transaction type + "withdrawal": "\033[31m", # red (money out) + "deposit": "\033[32m", # green (money in) + "transfer": "\033[36m", # cyan (internal) +} + def emit(data, human=False, stream=None): stream = stream or sys.stdout if not human: @@ -25,11 +100,28 @@ def emit(data, human=False, stream=None): if not rows: stream.write("(no results)\n") return - cols = list(rows[0].keys()) - widths = {c: max(len(c), *(len(str(r.get(c, ""))) for r in rows)) for c in cols} - stream.write(" ".join(c.ljust(widths[c]) for c in cols) + "\n") + if _is_tx(rows): + rows = _tx_rows(rows) + cols = list(_TX_COLS) + else: + cols = _cols_for(rows[0]) + if cols is None: + # Generic fallback: drop nested (dict/list) columns that would dump + # unreadable blobs; show the scalar fields only. + cols = [c for c in rows[0] + if not any(isinstance(r.get(c), (dict, list)) for r in rows)] + widths = {c: max(len(c), *(len(_cell(r.get(c))) for r in rows)) for c in cols} + color = getattr(stream, "isatty", lambda: False)() + sep = " " + stream.write(sep.join(c.ljust(widths[c]) for c in cols) + "\n") + stream.write(sep.join("─" * widths[c] for c in cols) + "\n") # header rule for r in rows: - stream.write(" ".join(str(r.get(c, "")).ljust(widths[c]) for c in cols) + "\n") + line = sep.join(_cell(r.get(c)).ljust(widths[c]) for c in cols) + if color: + tint = _TYPE_COLOR.get(str(r.get("type", "")).lower()) + if tint: + line = tint + line + _RESET + stream.write(line + "\n") def emit_error(message, stream=None): stream = stream or sys.stderr |
