dao-treasury 0.0.42__cp312-cp312-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (51) hide show
  1. bf2b4fe1f86ad2ea158b__mypyc.cpython-312-i386-linux-gnu.so +0 -0
  2. dao_treasury/.grafana/provisioning/dashboards/dashboards.yaml +60 -0
  3. dao_treasury/.grafana/provisioning/dashboards/streams/LlamaPay.json +225 -0
  4. dao_treasury/.grafana/provisioning/dashboards/summary/Monthly.json +107 -0
  5. dao_treasury/.grafana/provisioning/dashboards/transactions/Treasury Transactions.json +387 -0
  6. dao_treasury/.grafana/provisioning/dashboards/treasury/Cashflow (Including Unsorted).json +835 -0
  7. dao_treasury/.grafana/provisioning/dashboards/treasury/Cashflow.json +615 -0
  8. dao_treasury/.grafana/provisioning/dashboards/treasury/Operating Cashflow.json +492 -0
  9. dao_treasury/.grafana/provisioning/dashboards/treasury/Treasury.json +2018 -0
  10. dao_treasury/.grafana/provisioning/datasources/datasources.yaml +17 -0
  11. dao_treasury/ENVIRONMENT_VARIABLES.py +20 -0
  12. dao_treasury/__init__.py +62 -0
  13. dao_treasury/_docker.cpython-312-i386-linux-gnu.so +0 -0
  14. dao_treasury/_docker.py +190 -0
  15. dao_treasury/_nicknames.cpython-312-i386-linux-gnu.so +0 -0
  16. dao_treasury/_nicknames.py +32 -0
  17. dao_treasury/_wallet.cpython-312-i386-linux-gnu.so +0 -0
  18. dao_treasury/_wallet.py +250 -0
  19. dao_treasury/constants.cpython-312-i386-linux-gnu.so +0 -0
  20. dao_treasury/constants.py +34 -0
  21. dao_treasury/db.py +1408 -0
  22. dao_treasury/docker-compose.yaml +41 -0
  23. dao_treasury/main.py +247 -0
  24. dao_treasury/py.typed +0 -0
  25. dao_treasury/sorting/__init__.cpython-312-i386-linux-gnu.so +0 -0
  26. dao_treasury/sorting/__init__.py +295 -0
  27. dao_treasury/sorting/_matchers.cpython-312-i386-linux-gnu.so +0 -0
  28. dao_treasury/sorting/_matchers.py +387 -0
  29. dao_treasury/sorting/_rules.cpython-312-i386-linux-gnu.so +0 -0
  30. dao_treasury/sorting/_rules.py +235 -0
  31. dao_treasury/sorting/factory.cpython-312-i386-linux-gnu.so +0 -0
  32. dao_treasury/sorting/factory.py +299 -0
  33. dao_treasury/sorting/rule.cpython-312-i386-linux-gnu.so +0 -0
  34. dao_treasury/sorting/rule.py +346 -0
  35. dao_treasury/sorting/rules/__init__.cpython-312-i386-linux-gnu.so +0 -0
  36. dao_treasury/sorting/rules/__init__.py +1 -0
  37. dao_treasury/sorting/rules/ignore/__init__.cpython-312-i386-linux-gnu.so +0 -0
  38. dao_treasury/sorting/rules/ignore/__init__.py +1 -0
  39. dao_treasury/sorting/rules/ignore/llamapay.cpython-312-i386-linux-gnu.so +0 -0
  40. dao_treasury/sorting/rules/ignore/llamapay.py +20 -0
  41. dao_treasury/streams/__init__.cpython-312-i386-linux-gnu.so +0 -0
  42. dao_treasury/streams/__init__.py +0 -0
  43. dao_treasury/streams/llamapay.cpython-312-i386-linux-gnu.so +0 -0
  44. dao_treasury/streams/llamapay.py +388 -0
  45. dao_treasury/treasury.py +191 -0
  46. dao_treasury/types.cpython-312-i386-linux-gnu.so +0 -0
  47. dao_treasury/types.py +133 -0
  48. dao_treasury-0.0.42.dist-info/METADATA +119 -0
  49. dao_treasury-0.0.42.dist-info/RECORD +51 -0
  50. dao_treasury-0.0.42.dist-info/WHEEL +7 -0
  51. dao_treasury-0.0.42.dist-info/top_level.txt +2 -0
