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.
Files changed (40) hide show
  1. {allowances-0.2.0 → allowances-0.3.0}/PKG-INFO +15 -17
  2. {allowances-0.2.0 → allowances-0.3.0}/README.md +7 -7
  3. {allowances-0.2.0 → allowances-0.3.0}/allowances/__init__.py +19 -1
  4. {allowances-0.2.0 → allowances-0.3.0}/allowances/backends/memory.py +17 -10
  5. {allowances-0.2.0 → allowances-0.3.0}/allowances/definitions.py +16 -7
  6. {allowances-0.2.0 → allowances-0.3.0}/allowances/engine.py +391 -40
  7. {allowances-0.2.0 → allowances-0.3.0}/allowances/errors.py +12 -0
  8. {allowances-0.2.0 → allowances-0.3.0}/allowances/store.py +18 -7
  9. {allowances-0.2.0 → allowances-0.3.0}/allowances/types.py +43 -1
  10. {allowances-0.2.0 → allowances-0.3.0}/allowances_django/admin.py +2 -2
  11. allowances-0.3.0/allowances_django/migrations/0002_wallet_source_allotment_scheduled.py +38 -0
  12. {allowances-0.2.0 → allowances-0.3.0}/allowances_django/models.py +24 -0
  13. {allowances-0.2.0 → allowances-0.3.0}/allowances_django/shortcuts.py +59 -4
  14. {allowances-0.2.0 → allowances-0.3.0}/allowances_django/store.py +37 -11
  15. allowances-0.3.0/pyproject.toml +101 -0
  16. allowances-0.2.0/pyproject.toml → allowances-0.3.0/pyproject.toml.orig +22 -6
  17. allowances-0.2.0/allowances.egg-info/PKG-INFO +0 -137
  18. allowances-0.2.0/allowances.egg-info/SOURCES.txt +0 -36
  19. allowances-0.2.0/allowances.egg-info/dependency_links.txt +0 -1
  20. allowances-0.2.0/allowances.egg-info/requires.txt +0 -10
  21. allowances-0.2.0/allowances.egg-info/top_level.txt +0 -2
  22. allowances-0.2.0/setup.cfg +0 -4
  23. allowances-0.2.0/tests/test_definitions.py +0 -134
  24. allowances-0.2.0/tests/test_engine_archive.py +0 -95
  25. allowances-0.2.0/tests/test_engine_attach_unfulfill.py +0 -128
  26. allowances-0.2.0/tests/test_engine_fulfill.py +0 -404
  27. allowances-0.2.0/tests/test_engine_peek.py +0 -61
  28. allowances-0.2.0/tests/test_engine_plan_switch.py +0 -132
  29. allowances-0.2.0/tests/test_engine_sliding_window.py +0 -94
  30. allowances-0.2.0/tests/test_windows.py +0 -100
  31. {allowances-0.2.0 → allowances-0.3.0}/allowances/backends/__init__.py +0 -0
  32. {allowances-0.2.0 → allowances-0.3.0}/allowances/rounding.py +0 -0
  33. {allowances-0.2.0 → allowances-0.3.0}/allowances/windows.py +0 -0
  34. {allowances-0.2.0 → allowances-0.3.0}/allowances_django/__init__.py +0 -0
  35. {allowances-0.2.0 → allowances-0.3.0}/allowances_django/apps.py +0 -0
  36. {allowances-0.2.0 → allowances-0.3.0}/allowances_django/checks.py +0 -0
  37. {allowances-0.2.0 → allowances-0.3.0}/allowances_django/migrations/0001_initial.py +0 -0
  38. {allowances-0.2.0 → allowances-0.3.0}/allowances_django/migrations/__init__.py +0 -0
  39. {allowances-0.2.0 → allowances-0.3.0}/allowances_django/providers.py +0 -0
  40. {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.2.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
- Description-Content-Type: text/markdown
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
- 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"
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 sixteen-method port.
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 sixteen-method store port, which is why the same code runs against Postgres, SQLite or a dict.
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
- pip install -e ".[test]"
122
- pytest # 188 tests, both stores, no setup
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 sixteen-method port.
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 sixteen-method store port, which is why the same code runs against Postgres, SQLite or a dict.
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
- pip install -e ".[test]"
105
- pytest # 188 tests, both stores, no setup
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.1.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 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
+ 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 callable
90
- that receives the candidate wallets and returns them in draw order. Note the callable receives
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": [("grant", "device"), ("grant", None), "team"],
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
- else:
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