walletcap 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.
walletcap/__init__.py ADDED
@@ -0,0 +1,74 @@
1
+ # SPDX-License-Identifier: Apache-2.0
2
+ """walletcap: one spend budget per wallet, across every process, replica, key and rail.
3
+
4
+ from walletcap import open_budget
5
+
6
+ budget = open_budget(agent_address, per_payment="$50", per_day="$150")
7
+ with budget.pay("$12.50", to=payee) as payment:
8
+ payment.tx = send_usdc(payee, "12.50")
9
+ """
10
+
11
+ from walletcap.budget import (
12
+ Budget,
13
+ Check,
14
+ Paying,
15
+ Payment,
16
+ Reservation,
17
+ Status,
18
+ open_budget,
19
+ )
20
+ from walletcap.errors import (
21
+ BudgetError,
22
+ BudgetRefused,
23
+ BudgetRevoked,
24
+ BudgetWarning,
25
+ NotSent,
26
+ OverBudget,
27
+ OverPerPayment,
28
+ OverUses,
29
+ )
30
+ from walletcap.ledger import (
31
+ MAX_AMOUNT,
32
+ SCHEMA_VERSION,
33
+ UNSET,
34
+ BudgetLimits,
35
+ BudgetStatus,
36
+ Ledger,
37
+ Spend,
38
+ )
39
+ from walletcap.money import (
40
+ default_budget_path,
41
+ format_usd,
42
+ to_micros,
43
+ )
44
+
45
+ __version__ = "0.2.0"
46
+
47
+ __all__ = [
48
+ "MAX_AMOUNT",
49
+ "SCHEMA_VERSION",
50
+ "UNSET",
51
+ "Budget",
52
+ "BudgetError",
53
+ "BudgetLimits",
54
+ "BudgetRefused",
55
+ "BudgetRevoked",
56
+ "BudgetStatus",
57
+ "BudgetWarning",
58
+ "Check",
59
+ "Ledger",
60
+ "NotSent",
61
+ "OverBudget",
62
+ "OverPerPayment",
63
+ "OverUses",
64
+ "Payment",
65
+ "Paying",
66
+ "Reservation",
67
+ "Spend",
68
+ "Status",
69
+ "__version__",
70
+ "default_budget_path",
71
+ "format_usd",
72
+ "open_budget",
73
+ "to_micros",
74
+ ]
walletcap/__main__.py ADDED
@@ -0,0 +1,284 @@
1
+ # SPDX-License-Identifier: Apache-2.0
2
+ """``walletcap`` (or ``python -m walletcap``): see, set and freeze a budget.
3
+
4
+ The same commands, options and output as ``npx walletcap``, on the same file.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import argparse
10
+ import dataclasses
11
+ import json
12
+ import sys
13
+ from collections.abc import Sequence
14
+ from pathlib import Path
15
+ from typing import Any
16
+
17
+ from walletcap import __version__
18
+ from walletcap.budget import Budget, Status, _limits
19
+ from walletcap.errors import BudgetError
20
+ from walletcap.ledger import UNSET, Ledger
21
+ from walletcap.money import (
22
+ default_budget_path,
23
+ describe_limits,
24
+ expand_home,
25
+ format_usd,
26
+ format_window,
27
+ normalize_wallet,
28
+ short_wallet,
29
+ )
30
+
31
+ _EPILOG = "Amounts are dollars: --per-day 150. Leave out the $ sign, which your shell would expand."
32
+
33
+
34
+ class _Usage(Exception):
35
+ pass
36
+
37
+
38
+ def _parser() -> argparse.ArgumentParser:
39
+ parser = argparse.ArgumentParser(
40
+ prog="walletcap",
41
+ description="One spend budget per wallet, for every agent that pays from it.",
42
+ epilog=_EPILOG,
43
+ )
44
+ parser.add_argument("--version", action="version", version=__version__)
45
+ parser.add_argument(
46
+ "command",
47
+ nargs="?",
48
+ default="status",
49
+ choices=["status", "set", "freeze", "unfreeze", "history", "release"],
50
+ )
51
+ parser.add_argument("id", nargs="?", help="the payment to release")
52
+ parser.add_argument("--wallet", help="the wallet the money leaves from")
53
+ parser.add_argument("--path", help=f"the budget file (default: {default_budget_path()})")
54
+ parser.add_argument("--per-payment", help="most one payment may spend, or none")
55
+ parser.add_argument("--per-hour")
56
+ parser.add_argument("--per-day")
57
+ parser.add_argument("--per-week")
58
+ parser.add_argument("--cap")
59
+ parser.add_argument("--window", help="24h, 7d, 30m")
60
+ parser.add_argument("--max-payments", help="most payments in one window, or none")
61
+ parser.add_argument("--limit", type=int, default=20, help="how many payments history shows")
62
+ parser.add_argument("--json", action="store_true", help="machine-readable output")
63
+ return parser
64
+
65
+
66
+ def _json(value: Any) -> str:
67
+ def plain(item: Any) -> Any:
68
+ if dataclasses.is_dataclass(item) and not isinstance(item, type):
69
+ fields = {
70
+ field.name: plain(getattr(item, field.name)) for field in dataclasses.fields(item)
71
+ }
72
+ if isinstance(item, Status):
73
+ fields["summary"] = item.summary
74
+ return fields
75
+ if isinstance(item, list):
76
+ return [plain(entry) for entry in item]
77
+ if isinstance(item, dict):
78
+ return {key: plain(entry) for key, entry in item.items()}
79
+ if isinstance(item, int) and not isinstance(item, bool):
80
+ return str(item)
81
+ if isinstance(item, Path):
82
+ return str(item)
83
+ if hasattr(item, "isoformat"):
84
+ return item.isoformat()
85
+ return item
86
+
87
+ return json.dumps(plain(value), indent=2, ensure_ascii=False)
88
+
89
+
90
+ def _wallets(path: Path) -> list[str]:
91
+ if not path.exists():
92
+ return []
93
+ return [budget.instrument for budget in Ledger(path).budgets()]
94
+
95
+
96
+ def _wallet(args: argparse.Namespace, path: Path) -> str:
97
+ if args.wallet is not None:
98
+ return normalize_wallet(args.wallet)
99
+ known = _wallets(path)
100
+ if len(known) == 1:
101
+ return known[0]
102
+ if not known:
103
+ raise BudgetError(
104
+ f"no budget in {path} yet: walletcap set --wallet <address> --per-day <dollars>"
105
+ )
106
+ raise _Usage(
107
+ f"the file holds {len(known)} budgets; pick one with --wallet:\n " + "\n ".join(known)
108
+ )
109
+
110
+
111
+ def _open(args: argparse.Namespace, path: Path) -> Budget:
112
+ return Budget(_wallet(args, path), path=path, create=False)
113
+
114
+
115
+ def _detail(status: Status) -> str:
116
+ window = format_window(status.window_ms)
117
+ plural = "" if status.payments == 1 else "s"
118
+ lines = [
119
+ f"Wallet {status.wallet}",
120
+ f"Limit {format_usd(status.cap)} per {window}, rolling",
121
+ "Per payment "
122
+ + ("no limit" if status.per_payment is None else format_usd(status.per_payment)),
123
+ ]
124
+ if status.max_payments is not None:
125
+ lines.append(f"Payments at most {status.max_payments} per {window}")
126
+ lines += [
127
+ f"Spent {format_usd(status.spent)} in the last {window} "
128
+ f"({status.payments} payment{plural})",
129
+ f"In flight {format_usd(status.in_flight)}",
130
+ f"Left {format_usd(status.remaining)}",
131
+ "State " + ("FROZEN: every payment is refused" if status.frozen else "active"),
132
+ ]
133
+ return "\n".join(lines)
134
+
135
+
136
+ def _status(args: argparse.Namespace, path: Path) -> str:
137
+ if args.wallet is None:
138
+ statuses = [Budget(wallet, path=path, create=False).status() for wallet in _wallets(path)]
139
+ if args.json:
140
+ return _json({"path": path, "budgets": statuses})
141
+ if not statuses:
142
+ return (
143
+ f"No budget in {path} yet. Create one:\n"
144
+ " walletcap set --wallet <address> --per-day 150 --per-payment 50"
145
+ )
146
+ return "\n".join([f"Budget file {path}", "", *(status.summary for status in statuses)])
147
+ status = _open(args, path).status()
148
+ return _json(status) if args.json else _detail(status)
149
+
150
+
151
+ def _set(args: argparse.Namespace, path: Path) -> str:
152
+ def none(value: str | None) -> object:
153
+ if value is None:
154
+ return UNSET
155
+ return None if value.strip().lower() == "none" else value
156
+
157
+ max_payments = none(args.max_payments)
158
+ if isinstance(max_payments, str):
159
+ if not max_payments.strip().isdigit():
160
+ raise BudgetError("--max-payments must be a whole number of payments, or none")
161
+ max_payments = int(max_payments)
162
+ limits = _limits(
163
+ per_payment=none(args.per_payment),
164
+ per_hour=args.per_hour,
165
+ per_day=args.per_day,
166
+ per_week=args.per_week,
167
+ cap=args.cap,
168
+ window=args.window,
169
+ max_payments=max_payments,
170
+ )
171
+ if not limits:
172
+ raise _Usage("set needs a limit, like --per-day 150 or --per-payment 50")
173
+ if args.wallet is None and len(_wallets(path)) != 1:
174
+ raise _Usage("set needs --wallet <address>")
175
+ wallet = _wallet(args, path)
176
+ path.parent.mkdir(mode=0o700, parents=True, exist_ok=True)
177
+ Ledger(path).set_limits(wallet, unit="usd", create=True, **limits)
178
+ status = Budget(wallet, path=path, create=False).status()
179
+ if args.json:
180
+ return _json(status)
181
+ now = describe_limits(
182
+ cap=status.cap,
183
+ window_ms=status.window_ms,
184
+ per_payment=status.per_payment,
185
+ max_uses=status.max_payments,
186
+ )
187
+ return (
188
+ f"{short_wallet(wallet)}: {now}.\nWhat was spent still counts: "
189
+ f"{format_usd(status.spent)} spent, {format_usd(status.in_flight)} in flight, "
190
+ f"{format_usd(status.remaining)} left."
191
+ )
192
+
193
+
194
+ def _freeze(args: argparse.Namespace, path: Path) -> str:
195
+ budget = _open(args, path)
196
+ budget.freeze()
197
+ if args.json:
198
+ return _json(budget.status())
199
+ return (
200
+ f"Frozen {short_wallet(budget.wallet)}: every payment from it is refused, "
201
+ "on every rail and in every process, until you run unfreeze."
202
+ )
203
+
204
+
205
+ def _unfreeze(args: argparse.Namespace, path: Path) -> str:
206
+ budget = _open(args, path)
207
+ budget.unfreeze()
208
+ return _json(budget.status()) if args.json else f"Unfrozen. {budget.status().summary}"
209
+
210
+
211
+ def _history(args: argparse.Namespace, path: Path) -> str:
212
+ budget = _open(args, path)
213
+ payments = budget.history(limit=args.limit)
214
+ if args.json:
215
+ return _json(payments)
216
+ if not payments:
217
+ return f"No payments from {short_wallet(budget.wallet)} yet."
218
+ lines = []
219
+ for payment in payments:
220
+ fields = [
221
+ payment.at.strftime("%Y-%m-%dT%H:%M:%SZ"),
222
+ format_usd(payment.amount).rjust(10),
223
+ payment.status.ljust(9),
224
+ payment.rail.ljust(6),
225
+ f"to {short_wallet(payment.to.lower())}" if payment.to else "",
226
+ f"tx {payment.tx}" if payment.tx else (payment.note or ""),
227
+ f"({payment.id})" if payment.status in ("in flight", "refused", "unknown") else "",
228
+ ]
229
+ lines.append(" ".join(field for field in fields if field))
230
+ return "\n".join(lines)
231
+
232
+
233
+ def _release(args: argparse.Namespace, path: Path) -> str:
234
+ if args.id is None:
235
+ raise _Usage("release needs the id of an in-flight payment; history shows it")
236
+ if not path.exists():
237
+ raise BudgetError(f"no budget file at {path}")
238
+ ledger = Ledger(path)
239
+ payment = ledger.payment(args.id)
240
+ if payment is None:
241
+ raise BudgetError(f"no payment {args.id} in {path}")
242
+ if args.wallet is not None and payment.instrument != normalize_wallet(args.wallet):
243
+ raise BudgetError(
244
+ f"{args.id} is {short_wallet(payment.instrument)}'s payment, "
245
+ f"not {short_wallet(normalize_wallet(args.wallet))}'s"
246
+ )
247
+ if payment.status == "committed":
248
+ raise BudgetError(
249
+ f"{args.id} is recorded as settled; only an in-flight payment can be released"
250
+ )
251
+ ledger.release(args.id)
252
+ return (
253
+ f"Released {args.id} ({format_usd(payment.amount)} from "
254
+ f"{short_wallet(payment.instrument)}): it no longer counts. "
255
+ "Only do this for a payment you know never left the wallet."
256
+ )
257
+
258
+
259
+ _COMMANDS = {
260
+ "status": _status,
261
+ "set": _set,
262
+ "freeze": _freeze,
263
+ "unfreeze": _unfreeze,
264
+ "history": _history,
265
+ "release": _release,
266
+ }
267
+
268
+
269
+ def main(argv: Sequence[str] | None = None) -> int:
270
+ args = _parser().parse_args(argv)
271
+ path = expand_home(args.path if args.path is not None else default_budget_path()).absolute()
272
+ try:
273
+ print(_COMMANDS[args.command](args, path))
274
+ except _Usage as error:
275
+ print(error, file=sys.stderr)
276
+ return 2
277
+ except BudgetError as error:
278
+ print(error, file=sys.stderr)
279
+ return 1
280
+ return 0
281
+
282
+
283
+ if __name__ == "__main__":
284
+ raise SystemExit(main())