summaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
authorDanilo M. <danix@danix.xyz>2026-06-30 11:02:43 +0200
committerDanilo M. <danix@danix.xyz>2026-06-30 11:02:43 +0200
commit49c9af4c213d5f420a405d860c0454fade26c297 (patch)
treef6d3a1f7da1bb73b5cd125cac464354467bb356f
parentba872d1e48ad229903316fc30e61cebe9c115258 (diff)
downloadfirefly-cli-49c9af4c213d5f420a405d860c0454fade26c297.tar.gz
firefly-cli-49c9af4c213d5f420a405d860c0454fade26c297.zip
feat: name-to-id resolver with loud ambiguity errors
-rw-r--r--firefly_cli/resolver.py35
-rw-r--r--tests/unit/test_resolver.py43
2 files changed, 78 insertions, 0 deletions
diff --git a/firefly_cli/resolver.py b/firefly_cli/resolver.py
new file mode 100644
index 0000000..7673af6
--- /dev/null
+++ b/firefly_cli/resolver.py
@@ -0,0 +1,35 @@
+# Copyright (C) 2026 Danilo M. <danix@danix.xyz> GPL-2.0-only
+from firefly_cli.errors import ResolutionError
+
+class Resolver:
+ def __init__(self, client):
+ self.client = client
+
+ def _list(self, path):
+ resp = self.client.request("GET", path, params={"limit": 1000})
+ out = []
+ for item in resp.get("data", []):
+ attrs = item.get("attributes", {})
+ out.append({"id": item.get("id"), **attrs})
+ return out
+
+ def _match(self, kind, items, name):
+ hits = [i for i in items if str(i.get("name", "")).lower() == name.lower()]
+ if len(hits) == 1:
+ return hits[0]
+ names = ", ".join(f'{i.get("name")}(id={i.get("id")})' for i in items)
+ if not hits:
+ raise ResolutionError(
+ f'No {kind} named "{name}". Available: {names or "(none)"}')
+ raise ResolutionError(
+ f'Ambiguous {kind} "{name}" matches ids '
+ + ", ".join(i["id"] for i in hits))
+
+ def account(self, name):
+ return self._match("account", self._list("/api/v1/accounts"), name)
+
+ def tag(self, name):
+ return self._match("tag", self._list("/api/v1/tags"), name)
+
+ def category(self, name):
+ return self._match("category", self._list("/api/v1/categories"), name)
diff --git a/tests/unit/test_resolver.py b/tests/unit/test_resolver.py
new file mode 100644
index 0000000..2e00e83
--- /dev/null
+++ b/tests/unit/test_resolver.py
@@ -0,0 +1,43 @@
+import unittest
+from unittest.mock import MagicMock
+from firefly_cli.resolver import Resolver
+from firefly_cli.errors import ResolutionError
+
+def client_returning(items):
+ c = MagicMock()
+ c.request.return_value = {
+ "data": [
+ {"id": i["id"], "type": "accounts", "attributes": i["attrs"]}
+ for i in items
+ ]
+ }
+ return c
+
+class TestResolver(unittest.TestCase):
+ def test_resolves_unique_account_name(self):
+ c = client_returning([
+ {"id": "3", "attrs": {"name": "Checking", "type": "asset"}},
+ {"id": "4", "attrs": {"name": "Savings", "type": "asset"}},
+ ])
+ r = Resolver(c)
+ acc = r.account("checking") # case-insensitive
+ self.assertEqual(acc["id"], "3")
+ self.assertEqual(acc["type"], "asset")
+
+ def test_no_match_raises_with_candidates(self):
+ c = client_returning([{"id": "3", "attrs": {"name": "Checking", "type": "asset"}}])
+ r = Resolver(c)
+ with self.assertRaises(ResolutionError) as ctx:
+ r.account("Nope")
+ self.assertIn("Checking", str(ctx.exception))
+
+ def test_ambiguous_match_raises(self):
+ c = client_returning([
+ {"id": "3", "attrs": {"name": "Cash", "type": "asset"}},
+ {"id": "9", "attrs": {"name": "Cash", "type": "asset"}},
+ ])
+ r = Resolver(c)
+ with self.assertRaises(ResolutionError) as ctx:
+ r.account("Cash")
+ self.assertIn("3", str(ctx.exception))
+ self.assertIn("9", str(ctx.exception))