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.
- bf2b4fe1f86ad2ea158b__mypyc.cpython-312-i386-linux-gnu.so +0 -0
- dao_treasury/.grafana/provisioning/dashboards/dashboards.yaml +60 -0
- dao_treasury/.grafana/provisioning/dashboards/streams/LlamaPay.json +225 -0
- dao_treasury/.grafana/provisioning/dashboards/summary/Monthly.json +107 -0
- dao_treasury/.grafana/provisioning/dashboards/transactions/Treasury Transactions.json +387 -0
- dao_treasury/.grafana/provisioning/dashboards/treasury/Cashflow (Including Unsorted).json +835 -0
- dao_treasury/.grafana/provisioning/dashboards/treasury/Cashflow.json +615 -0
- dao_treasury/.grafana/provisioning/dashboards/treasury/Operating Cashflow.json +492 -0
- dao_treasury/.grafana/provisioning/dashboards/treasury/Treasury.json +2018 -0
- dao_treasury/.grafana/provisioning/datasources/datasources.yaml +17 -0
- dao_treasury/ENVIRONMENT_VARIABLES.py +20 -0
- dao_treasury/__init__.py +62 -0
- dao_treasury/_docker.cpython-312-i386-linux-gnu.so +0 -0
- dao_treasury/_docker.py +190 -0
- dao_treasury/_nicknames.cpython-312-i386-linux-gnu.so +0 -0
- dao_treasury/_nicknames.py +32 -0
- dao_treasury/_wallet.cpython-312-i386-linux-gnu.so +0 -0
- dao_treasury/_wallet.py +250 -0
- dao_treasury/constants.cpython-312-i386-linux-gnu.so +0 -0
- dao_treasury/constants.py +34 -0
- dao_treasury/db.py +1408 -0
- dao_treasury/docker-compose.yaml +41 -0
- dao_treasury/main.py +247 -0
- dao_treasury/py.typed +0 -0
- dao_treasury/sorting/__init__.cpython-312-i386-linux-gnu.so +0 -0
- dao_treasury/sorting/__init__.py +295 -0
- dao_treasury/sorting/_matchers.cpython-312-i386-linux-gnu.so +0 -0
- dao_treasury/sorting/_matchers.py +387 -0
- dao_treasury/sorting/_rules.cpython-312-i386-linux-gnu.so +0 -0
- dao_treasury/sorting/_rules.py +235 -0
- dao_treasury/sorting/factory.cpython-312-i386-linux-gnu.so +0 -0
- dao_treasury/sorting/factory.py +299 -0
- dao_treasury/sorting/rule.cpython-312-i386-linux-gnu.so +0 -0
- dao_treasury/sorting/rule.py +346 -0
- dao_treasury/sorting/rules/__init__.cpython-312-i386-linux-gnu.so +0 -0
- dao_treasury/sorting/rules/__init__.py +1 -0
- dao_treasury/sorting/rules/ignore/__init__.cpython-312-i386-linux-gnu.so +0 -0
- dao_treasury/sorting/rules/ignore/__init__.py +1 -0
- dao_treasury/sorting/rules/ignore/llamapay.cpython-312-i386-linux-gnu.so +0 -0
- dao_treasury/sorting/rules/ignore/llamapay.py +20 -0
- dao_treasury/streams/__init__.cpython-312-i386-linux-gnu.so +0 -0
- dao_treasury/streams/__init__.py +0 -0
- dao_treasury/streams/llamapay.cpython-312-i386-linux-gnu.so +0 -0
- dao_treasury/streams/llamapay.py +388 -0
- dao_treasury/treasury.py +191 -0
- dao_treasury/types.cpython-312-i386-linux-gnu.so +0 -0
- dao_treasury/types.py +133 -0
- dao_treasury-0.0.42.dist-info/METADATA +119 -0
- dao_treasury-0.0.42.dist-info/RECORD +51 -0
- dao_treasury-0.0.42.dist-info/WHEEL +7 -0
- 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)})"
|
Binary file
|
@@ -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)
|
Binary file
|