summaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
authorDanilo M. <danix@danix.xyz>2026-06-30 15:33:36 +0200
committerDanilo M. <danix@danix.xyz>2026-06-30 15:33:36 +0200
commit3a12128d12a0217a6200689465422caa65063cfb (patch)
treed5589b7c430bcda04a0bfc95f34d6f6120e071f3
parent9c15e172eb5b50796eb050cc5704471bce09e024 (diff)
downloadfirefly-cli-0.2.2.tar.gz
firefly-cli-0.2.2.zip
output: decorate --human tables, color by tx type, IT datesv0.2.2
The --human transaction table now uses Italian dates (DD/MM/YYYY), a header rule line, and colors each row by transaction type (withdrawal red, deposit green, transfer cyan) when writing to a TTY. Colors are suppressed when piped; JSON output is unchanged. Bump to 0.2.2. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
-rw-r--r--firefly_cli/__init__.py2
-rw-r--r--firefly_cli/output.py100
-rw-r--r--pyproject.toml2
-rw-r--r--tests/unit/test_output.py46
4 files changed, 144 insertions, 6 deletions
diff --git a/firefly_cli/__init__.py b/firefly_cli/__init__.py
index e025931..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.2.1"
+__version__ = "0.2.2"
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
diff --git a/pyproject.toml b/pyproject.toml
index fa4a82f..7812540 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
[project]
name = "firefly-iii-agent"
-version = "0.2.1"
+version = "0.2.2"
description = "CLI tool for agent interaction with Firefly III"
readme = "README.md"
requires-python = ">=3.11"
diff --git a/tests/unit/test_output.py b/tests/unit/test_output.py
index c71a424..5b154d9 100644
--- a/tests/unit/test_output.py
+++ b/tests/unit/test_output.py
@@ -29,3 +29,49 @@ class TestOutput(unittest.TestCase):
out = buf.getvalue()
self.assertIn("Checking", out)
self.assertIn("id", out)
+
+ def test_emit_human_drops_nested_columns(self):
+ # dict/list-valued fields would dump unreadable blobs; they must be cut.
+ buf = io.StringIO()
+ with redirect_stdout(buf):
+ emit([{"id": "1", "name": "x", "junk": {"a": 1}, "tags": [1, 2]}],
+ human=True)
+ out = buf.getvalue()
+ self.assertIn("name", out)
+ self.assertNotIn("junk", out)
+ self.assertNotIn("tags", out)
+
+ def test_emit_human_transaction_flattens_splits(self):
+ # The useful fields live in the nested `transactions` split list, and the
+ # raw 12-decimal amount should be trimmed to 2 dp.
+ tx = {"id": "77", "transactions": [{
+ "type": "withdrawal", "date": "2026-06-28T00:00:00+02:00",
+ "amount": "7.400000000000", "currency_code": "EUR",
+ "description": "McDonald", "source_name": "BBVA",
+ "destination_name": "McDonald's", "category_name": "Food"}]}
+ buf = io.StringIO()
+ with redirect_stdout(buf):
+ emit([tx], human=True)
+ out = buf.getvalue()
+ self.assertIn("28/06/2026", out) # Italian date, no time/zone
+ self.assertIn("7.40", out) # amount trimmed to 2 dp
+ self.assertNotIn("7.400000", out)
+ self.assertIn("McDonald's", out) # destination surfaced
+ self.assertIn("Food", out)
+ self.assertNotIn("import_hash", out) # raw blob not dumped
+
+ def test_emit_color_only_on_tty(self):
+ tx = {"id": "1", "transactions": [{"type": "withdrawal",
+ "date": "2026-06-28", "amount": "1", "currency_code": "EUR",
+ "description": "x", "source_name": "a", "destination_name": "b",
+ "category_name": "c"}]}
+ plain = io.StringIO()
+ emit([tx], human=True, stream=plain)
+ self.assertNotIn("\033[", plain.getvalue()) # piped: no ANSI
+
+ class TTY(io.StringIO):
+ def isatty(self):
+ return True
+ tty = TTY()
+ emit([tx], human=True, stream=tty)
+ self.assertIn("\033[31m", tty.getvalue()) # tty: withdrawal is red