diff options
| author | Danilo M. <danix@danix.xyz> | 2026-07-01 10:40:32 +0200 |
|---|---|---|
| committer | Danilo M. <danix@danix.xyz> | 2026-07-01 10:40:32 +0200 |
| commit | 69647b1ca6f2e11429850b0782c65d04b86509cb (patch) | |
| tree | ab6b98c3595aaf04a9391f881357a73eb3d29c01 | |
| parent | b078c5980facc0ebe4acd1f251f6ae3dad561292 (diff) | |
| download | firefly-cli-69647b1ca6f2e11429850b0782c65d04b86509cb.tar.gz firefly-cli-69647b1ca6f2e11429850b0782c65d04b86509cb.zip | |
feat: tx add --dry-run to validate before writing (v0.3.3)
An agent importing rows in a loop could fail mid-batch: a --to account that
didn't exist yet errored after earlier rows had already been written, leaving
a half-applied batch to clean up by hand.
--dry-run resolves --from/--to and infers the type but sends nothing, printing
{"dry_run": true, "would_send": {...}}. A missing account stays a hard error
(exit 1) so the agent can dry-run every row first, create the accounts the
errors name, then run the batch for real. No batch input mode: the agent
already owns the loop.
Verified live read-only: valid accounts emit would_send and write nothing
(search confirms []), missing account exits 1 with the candidate list.
PATCH: new optional flag, contract unchanged.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
| -rw-r--r-- | SKILL.md | 10 | ||||
| -rw-r--r-- | completions/firefly.bash | 2 | ||||
| -rw-r--r-- | firefly_cli/__init__.py | 2 | ||||
| -rw-r--r-- | firefly_cli/commands/transaction.py | 6 | ||||
| -rw-r--r-- | pyproject.toml | 2 | ||||
| -rw-r--r-- | tests/unit/test_commands_transaction.py | 31 |
6 files changed, 45 insertions, 8 deletions
@@ -56,7 +56,7 @@ firefly account balance <name|id> [--at YYYY-MM-DD] firefly account create <name> --type asset|expense|revenue [--opening-balance N] [--currency CODE] firefly tx add <amount> --from <acct> --to <acct> - [--desc TEXT] [--date YYYY-MM-DD] [--category NAME] [--tags a,b] [--type T] + [--desc TEXT] [--date YYYY-MM-DD] [--category NAME] [--tags a,b] [--type T] [--dry-run] firefly tx edit <id> [--amount N] [--date YYYY-MM-DD] [--desc TEXT] [--from <acct>] [--to <acct>] [--category NAME] [--tags a,b] [--type T] # only fields passed are changed @@ -102,6 +102,14 @@ Supports asset, expense, revenue. asset accounts get the default role automatically. Unlike categories/tags, accounts are NOT auto-created by `tx add`, create them explicitly here first. +**Validate a batch before writing any of it:** when importing many rows in a +loop, a mid-batch failure (a `--to` account that doesn't exist yet) leaves the +earlier rows already written. Dry-run each row first so the whole batch fails +fast: `tx add ... --dry-run` resolves `--from`/`--to` and infers the type but +sends nothing, printing `{"dry_run": true, "would_send": {...}}`. A missing +account is still a hard error (exit 1). Recipe: dry-run every row, create any +accounts the errors name, then run the batch for real. + **Check a balance:** ```bash firefly account balance test01 # -> {"id","name","current_balance"} diff --git a/completions/firefly.bash b/completions/firefly.bash index b23f081..2dbe218 100644 --- a/completions/firefly.bash +++ b/completions/firefly.bash @@ -40,7 +40,7 @@ _firefly() { "account balance") leaf_opts="--at";; "account create") leaf_opts="--currency --opening-balance --type";; "account list") leaf_opts="--type";; - "tx add") leaf_opts="--category --date --desc --from --tags --to --type";; + "tx add") leaf_opts="--category --date --desc --dry-run --from --tags --to --type";; "tx delete") leaf_opts="--yes";; "tx edit") leaf_opts="--amount --category --date --desc --from --tags --to --type";; "tx list") leaf_opts="--account --all --limit --since --until";; diff --git a/firefly_cli/__init__.py b/firefly_cli/__init__.py index eb73e59..23ac8eb 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.3.2" +__version__ = "0.3.3" diff --git a/firefly_cli/commands/transaction.py b/firefly_cli/commands/transaction.py index 1d6f219..72d2606 100644 --- a/firefly_cli/commands/transaction.py +++ b/firefly_cli/commands/transaction.py @@ -30,6 +30,8 @@ def _add_args(p): p.add_argument("--tags", default=None, help="comma-separated") p.add_argument("--type", default=None, help="withdrawal|deposit|transfer (overrides inference)") + p.add_argument("--dry-run", dest="dry_run", action="store_true", + help="resolve accounts and show what would be sent; write nothing") @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): @@ -50,6 +52,10 @@ def cmd_add(args, ctx): split["category_name"] = args.category if args.tags: split["tags"] = [t.strip() for t in args.tags.split(",") if t.strip()] + if args.dry_run: + # Accounts already resolved above (missing name = hard error). Write nothing. + output.emit({"dry_run": True, "would_send": split}, human=ctx.human) + return 0 resp = ctx.client.request("POST", "/api/v1/transactions", body={"transactions": [split]}) output.emit(output.unwrap(resp), human=ctx.human) diff --git a/pyproject.toml b/pyproject.toml index fd0d369..4b4bf9b 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "firefly-iii-agent" -version = "0.3.2" +version = "0.3.3" description = "CLI tool for agent interaction with Firefly III" readme = "README.md" requires-python = ">=3.11" diff --git a/tests/unit/test_commands_transaction.py b/tests/unit/test_commands_transaction.py index d8b81da..a6b5228 100644 --- a/tests/unit/test_commands_transaction.py +++ b/tests/unit/test_commands_transaction.py @@ -19,7 +19,7 @@ class TestTxAdd(unittest.TestCase): "attributes": {}}} args = MagicMock(amount="42.50", source="Checking", dest="Groceries", desc="food", date="2026-06-30", category=None, - tags=None, type=None) + tags=None, type=None, dry_run=False) rc = tx.cmd_add(args, ctx) self.assertEqual(rc, 0) method, path = client.request.call_args[0][:2] @@ -39,7 +39,8 @@ class TestTxAdd(unittest.TestCase): }[n] client.request.return_value = {"data": {"id": "1", "attributes": {}}} args = MagicMock(amount="1000", source="Salary", dest="Checking", - desc="pay", date=None, category=None, tags=None, type=None) + desc="pay", date=None, category=None, tags=None, + type=None, dry_run=False) tx.cmd_add(args, ctx) self.assertEqual(client.request.call_args[1]["body"]["transactions"][0]["type"], "deposit") @@ -49,7 +50,8 @@ class TestTxAdd(unittest.TestCase): resolver.account.side_effect = lambda n: {"id": "1", "type": "asset", "name": n} client.request.return_value = {"data": {"id": "1", "attributes": {}}} args = MagicMock(amount="5", source="A", dest="B", desc=None, date=None, - category=None, tags="food,fun", type="transfer") + category=None, tags="food,fun", type="transfer", + dry_run=False) tx.cmd_add(args, ctx) split = client.request.call_args[1]["body"]["transactions"][0] self.assertEqual(split["type"], "transfer") @@ -61,10 +63,31 @@ class TestTxAdd(unittest.TestCase): resolver.account.side_effect = lambda n: {"id": "1", "type": "asset", "name": n} client.request.return_value = {"data": {"id": "1", "attributes": {}}} args = MagicMock(amount="5", source="A", dest="B", desc=None, date=None, - category="Brand New Cat", tags=None, type="withdrawal") + category="Brand New Cat", tags=None, type="withdrawal", + dry_run=False) tx.cmd_add(args, ctx) split = client.request.call_args[1]["body"]["transactions"][0] self.assertEqual(split["category_name"], "Brand New Cat") + + def test_dry_run_resolves_but_does_not_post(self): + ctx, client, resolver = make_ctx() + resolver.account.side_effect = lambda n: {"id": "1", "type": "asset", "name": n} + args = MagicMock(amount="5", source="A", dest="B", desc="x", date="2026-06-01", + category=None, tags=None, type="withdrawal", dry_run=True) + rc = tx.cmd_add(args, ctx) + self.assertEqual(rc, 0) + client.request.assert_not_called() # accounts resolved, nothing written + self.assertEqual(resolver.account.call_count, 2) + + def test_dry_run_missing_account_is_hard_error(self): + from firefly_cli.errors import ResolutionError + ctx, client, resolver = make_ctx() + resolver.account.side_effect = ResolutionError('No account named "B"') + args = MagicMock(amount="5", source="A", dest="B", desc=None, date=None, + category=None, tags=None, type="withdrawal", dry_run=True) + with self.assertRaises(ResolutionError): + tx.cmd_add(args, ctx) + client.request.assert_not_called() resolver.category.assert_not_called() class TestTxEdit(unittest.TestCase): |
