dao-treasury 0.0.10__cp310-cp310-win32.whl → 0.0.70__cp310-cp310-win32.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 (58) hide show
  1. dao_treasury/.grafana/provisioning/dashboards/breakdowns/Expenses.json +551 -0
  2. dao_treasury/.grafana/provisioning/dashboards/breakdowns/Revenue.json +551 -0
  3. dao_treasury/.grafana/provisioning/dashboards/dashboards.yaml +7 -7
  4. dao_treasury/.grafana/provisioning/dashboards/streams/LlamaPay.json +220 -0
  5. dao_treasury/.grafana/provisioning/dashboards/summary/Monthly.json +153 -29
  6. dao_treasury/.grafana/provisioning/dashboards/transactions/Treasury Transactions.json +181 -29
  7. dao_treasury/.grafana/provisioning/dashboards/treasury/Cashflow (Including Unsorted).json +808 -0
  8. dao_treasury/.grafana/provisioning/dashboards/treasury/Cashflow.json +602 -0
  9. dao_treasury/.grafana/provisioning/dashboards/treasury/Current Treasury Assets.json +981 -0
  10. dao_treasury/.grafana/provisioning/dashboards/treasury/Historical Treasury Balances.json +2989 -0
  11. dao_treasury/.grafana/provisioning/dashboards/treasury/Operating Cashflow.json +478 -0
  12. dao_treasury/.grafana/provisioning/datasources/datasources.yaml +17 -0
  13. dao_treasury/ENVIRONMENT_VARIABLES.py +20 -0
  14. dao_treasury/__init__.py +36 -10
  15. dao_treasury/_docker.cp310-win32.pyd +0 -0
  16. dao_treasury/_docker.py +169 -37
  17. dao_treasury/_nicknames.cp310-win32.pyd +0 -0
  18. dao_treasury/_nicknames.py +32 -0
  19. dao_treasury/_wallet.cp310-win32.pyd +0 -0
  20. dao_treasury/_wallet.py +164 -12
  21. dao_treasury/constants.cp310-win32.pyd +0 -0
  22. dao_treasury/constants.py +39 -0
  23. dao_treasury/db.py +925 -150
  24. dao_treasury/docker-compose.yaml +6 -5
  25. dao_treasury/main.py +238 -28
  26. dao_treasury/sorting/__init__.cp310-win32.pyd +0 -0
  27. dao_treasury/sorting/__init__.py +219 -115
  28. dao_treasury/sorting/_matchers.cp310-win32.pyd +0 -0
  29. dao_treasury/sorting/_matchers.py +261 -17
  30. dao_treasury/sorting/_rules.cp310-win32.pyd +0 -0
  31. dao_treasury/sorting/_rules.py +166 -21
  32. dao_treasury/sorting/factory.cp310-win32.pyd +0 -0
  33. dao_treasury/sorting/factory.py +245 -37
  34. dao_treasury/sorting/rule.cp310-win32.pyd +0 -0
  35. dao_treasury/sorting/rule.py +228 -46
  36. dao_treasury/sorting/rules/__init__.cp310-win32.pyd +0 -0
  37. dao_treasury/sorting/rules/__init__.py +1 -0
  38. dao_treasury/sorting/rules/ignore/__init__.cp310-win32.pyd +0 -0
  39. dao_treasury/sorting/rules/ignore/__init__.py +1 -0
  40. dao_treasury/sorting/rules/ignore/llamapay.cp310-win32.pyd +0 -0
  41. dao_treasury/sorting/rules/ignore/llamapay.py +20 -0
  42. dao_treasury/streams/__init__.cp310-win32.pyd +0 -0
  43. dao_treasury/streams/__init__.py +0 -0
  44. dao_treasury/streams/llamapay.cp310-win32.pyd +0 -0
  45. dao_treasury/streams/llamapay.py +388 -0
  46. dao_treasury/treasury.py +118 -25
  47. dao_treasury/types.cp310-win32.pyd +0 -0
  48. dao_treasury/types.py +104 -7
  49. dao_treasury-0.0.70.dist-info/METADATA +134 -0
  50. dao_treasury-0.0.70.dist-info/RECORD +54 -0
  51. dao_treasury-0.0.70.dist-info/top_level.txt +2 -0
  52. dao_treasury__mypyc.cp310-win32.pyd +0 -0
  53. a743a720bbc4482d330e__mypyc.cp310-win32.pyd +0 -0
  54. dao_treasury/.grafana/provisioning/datasources/sqlite.yaml +0 -10
  55. dao_treasury-0.0.10.dist-info/METADATA +0 -36
  56. dao_treasury-0.0.10.dist-info/RECORD +0 -28
  57. dao_treasury-0.0.10.dist-info/top_level.txt +0 -2
  58. {dao_treasury-0.0.10.dist-info → dao_treasury-0.0.70.dist-info}/WHEEL +0 -0
@@ -14,12 +14,55 @@ logger: Final = getLogger("dao_treasury")
14
14
 
