allowances 0.2.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/PKG-INFO +137 -0
- allowances-0.2.0/README.md +120 -0
- allowances-0.2.0/allowances/__init__.py +86 -0
- allowances-0.2.0/allowances/backends/__init__.py +5 -0
- allowances-0.2.0/allowances/backends/memory.py +291 -0
- allowances-0.2.0/allowances/definitions.py +199 -0
- allowances-0.2.0/allowances/engine.py +493 -0
- allowances-0.2.0/allowances/errors.py +51 -0
- allowances-0.2.0/allowances/rounding.py +25 -0
- allowances-0.2.0/allowances/store.py +190 -0
- allowances-0.2.0/allowances/types.py +186 -0
- allowances-0.2.0/allowances/windows.py +86 -0
- allowances-0.2.0/allowances.egg-info/PKG-INFO +137 -0
- allowances-0.2.0/allowances.egg-info/SOURCES.txt +36 -0
- allowances-0.2.0/allowances.egg-info/dependency_links.txt +1 -0
- allowances-0.2.0/allowances.egg-info/requires.txt +10 -0
- allowances-0.2.0/allowances.egg-info/top_level.txt +2 -0
- allowances-0.2.0/allowances_django/__init__.py +14 -0
- allowances-0.2.0/allowances_django/admin.py +90 -0
- allowances-0.2.0/allowances_django/apps.py +15 -0
- allowances-0.2.0/allowances_django/checks.py +48 -0
- allowances-0.2.0/allowances_django/migrations/0001_initial.py +125 -0
- allowances-0.2.0/allowances_django/migrations/__init__.py +0 -0
- allowances-0.2.0/allowances_django/models.py +279 -0
- allowances-0.2.0/allowances_django/providers.py +25 -0
- allowances-0.2.0/allowances_django/shortcuts.py +107 -0
- allowances-0.2.0/allowances_django/store.py +367 -0
- allowances-0.2.0/allowances_django/subjects.py +59 -0
- allowances-0.2.0/pyproject.toml +62 -0
- allowances-0.2.0/setup.cfg +4 -0
- allowances-0.2.0/tests/test_definitions.py +134 -0
- allowances-0.2.0/tests/test_engine_archive.py +95 -0
- allowances-0.2.0/tests/test_engine_attach_unfulfill.py +128 -0
- allowances-0.2.0/tests/test_engine_fulfill.py +404 -0
- allowances-0.2.0/tests/test_engine_peek.py +61 -0
- allowances-0.2.0/tests/test_engine_plan_switch.py +132 -0
- allowances-0.2.0/tests/test_engine_sliding_window.py +94 -0
- allowances-0.2.0/tests/test_windows.py +100 -0
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: allowances
|
|
3
|
+
Version: 0.2.0
|
|
4
|
+
Summary: Grants and consumable budgets — enforced before the fact, auditable after it. Storage-agnostic, with a Django backend.
|
|
5
|
+
License-Expression: MIT
|
|
6
|
+
Keywords: licensing,entitlements,quota,metering,billing,ledger,django
|
|
7
|
+
Requires-Python: >=3.11
|
|
8
|
+
Description-Content-Type: text/markdown
|
|
9
|
+
Provides-Extra: django
|
|
10
|
+
Requires-Dist: Django<7,>=5.2; extra == "django"
|
|
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"
|
|
17
|
+
|
|
18
|
+
# allowances
|
|
19
|
+
|
|
20
|
+
Grants and consumable budgets — enforced before the fact, auditable after it. Storage-agnostic, with a Django backend.
|
|
21
|
+
|
|
22
|
+
📖 **Documentation:** [docs.velis.si/allowances](https://docs.velis.si/allowances). The source lives in `docs/` — `npm install && npm run docs:dev` serves it locally.
|
|
23
|
+
|
|
24
|
+
---
|
|
25
|
+
|
|
26
|
+
## What it does
|
|
27
|
+
|
|
28
|
+
Applications keep needing to answer the same shape of question:
|
|
29
|
+
|
|
30
|
+
> Can this user send an email right now? How many API calls does this organisation have left this month? Does this device still have a service contract, and does the customer have enough coolant credit to fill it?
|
|
31
|
+
|
|
32
|
+
`allowances` answers it, records what the answer cost, and lets you take it back if the operation it paid for never happened.
|
|
33
|
+
|
|
34
|
+
It is **not a payment system** — no invoices, no card charges, no money anywhere. The units it counts are whatever you say they are. Use it for rights that are bought, expire, or get spent.
|
|
35
|
+
|
|
36
|
+
## Install
|
|
37
|
+
|
|
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 sixteen-method port.
|
|
39
|
+
|
|
40
|
+
```bash
|
|
41
|
+
pip install allowances # the engine, zero dependencies
|
|
42
|
+
pip install "allowances[django]" # ...and the Django models, store and admin
|
|
43
|
+
```
|
|
44
|
+
|
|
45
|
+
The Django binding is one extra line, and the rest of this README assumes you took it:
|
|
46
|
+
|
|
47
|
+
```python
|
|
48
|
+
INSTALLED_APPS = ["allowances_django"]
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
Without it you supply your own `Store` — a dict-backed one ships in the box for tests, and [writing another](https://docs.velis.si/allowances/backends/writing-a-backend) is an afternoon.
|
|
52
|
+
|
|
53
|
+
## Use
|
|
54
|
+
|
|
55
|
+
```python
|
|
56
|
+
# settings.py
|
|
57
|
+
ALLOWANCES = {
|
|
58
|
+
"currencies": {
|
|
59
|
+
"email_send": {},
|
|
60
|
+
"credits": {"general": True, "round_fn": "ceil"},
|
|
61
|
+
},
|
|
62
|
+
"features": {
|
|
63
|
+
"send_email": {
|
|
64
|
+
"consumable": {"currencies": ["email_send", ("credits", 0.5)]},
|
|
65
|
+
"subjects": {"user", "team"},
|
|
66
|
+
"fulfillment_order": [("consumable", "user"), ("consumable", "team")],
|
|
67
|
+
},
|
|
68
|
+
},
|
|
69
|
+
}
|
|
70
|
+
```
|
|
71
|
+
|
|
72
|
+
```python
|
|
73
|
+
from allowances import InsufficientBalanceError
|
|
74
|
+
from allowances_django.shortcuts import fulfill, attach, unfulfill
|
|
75
|
+
|
|
76
|
+
try:
|
|
77
|
+
result = fulfill("send_email", 1, {"user": request.user, "team": team})
|
|
78
|
+
except InsufficientBalanceError as exc:
|
|
79
|
+
return http_402(f"Only {exc.result.granted} left")
|
|
80
|
+
|
|
81
|
+
message = send_the_email()
|
|
82
|
+
attach(result.guid, message) # record what the quota bought
|
|
83
|
+
# ...or unfulfill(result.guid) if it failed
|
|
84
|
+
```
|
|
85
|
+
|
|
86
|
+
## The shape of it
|
|
87
|
+
|
|
88
|
+
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
|
+
|
|
90
|
+
The engine sits between them and knows nothing about either's storage. It receives definitions through a provider and state through a sixteen-method store port, which is why the same code runs against Postgres, SQLite or a dict.
|
|
91
|
+
|
|
92
|
+
```
|
|
93
|
+
allowances.engine peek · fulfill · attach · unfulfill · plan_switch
|
|
94
|
+
│
|
|
95
|
+
┌───────┴────────┐
|
|
96
|
+
▼ ▼
|
|
97
|
+
Store port DefinitionProvider
|
|
98
|
+
│ │
|
|
99
|
+
▼ ▼
|
|
100
|
+
DjangoStore SettingsProvider
|
|
101
|
+
MemoryStore DictProvider
|
|
102
|
+
```
|
|
103
|
+
|
|
104
|
+
Highlights:
|
|
105
|
+
|
|
106
|
+
- **Grants and consumables in one flow.** A boolean entitlement and a metered budget resolve together, in an order you declare.
|
|
107
|
+
- **Multi-source payment.** One operation can draw from a dedicated budget, then general credits, then a promotional grant, at conversion rates you set.
|
|
108
|
+
- **Nothing edited in place.** Consumption writes a ledger entry; reversal writes a matching storno; spent-out wallets move to an archive.
|
|
109
|
+
- **Sliding windows without a reset job.** "200 a day" works by ageing consumption out of a window, not by a nightly job that can fail.
|
|
110
|
+
- **Multi-subject.** A licence can belong to a user, an organisation, a device, a location — resolved across all of them in one call.
|
|
111
|
+
|
|
112
|
+
## What it could also be
|
|
113
|
+
|
|
114
|
+
A permissions layer, if you push it. Not the point of the library, but the pieces line up: a grant with no expiry *is* a permission bit, the fulfillment order *is* a resolution order across user, team and organisation, and the ledger is the audit trail permission systems usually bolt on afterwards. You get things they rarely offer natively — rights that expire on a date, that may be exercised *n* times, or that are inherited from an organisation and revoked with it.
|
|
115
|
+
|
|
116
|
+
Only the plumbing is missing, and `NEXT_STEPS.md` already scopes it: a grant-only permissions backend, after which the decorators, mixins and checks you have already written keep working unchanged. See [the guide](https://docs.velis.si/allowances/guide/).
|
|
117
|
+
|
|
118
|
+
## Development
|
|
119
|
+
|
|
120
|
+
```bash
|
|
121
|
+
pip install -e ".[test]"
|
|
122
|
+
pytest # 188 tests, both stores, no setup
|
|
123
|
+
ruff check . && ruff format --check .
|
|
124
|
+
|
|
125
|
+
npm install
|
|
126
|
+
npm run docs:dev
|
|
127
|
+
```
|
|
128
|
+
|
|
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.
|
|
130
|
+
|
|
131
|
+
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
|
+
|
|
133
|
+
`DECISIONS.md` records every judgment call and what it would cost to revisit — read it before changing behaviour.
|
|
134
|
+
|
|
135
|
+
## Licence
|
|
136
|
+
|
|
137
|
+
MIT.
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
# allowances
|
|
2
|
+
|
|
3
|
+
Grants and consumable budgets — enforced before the fact, auditable after it. Storage-agnostic, with a Django backend.
|
|
4
|
+
|
|
5
|
+
📖 **Documentation:** [docs.velis.si/allowances](https://docs.velis.si/allowances). The source lives in `docs/` — `npm install && npm run docs:dev` serves it locally.
|
|
6
|
+
|
|
7
|
+
---
|
|
8
|
+
|
|
9
|
+
## What it does
|
|
10
|
+
|
|
11
|
+
Applications keep needing to answer the same shape of question:
|
|
12
|
+
|
|
13
|
+
> Can this user send an email right now? How many API calls does this organisation have left this month? Does this device still have a service contract, and does the customer have enough coolant credit to fill it?
|
|
14
|
+
|
|
15
|
+
`allowances` answers it, records what the answer cost, and lets you take it back if the operation it paid for never happened.
|
|
16
|
+
|
|
17
|
+
It is **not a payment system** — no invoices, no card charges, no money anywhere. The units it counts are whatever you say they are. Use it for rights that are bought, expire, or get spent.
|
|
18
|
+
|
|
19
|
+
## Install
|
|
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 sixteen-method port.
|
|
22
|
+
|
|
23
|
+
```bash
|
|
24
|
+
pip install allowances # the engine, zero dependencies
|
|
25
|
+
pip install "allowances[django]" # ...and the Django models, store and admin
|
|
26
|
+
```
|
|
27
|
+
|
|
28
|
+
The Django binding is one extra line, and the rest of this README assumes you took it:
|
|
29
|
+
|
|
30
|
+
```python
|
|
31
|
+
INSTALLED_APPS = ["allowances_django"]
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
Without it you supply your own `Store` — a dict-backed one ships in the box for tests, and [writing another](https://docs.velis.si/allowances/backends/writing-a-backend) is an afternoon.
|
|
35
|
+
|
|
36
|
+
## Use
|
|
37
|
+
|
|
38
|
+
```python
|
|
39
|
+
# settings.py
|
|
40
|
+
ALLOWANCES = {
|
|
41
|
+
"currencies": {
|
|
42
|
+
"email_send": {},
|
|
43
|
+
"credits": {"general": True, "round_fn": "ceil"},
|
|
44
|
+
},
|
|
45
|
+
"features": {
|
|
46
|
+
"send_email": {
|
|
47
|
+
"consumable": {"currencies": ["email_send", ("credits", 0.5)]},
|
|
48
|
+
"subjects": {"user", "team"},
|
|
49
|
+
"fulfillment_order": [("consumable", "user"), ("consumable", "team")],
|
|
50
|
+
},
|
|
51
|
+
},
|
|
52
|
+
}
|
|
53
|
+
```
|
|
54
|
+
|
|
55
|
+
```python
|
|
56
|
+
from allowances import InsufficientBalanceError
|
|
57
|
+
from allowances_django.shortcuts import fulfill, attach, unfulfill
|
|
58
|
+
|
|
59
|
+
try:
|
|
60
|
+
result = fulfill("send_email", 1, {"user": request.user, "team": team})
|
|
61
|
+
except InsufficientBalanceError as exc:
|
|
62
|
+
return http_402(f"Only {exc.result.granted} left")
|
|
63
|
+
|
|
64
|
+
message = send_the_email()
|
|
65
|
+
attach(result.guid, message) # record what the quota bought
|
|
66
|
+
# ...or unfulfill(result.guid) if it failed
|
|
67
|
+
```
|
|
68
|
+
|
|
69
|
+
## The shape of it
|
|
70
|
+
|
|
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
|
+
|
|
73
|
+
The engine sits between them and knows nothing about either's storage. It receives definitions through a provider and state through a sixteen-method store port, which is why the same code runs against Postgres, SQLite or a dict.
|
|
74
|
+
|
|
75
|
+
```
|
|
76
|
+
allowances.engine peek · fulfill · attach · unfulfill · plan_switch
|
|
77
|
+
│
|
|
78
|
+
┌───────┴────────┐
|
|
79
|
+
▼ ▼
|
|
80
|
+
Store port DefinitionProvider
|
|
81
|
+
│ │
|
|
82
|
+
▼ ▼
|
|
83
|
+
DjangoStore SettingsProvider
|
|
84
|
+
MemoryStore DictProvider
|
|
85
|
+
```
|
|
86
|
+
|
|
87
|
+
Highlights:
|
|
88
|
+
|
|
89
|
+
- **Grants and consumables in one flow.** A boolean entitlement and a metered budget resolve together, in an order you declare.
|
|
90
|
+
- **Multi-source payment.** One operation can draw from a dedicated budget, then general credits, then a promotional grant, at conversion rates you set.
|
|
91
|
+
- **Nothing edited in place.** Consumption writes a ledger entry; reversal writes a matching storno; spent-out wallets move to an archive.
|
|
92
|
+
- **Sliding windows without a reset job.** "200 a day" works by ageing consumption out of a window, not by a nightly job that can fail.
|
|
93
|
+
- **Multi-subject.** A licence can belong to a user, an organisation, a device, a location — resolved across all of them in one call.
|
|
94
|
+
|
|
95
|
+
## What it could also be
|
|
96
|
+
|
|
97
|
+
A permissions layer, if you push it. Not the point of the library, but the pieces line up: a grant with no expiry *is* a permission bit, the fulfillment order *is* a resolution order across user, team and organisation, and the ledger is the audit trail permission systems usually bolt on afterwards. You get things they rarely offer natively — rights that expire on a date, that may be exercised *n* times, or that are inherited from an organisation and revoked with it.
|
|
98
|
+
|
|
99
|
+
Only the plumbing is missing, and `NEXT_STEPS.md` already scopes it: a grant-only permissions backend, after which the decorators, mixins and checks you have already written keep working unchanged. See [the guide](https://docs.velis.si/allowances/guide/).
|
|
100
|
+
|
|
101
|
+
## Development
|
|
102
|
+
|
|
103
|
+
```bash
|
|
104
|
+
pip install -e ".[test]"
|
|
105
|
+
pytest # 188 tests, both stores, no setup
|
|
106
|
+
ruff check . && ruff format --check .
|
|
107
|
+
|
|
108
|
+
npm install
|
|
109
|
+
npm run docs:dev
|
|
110
|
+
```
|
|
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.
|
|
113
|
+
|
|
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
|
+
|
|
116
|
+
`DECISIONS.md` records every judgment call and what it would cost to revisit — read it before changing behaviour.
|
|
117
|
+
|
|
118
|
+
## Licence
|
|
119
|
+
|
|
120
|
+
MIT.
|
|
@@ -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
|
+
]
|