allowances 0.2.0__py3-none-any.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.
- allowances/__init__.py +86 -0
- allowances/backends/__init__.py +5 -0
- allowances/backends/memory.py +291 -0
- allowances/definitions.py +199 -0
- allowances/engine.py +493 -0
- allowances/errors.py +51 -0
- allowances/rounding.py +25 -0
- allowances/store.py +190 -0
- allowances/types.py +186 -0
- allowances/windows.py +86 -0
- allowances-0.2.0.dist-info/METADATA +137 -0
- allowances-0.2.0.dist-info/RECORD +25 -0
- allowances-0.2.0.dist-info/WHEEL +5 -0
- allowances-0.2.0.dist-info/top_level.txt +2 -0
- allowances_django/__init__.py +14 -0
- allowances_django/admin.py +90 -0
- allowances_django/apps.py +15 -0
- allowances_django/checks.py +48 -0
- allowances_django/migrations/0001_initial.py +125 -0
- allowances_django/migrations/__init__.py +0 -0
- allowances_django/models.py +279 -0
- allowances_django/providers.py +25 -0
- allowances_django/shortcuts.py +107 -0
- allowances_django/store.py +367 -0
- allowances_django/subjects.py +59 -0
allowances/__init__.py
ADDED
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
"""
|
|
2
|
+
allowances — grants and consumable budgets, enforced before the fact and auditable after it.
|
|
3
|
+
|
|
4
|
+
The core is dependency-free and storage-agnostic: it does arithmetic against a
|
|
5
|
+
:class:`~allowances.store.Store` you provide. ``import allowances`` never imports a web framework
|
|
6
|
+
or a database driver.
|
|
7
|
+
|
|
8
|
+
Most applications don't use this module directly — they install a backend
|
|
9
|
+
(``pip install allowances[django]``) and call its shortcuts, which supply the store for you.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from allowances.definitions import (
|
|
13
|
+
CurrencyDefinition,
|
|
14
|
+
CurrencySpec,
|
|
15
|
+
DefinitionProvider,
|
|
16
|
+
DictProvider,
|
|
17
|
+
FeatureDefinition,
|
|
18
|
+
FeatureSection,
|
|
19
|
+
)
|
|
20
|
+
from allowances.engine import (
|
|
21
|
+
archive_expired_wallets,
|
|
22
|
+
attach,
|
|
23
|
+
effective_balance,
|
|
24
|
+
find_unattached_fulfillment_records,
|
|
25
|
+
fulfill,
|
|
26
|
+
peek,
|
|
27
|
+
plan_switch,
|
|
28
|
+
unfulfill,
|
|
29
|
+
)
|
|
30
|
+
from allowances.errors import (
|
|
31
|
+
AllowancesError,
|
|
32
|
+
DefinitionNotFoundError,
|
|
33
|
+
InsufficientBalanceError,
|
|
34
|
+
RecordNotFoundError,
|
|
35
|
+
UnsupportedOperationError,
|
|
36
|
+
)
|
|
37
|
+
from allowances.store import Store
|
|
38
|
+
from allowances.types import (
|
|
39
|
+
FulfillmentRecord,
|
|
40
|
+
FulfillmentResult,
|
|
41
|
+
Modifier,
|
|
42
|
+
ObjectRef,
|
|
43
|
+
SubjectRef,
|
|
44
|
+
Transaction,
|
|
45
|
+
TransactionKind,
|
|
46
|
+
Wallet,
|
|
47
|
+
WalletSpec,
|
|
48
|
+
WalletType,
|
|
49
|
+
)
|
|
50
|
+
from allowances.windows import window_start
|
|
51
|
+
|
|
52
|
+
__version__ = "0.1.0"
|
|
53
|
+
|
|
54
|
+
__all__ = [
|
|
55
|
+
"AllowancesError",
|
|
56
|
+
"CurrencyDefinition",
|
|
57
|
+
"CurrencySpec",
|
|
58
|
+
"DefinitionNotFoundError",
|
|
59
|
+
"DefinitionProvider",
|
|
60
|
+
"DictProvider",
|
|
61
|
+
"FeatureDefinition",
|
|
62
|
+
"FeatureSection",
|
|
63
|
+
"FulfillmentRecord",
|
|
64
|
+
"FulfillmentResult",
|
|
65
|
+
"InsufficientBalanceError",
|
|
66
|
+
"Modifier",
|
|
67
|
+
"ObjectRef",
|
|
68
|
+
"RecordNotFoundError",
|
|
69
|
+
"Store",
|
|
70
|
+
"SubjectRef",
|
|
71
|
+
"Transaction",
|
|
72
|
+
"TransactionKind",
|
|
73
|
+
"UnsupportedOperationError",
|
|
74
|
+
"Wallet",
|
|
75
|
+
"WalletSpec",
|
|
76
|
+
"WalletType",
|
|
77
|
+
"archive_expired_wallets",
|
|
78
|
+
"attach",
|
|
79
|
+
"effective_balance",
|
|
80
|
+
"find_unattached_fulfillment_records",
|
|
81
|
+
"fulfill",
|
|
82
|
+
"peek",
|
|
83
|
+
"plan_switch",
|
|
84
|
+
"unfulfill",
|
|
85
|
+
"window_start",
|
|
86
|
+
]
|
|
@@ -0,0 +1,291 @@
|
|
|
1
|
+
"""
|
|
2
|
+
An in-memory :class:`~allowances.store.Store`.
|
|
3
|
+
|
|
4
|
+
Two jobs. It makes the test suite fast — the engine's behaviour is arithmetic, and proving it
|
|
5
|
+
doesn't need a database. And it keeps the port honest: every time the engine reaches for something
|
|
6
|
+
only an ORM could do, this backend stops compiling, which is the signal that the abstraction leaked.
|
|
7
|
+
|
|
8
|
+
Not for production. State lives in a dict, so it dies with the process and is invisible to every
|
|
9
|
+
other one. Locking is a process-local ``RLock``, which is exactly as much concurrency control as a
|
|
10
|
+
single-process test needs and no more.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
import itertools
|
|
16
|
+
import threading
|
|
17
|
+
import uuid
|
|
18
|
+
|
|
19
|
+
from contextlib import contextmanager
|
|
20
|
+
from dataclasses import replace
|
|
21
|
+
from datetime import datetime
|
|
22
|
+
from decimal import Decimal
|
|
23
|
+
from typing import Sequence
|
|
24
|
+
|
|
25
|
+
from allowances.types import (
|
|
26
|
+
FulfillmentRecord,
|
|
27
|
+
Modifier,
|
|
28
|
+
ObjectRef,
|
|
29
|
+
SubjectRef,
|
|
30
|
+
Transaction,
|
|
31
|
+
TransactionKind,
|
|
32
|
+
Wallet,
|
|
33
|
+
WalletType,
|
|
34
|
+
)
|
|
35
|
+
from allowances.windows import utcnow
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
class MemoryStore:
|
|
39
|
+
"""A complete Store implementation over plain dicts."""
|
|
40
|
+
|
|
41
|
+
def __init__(self):
|
|
42
|
+
self._wallets: dict[int, Wallet] = {}
|
|
43
|
+
self._transactions: dict[int, Transaction] = {}
|
|
44
|
+
self._records: dict[uuid.UUID, FulfillmentRecord] = {}
|
|
45
|
+
self._modifiers: list[Modifier] = []
|
|
46
|
+
self.archived_wallets: list[Wallet] = []
|
|
47
|
+
self.archived_transactions: list[Transaction] = []
|
|
48
|
+
|
|
49
|
+
self._wallet_ids = itertools.count(1)
|
|
50
|
+
self._transaction_ids = itertools.count(1)
|
|
51
|
+
self._modifier_ids = itertools.count(1)
|
|
52
|
+
self._lock = threading.RLock()
|
|
53
|
+
self._depth = 0
|
|
54
|
+
# Distinguishes rows created within the same microsecond, so ordering stays deterministic
|
|
55
|
+
# on clocks too coarse to separate them.
|
|
56
|
+
self._sequence = itertools.count(1)
|
|
57
|
+
self._created_seq: dict[tuple[str, int], int] = {}
|
|
58
|
+
|
|
59
|
+
# -- transactions / locking ----------------------------------------------------------------
|
|
60
|
+
|
|
61
|
+
@contextmanager
|
|
62
|
+
def unit_of_work(self):
|
|
63
|
+
"""
|
|
64
|
+
Atomic and re-entrant. The outermost block snapshots every collection and restores it if
|
|
65
|
+
anything raises; nested blocks join the outer one, exactly as a nested ``atomic()`` does.
|
|
66
|
+
"""
|
|
67
|
+
self._lock.acquire()
|
|
68
|
+
self._depth += 1
|
|
69
|
+
snapshot = self._snapshot() if self._depth == 1 else None
|
|
70
|
+
try:
|
|
71
|
+
yield
|
|
72
|
+
except BaseException:
|
|
73
|
+
if snapshot is not None:
|
|
74
|
+
self._restore(snapshot)
|
|
75
|
+
raise
|
|
76
|
+
finally:
|
|
77
|
+
self._depth -= 1
|
|
78
|
+
self._lock.release()
|
|
79
|
+
|
|
80
|
+
def _snapshot(self):
|
|
81
|
+
# Every stored value is a frozen dataclass, so copying the containers is enough.
|
|
82
|
+
return (
|
|
83
|
+
dict(self._wallets),
|
|
84
|
+
dict(self._transactions),
|
|
85
|
+
dict(self._records),
|
|
86
|
+
list(self._modifiers),
|
|
87
|
+
list(self.archived_wallets),
|
|
88
|
+
list(self.archived_transactions),
|
|
89
|
+
dict(self._created_seq),
|
|
90
|
+
)
|
|
91
|
+
|
|
92
|
+
def _restore(self, snapshot):
|
|
93
|
+
(
|
|
94
|
+
self._wallets,
|
|
95
|
+
self._transactions,
|
|
96
|
+
self._records,
|
|
97
|
+
self._modifiers,
|
|
98
|
+
self.archived_wallets,
|
|
99
|
+
self.archived_transactions,
|
|
100
|
+
self._created_seq,
|
|
101
|
+
) = snapshot
|
|
102
|
+
|
|
103
|
+
def _sort_key(self, kind: str, obj):
|
|
104
|
+
return (obj.created_at, self._created_seq.get((kind, obj.id), 0), obj.id)
|
|
105
|
+
|
|
106
|
+
# -- wallets -------------------------------------------------------------------------------
|
|
107
|
+
|
|
108
|
+
def find_wallets(
|
|
109
|
+
self,
|
|
110
|
+
*,
|
|
111
|
+
subjects: Sequence[SubjectRef],
|
|
112
|
+
wallet_types: Sequence[WalletType],
|
|
113
|
+
currencies: Sequence[str],
|
|
114
|
+
at: datetime,
|
|
115
|
+
lock: bool = False,
|
|
116
|
+
) -> list[Wallet]:
|
|
117
|
+
subject_set = set(subjects)
|
|
118
|
+
type_set = {str(wallet_type) for wallet_type in wallet_types}
|
|
119
|
+
currency_set = set(currencies)
|
|
120
|
+
found = [
|
|
121
|
+
wallet
|
|
122
|
+
for wallet in self._wallets.values()
|
|
123
|
+
if wallet.subject in subject_set
|
|
124
|
+
and str(wallet.wallet_type) in type_set
|
|
125
|
+
and wallet.currency in currency_set
|
|
126
|
+
and wallet.is_valid_at(at)
|
|
127
|
+
]
|
|
128
|
+
return sorted(found, key=lambda w: self._sort_key("wallet", w))
|
|
129
|
+
|
|
130
|
+
def get_wallet(self, wallet_id: object, *, lock: bool = False) -> Wallet | None:
|
|
131
|
+
return self._wallets.get(wallet_id)
|
|
132
|
+
|
|
133
|
+
def save_wallet(self, wallet: Wallet) -> None:
|
|
134
|
+
if wallet.id in self._wallets:
|
|
135
|
+
self._wallets[wallet.id] = wallet
|
|
136
|
+
|
|
137
|
+
def create_wallet(
|
|
138
|
+
self,
|
|
139
|
+
*,
|
|
140
|
+
subject: SubjectRef,
|
|
141
|
+
wallet_type: WalletType,
|
|
142
|
+
currency: str,
|
|
143
|
+
balance: Decimal,
|
|
144
|
+
overdraft_limit: Decimal | None = None,
|
|
145
|
+
reset_config: dict | None = None,
|
|
146
|
+
valid_from: datetime | None = None,
|
|
147
|
+
valid_until: datetime | None = None,
|
|
148
|
+
) -> Wallet:
|
|
149
|
+
wallet = Wallet(
|
|
150
|
+
id=next(self._wallet_ids),
|
|
151
|
+
subject=subject,
|
|
152
|
+
wallet_type=WalletType(wallet_type),
|
|
153
|
+
currency=currency,
|
|
154
|
+
balance=Decimal(str(balance)),
|
|
155
|
+
created_at=utcnow(),
|
|
156
|
+
valid_from=valid_from,
|
|
157
|
+
valid_until=valid_until,
|
|
158
|
+
overdraft_limit=None if overdraft_limit is None else Decimal(str(overdraft_limit)),
|
|
159
|
+
reset_config=reset_config,
|
|
160
|
+
)
|
|
161
|
+
self._wallets[wallet.id] = wallet
|
|
162
|
+
self._created_seq[("wallet", wallet.id)] = next(self._sequence)
|
|
163
|
+
return wallet
|
|
164
|
+
|
|
165
|
+
def close_wallets(self, *, subject: SubjectRef, at: datetime) -> int:
|
|
166
|
+
closed = 0
|
|
167
|
+
for wallet_id, wallet in list(self._wallets.items()):
|
|
168
|
+
if wallet.subject == subject and wallet.is_valid_at(at):
|
|
169
|
+
self._wallets[wallet_id] = replace(wallet, valid_until=at)
|
|
170
|
+
closed += 1
|
|
171
|
+
return closed
|
|
172
|
+
|
|
173
|
+
def find_expired_wallets(self, *, at: datetime, lock: bool = False) -> list[Wallet]:
|
|
174
|
+
expired = [w for w in self._wallets.values() if w.valid_until is not None and w.valid_until <= at]
|
|
175
|
+
return sorted(expired, key=lambda w: self._sort_key("wallet", w))
|
|
176
|
+
|
|
177
|
+
def archive_wallet(self, wallet: Wallet) -> None:
|
|
178
|
+
stored = self._wallets.pop(wallet.id, None)
|
|
179
|
+
if stored is None:
|
|
180
|
+
return
|
|
181
|
+
self.archived_wallets.append(wallet)
|
|
182
|
+
for txn_id, txn in list(self._transactions.items()):
|
|
183
|
+
if txn.wallet_id == wallet.id:
|
|
184
|
+
self.archived_transactions.append(txn)
|
|
185
|
+
del self._transactions[txn_id]
|
|
186
|
+
|
|
187
|
+
# -- ledger --------------------------------------------------------------------------------
|
|
188
|
+
|
|
189
|
+
def sum_transactions(self, *, wallet_id: object, since: datetime, until: datetime) -> Decimal:
|
|
190
|
+
return sum(
|
|
191
|
+
(
|
|
192
|
+
t.amount
|
|
193
|
+
for t in self._transactions.values()
|
|
194
|
+
if t.wallet_id == wallet_id and since <= t.created_at <= until
|
|
195
|
+
),
|
|
196
|
+
Decimal("0"),
|
|
197
|
+
)
|
|
198
|
+
|
|
199
|
+
def create_transaction(
|
|
200
|
+
self,
|
|
201
|
+
*,
|
|
202
|
+
wallet_id: object,
|
|
203
|
+
kind: TransactionKind,
|
|
204
|
+
amount: Decimal,
|
|
205
|
+
record_guid: uuid.UUID | None = None,
|
|
206
|
+
feature: str = "",
|
|
207
|
+
storno_of_id: object = None,
|
|
208
|
+
at: datetime | None = None,
|
|
209
|
+
) -> Transaction:
|
|
210
|
+
if storno_of_id is not None and any(t.storno_of_id == storno_of_id for t in self._transactions.values()):
|
|
211
|
+
# The relational backends enforce this with a unique constraint; a lost race here would
|
|
212
|
+
# silently refund twice, so the in-memory one refuses just as loudly.
|
|
213
|
+
raise ValueError(f"Transaction {storno_of_id!r} has already been stornoed")
|
|
214
|
+
txn = Transaction(
|
|
215
|
+
id=next(self._transaction_ids),
|
|
216
|
+
wallet_id=wallet_id,
|
|
217
|
+
kind=TransactionKind(kind),
|
|
218
|
+
amount=Decimal(str(amount)),
|
|
219
|
+
created_at=at or utcnow(),
|
|
220
|
+
record_guid=record_guid,
|
|
221
|
+
feature=feature,
|
|
222
|
+
storno_of_id=storno_of_id,
|
|
223
|
+
)
|
|
224
|
+
self._transactions[txn.id] = txn
|
|
225
|
+
self._created_seq[("transaction", txn.id)] = next(self._sequence)
|
|
226
|
+
return txn
|
|
227
|
+
|
|
228
|
+
def transactions_for_record(self, *, record_guid: uuid.UUID, lock: bool = False) -> list[Transaction]:
|
|
229
|
+
found = [t for t in self._transactions.values() if t.record_guid == record_guid]
|
|
230
|
+
return sorted(found, key=lambda t: self._sort_key("transaction", t))
|
|
231
|
+
|
|
232
|
+
def attach_object(self, *, transaction_ids: Sequence[object], obj: ObjectRef) -> None:
|
|
233
|
+
for txn_id in transaction_ids:
|
|
234
|
+
txn = self._transactions.get(txn_id)
|
|
235
|
+
if txn is not None:
|
|
236
|
+
self._transactions[txn_id] = replace(txn, obj=obj)
|
|
237
|
+
|
|
238
|
+
# -- fulfillment records -------------------------------------------------------------------
|
|
239
|
+
|
|
240
|
+
def get_or_create_record(self, guid: uuid.UUID | None = None) -> FulfillmentRecord:
|
|
241
|
+
if guid is None:
|
|
242
|
+
guid = uuid.uuid4()
|
|
243
|
+
record = self._records.get(guid)
|
|
244
|
+
if record is None:
|
|
245
|
+
record = FulfillmentRecord(guid=guid, created_at=utcnow())
|
|
246
|
+
self._records[guid] = record
|
|
247
|
+
return record
|
|
248
|
+
|
|
249
|
+
def get_record(self, guid: uuid.UUID, *, lock: bool = False) -> FulfillmentRecord | None:
|
|
250
|
+
return self._records.get(guid)
|
|
251
|
+
|
|
252
|
+
def records_with_unattached_transactions(self, *, older_than: datetime | None = None) -> list[FulfillmentRecord]:
|
|
253
|
+
guids = {t.record_guid for t in self._transactions.values() if t.obj is None and t.record_guid is not None}
|
|
254
|
+
records = [self._records[g] for g in guids if g in self._records]
|
|
255
|
+
if older_than is not None:
|
|
256
|
+
records = [r for r in records if r.created_at <= older_than]
|
|
257
|
+
return sorted(records, key=lambda r: (r.created_at, str(r.guid)))
|
|
258
|
+
|
|
259
|
+
# -- modifiers -----------------------------------------------------------------------------
|
|
260
|
+
|
|
261
|
+
def add_modifier(
|
|
262
|
+
self,
|
|
263
|
+
*,
|
|
264
|
+
subject: SubjectRef,
|
|
265
|
+
feature: str,
|
|
266
|
+
factor: Decimal,
|
|
267
|
+
valid_from: datetime | None = None,
|
|
268
|
+
valid_until: datetime | None = None,
|
|
269
|
+
) -> Modifier:
|
|
270
|
+
"""Not part of the port — modifiers are seeded by an application, not by the engine."""
|
|
271
|
+
modifier = Modifier(
|
|
272
|
+
id=next(self._modifier_ids),
|
|
273
|
+
subject=subject,
|
|
274
|
+
feature=feature,
|
|
275
|
+
factor=Decimal(str(factor)),
|
|
276
|
+
valid_from=valid_from,
|
|
277
|
+
valid_until=valid_until,
|
|
278
|
+
)
|
|
279
|
+
self._modifiers.append(modifier)
|
|
280
|
+
return modifier
|
|
281
|
+
|
|
282
|
+
def find_modifier_factors(self, *, feature: str, subjects: Sequence[SubjectRef], at: datetime) -> list[Decimal]:
|
|
283
|
+
subject_set = set(subjects)
|
|
284
|
+
return [
|
|
285
|
+
m.factor
|
|
286
|
+
for m in self._modifiers
|
|
287
|
+
if m.feature == feature
|
|
288
|
+
and m.subject in subject_set
|
|
289
|
+
and (m.valid_from is None or m.valid_from <= at)
|
|
290
|
+
and (m.valid_until is None or m.valid_until >= at)
|
|
291
|
+
]
|
|
@@ -0,0 +1,199 @@
|
|
|
1
|
+
"""
|
|
2
|
+
The definition layer: what rights *exist*, as opposed to what a subject currently *holds*.
|
|
3
|
+
|
|
4
|
+
Definitions (features, currencies, conversion rates, fulfillment order) are configuration, not
|
|
5
|
+
state — they are not rows in a database and they are not versioned per subject. The engine never
|
|
6
|
+
reads them directly; it asks a :class:`DefinitionProvider`.
|
|
7
|
+
|
|
8
|
+
:class:`DictProvider` reads them from a plain dict. The Django adapter subclasses it to read
|
|
9
|
+
``settings.ALLOWANCES``. A remote licence server would be a third implementation, and the engine
|
|
10
|
+
would not notice.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
import abc
|
|
16
|
+
import importlib
|
|
17
|
+
|
|
18
|
+
from dataclasses import dataclass
|
|
19
|
+
from decimal import Decimal
|
|
20
|
+
|
|
21
|
+
from allowances.errors import DefinitionNotFoundError
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def import_string(dotted_path: str):
|
|
25
|
+
"""
|
|
26
|
+
Import ``"package.module.attribute"`` and return the attribute.
|
|
27
|
+
|
|
28
|
+
A dependency-free equivalent of ``django.utils.module_loading.import_string`` — the core must
|
|
29
|
+
not import Django to resolve a ``fulfillment_order`` callable.
|
|
30
|
+
"""
|
|
31
|
+
module_path, _, attr = dotted_path.rpartition(".")
|
|
32
|
+
if not module_path:
|
|
33
|
+
raise ImportError(f"{dotted_path!r} is not a dotted module path")
|
|
34
|
+
try:
|
|
35
|
+
return getattr(importlib.import_module(module_path), attr)
|
|
36
|
+
except AttributeError as exc:
|
|
37
|
+
raise ImportError(f"Module {module_path!r} has no attribute {attr!r}") from exc
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
@dataclass(frozen=True)
|
|
41
|
+
class CurrencyDefinition:
|
|
42
|
+
"""
|
|
43
|
+
A named unit of entitlement.
|
|
44
|
+
|
|
45
|
+
``general=True`` marks a fallback currency (e.g. ``credits``) that any feature accepting it may
|
|
46
|
+
draw from. ``round_fn`` decides how a fractional cost is turned into a whole unit —
|
|
47
|
+
``"floor"``, ``"ceil"``, ``"round"``, or ``None`` to leave fractions alone.
|
|
48
|
+
"""
|
|
49
|
+
|
|
50
|
+
name: str
|
|
51
|
+
general: bool = False
|
|
52
|
+
round_fn: str | None = None
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
@dataclass(frozen=True)
|
|
56
|
+
class CurrencySpec:
|
|
57
|
+
"""
|
|
58
|
+
One entry in a feature section's ``currencies`` list: a currency plus its conversion rate
|
|
59
|
+
against the feature's own request unit. ``("credits", 0.5)`` means one unit of this feature
|
|
60
|
+
costs half a credit. A bare string means a rate of 1.
|
|
61
|
+
"""
|
|
62
|
+
|
|
63
|
+
currency: str
|
|
64
|
+
rate: Decimal = Decimal("1")
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
@dataclass(frozen=True)
|
|
68
|
+
class FeatureSection:
|
|
69
|
+
"""The ``grant`` or ``consumable`` half of a feature definition."""
|
|
70
|
+
|
|
71
|
+
currencies: tuple[CurrencySpec, ...]
|
|
72
|
+
|
|
73
|
+
def rate_for(self, currency: str) -> Decimal | None:
|
|
74
|
+
for spec in self.currencies:
|
|
75
|
+
if spec.currency == currency:
|
|
76
|
+
return spec.rate
|
|
77
|
+
return None
|
|
78
|
+
|
|
79
|
+
@property
|
|
80
|
+
def currency_names(self) -> tuple[str, ...]:
|
|
81
|
+
return tuple(spec.currency for spec in self.currencies)
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
@dataclass(frozen=True)
|
|
85
|
+
class FeatureDefinition:
|
|
86
|
+
"""
|
|
87
|
+
A named capability: ``send_email``, ``coolant_fill``.
|
|
88
|
+
|
|
89
|
+
``fulfillment_order`` is either a list of ``(wallet_type, subject_name)`` steps or a callable
|
|
90
|
+
that receives the candidate wallets and returns them in draw order. Note the callable receives
|
|
91
|
+
a ``list[Wallet]`` — plain value objects, not a database query — so the same ordering function
|
|
92
|
+
works against any backend.
|
|
93
|
+
"""
|
|
94
|
+
|
|
95
|
+
name: str
|
|
96
|
+
grant: FeatureSection | None
|
|
97
|
+
consumable: FeatureSection | None
|
|
98
|
+
subjects: frozenset
|
|
99
|
+
fulfillment_order: object
|
|
100
|
+
overdraft: bool = False
|
|
101
|
+
|
|
102
|
+
def section_for(self, wallet_type: str) -> FeatureSection | None:
|
|
103
|
+
return self.grant if wallet_type == "grant" else self.consumable
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
class DefinitionProvider(abc.ABC):
|
|
107
|
+
"""Abstract source of feature and currency definitions."""
|
|
108
|
+
|
|
109
|
+
@abc.abstractmethod
|
|
110
|
+
def get_feature(self, name: str) -> FeatureDefinition: ...
|
|
111
|
+
|
|
112
|
+
@abc.abstractmethod
|
|
113
|
+
def get_currency(self, name: str) -> CurrencyDefinition: ...
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
class DictProvider(DefinitionProvider):
|
|
117
|
+
"""
|
|
118
|
+
Reads definitions from a dict of the shape::
|
|
119
|
+
|
|
120
|
+
{
|
|
121
|
+
"currencies": {
|
|
122
|
+
"email_send": {},
|
|
123
|
+
"credits": {"general": True, "round_fn": "ceil"},
|
|
124
|
+
},
|
|
125
|
+
"features": {
|
|
126
|
+
"send_email": {
|
|
127
|
+
"grant": {"currencies": ["email_send"]},
|
|
128
|
+
"consumable": {"currencies": ["email_send", ("credits", 0.5)]},
|
|
129
|
+
"subjects": {"device", "team"},
|
|
130
|
+
"fulfillment_order": [("grant", "device"), ("grant", None), "team"],
|
|
131
|
+
"overdraft": False,
|
|
132
|
+
},
|
|
133
|
+
},
|
|
134
|
+
}
|
|
135
|
+
"""
|
|
136
|
+
|
|
137
|
+
def __init__(self, config: dict | None = None):
|
|
138
|
+
self._config = config
|
|
139
|
+
|
|
140
|
+
@property
|
|
141
|
+
def config(self) -> dict:
|
|
142
|
+
return self._config if self._config is not None else {}
|
|
143
|
+
|
|
144
|
+
def get_feature(self, name: str) -> FeatureDefinition:
|
|
145
|
+
try:
|
|
146
|
+
raw = self.config["features"][name]
|
|
147
|
+
except KeyError:
|
|
148
|
+
raise DefinitionNotFoundError(f"Unknown feature: {name!r}") from None
|
|
149
|
+
return self._parse_feature(name, raw)
|
|
150
|
+
|
|
151
|
+
def get_currency(self, name: str) -> CurrencyDefinition:
|
|
152
|
+
try:
|
|
153
|
+
raw = self.config["currencies"][name]
|
|
154
|
+
except KeyError:
|
|
155
|
+
raise DefinitionNotFoundError(f"Unknown currency: {name!r}") from None
|
|
156
|
+
return CurrencyDefinition(
|
|
157
|
+
name=name,
|
|
158
|
+
general=bool(raw.get("general", False)),
|
|
159
|
+
round_fn=raw.get("round_fn"),
|
|
160
|
+
)
|
|
161
|
+
|
|
162
|
+
def _parse_feature(self, name: str, raw: dict) -> FeatureDefinition:
|
|
163
|
+
return FeatureDefinition(
|
|
164
|
+
name=name,
|
|
165
|
+
grant=self._parse_section(raw.get("grant")),
|
|
166
|
+
consumable=self._parse_section(raw.get("consumable")),
|
|
167
|
+
subjects=frozenset(raw.get("subjects", ())),
|
|
168
|
+
fulfillment_order=self._parse_fulfillment_order(raw.get("fulfillment_order", [])),
|
|
169
|
+
overdraft=bool(raw.get("overdraft", False)),
|
|
170
|
+
)
|
|
171
|
+
|
|
172
|
+
@staticmethod
|
|
173
|
+
def _parse_section(raw: dict | None) -> FeatureSection | None:
|
|
174
|
+
if raw is None:
|
|
175
|
+
return None
|
|
176
|
+
specs = []
|
|
177
|
+
for entry in raw.get("currencies", []):
|
|
178
|
+
if isinstance(entry, (tuple, list)):
|
|
179
|
+
currency, rate = entry
|
|
180
|
+
specs.append(CurrencySpec(currency=currency, rate=Decimal(str(rate))))
|
|
181
|
+
else:
|
|
182
|
+
specs.append(CurrencySpec(currency=entry, rate=Decimal("1")))
|
|
183
|
+
return FeatureSection(currencies=tuple(specs))
|
|
184
|
+
|
|
185
|
+
@staticmethod
|
|
186
|
+
def _parse_fulfillment_order(raw):
|
|
187
|
+
if callable(raw):
|
|
188
|
+
return raw
|
|
189
|
+
if isinstance(raw, str):
|
|
190
|
+
return import_string(raw)
|
|
191
|
+
parsed = []
|
|
192
|
+
for entry in raw:
|
|
193
|
+
if isinstance(entry, str):
|
|
194
|
+
# Shorthand: "team" means (None, "team") — any wallet type, that subject.
|
|
195
|
+
parsed.append((None, entry))
|
|
196
|
+
else:
|
|
197
|
+
wallet_type, subject = entry
|
|
198
|
+
parsed.append((wallet_type, subject))
|
|
199
|
+
return parsed
|