From 49c9af4c213d5f420a405d860c0454fade26c297 Mon Sep 17 00:00:00 2001 From: "Danilo M." Date: Tue, 30 Jun 2026 11:02:43 +0200 Subject: feat: name-to-id resolver with loud ambiguity errors --- firefly_cli/resolver.py | 35 +++++++++++++++++++++++++++++++++++ tests/unit/test_resolver.py | 43 +++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 78 insertions(+) create mode 100644 firefly_cli/resolver.py create mode 100644 tests/unit/test_resolver.py 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. 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)) -- cgit v1.2.3