15
15
 
16
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
+
17
38
  __instances__: ClassVar[List[Self]]
18
39
  __cache__: ClassVar[Dict[str, TxGroupDbid]]
19
40
 
20
41
  @classmethod
21
42
  def match(cls, string: str) -> Optional[TxGroupDbid]:
22
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
+ """
23
66
  try:
24
67
  return cls.__cache__[string]
25
68
  except KeyError:
@@ -31,45 +74,158 @@ class _Matcher:
31
74
  return None
32
75
 
33
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
+ """
34
92
  if not isinstance(txgroup, int):
35
93
  raise TypeError(txgroup)
36
-
94
+
37
95
  for matcher in self.__instances__:
38
96
  if matcher.txgroup_id == txgroup:
39
- raise ValueError(f"TxGroup[{txgroup}] already has a {type(self).__name__}: {matcher}")
97
+ raise ValueError(
98
+ f"TxGroup[{txgroup}] already has a {type(self).__name__}: {matcher}"
99
+ )
40
100
  self.txgroup_id: Final[TxGroupDbid] = txgroup
41
-
101
+
42
102
  self.__one_value: Final = len(validated_values) == 1
43
103
  self.__value: Final = list(validated_values)[0] if self.__one_value else ""
44
104
  self.__values: Final = validated_values
45
-
105
+
46
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
+ """
47
117
  return string == self.__value if self.__one_value else string in self.values
48
-
118
+
49
119
  @property
50
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
+ """
51
137
  return self.__values
52
138
 
53
139
 
54
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
+
55
151
  expected_length: ClassVar[int]
56
152
 
57
153
  @classmethod
58
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
+ """
59
168
  if not is_hexstr(hexstr):
60
169
  raise ValueError(f"value must be a hex string, not {hexstr}")
61
170
  hexstr = hexstr.lower()
62
171
  if not hexstr.startswith("0x"):
63
172
  hexstr = f"0x{hexstr}"
64
173
  if len(hexstr) != cls.expected_length:
65
- raise ValueError(f"{hexstr} has incorrect length (expected {cls.expected_length}, actual {len(hexstr)})")
174
+ raise ValueError(
175
+ f"{hexstr} has incorrect length (expected {cls.expected_length}, actual {len(hexstr)})"
176
+ )
66
177
  return hexstr
67
178
 
68
179
 
69
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
+
70
200
  expected_length: ClassVar[int] = 42
71
-
201
+
72
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
+ """
73
229
  addresses = list(addresses)
74
230
  if not addresses:
75
231
  raise ValueError("You must provide at least one address")
@@ -79,43 +235,119 @@ class _AddressMatcher(_HexStringMatcher):
79
235
  address = convert.to_address(self._validate_hexstr(address))
80
236
  for matcher in self.__instances__:
81
237
  if address in matcher:
82
- raise ValueError(f"address {address} already has a matcher: {matcher}")
238
+ raise ValueError(
239
+ f"address {address} already has a matcher: {matcher}"
240
+ )
83
241
  if address in validated:
84
- logger.warning("duplicate hash %s", address)
242
+ logger.warning("duplicate address %s", address)
85
243
  validated.add(address)
86
-
244
+
87
245
  super().__init__(txgroup, validated)
88
246
 
89
247
  logger.info("%s created", self)
90
248
  self.__instances__.append(self) # type: ignore [arg-type]
91
-
249
+
92
250
  @db_session # type: ignore [misc]
93
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
+ """
94
263
  from dao_treasury.db import TxGroup
95
264
 
96
265
  txgroup = TxGroup.get(txgroup_id=self.txgroup_id)
97
- return f"{type(self).__name__}(txgroup='{txgroup.full_string}', addresses={list(self.values)})"
266
+ return f"{type(self).__name__}(txgroup='{txgroup.fullname}', addresses={list(self.values)})"
98
267
 
99
268
 
100
269
  @final
101
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
+
102
282
  __instances__: ClassVar[List["FromAddressMatcher"]] = []
103
283
  __cache__: ClassVar[Dict[ChecksumAddress, TxGroupDbid]] = {}
104
284
 
105
285
 
106
286
  @final
107
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
+
108
299
  __instances__: ClassVar[List["ToAddressMatcher"]] = []
109
300
  __cache__: ClassVar[Dict[ChecksumAddress, TxGroupDbid]] = {}
110
301
 
111
302
 
112
303
  @final
113
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
+
114
320
  expected_length: ClassVar[int] = 66
115
321
  __instances__: ClassVar[List["HashMatcher"]] = []
116
322
  __cache__: ClassVar[Dict[HexStr, TxGroupDbid]] = {}
117
-
118
- def __init__(self, txgroup: TxGroupDbid, hashes: Iterable[HexStr]) -> None:
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
+ """
119
351
  hashes = list(hashes)
120
352
  if not hashes:
121
353
  raise ValueError("You must provide at least one transaction hash")
@@ -129,15 +361,27 @@ class HashMatcher(_HexStringMatcher):
129
361
  if txhash in validated:
