allowances 0.2.0__tar.gz → 0.3.0__tar.gz
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-0.2.0 → allowances-0.3.0}/PKG-INFO +15 -17
- {allowances-0.2.0 → allowances-0.3.0}/README.md +7 -7
- {allowances-0.2.0 → allowances-0.3.0}/allowances/__init__.py +19 -1
- {allowances-0.2.0 → allowances-0.3.0}/allowances/backends/memory.py +17 -10
- {allowances-0.2.0 → allowances-0.3.0}/allowances/definitions.py +16 -7
- {allowances-0.2.0 → allowances-0.3.0}/allowances/engine.py +391 -40
- {allowances-0.2.0 → allowances-0.3.0}/allowances/errors.py +12 -0
- {allowances-0.2.0 → allowances-0.3.0}/allowances/store.py +18 -7
- {allowances-0.2.0 → allowances-0.3.0}/allowances/types.py +43 -1
- {allowances-0.2.0 → allowances-0.3.0}/allowances_django/admin.py +2 -2
- allowances-0.3.0/allowances_django/migrations/0002_wallet_source_allotment_scheduled.py +38 -0
- {allowances-0.2.0 → allowances-0.3.0}/allowances_django/models.py +24 -0
- {allowances-0.2.0 → allowances-0.3.0}/allowances_django/shortcuts.py +59 -4
- {allowances-0.2.0 → allowances-0.3.0}/allowances_django/store.py +37 -11
- allowances-0.3.0/pyproject.toml +101 -0
- allowances-0.2.0/pyproject.toml → allowances-0.3.0/pyproject.toml.orig +22 -6
- allowances-0.2.0/allowances.egg-info/PKG-INFO +0 -137
- allowances-0.2.0/allowances.egg-info/SOURCES.txt +0 -36
- allowances-0.2.0/allowances.egg-info/dependency_links.txt +0 -1
- allowances-0.2.0/allowances.egg-info/requires.txt +0 -10
- allowances-0.2.0/allowances.egg-info/top_level.txt +0 -2
- allowances-0.2.0/setup.cfg +0 -4
- allowances-0.2.0/tests/test_definitions.py +0 -134
- allowances-0.2.0/tests/test_engine_archive.py +0 -95
- allowances-0.2.0/tests/test_engine_attach_unfulfill.py +0 -128
- allowances-0.2.0/tests/test_engine_fulfill.py +0 -404
- allowances-0.2.0/tests/test_engine_peek.py +0 -61
- allowances-0.2.0/tests/test_engine_plan_switch.py +0 -132
- allowances-0.2.0/tests/test_engine_sliding_window.py +0 -94
- allowances-0.2.0/tests/test_windows.py +0 -100
- {allowances-0.2.0 → allowances-0.3.0}/allowances/backends/__init__.py +0 -0
- {allowances-0.2.0 → allowances-0.3.0}/allowances/rounding.py +0 -0
- {allowances-0.2.0 → allowances-0.3.0}/allowances/windows.py +0 -0
- {allowances-0.2.0 → allowances-0.3.0}/allowances_django/__init__.py +0 -0
- {allowances-0.2.0 → allowances-0.3.0}/allowances_django/apps.py +0 -0
- {allowances-0.2.0 → allowances-0.3.0}/allowances_django/checks.py +0 -0
- {allowances-0.2.0 → allowances-0.3.0}/allowances_django/migrations/0001_initial.py +0 -0
- {allowances-0.2.0 → allowances-0.3.0}/allowances_django/migrations/__init__.py +0 -0
- {allowances-0.2.0 → allowances-0.3.0}/allowances_django/providers.py +0 -0
- {allowances-0.2.0 → allowances-0.3.0}/allowances_django/subjects.py +0 -0
|
@@ -1,19 +1,17 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: allowances
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.3.0
|
|
4
4
|
Summary: Grants and consumable budgets — enforced before the fact, auditable after it. Storage-agnostic, with a Django backend.
|
|
5
|
-
License-Expression: MIT
|
|
6
5
|
Keywords: licensing,entitlements,quota,metering,billing,ledger,django
|
|
6
|
+
License-Expression: MIT
|
|
7
|
+
Requires-Dist: django>=5.2,<7 ; extra == 'django'
|
|
7
8
|
Requires-Python: >=3.11
|
|
8
|
-
|
|
9
|
+
Project-URL: Homepage, https://github.com/velis74/allowances
|
|
10
|
+
Project-URL: Documentation, https://docs.velis.si/allowances
|
|
11
|
+
Project-URL: Source, https://github.com/velis74/allowances
|
|
12
|
+
Project-URL: Issues, https://github.com/velis74/allowances/issues
|
|
9
13
|
Provides-Extra: django
|
|
10
|
-
|
|
11
|
-
Provides-Extra: test
|
|
12
|
-
Requires-Dist: pytest; extra == "test"
|
|
13
|
-
Requires-Dist: pytest-django; extra == "test"
|
|
14
|
-
Requires-Dist: psycopg2-binary; extra == "test"
|
|
15
|
-
Requires-Dist: coverage; extra == "test"
|
|
16
|
-
Requires-Dist: Django<7,>=5.2; extra == "test"
|
|
14
|
+
Description-Content-Type: text/markdown
|
|
17
15
|
|
|
18
16
|
# allowances
|
|
19
17
|
|
|
@@ -35,7 +33,7 @@ It is **not a payment system** — no invoices, no card charges, no money anywhe
|
|
|
35
33
|
|
|
36
34
|
## Install
|
|
37
35
|
|
|
38
|
-
Django is optional. The core is `import allowances` and depends on nothing at all — not Django, not a database driver, not anything outside the standard library. Everything that matters (the ordering walk, conversion, rounding, overdraft, sliding windows) lives there, and talks to storage through a
|
|
36
|
+
Django is optional. The core is `import allowances` and depends on nothing at all — not Django, not a database driver, not anything outside the standard library. Everything that matters (the ordering walk, conversion, rounding, overdraft, sliding windows) lives there, and talks to storage through a seventeen-method port.
|
|
39
37
|
|
|
40
38
|
```bash
|
|
41
39
|
pip install allowances # the engine, zero dependencies
|
|
@@ -87,10 +85,10 @@ attach(result.guid, message) # record what the quota bought
|
|
|
87
85
|
|
|
88
86
|
Two layers, kept apart. **Definition** — what rights exist at all: features, currencies, conversion rates, priority order. Configuration, not rows. **State** — what a subject holds and has spent: wallets and a transaction ledger.
|
|
89
87
|
|
|
90
|
-
The engine sits between them and knows nothing about either's storage. It receives definitions through a provider and state through a
|
|
88
|
+
The engine sits between them and knows nothing about either's storage. It receives definitions through a provider and state through a seventeen-method store port, which is why the same code runs against Postgres, SQLite or a dict.
|
|
91
89
|
|
|
92
90
|
```
|
|
93
|
-
allowances.engine peek · fulfill · attach · unfulfill · plan_switch
|
|
91
|
+
allowances.engine peek · fulfill · attach · unfulfill · credit · plan_switch
|
|
94
92
|
│
|
|
95
93
|
┌───────┴────────┐
|
|
96
94
|
▼ ▼
|
|
@@ -118,15 +116,15 @@ Only the plumbing is missing, and `NEXT_STEPS.md` already scopes it: a grant-onl
|
|
|
118
116
|
## Development
|
|
119
117
|
|
|
120
118
|
```bash
|
|
121
|
-
|
|
122
|
-
pytest
|
|
123
|
-
ruff check . && ruff format --check .
|
|
119
|
+
uv sync
|
|
120
|
+
uv run pytest # both stores, no setup
|
|
121
|
+
uv run ruff check . && uv run ruff format --check .
|
|
124
122
|
|
|
125
123
|
npm install
|
|
126
124
|
npm run docs:dev
|
|
127
125
|
```
|
|
128
126
|
|
|
129
|
-
No database server required: the suite defaults to a file-backed SQLite. Copy `tests/env.example.py` to `tests/env.py` to point it at a local Postgres instead, and `ALLOWANCES_TEST_DB=sqlite pytest` to go back. Both engines are supported and both are covered — including `tests/django/test_concurrency.py`, which races eight threads for the same wallet and insists it is granted exactly once.
|
|
127
|
+
No database server required: the suite defaults to a file-backed SQLite. Copy `tests/env.example.py` to `tests/env.py` to point it at a local Postgres instead, and `ALLOWANCES_TEST_DB=sqlite uv run pytest` to go back. Both engines are supported and both are covered — including `tests/django/test_concurrency.py`, which races eight threads for the same wallet and insists it is granted exactly once.
|
|
130
128
|
|
|
131
129
|
Locally that runs against whichever Django you have installed. CI runs the suite five times — Django 5.2 on Python 3.11, 6.0 on 3.12 and 6.1 on 3.13 against Postgres, plus each end of that range against SQLite — which is what the `Django>=5.2,<7` pin is based on.
|
|
132
130
|
|
|
@@ -18,7 +18,7 @@ It is **not a payment system** — no invoices, no card charges, no money anywhe
|
|
|
18
18
|
|
|
19
19
|
## Install
|
|
20
20
|
|
|
21
|
-
Django is optional. The core is `import allowances` and depends on nothing at all — not Django, not a database driver, not anything outside the standard library. Everything that matters (the ordering walk, conversion, rounding, overdraft, sliding windows) lives there, and talks to storage through a
|
|
21
|
+
Django is optional. The core is `import allowances` and depends on nothing at all — not Django, not a database driver, not anything outside the standard library. Everything that matters (the ordering walk, conversion, rounding, overdraft, sliding windows) lives there, and talks to storage through a seventeen-method port.
|
|
22
22
|
|
|
23
23
|
```bash
|
|
24
24
|
pip install allowances # the engine, zero dependencies
|
|
@@ -70,10 +70,10 @@ attach(result.guid, message) # record what the quota bought
|
|
|
70
70
|
|
|
71
71
|
Two layers, kept apart. **Definition** — what rights exist at all: features, currencies, conversion rates, priority order. Configuration, not rows. **State** — what a subject holds and has spent: wallets and a transaction ledger.
|
|
72
72
|
|
|
73
|
-
The engine sits between them and knows nothing about either's storage. It receives definitions through a provider and state through a
|
|
73
|
+
The engine sits between them and knows nothing about either's storage. It receives definitions through a provider and state through a seventeen-method store port, which is why the same code runs against Postgres, SQLite or a dict.
|
|
74
74
|
|
|
75
75
|
```
|
|
76
|
-
allowances.engine peek · fulfill · attach · unfulfill · plan_switch
|
|
76
|
+
allowances.engine peek · fulfill · attach · unfulfill · credit · plan_switch
|
|
77
77
|
│
|
|
78
78
|
┌───────┴────────┐
|
|
79
79
|
▼ ▼
|
|
@@ -101,15 +101,15 @@ Only the plumbing is missing, and `NEXT_STEPS.md` already scopes it: a grant-onl
|
|
|
101
101
|
## Development
|
|
102
102
|
|
|
103
103
|
```bash
|
|
104
|
-
|
|
105
|
-
pytest
|
|
106
|
-
ruff check . && ruff format --check .
|
|
104
|
+
uv sync
|
|
105
|
+
uv run pytest # both stores, no setup
|
|
106
|
+
uv run ruff check . && uv run ruff format --check .
|
|
107
107
|
|
|
108
108
|
npm install
|
|
109
109
|
npm run docs:dev
|
|
110
110
|
```
|
|
111
111
|
|
|
112
|
-
No database server required: the suite defaults to a file-backed SQLite. Copy `tests/env.example.py` to `tests/env.py` to point it at a local Postgres instead, and `ALLOWANCES_TEST_DB=sqlite pytest` to go back. Both engines are supported and both are covered — including `tests/django/test_concurrency.py`, which races eight threads for the same wallet and insists it is granted exactly once.
|
|
112
|
+
No database server required: the suite defaults to a file-backed SQLite. Copy `tests/env.example.py` to `tests/env.py` to point it at a local Postgres instead, and `ALLOWANCES_TEST_DB=sqlite uv run pytest` to go back. Both engines are supported and both are covered — including `tests/django/test_concurrency.py`, which races eight threads for the same wallet and insists it is granted exactly once.
|
|
113
113
|
|
|
114
114
|
Locally that runs against whichever Django you have installed. CI runs the suite five times — Django 5.2 on Python 3.11, 6.0 on 3.12 and 6.1 on 3.13 against Postgres, plus each end of that range against SQLite — which is what the `Django>=5.2,<7` pin is based on.
|
|
115
115
|
|
|
@@ -18,11 +18,16 @@ from allowances.definitions import (
|
|
|
18
18
|
FeatureSection,
|
|
19
19
|
)
|
|
20
20
|
from allowances.engine import (
|
|
21
|
+
adjust_wallet,
|
|
21
22
|
archive_expired_wallets,
|
|
22
23
|
attach,
|
|
24
|
+
cancel_scheduled_switch,
|
|
25
|
+
consumed,
|
|
26
|
+
credit,
|
|
23
27
|
effective_balance,
|
|
24
28
|
find_unattached_fulfillment_records,
|
|
25
29
|
fulfill,
|
|
30
|
+
open_wallet,
|
|
26
31
|
peek,
|
|
27
32
|
plan_switch,
|
|
28
33
|
unfulfill,
|
|
@@ -32,7 +37,9 @@ from allowances.errors import (
|
|
|
32
37
|
DefinitionNotFoundError,
|
|
33
38
|
InsufficientBalanceError,
|
|
34
39
|
RecordNotFoundError,
|
|
40
|
+
UnsuitableWalletError,
|
|
35
41
|
UnsupportedOperationError,
|
|
42
|
+
WalletNotFoundError,
|
|
36
43
|
)
|
|
37
44
|
from allowances.store import Store
|
|
38
45
|
from allowances.types import (
|
|
@@ -40,7 +47,9 @@ from allowances.types import (
|
|
|
40
47
|
FulfillmentResult,
|
|
41
48
|
Modifier,
|
|
42
49
|
ObjectRef,
|
|
50
|
+
PLAN_SOURCE,
|
|
43
51
|
SubjectRef,
|
|
52
|
+
SwitchPolicy,
|
|
44
53
|
Transaction,
|
|
45
54
|
TransactionKind,
|
|
46
55
|
Wallet,
|
|
@@ -49,7 +58,7 @@ from allowances.types import (
|
|
|
49
58
|
)
|
|
50
59
|
from allowances.windows import window_start
|
|
51
60
|
|
|
52
|
-
__version__ = "0.
|
|
61
|
+
__version__ = "0.3.0"
|
|
53
62
|
|
|
54
63
|
__all__ = [
|
|
55
64
|
"AllowancesError",
|
|
@@ -65,20 +74,29 @@ __all__ = [
|
|
|
65
74
|
"InsufficientBalanceError",
|
|
66
75
|
"Modifier",
|
|
67
76
|
"ObjectRef",
|
|
77
|
+
"PLAN_SOURCE",
|
|
68
78
|
"RecordNotFoundError",
|
|
69
79
|
"Store",
|
|
70
80
|
"SubjectRef",
|
|
81
|
+
"SwitchPolicy",
|
|
71
82
|
"Transaction",
|
|
72
83
|
"TransactionKind",
|
|
84
|
+
"UnsuitableWalletError",
|
|
73
85
|
"UnsupportedOperationError",
|
|
74
86
|
"Wallet",
|
|
87
|
+
"WalletNotFoundError",
|
|
75
88
|
"WalletSpec",
|
|
76
89
|
"WalletType",
|
|
77
90
|
"archive_expired_wallets",
|
|
91
|
+
"adjust_wallet",
|
|
78
92
|
"attach",
|
|
93
|
+
"cancel_scheduled_switch",
|
|
94
|
+
"consumed",
|
|
95
|
+
"credit",
|
|
79
96
|
"effective_balance",
|
|
80
97
|
"find_unattached_fulfillment_records",
|
|
81
98
|
"fulfill",
|
|
99
|
+
"open_wallet",
|
|
82
100
|
"peek",
|
|
83
101
|
"plan_switch",
|
|
84
102
|
"unfulfill",
|
|
@@ -26,6 +26,7 @@ from allowances.types import (
|
|
|
26
26
|
FulfillmentRecord,
|
|
27
27
|
Modifier,
|
|
28
28
|
ObjectRef,
|
|
29
|
+
PLAN_SOURCE,
|
|
29
30
|
SubjectRef,
|
|
30
31
|
Transaction,
|
|
31
32
|
TransactionKind,
|
|
@@ -110,19 +111,19 @@ class MemoryStore:
|
|
|
110
111
|
*,
|
|
111
112
|
subjects: Sequence[SubjectRef],
|
|
112
113
|
wallet_types: Sequence[WalletType],
|
|
113
|
-
currencies: Sequence[str],
|
|
114
|
+
currencies: Sequence[str] | None,
|
|
114
115
|
at: datetime,
|
|
115
116
|
lock: bool = False,
|
|
116
117
|
) -> list[Wallet]:
|
|
117
118
|
subject_set = set(subjects)
|
|
118
119
|
type_set = {str(wallet_type) for wallet_type in wallet_types}
|
|
119
|
-
currency_set = set(currencies)
|
|
120
|
+
currency_set = None if currencies is None else set(currencies)
|
|
120
121
|
found = [
|
|
121
122
|
wallet
|
|
122
123
|
for wallet in self._wallets.values()
|
|
123
124
|
if wallet.subject in subject_set
|
|
124
125
|
and str(wallet.wallet_type) in type_set
|
|
125
|
-
and wallet.currency in currency_set
|
|
126
|
+
and (currency_set is None or wallet.currency in currency_set)
|
|
126
127
|
and wallet.is_valid_at(at)
|
|
127
128
|
]
|
|
128
129
|
return sorted(found, key=lambda w: self._sort_key("wallet", w))
|
|
@@ -145,6 +146,9 @@ class MemoryStore:
|
|
|
145
146
|
reset_config: dict | None = None,
|
|
146
147
|
valid_from: datetime | None = None,
|
|
147
148
|
valid_until: datetime | None = None,
|
|
149
|
+
source: str = PLAN_SOURCE,
|
|
150
|
+
allotment: Decimal | None = None,
|
|
151
|
+
scheduled: dict | None = None,
|
|
148
152
|
) -> Wallet:
|
|
149
153
|
wallet = Wallet(
|
|
150
154
|
id=next(self._wallet_ids),
|
|
@@ -157,18 +161,17 @@ class MemoryStore:
|
|
|
157
161
|
valid_until=valid_until,
|
|
158
162
|
overdraft_limit=None if overdraft_limit is None else Decimal(str(overdraft_limit)),
|
|
159
163
|
reset_config=reset_config,
|
|
164
|
+
source=source,
|
|
165
|
+
allotment=None if allotment is None else Decimal(str(allotment)),
|
|
166
|
+
scheduled=scheduled,
|
|
160
167
|
)
|
|
161
168
|
self._wallets[wallet.id] = wallet
|
|
162
169
|
self._created_seq[("wallet", wallet.id)] = next(self._sequence)
|
|
163
170
|
return wallet
|
|
164
171
|
|
|
165
|
-
def
|
|
166
|
-
|
|
167
|
-
|
|
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
|
+
def find_scheduled_wallets(self, *, subject: SubjectRef, lock: bool = False) -> list[Wallet]:
|
|
173
|
+
found = [w for w in self._wallets.values() if w.subject == subject and w.scheduled]
|
|
174
|
+
return sorted(found, key=lambda w: self._sort_key("wallet", w))
|
|
172
175
|
|
|
173
176
|
def find_expired_wallets(self, *, at: datetime, lock: bool = False) -> list[Wallet]:
|
|
174
177
|
expired = [w for w in self._wallets.values() if w.valid_until is not None and w.valid_until <= at]
|
|
@@ -196,6 +199,10 @@ class MemoryStore:
|
|
|
196
199
|
Decimal("0"),
|
|
197
200
|
)
|
|
198
201
|
|
|
202
|
+
def transactions_for_wallet(self, *, wallet_id: object, since: datetime, until: datetime) -> list[Transaction]:
|
|
203
|
+
found = [t for t in self._transactions.values() if t.wallet_id == wallet_id and since <= t.created_at <= until]
|
|
204
|
+
return sorted(found, key=lambda t: self._sort_key("transaction", t))
|
|
205
|
+
|
|
199
206
|
def create_transaction(
|
|
200
207
|
self,
|
|
201
208
|
*,
|
|
@@ -86,8 +86,9 @@ class FeatureDefinition:
|
|
|
86
86
|
"""
|
|
87
87
|
A named capability: ``send_email``, ``coolant_fill``.
|
|
88
88
|
|
|
89
|
-
``fulfillment_order`` is either a list of ``(wallet_type, subject_name)`` steps or a
|
|
90
|
-
that receives the candidate wallets and returns them in draw order.
|
|
89
|
+
``fulfillment_order`` is either a list of ``(wallet_type, subject_name, source)`` steps or a
|
|
90
|
+
callable that receives the candidate wallets and returns them in draw order. A ``source`` of
|
|
91
|
+
``None`` matches wallets of every source. Note the callable receives
|
|
91
92
|
a ``list[Wallet]`` — plain value objects, not a database query — so the same ordering function
|
|
92
93
|
works against any backend.
|
|
93
94
|
"""
|
|
@@ -127,7 +128,12 @@ class DictProvider(DefinitionProvider):
|
|
|
127
128
|
"grant": {"currencies": ["email_send"]},
|
|
128
129
|
"consumable": {"currencies": ["email_send", ("credits", 0.5)]},
|
|
129
130
|
"subjects": {"device", "team"},
|
|
130
|
-
"fulfillment_order": [
|
|
131
|
+
"fulfillment_order": [
|
|
132
|
+
("grant", "device"),
|
|
133
|
+
("grant", None),
|
|
134
|
+
("consumable", "team", "plan"),
|
|
135
|
+
"team",
|
|
136
|
+
],
|
|
131
137
|
"overdraft": False,
|
|
132
138
|
},
|
|
133
139
|
},
|
|
@@ -191,9 +197,12 @@ class DictProvider(DefinitionProvider):
|
|
|
191
197
|
parsed = []
|
|
192
198
|
for entry in raw:
|
|
193
199
|
if isinstance(entry, str):
|
|
194
|
-
# Shorthand: "team" means (None, "team") — any wallet type, that subject.
|
|
195
|
-
parsed.append((None, entry))
|
|
196
|
-
|
|
200
|
+
# Shorthand: "team" means (None, "team", None) — any wallet type and source, that subject.
|
|
201
|
+
parsed.append((None, entry, None))
|
|
202
|
+
elif len(entry) == 2:
|
|
197
203
|
wallet_type, subject = entry
|
|
198
|
-
parsed.append((wallet_type, subject))
|
|
204
|
+
parsed.append((wallet_type, subject, None))
|
|
205
|
+
else:
|
|
206
|
+
wallet_type, subject, source = entry
|
|
207
|
+
parsed.append((wallet_type, subject, source))
|
|
199
208
|
return parsed
|