@@ -0,0 +1,387 @@
1
+ from logging import getLogger
2
+ from typing import ClassVar, Dict, Final, Iterable, List, Optional, Set, final
3
+
4
+ from eth_typing import ChecksumAddress, HexAddress, HexStr
5
+ from eth_utils import is_hexstr
6
+ from pony.orm import db_session
7
+ from typing_extensions import Self
8
+ from y import convert
9
+
10
+ from dao_treasury.types import TxGroupDbid
11
+
12
+
13
+ logger: Final = getLogger("dao_treasury")
14
+
15
+
16
+ class _Matcher:
17
+ """Base class for matching strings to a transaction group identifier.
18
+
19
+ Each subclass maintains a registry of instances and a cache for fast lookups.
20
+ Matching is performed by testing membership via `__contains__`.
21
+
22
+ Examples:
23
+ >>> from dao_treasury.sorting._matchers import FromAddressMatcher
24
+ >>> from dao_treasury.types import TxGroupDbid
25
+ >>> address = "0xAbC1230000000000000000000000000000000000"
26
+ >>> fam = FromAddressMatcher(TxGroupDbid(1), [address])
27
+ >>> FromAddressMatcher.match(address)
28
+ TxGroupDbid(1)
29
+ >>> FromAddressMatcher.match("missing")
30
+ None
31
+
32
+ See Also:
33
+ :class:`dao_treasury.sorting._matchers._HexStringMatcher`,
34
+ :class:`dao_treasury.sorting._matchers._AddressMatcher`,
35
+ :meth:`match`
36
+ """
37
+
38
+ __instances__: ClassVar[List[Self]]
39
+ __cache__: ClassVar[Dict[str, TxGroupDbid]]
40
+
41
+ @classmethod
42
+ def match(cls, string: str) -> Optional[TxGroupDbid]:
43
+ # sourcery skip: use-next
44
+ """Return the TxGroupDbid for a matching instance or None if no match.
45
+
46
+ The lookup first checks the internal cache, then iterates through
47
+ all instances and tests membership with `__contains__`. On first hit,
48
+ the result is cached for future calls.
49
+
50
+ Args:
51
+ string: Input string to match (e.g., address or hash).
52
+
53
+ Examples:
54
+ >>> from dao_treasury.sorting._matchers import HashMatcher
55
+ >>> from dao_treasury.types import TxGroupDbid
56
+ >>> hash_str = "0xdeadbeef" + "00"*28
57
+ >>> hmatch = HashMatcher(TxGroupDbid(2), [hash_str])
58
+ >>> HashMatcher.match(hash_str)
59
+ TxGroupDbid(2)
60
+ >>> HashMatcher.match("0xother")
61
+ None
62
+
63
+ See Also:
64
+ :attr:`__cache__`
65
+ """
66
+ try:
67
+ return cls.__cache__[string]
68
+ except KeyError:
69
+ for matcher in cls.__instances__:
70
+ if string in matcher:
71
+ txgroup_id = matcher.txgroup_id
72
+ cls.__cache__[string] = txgroup_id
73
+ return txgroup_id
74
+ return None
75
+
76
+ def __init__(self, txgroup: TxGroupDbid, validated_values: Set[str]) -> None:
77
+ """Initialize matcher with a txgroup and a set of validated strings.
78
+
79
+ Ensures that the txgroup identifier is unique among instances.
80
+
81
+ Args:
82
+ txgroup: Identifier of the transaction group.
83
+ validated_values: Set of unique, pre-validated strings for matching.
84
+
85
+ Raises:
86
+ TypeError: If txgroup is not an integer.
87
+ ValueError: If an instance for the same txgroup already exists.
88
+
89
+ See Also:
90
+ :attr:`txgroup_id`
91
+ """
92
+ if not isinstance(txgroup, int):
93
+ raise TypeError(txgroup)
94
+
95
+ for matcher in self.__instances__:
96
+ if matcher.txgroup_id == txgroup:
97
+ raise ValueError(
98
+ f"TxGroup[{txgroup}] already has a {type(self).__name__}: {matcher}"
99
+ )
100
+ self.txgroup_id: Final[TxGroupDbid] = txgroup
101
+
102
+ self.__one_value: Final = len(validated_values) == 1
103
+ self.__value: Final = list(validated_values)[0] if self.__one_value else ""
104
+ self.__values: Final = validated_values
105
+
106
+ def __contains__(self, string: str) -> bool:
107
+ """Return True if the given string matches one of the validated values.
108
+
109
+ For a single-value matcher, performs equality; otherwise membership.
110
+
111
+ Args:
112
+ string: Input to test for membership.
113
+
114
+ See Also:
115
+ :meth:`match`
116
+ """
117
+ return string == self.__value if self.__one_value else string in self.values
118
+
119
+ @property
120
+ def values(self) -> Set[HexStr]:
121
+ """Set of all validated strings used for matching.
122
+
123
+ Returns:
124
+ The original set of strings passed at initialization.
125
+
126
+ Example:
127
+ >>> from dao_treasury.sorting._matchers import HashMatcher
128
+ >>> from dao_treasury.types import TxGroupDbid
129
+ >>> hex_str = "0x" + "f"*64
130
+ >>> matcher = HashMatcher(TxGroupDbid(4), [hex_str])
131
+ >>> matcher.values
132
+ {'0xffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff'}
133
+
134
+ See Also:
135
+ :meth:`match`
136
+ """
137
+ return self.__values
138
+
139
+
140
+ class _HexStringMatcher(_Matcher):
141
+ """Matcher for fixed-length hexadecimal strings.
142
+
143
+ Validates and normalizes hex strings (must start with “0x”, lowercase)
144
+ to the length specified by :attr:`expected_length`.
145
+
146
+ See Also:
147
+ :attr:`expected_length`,
148
+ :meth:`_validate_hexstr`
149
+ """
150
+
151
+ expected_length: ClassVar[int]
152
+
153
+ @classmethod
154
+ def _validate_hexstr(cls, hexstr: HexStr) -> HexStr:
155
+ """Validate that input is a hex string of length `expected_length`.
156
+
157
+ Normalizes to lowercase and prepends '0x' if necessary.
158
+
159
+ Args:
160
+ hexstr: Candidate hex string.
161
+
162
+ Raises:
163
+ ValueError: If not a valid hex string or wrong length.
164
+
165
+ See Also:
166
+ :attr:`expected_length`
167
+ """
168
+ if not is_hexstr(hexstr):
169
+ raise ValueError(f"value must be a hex string, not {hexstr}")
170
+ hexstr = hexstr.lower()
171
+ if not hexstr.startswith("0x"):
172
+ hexstr = f"0x{hexstr}"
173
+ if len(hexstr) != cls.expected_length:
174
+ raise ValueError(
175
+ f"{hexstr} has incorrect length (expected {cls.expected_length}, actual {len(hexstr)})"
176
+ )
177
+ return hexstr
178
+
179
+
180
+ class _AddressMatcher(_HexStringMatcher):
181
+ """Matcher for Ethereum addresses, mapping them to a TxGroupDbid.
182
+
183
+ Ensures each address is unique across all matchers.
184
+
185
+ Examples:
186
+ >>> from dao_treasury.sorting._matchers import _AddressMatcher
187
+ >>> from dao_treasury.types import TxGroupDbid
188
+ >>> addr = "0xAbC1230000000000000000000000000000000000"
189
+ >>> am = _AddressMatcher(TxGroupDbid(5), [addr])
190
+ >>> addr in am
191
+ True
192
+ >>> "0x0000000000000000000000000000000000000000" in am
193
+ False
194
+
195
+ See Also:
196
+ :class:`FromAddressMatcher`,
197
+ :class:`ToAddressMatcher`
198
+ """
199
+
200
+ expected_length: ClassVar[int] = 42
201
+
202
+ def __init__(self, txgroup: TxGroupDbid, addresses: Iterable[HexAddress]) -> None:
203
+ """Create an address matcher with checksum validation.
204
+
205
+ Converts inputs to checksummed addresses and ensures that each address is only
206
+ registered once. Duplicate addresses in the input iterable will log a warning,
207
+ but only the first occurrence is used.
208
+
209
+ Args:
210
+ txgroup: Identifier of the transaction group.
211
+ addresses: Iterable of hex address strings.
212
+
213
+ Raises:
214
+ ValueError: If `addresses` is empty, or if any address already has an
215
+ existing matcher.
216
+
217
+ Examples:
218
+ >>> from dao_treasury.sorting._matchers import _AddressMatcher
219
+ >>> from dao_treasury.types import TxGroupDbid
220
+ >>> addr = "0xAbC1230000000000000000000000000000000000"
221
+ >>> # duplicate in list triggers warning but does not raise
222
+ >>> am = _AddressMatcher(TxGroupDbid(5), [addr, addr])
223
+ >>> addr in am
224
+ True
225
+
226
+ See Also:
227
+ :meth:`_validate_hexstr`
228
+ """
229
+ addresses = list(addresses)
230
+ if not addresses:
231
+ raise ValueError("You must provide at least one address")
232
+
233
+ validated: Set[ChecksumAddress] = set()
234
+ for address in addresses:
235
+ address = convert.to_address(self._validate_hexstr(address))
236
+ for matcher in self.__instances__:
237
+ if address in matcher:
238
+ raise ValueError(
239
+ f"address {address} already has a matcher: {matcher}"
240
+ )
241
+ if address in validated:
242
+ logger.warning("duplicate address %s", address)
243
+ validated.add(address)
244
+
245
+ super().__init__(txgroup, validated)
246
+
247
+ logger.info("%s created", self)
248
+ self.__instances__.append(self) # type: ignore [arg-type]
249
+
250
+ @db_session # type: ignore [misc]
251
+ def __repr__(self) -> str:
252
+ """Return a string representation including the full txgroup path and addresses.
253
+
254
+ Queries the database for the TxGroup entity to show its full path.
255
+
256
+ Examples:
257
+ >>> from dao_treasury.sorting._matchers import FromAddressMatcher
258
+ >>> from dao_treasury.types import TxGroupDbid
259
+ >>> fam = FromAddressMatcher(TxGroupDbid(6), ["0xAbC1230000000000000000000000000000000000"])
260
+ >>> repr(fam)
261
+ "FromAddressMatcher(txgroup='Parent:Child', addresses=['0xAbC1230000000000000000000000000000000000'])"
262
+ """
263
+ from dao_treasury.db import TxGroup
264
+
265
+ txgroup = TxGroup.get(txgroup_id=self.txgroup_id)
266
+ return f"{type(self).__name__}(txgroup='{txgroup.fullname}', addresses={list(self.values)})"
267
+
268
+
269
+ @final
270
+ class FromAddressMatcher(_AddressMatcher):
271
+ """Final matcher that categorizes by transaction `from_address`.
272
+
273
+ Examples:
274
+ >>> from dao_treasury.sorting._matchers import FromAddressMatcher
275
+ >>> from dao_treasury.types import TxGroupDbid
276
+ >>> address = "0xAbC1230000000000000000000000000000000000"
277
+ >>> fam = FromAddressMatcher(TxGroupDbid(7), [address])
278
+ >>> FromAddressMatcher.match(address)
279
+ TxGroupDbid(7)
280
+ """
281
+
282
+ __instances__: ClassVar[List["FromAddressMatcher"]] = []
283
+ __cache__: ClassVar[Dict[ChecksumAddress, TxGroupDbid]] = {}
284
+
285
+
286
+ @final
287
+ class ToAddressMatcher(_AddressMatcher):
288
+ """Final matcher that categorizes by transaction `to_address`.
289
+
290
+ Examples:
291
+ >>> from dao_treasury.sorting._matchers import ToAddressMatcher
292
+ >>> from dao_treasury.types import TxGroupDbid
293
+ >>> address = "0xDef4560000000000000000000000000000000000"
294
+ >>> tam = ToAddressMatcher(TxGroupDbid(8), [address])
295
+ >>> ToAddressMatcher.match(address)
296
+ TxGroupDbid(8)
297
+ """
298
+
299
+ __instances__: ClassVar[List["ToAddressMatcher"]] = []
300
+ __cache__: ClassVar[Dict[ChecksumAddress, TxGroupDbid]] = {}
301
+
302
+
303
+ @final
304
+ class HashMatcher(_HexStringMatcher):
305
+ """Final matcher that categorizes by transaction hash.
306
+
307
+ Matches full 66-character hex transaction hashes.
308
+
309
+ Examples:
310
+ >>> from dao_treasury.sorting._matchers import HashMatcher
311
+ >>> from dao_treasury.types import TxGroupDbid
312
+ >>> hash_str = '0x' + 'f' * 64
313
+ >>> hm = HashMatcher(TxGroupDbid(9), [hash_str])
314
+ >>> HashMatcher.match(hash_str)
315
+ TxGroupDbid(9)
316
+ >>> repr(hm)
317
+ "HashMatcher(txgroup='Root:Group', hashes=['0xffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff'])"
318
+ """
319
+
320
+ expected_length: ClassVar[int] = 66
321
+ __instances__: ClassVar[List["HashMatcher"]] = []
322
+ __cache__: ClassVar[Dict[HexStr, TxGroupDbid]] = {}
323
+
324
+ def __init__(self, txgroup: TxGroupDbid, hashes: Iterable[HexStr]) -> None:
325
+ """Initialize hash matcher ensuring unique transaction hashes.
326
+
327
+ Validates and normalizes hashes to fixed length, and ensures that each hash is
328
+ only registered once. Duplicate hashes in the input iterable will log a warning,
329
+ but only the first occurrence is used.
330
+
331
+ Args:
332
+ txgroup: Identifier of the transaction group.
333
+ hashes: Iterable of hex string hashes.
334
+
335
+ Raises:
336
+ ValueError: If `hashes` is empty, or if any hash already has an existing
337
+ matcher.
338
+
339
+ Examples:
340
+ >>> from dao_treasury.sorting._matchers import HashMatcher
341
+ >>> from dao_treasury.types import TxGroupDbid
342
+ >>> hash_str = '0x' + 'f' * 64
343
+ >>> # duplicate in list logs warning but does not raise
344
+ >>> hm = HashMatcher(TxGroupDbid(9), [hash_str, hash_str])
345
+ >>> HashMatcher.match(hash_str)
346
+ TxGroupDbid(9)
347
+
348
+ See Also:
349
+ :meth:`_validate_hexstr`
350
+ """
351
+ hashes = list(hashes)
352
+ if not hashes:
353
+ raise ValueError("You must provide at least one transaction hash")
354
+
355
+ validated: Set[HexStr] = set()
356
+ for txhash in hashes:
357
+ txhash = self._validate_hexstr(txhash)
358
+ for matcher in self.__instances__:
359
+ if txhash in matcher:
360
+ raise ValueError(f"hash {txhash} already has a matcher: {matcher}")
361
+ if txhash in validated:
362
+ logger.warning("duplicate hash %s", txhash)
363
+ validated.add(txhash)
364
+
365
+ super().__init__(txgroup, validated)
366
+
367
+ logger.info("%s created", self)
368
+ HashMatcher.__instances__.append(self)
369
+
370
+ @db_session # type: ignore [misc]
371
+ def __repr__(self) -> str:
372
+ """Return a string representation including the full txgroup path and hashes.
373
+
374
+ Queries the database for the TxGroup entity to show its full path.
375
+
376
+ Examples:
377
+ >>> from dao_treasury.sorting._matchers import HashMatcher
378
+ >>> from dao_treasury.types import TxGroupDbid
379
+ >>> hash_str = '0x' + 'f' * 64
380
+ >>> hm = HashMatcher(TxGroupDbid(10), [hash_str])
381
+ >>> repr(hm)
382
+ "HashMatcher(txgroup='Root:Group', hashes=['0xffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff'])"
383
+ """
384
+ from dao_treasury.db import TxGroup
385
+
386
+ txgroup = TxGroup.get(txgroup_id=self.txgroup_id)
387
+ return f"{type(self).__name__}(txgroup='{txgroup.fullname}', hashes={list(self.values)})"
@@ -0,0 +1,235 @@
1
+ from logging import getLogger
2
+ from pathlib import Path
3
+ from typing import Final, Type, Union, final
4
+
5
+ import yaml
6
+ from pony.orm import db_session
7
+
8
+ from dao_treasury.constants import CHAINID
9
+ from dao_treasury.sorting import (
10
+ _Matcher,
11
+ FromAddressMatcher,
12
+ HashMatcher,
13
+ ToAddressMatcher,
14
+ )
15
+ from dao_treasury.types import TopLevelCategory, TxGroupDbid
16
+
17
+
18
+ logger: Final = getLogger("dao_treasury.rules")
19
+
20
+
21
+ @final
22
+ class Rules:
23
+ """Loader for transaction‐sorting rule matchers defined in YAML files.
24
+
25
+ This class discovers and instantiates matchers based on simple YAML definitions
26
+ organized under subdirectories for each top‐level category in a given base path.
27
+
28
+ The expected directory layout is:
29
+
30
+ base_path/
31
+ revenue/
32
+ cost_of_revenue/
33
+ expenses/
34
+ other_income/
35
+ other_expense/
36
+ ignore/
37
+
38
+ Under each category directory, files named `match_on_hash.(yml|yaml)`,
39
+ `match_on_from_address.(yml|yaml)`, and `match_on_to_address.(yml|yaml)`
40
+ define mappings of subgroup names to lists or nested dicts of values keyed by
41
+ the active chain ID.
42
+
43
+ Upon initialization, all available matchers are built exactly once and registered
44
+ in the global in‐memory registry, allowing transactions to be routed to the
45
+ appropriate `TxGroup` by hash, sender address, or recipient address.
46
+
47
+ Examples:
48
+ >>> from pathlib import Path
49
+ >>> from dao_treasury.sorting._rules import Rules
50
+ >>> rules = Rules(Path("config/sorting_rules"))
51
+ # If config/sorting_rules/revenue/match_on_hash.yml contains:
52
+ # 1:
53
+ # DonationReceived:
54
+ # - 0xabc123...
55
+ # Then this creates a `TxGroup` named "Revenue:DonationReceived"
56
+ # and a `HashMatcher` that routes hash "0xabc123..." accordingly.
57
+
58
+ See Also:
59
+ :class:`dao_treasury.sorting.HashMatcher`
60
+ :class:`dao_treasury.sorting.FromAddressMatcher`
61
+ :class:`dao_treasury.sorting.ToAddressMatcher`
62
+ """
63
+
64
+ def __init__(self, path: Path):
65
+ """Initialize rule directories and build matchers.
66
+
67
+ Args:
68
+ path: Base directory containing subdirectories for each top‐level category.
69
+
70
+ Example:
71
+ >>> from pathlib import Path
72
+ >>> rules = Rules(Path("/absolute/path/to/rules"))
73
+ """
74
+ self.__initialized = False
75
+ self.rules_dir: Final = path
76
+ self.revenue_dir: Final = path / "revenue"
77
+ self.cost_of_revenue_dir: Final = path / "cost_of_revenue"
78
+ self.expenses_dir: Final = path / "expenses"
79
+ self.other_income_dir: Final = path / "other_income"
80
+ self.other_expense_dir: Final = path / "other_expense"
81
+ self.ignore_dir: Final = path / "ignore"
82
+ self.__build_matchers()
83
+
84
+ @db_session # type: ignore [misc]
85
+ def __build_matchers(self) -> None:
86
+ """Scan all categories and rule types, instantiate matchers.
87
+
88
+ This method must only run once per `Rules` instance, raising a RuntimeError
89
+ if invoked again. It iterates over the three rule file prefixes and calls
90
+ :meth:`__build_matchers_for_all_groups` for each.
91
+
92
+ Raises:
93
+ RuntimeError: If this method is called more than once on the same object.
94
+
95
+ Example:
96
+ >>> rules = Rules(Path("rules_dir"))
97
+ # Second build attempt:
98
+ >>> rules._Rules__build_matchers()
99
+ RuntimeError: You cannot initialize the rules more than once
100
+ """
101
+ if self.__initialized:
102
+ raise RuntimeError("You cannot initialize the rules more than once")
103
+ self.__build_matchers_for_all_groups("match_on_hash", HashMatcher)
104
+ self.__build_matchers_for_all_groups(
105
+ "match_on_from_address", FromAddressMatcher
106
+ )
107
+ self.__build_matchers_for_all_groups("match_on_to_address", ToAddressMatcher)
108
+ self.__initialized = True
109
+
110
+ def __build_matchers_for_all_groups(
111
+ self, match_rules_filename: str, matcher_cls: Type[_Matcher]
112
+ ) -> None:
113
+ """Register one type of matcher across all top‐level categories.
114
+
115
+ Args:
116
+ match_rules_filename: Base filename of the YAML rule files (without extension),
117
+ e.g. `"match_on_hash"`.
118
+ matcher_cls: Matcher class to instantiate
119
+ (HashMatcher, FromAddressMatcher, or ToAddressMatcher).
120
+
121
+ This will call :meth:`__build_matchers_for_group` for each of the
122
+ fixed categories: Revenue, Cost of Revenue, Expenses, Other Income,
123
+ Other Expenses, Ignore.
124
+
125
+ Example:
126
+ >>> rules = Rules(Path("rules"))
127
+ >>> rules._Rules__build_matchers_for_all_groups("match_on_hash", HashMatcher)
128
+ """
129
+ self.__build_matchers_for_group(
130
+ "Revenue", self.revenue_dir, match_rules_filename, matcher_cls
131
+ )
132
+ self.__build_matchers_for_group(
133
+ "Cost of Revenue",
134
+ self.cost_of_revenue_dir,
135
+ match_rules_filename,
136
+ matcher_cls,
137
+ )
138
+ self.__build_matchers_for_group(
139
+ "Expenses", self.expenses_dir, match_rules_filename, matcher_cls
140
+ )
141
+ self.__build_matchers_for_group(
142
+ "Other Income", self.other_income_dir, match_rules_filename, matcher_cls
143
+ )
144
+ self.__build_matchers_for_group(
145
+ "Other Expenses", self.other_expense_dir, match_rules_filename, matcher_cls
146
+ )
147
+ self.__build_matchers_for_group(
148
+ "Ignore", self.ignore_dir, match_rules_filename, matcher_cls
149
+ )
150
+
151
+ def __build_matchers_for_group(
152
+ self,
153
+ top_level_name: TopLevelCategory,
154
+ rules: Path,
155
+ filename: str,
156
+ matcher_cls: Type[_Matcher],
157
+ ) -> None:
158
+ """Load and instantiate matchers defined in a specific category directory.
159
+
160
+ This method locates `<filename>.yml` or `<filename>.yaml` under `rules`
161
+ and parses it. If the file is missing, it is skipped silently. If the file
162
+ is empty, a warning is logged. Otherwise:
163
+
164
+ 1. Reads and YAML-parses the file.
165
+ 2. Extracts the mapping for the current `CHAINID`.
166
+ 3. For each subgroup name and its values (list or dict),
167
+ obtains or creates a child `TxGroup`, then instantiates `matcher_cls`
168
+ for the values.
169
+
170
+ Args:
171
+ top_level_name: Top‐level category name used as parent TxGroup
172
+ (e.g. `"Revenue"`, `"Expenses"`, `"Ignore"`).
173
+ rules: Path to the directory containing the rule file.
174
+ filename: Base filename of the rules (no extension).
175
+ matcher_cls: Matcher class to register rules.
176
+
177
+ Raises:
178
+ ValueError: If the YAML mapping under the chain ID is neither a list nor a dict.
179
+ """
180
+ try:
181
+ matchers = self.__get_rule_file(rules, filename)
182
+ except FileNotFoundError:
183
+ return
184
+
185
+ from dao_treasury.db import TxGroup
186
+
187
+ parent: Union[TxGroup, TxGroupDbid] = TxGroup.get_or_insert(
188
+ top_level_name, None
189
+ )
190
+ parsed = yaml.safe_load(matchers.read_bytes())
191
+ if not parsed:
192
+ logger.warning(f"no content in rule file: {rules}")
193
+ return
194
+
195
+ matching_rules: dict = parsed.get(CHAINID, {}) # type: ignore [type-arg]
196
+ for name, hashes in matching_rules.items():
197
+ txgroup_dbid = TxGroup.get_dbid(name, parent)
198
+ if isinstance(hashes, list):
199
+ # initialize the matcher and add it to the registry
200
+ matcher_cls(txgroup_dbid, hashes) # type: ignore [arg-type]
201
+ elif isinstance(hashes, dict):
202
+ parent = txgroup_dbid
203
+ for name, hashes in hashes.items():
204
+ txgroup_dbid = TxGroup.get_dbid(name, parent)
205
+ # initialize the matcher and add it to the registry
206
+ matcher_cls(txgroup_dbid, hashes)
207
+ else:
208
+ raise ValueError(hashes)
209
+
210
+ def __get_rule_file(self, path: Path, filename: str) -> Path:
211
+ """Locate a YAML rule file by trying `.yml` and `.yaml` extensions.
212
+
213
+ Args:
214
+ path: Directory in which to search.
215
+ filename: Base name of the file (no extension).
216
+
217
+ Returns:
218
+ Full `Path` to the found file.
219
+
220
+ Raises:
221
+ FileNotFoundError: If neither `<filename>.yml` nor `<filename>.yaml` exists.
222
+
223
+ Example:
224
+ >>> rules_dir = Path("rules/revenue")
225
+ >>> path = rules._Rules__get_rule_file(rules_dir, "match_on_hash")
226
+ >>> print(path.name)
227
+ match_on_hash.yaml
228
+ """
229
+ for suffix in (".yml", ".yaml"):
230
+ fullname = filename + suffix
231
+ p = path / fullname
232
+ if p.exists():
233
+ return p
234
+ logger.warning("%s does not exist", p)
235
+ raise FileNotFoundError(p)