130
362
  logger.warning("duplicate hash %s", txhash)
131
363
  validated.add(txhash)
132
-
364
+
133
365
  super().__init__(txgroup, validated)
134
366
 
135
367
  logger.info("%s created", self)
136
368
  HashMatcher.__instances__.append(self)
137
-
369
+
138
370
  @db_session # type: ignore [misc]
139
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
+ """
140
384
  from dao_treasury.db import TxGroup
141
385
 
142
386
  txgroup = TxGroup.get(txgroup_id=self.txgroup_id)
143
- return f"{type(self).__name__}(txgroup='{txgroup.full_string}', hashes={list(self.values)})"
387
+ return f"{type(self).__name__}(txgroup='{txgroup.fullname}', hashes={list(self.values)})"
Binary file
@@ -4,20 +4,73 @@ from typing import Final, Type, Union, final
4
4
 
5
5
  import yaml
6
6
  from pony.orm import db_session
7
- from y import constants
8
7
 
9
- from dao_treasury.sorting import _Matcher, FromAddressMatcher, HashMatcher, ToAddressMatcher
8
+ from dao_treasury.constants import CHAINID
9
+ from dao_treasury.sorting import (
10
+ _Matcher,
11
+ FromAddressMatcher,
12
+ HashMatcher,
13
+ ToAddressMatcher,
14
+ )
10
15
  from dao_treasury.types import TopLevelCategory, TxGroupDbid
11
16
 
12
17
 
13
- CHAINID: Final = constants.CHAINID
14
-
15
18
  logger: Final = getLogger("dao_treasury.rules")
16
19
 
17
20
 
18
21
  @final
19
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
+
20
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
+ """
21
74
  self.__initialized = False
22
75
  self.rules_dir: Final = path
23
76
  self.revenue_dir: Final = path / "revenue"
@@ -30,41 +83,115 @@ class Rules:
30
83
 
31
84
  @db_session # type: ignore [misc]
32
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
+ """
33
101
  if self.__initialized:
34
102
  raise RuntimeError("You cannot initialize the rules more than once")
35
103
  self.__build_matchers_for_all_groups("match_on_hash", HashMatcher)
36
- self.__build_matchers_for_all_groups("match_on_from_address", FromAddressMatcher)
104
+ self.__build_matchers_for_all_groups(
105
+ "match_on_from_address", FromAddressMatcher
106
+ )
37
107
  self.__build_matchers_for_all_groups("match_on_to_address", ToAddressMatcher)
38
108
  self.__initialized = True
39
-
40
- def __build_matchers_for_all_groups(self, match_rules_filename: str, matcher_cls: Type[_Matcher]) -> None:
41
- self.__build_matchers_for_group("Revenue", self.revenue_dir, match_rules_filename, matcher_cls)
42
- self.__build_matchers_for_group("Cost of Revenue", self.cost_of_revenue_dir, match_rules_filename, matcher_cls)
43
- self.__build_matchers_for_group("Expenses", self.expenses_dir, match_rules_filename, matcher_cls)
44
- self.__build_matchers_for_group("Other Income", self.other_income_dir, match_rules_filename, matcher_cls)
45
- self.__build_matchers_for_group("Other Expenses", self.other_expense_dir, match_rules_filename, matcher_cls)
46
- self.__build_matchers_for_group("Ignore", self.ignore_dir, match_rules_filename, matcher_cls)
47
-
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
+
48
151
  def __build_matchers_for_group(
49
- self,
50
- top_level_name: TopLevelCategory,
51
- rules: Path,
52
- filename: str,
152
+ self,
153
+ top_level_name: TopLevelCategory,
154
+ rules: Path,
155
+ filename: str,
53
156
  matcher_cls: Type[_Matcher],
54
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
+ """
55
180
  try:
56
181
  matchers = self.__get_rule_file(rules, filename)
57
182
  except FileNotFoundError:
58
183
  return
59
-
184
+
60
185
  from dao_treasury.db import TxGroup
61
186
 
62
- parent: Union[TxGroup, TxGroupDbid] = TxGroup.get_or_insert(top_level_name, None)
187
+ parent: Union[TxGroup, TxGroupDbid] = TxGroup.get_or_insert(
188
+ top_level_name, None
189
+ )
63
190
  parsed = yaml.safe_load(matchers.read_bytes())
64
191
  if not parsed:
65
192
  logger.warning(f"no content in rule file: {rules}")
66
193
  return
67
-
194
+
68
195
  matching_rules: dict = parsed.get(CHAINID, {}) # type: ignore [type-arg]
69
196
  for name, hashes in matching_rules.items():
70
197
  txgroup_dbid = TxGroup.get_dbid(name, parent)
@@ -81,6 +208,24 @@ class Rules:
81
208
  raise ValueError(hashes)
82
209
 
83
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
+ """
84
229
  for suffix in (".yml", ".yaml"):
85
230
  fullname = filename + suffix
86
231
  p = path / fullname