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 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,5 @@
1
+ """Storage backends shipped with the core. See ``docs/backends/`` for writing your own."""
2
+
3
+ from allowances.backends.memory import MemoryStore
4
+
5
+ __all__ = ["MemoryStore"]
@@ -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