tuppence 0.1.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.
- tuppence-0.1.0/.gitignore +58 -0
- tuppence-0.1.0/PKG-INFO +168 -0
- tuppence-0.1.0/README.md +145 -0
- tuppence-0.1.0/pyproject.toml +53 -0
- tuppence-0.1.0/scripts/generate.py +142 -0
- tuppence-0.1.0/src/tuppence/__init__.py +111 -0
- tuppence-0.1.0/src/tuppence/_async_resources.py +3614 -0
- tuppence-0.1.0/src/tuppence/_client.py +361 -0
- tuppence-0.1.0/src/tuppence/_errors.py +85 -0
- tuppence-0.1.0/src/tuppence/_http.py +283 -0
- tuppence-0.1.0/src/tuppence/_model.py +25 -0
- tuppence-0.1.0/src/tuppence/_sync_resources.py +3616 -0
- tuppence-0.1.0/src/tuppence/_webhooks.py +108 -0
- tuppence-0.1.0/src/tuppence/http402/__init__.py +334 -0
- tuppence-0.1.0/src/tuppence/http402/_verify.py +159 -0
- tuppence-0.1.0/src/tuppence/models.py +17133 -0
- tuppence-0.1.0/src/tuppence/py.typed +0 -0
- tuppence-0.1.0/tests/conformance_runner.py +210 -0
- tuppence-0.1.0/tests/conftest.py +68 -0
- tuppence-0.1.0/tests/test_card_details.py +133 -0
- tuppence-0.1.0/tests/test_card_disputes.py +334 -0
- tuppence-0.1.0/tests/test_client.py +429 -0
- tuppence-0.1.0/tests/test_conftest.py +26 -0
- tuppence-0.1.0/tests/test_connect.py +464 -0
- tuppence-0.1.0/tests/test_direct_debit.py +209 -0
- tuppence-0.1.0/tests/test_e2e.py +245 -0
- tuppence-0.1.0/tests/test_http402.py +321 -0
- tuppence-0.1.0/tests/test_http402_e2e.py +144 -0
- tuppence-0.1.0/tests/test_issuing_topup_links.py +81 -0
- tuppence-0.1.0/tests/test_issuing_topups.py +391 -0
- tuppence-0.1.0/tests/test_payouts.py +202 -0
- tuppence-0.1.0/tests/test_store.py +468 -0
- tuppence-0.1.0/tests/test_store_events.py +51 -0
- tuppence-0.1.0/tests/test_ticketing.py +142 -0
- tuppence-0.1.0/tests/test_ticketing_events.py +52 -0
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
node_modules/
|
|
2
|
+
dist/
|
|
3
|
+
# The booking origin's quality gates build here (apps/web/playwright.book.config.ts).
|
|
4
|
+
dist-book/
|
|
5
|
+
dist-store/
|
|
6
|
+
# …and the browser POS's acceptance test here (apps/web/playwright.pos.config.ts).
|
|
7
|
+
dist-pos/
|
|
8
|
+
# …and M32's quality gate for it here (apps/web/playwright.pos-gate.config.ts).
|
|
9
|
+
dist-pos-gate/
|
|
10
|
+
coverage/
|
|
11
|
+
*.tsbuildinfo
|
|
12
|
+
.DS_Store
|
|
13
|
+
|
|
14
|
+
# env and secrets — never commit
|
|
15
|
+
.env
|
|
16
|
+
.env.*
|
|
17
|
+
!.env.example
|
|
18
|
+
infra/gcp/*.env
|
|
19
|
+
!infra/gcp/*.env.example
|
|
20
|
+
*-key.json
|
|
21
|
+
*.pem
|
|
22
|
+
|
|
23
|
+
# local tooling
|
|
24
|
+
.firebase/
|
|
25
|
+
.vite/
|
|
26
|
+
.worktrees/
|
|
27
|
+
worktrees/
|
|
28
|
+
playwright-report/
|
|
29
|
+
# The press pack's renders (plan/102): published to R2 by press-publish, never committed.
|
|
30
|
+
apps/press/out/
|
|
31
|
+
# The assistant eval's reports (apps/api: pnpm assistant:eval)
|
|
32
|
+
eval-reports/
|
|
33
|
+
test-results/
|
|
34
|
+
/tmp/
|
|
35
|
+
|
|
36
|
+
# python (packages/python)
|
|
37
|
+
.venv
|
|
38
|
+
__pycache__/
|
|
39
|
+
*.egg-info/
|
|
40
|
+
.pytest_cache/
|
|
41
|
+
.mypy_cache/
|
|
42
|
+
.ruff_cache/
|
|
43
|
+
|
|
44
|
+
# The iOS release build's output: archives and .ipa files, hundreds of MB apiece.
|
|
45
|
+
apps/mobile/build/
|
|
46
|
+
|
|
47
|
+
# Swift Package Manager (packages/ios): the build and Xcode's package state.
|
|
48
|
+
.build/
|
|
49
|
+
.swiftpm/
|
|
50
|
+
|
|
51
|
+
# Wrangler's local state (infra/cloudflare: the edge Worker's deploy).
|
|
52
|
+
.wrangler/
|
|
53
|
+
|
|
54
|
+
# 81-26: sandbox refresh tokens for the live accounting suites (accounting:consent)
|
|
55
|
+
.accounting-sandbox/
|
|
56
|
+
|
|
57
|
+
# CI: the inputs each workspace package was built from (scripts/package-builds.mjs)
|
|
58
|
+
.ci-builds/
|
tuppence-0.1.0/PKG-INFO
ADDED
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: tuppence
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: The Tuppence API for Python — payments for developers and AI agents.
|
|
5
|
+
License-Expression: MIT
|
|
6
|
+
Classifier: Programming Language :: Python :: 3
|
|
7
|
+
Classifier: Typing :: Typed
|
|
8
|
+
Requires-Python: >=3.10
|
|
9
|
+
Requires-Dist: httpx<1,>=0.27
|
|
10
|
+
Requires-Dist: pydantic<3,>=2.7
|
|
11
|
+
Provides-Extra: dev
|
|
12
|
+
Requires-Dist: black==26.10.1; extra == 'dev'
|
|
13
|
+
Requires-Dist: cryptography>=42; extra == 'dev'
|
|
14
|
+
Requires-Dist: datamodel-code-generator==0.34.0; extra == 'dev'
|
|
15
|
+
Requires-Dist: isort==6.1.0; extra == 'dev'
|
|
16
|
+
Requires-Dist: mypy>=1.10; extra == 'dev'
|
|
17
|
+
Requires-Dist: pytest>=8; extra == 'dev'
|
|
18
|
+
Requires-Dist: ruff>=0.6; extra == 'dev'
|
|
19
|
+
Requires-Dist: starlette>=0.37; extra == 'dev'
|
|
20
|
+
Provides-Extra: http402
|
|
21
|
+
Requires-Dist: cryptography>=42; extra == 'http402'
|
|
22
|
+
Description-Content-Type: text/markdown
|
|
23
|
+
|
|
24
|
+
# tuppence
|
|
25
|
+
|
|
26
|
+
The Tuppence API for Python — payments for developers and AI agents.
|
|
27
|
+
|
|
28
|
+
```bash
|
|
29
|
+
pip install tuppence
|
|
30
|
+
```
|
|
31
|
+
|
|
32
|
+
```python
|
|
33
|
+
import os
|
|
34
|
+
from tuppence import Tuppence
|
|
35
|
+
|
|
36
|
+
tuppence = Tuppence(os.environ["TUPPENCE_SECRET_KEY"])
|
|
37
|
+
|
|
38
|
+
link = tuppence.payment_links.create(name="Research report", amount=2000, currency="gbp")
|
|
39
|
+
print(link.url) # send it to whoever should pay
|
|
40
|
+
|
|
41
|
+
for payment in tuppence.payments.list_all(status="succeeded"):
|
|
42
|
+
print(payment.id, payment.amount, payment.fee, payment.net) # integer pence
|
|
43
|
+
```
|
|
44
|
+
|
|
45
|
+
Python 3.10+. Money is always an integer number of minor units (pence).
|
|
46
|
+
|
|
47
|
+
## Async
|
|
48
|
+
|
|
49
|
+
```python
|
|
50
|
+
from tuppence import AsyncTuppence
|
|
51
|
+
|
|
52
|
+
async with AsyncTuppence(os.environ["TUPPENCE_SECRET_KEY"]) as tuppence:
|
|
53
|
+
payment = await tuppence.payments.create(amount=2000, currency="gbp")
|
|
54
|
+
async for refund in tuppence.refunds.list_all(payment=payment.id):
|
|
55
|
+
...
|
|
56
|
+
```
|
|
57
|
+
|
|
58
|
+
## Retries and idempotency
|
|
59
|
+
|
|
60
|
+
Every POST carries an `Idempotency-Key`: yours, or a fresh one reused by every retry of that
|
|
61
|
+
call — so retrying after a timeout never charges twice. Network errors, timeouts, `5xx`, `429`
|
|
62
|
+
(honouring `Retry-After`) and an idempotent request still in flight are retried twice by default,
|
|
63
|
+
with jittered backoff. Nothing else is.
|
|
64
|
+
|
|
65
|
+
```python
|
|
66
|
+
tuppence.refunds.create(payment="pay_…", amount=500, idempotency_key="order-42-refund")
|
|
67
|
+
tuppence = Tuppence(key, max_retries=4, timeout=10) # client-wide
|
|
68
|
+
tuppence.balance.retrieve(max_retries=0, timeout=2) # per request
|
|
69
|
+
```
|
|
70
|
+
|
|
71
|
+
## Another API address
|
|
72
|
+
|
|
73
|
+
The SDK calls `https://api.tuppence.ai`. To use another Tuppence environment you have been given,
|
|
74
|
+
set `TUPPENCE_API_URL`, or pass `base_url`:
|
|
75
|
+
|
|
76
|
+
```python
|
|
77
|
+
tuppence = Tuppence(key, base_url="https://…")
|
|
78
|
+
```
|
|
79
|
+
|
|
80
|
+
## Errors
|
|
81
|
+
|
|
82
|
+
```python
|
|
83
|
+
from tuppence import CardError, InvalidRequestError
|
|
84
|
+
|
|
85
|
+
try:
|
|
86
|
+
tuppence.refunds.create(payment="pay_…", amount=10_000)
|
|
87
|
+
except InvalidRequestError as e:
|
|
88
|
+
print(e.code, e.param, e.request_id) # branch on `code`; quote `request_id` to support
|
|
89
|
+
```
|
|
90
|
+
|
|
91
|
+
`CardError`, `InvalidRequestError`, `AuthenticationError`, `PermissionError`, `RateLimitError`,
|
|
92
|
+
`IdempotencyError`, `APIError` and `ConnectionError` all derive from `TuppenceError`.
|
|
93
|
+
|
|
94
|
+
## Webhooks
|
|
95
|
+
|
|
96
|
+
```python
|
|
97
|
+
event = tuppence.webhooks.construct_event(
|
|
98
|
+
request.body, # the RAW body, exactly as received
|
|
99
|
+
request.headers["Tuppence-Signature"],
|
|
100
|
+
os.environ["TUPPENCE_WEBHOOK_SECRET"],
|
|
101
|
+
)
|
|
102
|
+
```
|
|
103
|
+
|
|
104
|
+
A wrong secret, a tampered body or a timestamp more than five minutes old raises
|
|
105
|
+
`SignatureVerificationError` — never act on that payload.
|
|
106
|
+
|
|
107
|
+
## Tuppence Connect: acting as your connected accounts
|
|
108
|
+
|
|
109
|
+
A platform's connected accounts are full Tuppence accounts. Make one, send its owner onboarding,
|
|
110
|
+
then act as it with your own key — per call, or through a client bound to it:
|
|
111
|
+
|
|
112
|
+
```python
|
|
113
|
+
bella = tuppence.accounts.create(email="bella@example.com", business_type="individual")
|
|
114
|
+
link = tuppence.account_links.create(
|
|
115
|
+
account=bella.id,
|
|
116
|
+
type="account_onboarding",
|
|
117
|
+
refresh_url="https://groomly.example/onboarding/refresh",
|
|
118
|
+
return_url="https://groomly.example/onboarding/done",
|
|
119
|
+
)
|
|
120
|
+
|
|
121
|
+
# One call as Bella: the `Tuppence-Account` header, never part of the path.
|
|
122
|
+
tuppence.payments.create(
|
|
123
|
+
amount=4500, currency="gbp", application_fee_amount=100, tuppence_account=bella.id
|
|
124
|
+
)
|
|
125
|
+
|
|
126
|
+
# Or a client bound to her — a new one, on the same connections: `tuppence` is unchanged.
|
|
127
|
+
as_bella = tuppence.as_account(bella.id)
|
|
128
|
+
as_bella.bookings.list()
|
|
129
|
+
```
|
|
130
|
+
|
|
131
|
+
`accounts.persons`, `.external_accounts` and `.capabilities`, and `files.create(purpose=…,
|
|
132
|
+
file=open("passport.png", "rb"))` are the rest of onboarding through your own forms.
|
|
133
|
+
`application_fees` (and `.refunds`) and `transfers` (and `.reversals`) are the money, and
|
|
134
|
+
`balance.list_transactions(balance="connect")` lists your Connect balance's every movement. The
|
|
135
|
+
async client has the same, method for method. A Connect webhook's event says which account it happened
|
|
136
|
+
on: `event["account"]`.
|
|
137
|
+
|
|
138
|
+
## HTTP 402: put a price on a route
|
|
139
|
+
|
|
140
|
+
```python
|
|
141
|
+
from tuppence.http402 import Http402Middleware, PayWall
|
|
142
|
+
|
|
143
|
+
app.add_middleware( # FastAPI, Starlette, any ASGI app
|
|
144
|
+
Http402Middleware,
|
|
145
|
+
routes={"GET /v1/report": PayWall(AsyncTuppence(key), price=50, currency="gbp")},
|
|
146
|
+
)
|
|
147
|
+
```
|
|
148
|
+
|
|
149
|
+
`pip install "tuppence[http402]"`. Unpaid requests get `402 Payment Required` with where to pay;
|
|
150
|
+
the caller pays and retries with `Authorization: Tuppence-Receipt <token>` — verified offline
|
|
151
|
+
against Tuppence's published keys, and accepted once. Agents with prepaid credit pay with
|
|
152
|
+
`Authorization: Tuppence-Credits <token>` (`credits_meter=`). `paid_tool` does the same for a
|
|
153
|
+
tool on a Python MCP server.
|
|
154
|
+
|
|
155
|
+
## Payouts and prices
|
|
156
|
+
|
|
157
|
+
`tuppence.payouts.settings()` says when money is paid out and what could change it;
|
|
158
|
+
`.instant_quote()` and `.create(method="instant")` move pending money now, and
|
|
159
|
+
`.set_next_day(enabled=True)` switches next-day payouts (UK) on or off once `next_day.available`
|
|
160
|
+
says so. `tuppence.account.retrieve_pricing()` is every price the account pays — the list's, or
|
|
161
|
+
one agreed with Tuppence, with its dates.
|
|
162
|
+
|
|
163
|
+
## Models
|
|
164
|
+
|
|
165
|
+
Responses are pydantic models generated from the API's OpenAPI contract
|
|
166
|
+
(`apps/api/openapi.public.json`). Fields the API adds later are kept (`model_extra`), so an older
|
|
167
|
+
SDK still sees everything the server sent. Anything without a method yet:
|
|
168
|
+
`tuppence.request("GET", "/v1/…")` — same auth, retries and idempotency.
|
tuppence-0.1.0/README.md
ADDED
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
# tuppence
|
|
2
|
+
|
|
3
|
+
The Tuppence API for Python — payments for developers and AI agents.
|
|
4
|
+
|
|
5
|
+
```bash
|
|
6
|
+
pip install tuppence
|
|
7
|
+
```
|
|
8
|
+
|
|
9
|
+
```python
|
|
10
|
+
import os
|
|
11
|
+
from tuppence import Tuppence
|
|
12
|
+
|
|
13
|
+
tuppence = Tuppence(os.environ["TUPPENCE_SECRET_KEY"])
|
|
14
|
+
|
|
15
|
+
link = tuppence.payment_links.create(name="Research report", amount=2000, currency="gbp")
|
|
16
|
+
print(link.url) # send it to whoever should pay
|
|
17
|
+
|
|
18
|
+
for payment in tuppence.payments.list_all(status="succeeded"):
|
|
19
|
+
print(payment.id, payment.amount, payment.fee, payment.net) # integer pence
|
|
20
|
+
```
|
|
21
|
+
|
|
22
|
+
Python 3.10+. Money is always an integer number of minor units (pence).
|
|
23
|
+
|
|
24
|
+
## Async
|
|
25
|
+
|
|
26
|
+
```python
|
|
27
|
+
from tuppence import AsyncTuppence
|
|
28
|
+
|
|
29
|
+
async with AsyncTuppence(os.environ["TUPPENCE_SECRET_KEY"]) as tuppence:
|
|
30
|
+
payment = await tuppence.payments.create(amount=2000, currency="gbp")
|
|
31
|
+
async for refund in tuppence.refunds.list_all(payment=payment.id):
|
|
32
|
+
...
|
|
33
|
+
```
|
|
34
|
+
|
|
35
|
+
## Retries and idempotency
|
|
36
|
+
|
|
37
|
+
Every POST carries an `Idempotency-Key`: yours, or a fresh one reused by every retry of that
|
|
38
|
+
call — so retrying after a timeout never charges twice. Network errors, timeouts, `5xx`, `429`
|
|
39
|
+
(honouring `Retry-After`) and an idempotent request still in flight are retried twice by default,
|
|
40
|
+
with jittered backoff. Nothing else is.
|
|
41
|
+
|
|
42
|
+
```python
|
|
43
|
+
tuppence.refunds.create(payment="pay_…", amount=500, idempotency_key="order-42-refund")
|
|
44
|
+
tuppence = Tuppence(key, max_retries=4, timeout=10) # client-wide
|
|
45
|
+
tuppence.balance.retrieve(max_retries=0, timeout=2) # per request
|
|
46
|
+
```
|
|
47
|
+
|
|
48
|
+
## Another API address
|
|
49
|
+
|
|
50
|
+
The SDK calls `https://api.tuppence.ai`. To use another Tuppence environment you have been given,
|
|
51
|
+
set `TUPPENCE_API_URL`, or pass `base_url`:
|
|
52
|
+
|
|
53
|
+
```python
|
|
54
|
+
tuppence = Tuppence(key, base_url="https://…")
|
|
55
|
+
```
|
|
56
|
+
|
|
57
|
+
## Errors
|
|
58
|
+
|
|
59
|
+
```python
|
|
60
|
+
from tuppence import CardError, InvalidRequestError
|
|
61
|
+
|
|
62
|
+
try:
|
|
63
|
+
tuppence.refunds.create(payment="pay_…", amount=10_000)
|
|
64
|
+
except InvalidRequestError as e:
|
|
65
|
+
print(e.code, e.param, e.request_id) # branch on `code`; quote `request_id` to support
|
|
66
|
+
```
|
|
67
|
+
|
|
68
|
+
`CardError`, `InvalidRequestError`, `AuthenticationError`, `PermissionError`, `RateLimitError`,
|
|
69
|
+
`IdempotencyError`, `APIError` and `ConnectionError` all derive from `TuppenceError`.
|
|
70
|
+
|
|
71
|
+
## Webhooks
|
|
72
|
+
|
|
73
|
+
```python
|
|
74
|
+
event = tuppence.webhooks.construct_event(
|
|
75
|
+
request.body, # the RAW body, exactly as received
|
|
76
|
+
request.headers["Tuppence-Signature"],
|
|
77
|
+
os.environ["TUPPENCE_WEBHOOK_SECRET"],
|
|
78
|
+
)
|
|
79
|
+
```
|
|
80
|
+
|
|
81
|
+
A wrong secret, a tampered body or a timestamp more than five minutes old raises
|
|
82
|
+
`SignatureVerificationError` — never act on that payload.
|
|
83
|
+
|
|
84
|
+
## Tuppence Connect: acting as your connected accounts
|
|
85
|
+
|
|
86
|
+
A platform's connected accounts are full Tuppence accounts. Make one, send its owner onboarding,
|
|
87
|
+
then act as it with your own key — per call, or through a client bound to it:
|
|
88
|
+
|
|
89
|
+
```python
|
|
90
|
+
bella = tuppence.accounts.create(email="bella@example.com", business_type="individual")
|
|
91
|
+
link = tuppence.account_links.create(
|
|
92
|
+
account=bella.id,
|
|
93
|
+
type="account_onboarding",
|
|
94
|
+
refresh_url="https://groomly.example/onboarding/refresh",
|
|
95
|
+
return_url="https://groomly.example/onboarding/done",
|
|
96
|
+
)
|
|
97
|
+
|
|
98
|
+
# One call as Bella: the `Tuppence-Account` header, never part of the path.
|
|
99
|
+
tuppence.payments.create(
|
|
100
|
+
amount=4500, currency="gbp", application_fee_amount=100, tuppence_account=bella.id
|
|
101
|
+
)
|
|
102
|
+
|
|
103
|
+
# Or a client bound to her — a new one, on the same connections: `tuppence` is unchanged.
|
|
104
|
+
as_bella = tuppence.as_account(bella.id)
|
|
105
|
+
as_bella.bookings.list()
|
|
106
|
+
```
|
|
107
|
+
|
|
108
|
+
`accounts.persons`, `.external_accounts` and `.capabilities`, and `files.create(purpose=…,
|
|
109
|
+
file=open("passport.png", "rb"))` are the rest of onboarding through your own forms.
|
|
110
|
+
`application_fees` (and `.refunds`) and `transfers` (and `.reversals`) are the money, and
|
|
111
|
+
`balance.list_transactions(balance="connect")` lists your Connect balance's every movement. The
|
|
112
|
+
async client has the same, method for method. A Connect webhook's event says which account it happened
|
|
113
|
+
on: `event["account"]`.
|
|
114
|
+
|
|
115
|
+
## HTTP 402: put a price on a route
|
|
116
|
+
|
|
117
|
+
```python
|
|
118
|
+
from tuppence.http402 import Http402Middleware, PayWall
|
|
119
|
+
|
|
120
|
+
app.add_middleware( # FastAPI, Starlette, any ASGI app
|
|
121
|
+
Http402Middleware,
|
|
122
|
+
routes={"GET /v1/report": PayWall(AsyncTuppence(key), price=50, currency="gbp")},
|
|
123
|
+
)
|
|
124
|
+
```
|
|
125
|
+
|
|
126
|
+
`pip install "tuppence[http402]"`. Unpaid requests get `402 Payment Required` with where to pay;
|
|
127
|
+
the caller pays and retries with `Authorization: Tuppence-Receipt <token>` — verified offline
|
|
128
|
+
against Tuppence's published keys, and accepted once. Agents with prepaid credit pay with
|
|
129
|
+
`Authorization: Tuppence-Credits <token>` (`credits_meter=`). `paid_tool` does the same for a
|
|
130
|
+
tool on a Python MCP server.
|
|
131
|
+
|
|
132
|
+
## Payouts and prices
|
|
133
|
+
|
|
134
|
+
`tuppence.payouts.settings()` says when money is paid out and what could change it;
|
|
135
|
+
`.instant_quote()` and `.create(method="instant")` move pending money now, and
|
|
136
|
+
`.set_next_day(enabled=True)` switches next-day payouts (UK) on or off once `next_day.available`
|
|
137
|
+
says so. `tuppence.account.retrieve_pricing()` is every price the account pays — the list's, or
|
|
138
|
+
one agreed with Tuppence, with its dates.
|
|
139
|
+
|
|
140
|
+
## Models
|
|
141
|
+
|
|
142
|
+
Responses are pydantic models generated from the API's OpenAPI contract
|
|
143
|
+
(`apps/api/openapi.public.json`). Fields the API adds later are kept (`model_extra`), so an older
|
|
144
|
+
SDK still sees everything the server sent. Anything without a method yet:
|
|
145
|
+
`tuppence.request("GET", "/v1/…")` — same auth, retries and idempotency.
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["hatchling>=1.24"]
|
|
3
|
+
build-backend = "hatchling.build"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "tuppence"
|
|
7
|
+
version = "0.1.0"
|
|
8
|
+
description = "The Tuppence API for Python — payments for developers and AI agents."
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
license = "MIT"
|
|
11
|
+
requires-python = ">=3.10"
|
|
12
|
+
dependencies = ["httpx>=0.27,<1", "pydantic>=2.7,<3"]
|
|
13
|
+
classifiers = [
|
|
14
|
+
"Programming Language :: Python :: 3",
|
|
15
|
+
"Typing :: Typed",
|
|
16
|
+
]
|
|
17
|
+
|
|
18
|
+
[project.optional-dependencies]
|
|
19
|
+
# HTTP 402 for Python (tuppence.http402): receipts are Ed25519-signed, verified offline.
|
|
20
|
+
http402 = ["cryptography>=42"]
|
|
21
|
+
dev = [
|
|
22
|
+
"cryptography>=42",
|
|
23
|
+
"starlette>=0.37",
|
|
24
|
+
"pytest>=8",
|
|
25
|
+
"ruff>=0.6",
|
|
26
|
+
"mypy>=1.10",
|
|
27
|
+
# Pinned, with the formatters it runs: the generated models are committed, and a new version
|
|
28
|
+
# of any of the three reformats them (scripts/generate.py --check would then fail).
|
|
29
|
+
"datamodel-code-generator==0.34.0",
|
|
30
|
+
"black==26.10.1",
|
|
31
|
+
"isort==6.1.0",
|
|
32
|
+
]
|
|
33
|
+
|
|
34
|
+
[tool.hatch.build.targets.wheel]
|
|
35
|
+
packages = ["src/tuppence"]
|
|
36
|
+
|
|
37
|
+
[tool.pytest.ini_options]
|
|
38
|
+
testpaths = ["tests"]
|
|
39
|
+
pythonpath = ["src"]
|
|
40
|
+
|
|
41
|
+
[tool.ruff]
|
|
42
|
+
line-length = 100
|
|
43
|
+
target-version = "py310"
|
|
44
|
+
# Generated (scripts/generate.py). Their sources are linted; mypy still checks both.
|
|
45
|
+
extend-exclude = ["src/tuppence/models.py", "src/tuppence/_sync_resources.py"]
|
|
46
|
+
|
|
47
|
+
[tool.ruff.lint]
|
|
48
|
+
select = ["E", "F", "I", "UP", "B"]
|
|
49
|
+
|
|
50
|
+
[tool.mypy]
|
|
51
|
+
strict = true
|
|
52
|
+
files = ["src/tuppence"]
|
|
53
|
+
plugins = ["pydantic.mypy"]
|
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
"""Generates what the SDK does not write by hand (plan 11-03), and checks it is current.
|
|
2
|
+
|
|
3
|
+
python scripts/generate.py # write
|
|
4
|
+
python scripts/generate.py --check # fail if anything is stale (CI)
|
|
5
|
+
|
|
6
|
+
1. ``src/tuppence/models.py`` — pydantic models from ``apps/api/openapi.public.json``. The
|
|
7
|
+
server validates what it is SENT; the SDK only reads what comes back, so length, pattern and
|
|
8
|
+
range constraints are dropped before generating — an answer is never refused for being, say,
|
|
9
|
+
an empty string the spec calls ``minLength: 1`` — and so are enums: a status the API adds next
|
|
10
|
+
year must not break parsing in an SDK installed today.
|
|
11
|
+
2. ``src/tuppence/_sync_resources.py`` — the synchronous resources, from the async ones
|
|
12
|
+
(``_async_resources.py`` is the source of truth), the way ``unasync`` keeps httpcore's two
|
|
13
|
+
halves identical: one set of methods, two clients that cannot drift.
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
from __future__ import annotations
|
|
17
|
+
|
|
18
|
+
import json
|
|
19
|
+
import re
|
|
20
|
+
import sys
|
|
21
|
+
import tempfile
|
|
22
|
+
from pathlib import Path
|
|
23
|
+
|
|
24
|
+
ROOT = Path(__file__).resolve().parent.parent
|
|
25
|
+
SPEC = ROOT.parent.parent / "apps" / "api" / "openapi.public.json"
|
|
26
|
+
MODELS = ROOT / "src" / "tuppence" / "models.py"
|
|
27
|
+
ASYNC = ROOT / "src" / "tuppence" / "_async_resources.py"
|
|
28
|
+
SYNC = ROOT / "src" / "tuppence" / "_sync_resources.py"
|
|
29
|
+
|
|
30
|
+
DROPPED = {
|
|
31
|
+
"minLength",
|
|
32
|
+
"maxLength",
|
|
33
|
+
"pattern",
|
|
34
|
+
"minimum",
|
|
35
|
+
"maximum",
|
|
36
|
+
"minItems",
|
|
37
|
+
"maxItems",
|
|
38
|
+
"multipleOf",
|
|
39
|
+
"enum",
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def _loosen(node: object) -> object:
|
|
44
|
+
if isinstance(node, list):
|
|
45
|
+
return [_loosen(item) for item in node]
|
|
46
|
+
if isinstance(node, dict):
|
|
47
|
+
return {k: _loosen(v) for k, v in node.items() if k not in DROPPED}
|
|
48
|
+
return node
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def _keep_store_event_types(raw: dict, spec: dict) -> dict:
|
|
52
|
+
"""Each store event's ``type`` stays its one value (94-06): ``StoreEvent`` is a union
|
|
53
|
+
discriminated by it, so ``event.type == "order.created"`` narrows ``event.data.object``. A
|
|
54
|
+
new event type is a new schema, never a new value of an old one, so nothing breaks."""
|
|
55
|
+
schemas = raw["components"]["schemas"]
|
|
56
|
+
for item in schemas.get("StoreEvent", {}).get("oneOf", []):
|
|
57
|
+
name = item["$ref"].rsplit("/", 1)[-1]
|
|
58
|
+
spec["components"]["schemas"][name]["properties"]["type"] = schemas[name]["properties"][
|
|
59
|
+
"type"
|
|
60
|
+
]
|
|
61
|
+
return spec
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def models() -> str:
|
|
65
|
+
from datamodel_code_generator import DataModelType, InputFileType, LiteralType, generate
|
|
66
|
+
from datamodel_code_generator.format import PythonVersion
|
|
67
|
+
|
|
68
|
+
raw = json.loads(SPEC.read_text())
|
|
69
|
+
spec = _keep_store_event_types(raw, _loosen(raw))
|
|
70
|
+
with tempfile.TemporaryDirectory() as tmp:
|
|
71
|
+
source = Path(tmp) / "openapi.public.json"
|
|
72
|
+
source.write_text(json.dumps(spec))
|
|
73
|
+
target = Path(tmp) / "models.py"
|
|
74
|
+
generate(
|
|
75
|
+
source,
|
|
76
|
+
input_file_type=InputFileType.OpenAPI,
|
|
77
|
+
output=target,
|
|
78
|
+
output_model_type=DataModelType.PydanticV2BaseModel,
|
|
79
|
+
target_python_version=PythonVersion.PY_310,
|
|
80
|
+
base_class="tuppence._model.TuppenceModel",
|
|
81
|
+
use_standard_collections=True,
|
|
82
|
+
use_union_operator=True,
|
|
83
|
+
use_schema_description=True,
|
|
84
|
+
use_field_description=True,
|
|
85
|
+
strict_nullable=True,
|
|
86
|
+
# Only the store events' one-value `type`s keep an enum (see above): a Literal each.
|
|
87
|
+
enum_field_as_literal=LiteralType.One,
|
|
88
|
+
disable_timestamp=True,
|
|
89
|
+
custom_file_header=(
|
|
90
|
+
"# GENERATED from apps/api/openapi.public.json by scripts/generate.py.\n"
|
|
91
|
+
"# Do not edit: regenerate whenever the API contract changes."
|
|
92
|
+
),
|
|
93
|
+
)
|
|
94
|
+
return target.read_text()
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
# async → sync, token by token. Order matters: the longer patterns first.
|
|
98
|
+
UNASYNC = [
|
|
99
|
+
(r"AsyncIterator", "Iterator"),
|
|
100
|
+
(r"AsyncHttpClient", "SyncHttpClient"),
|
|
101
|
+
(r"async def ", "def "),
|
|
102
|
+
(r"async for ", "for "),
|
|
103
|
+
(r"await ", ""),
|
|
104
|
+
(r"\bAsync([A-Z]\w*)", r"\1"),
|
|
105
|
+
(r"_async_resources", "_sync_resources"),
|
|
106
|
+
]
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def sync_resources() -> str:
|
|
110
|
+
text = ASYNC.read_text()
|
|
111
|
+
for pattern, replacement in UNASYNC:
|
|
112
|
+
text = re.sub(pattern, replacement, text)
|
|
113
|
+
header = (
|
|
114
|
+
"# GENERATED from _async_resources.py by scripts/generate.py — do not edit.\n"
|
|
115
|
+
"# Change the async resources and regenerate: the two clients cannot drift.\n"
|
|
116
|
+
)
|
|
117
|
+
return header + text
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def main() -> int:
|
|
121
|
+
check = "--check" in sys.argv
|
|
122
|
+
stale = []
|
|
123
|
+
for path, text in ((MODELS, models()), (SYNC, sync_resources())):
|
|
124
|
+
if check:
|
|
125
|
+
if not path.exists() or path.read_text() != text:
|
|
126
|
+
stale.append(path.relative_to(ROOT))
|
|
127
|
+
else:
|
|
128
|
+
path.write_text(text)
|
|
129
|
+
print(f"wrote {path.relative_to(ROOT)}")
|
|
130
|
+
if stale:
|
|
131
|
+
print(
|
|
132
|
+
f"stale: {', '.join(map(str, stale))} — run `python scripts/generate.py` and commit",
|
|
133
|
+
file=sys.stderr,
|
|
134
|
+
)
|
|
135
|
+
return 1
|
|
136
|
+
if check:
|
|
137
|
+
print("generated files are current")
|
|
138
|
+
return 0
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
if __name__ == "__main__":
|
|
142
|
+
raise SystemExit(main())
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
"""The Tuppence API for Python — payments for developers and AI agents.
|
|
2
|
+
|
|
3
|
+
from tuppence import Tuppence
|
|
4
|
+
tuppence = Tuppence(os.environ["TUPPENCE_SECRET_KEY"])
|
|
5
|
+
payment = tuppence.payments.create(amount=2000, currency="gbp")
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from . import models
|
|
9
|
+
from ._client import AsyncTuppence, Tuppence
|
|
10
|
+
from ._errors import (
|
|
11
|
+
APIError,
|
|
12
|
+
AuthenticationError,
|
|
13
|
+
CardError,
|
|
14
|
+
ConnectionError,
|
|
15
|
+
IdempotencyError,
|
|
16
|
+
InvalidRequestError,
|
|
17
|
+
PermissionError,
|
|
18
|
+
RateLimitError,
|
|
19
|
+
SignatureVerificationError,
|
|
20
|
+
TuppenceError,
|
|
21
|
+
)
|
|
22
|
+
from ._http import DEFAULT_API_VERSION, VERSION
|
|
23
|
+
from ._model import Page, TuppenceModel
|
|
24
|
+
from ._webhooks import (
|
|
25
|
+
DEFAULT_TOLERANCE_SECONDS,
|
|
26
|
+
SIGNATURE_HEADER,
|
|
27
|
+
construct_event,
|
|
28
|
+
construct_store_event,
|
|
29
|
+
generate_test_header,
|
|
30
|
+
)
|
|
31
|
+
|
|
32
|
+
# The objects by the names a developer uses.
|
|
33
|
+
Account = models.AccountModel
|
|
34
|
+
#: Every price the account pays now — the list's, or one agreed with Tuppence.
|
|
35
|
+
AccountPricing = models.AccountPricingModel
|
|
36
|
+
Payment = models.PaymentModel
|
|
37
|
+
Refund = models.RefundModel
|
|
38
|
+
Customer = models.CustomerModel
|
|
39
|
+
PaymentLink = models.PaymentLinkModel
|
|
40
|
+
Balance = models.BalanceModel
|
|
41
|
+
BalanceTransaction = models.BalanceTransactionModel
|
|
42
|
+
Payout = models.PayoutModel
|
|
43
|
+
#: What an instant move would cost, to the bank or the card balance — or why it can't be made.
|
|
44
|
+
InstantPayoutQuote = models.InstantPayoutQuoteModel
|
|
45
|
+
PayoutSettings = models.PayoutSettingsModel
|
|
46
|
+
Dispute = models.DisputeModel
|
|
47
|
+
Event = models.EventModel
|
|
48
|
+
# Any of the store's webhook events (94-06): `.root` narrows on `type`.
|
|
49
|
+
StoreEvent = models.StoreEvent
|
|
50
|
+
WebhookEndpoint = models.WebhookEndpointModel
|
|
51
|
+
Meter = models.MeterModel
|
|
52
|
+
CreditBalance = models.CreditBalanceModel
|
|
53
|
+
# Tuppence Connect: a platform's connected accounts, and onboarding them.
|
|
54
|
+
ConnectedAccount = models.ConnectAccountModel
|
|
55
|
+
AccountLink = models.AccountLinkModel
|
|
56
|
+
Person = models.PersonModel
|
|
57
|
+
ExternalAccount = models.ExternalAccountModel
|
|
58
|
+
Capability = models.CapabilityModel
|
|
59
|
+
ApplicationFee = models.ApplicationFeeModel
|
|
60
|
+
Transfer = models.TransferModel
|
|
61
|
+
|
|
62
|
+
__version__ = VERSION
|
|
63
|
+
|
|
64
|
+
__all__ = [
|
|
65
|
+
"APIError",
|
|
66
|
+
"Account",
|
|
67
|
+
"AccountLink",
|
|
68
|
+
"AccountPricing",
|
|
69
|
+
"ApplicationFee",
|
|
70
|
+
"AsyncTuppence",
|
|
71
|
+
"AuthenticationError",
|
|
72
|
+
"Balance",
|
|
73
|
+
"BalanceTransaction",
|
|
74
|
+
"Capability",
|
|
75
|
+
"CardError",
|
|
76
|
+
"ConnectedAccount",
|
|
77
|
+
"ConnectionError",
|
|
78
|
+
"CreditBalance",
|
|
79
|
+
"Customer",
|
|
80
|
+
"DEFAULT_API_VERSION",
|
|
81
|
+
"DEFAULT_TOLERANCE_SECONDS",
|
|
82
|
+
"Dispute",
|
|
83
|
+
"Event",
|
|
84
|
+
"ExternalAccount",
|
|
85
|
+
"IdempotencyError",
|
|
86
|
+
"InstantPayoutQuote",
|
|
87
|
+
"InvalidRequestError",
|
|
88
|
+
"Meter",
|
|
89
|
+
"Page",
|
|
90
|
+
"Payment",
|
|
91
|
+
"PaymentLink",
|
|
92
|
+
"Payout",
|
|
93
|
+
"PayoutSettings",
|
|
94
|
+
"PermissionError",
|
|
95
|
+
"Person",
|
|
96
|
+
"RateLimitError",
|
|
97
|
+
"Refund",
|
|
98
|
+
"SIGNATURE_HEADER",
|
|
99
|
+
"SignatureVerificationError",
|
|
100
|
+
"StoreEvent",
|
|
101
|
+
"Transfer",
|
|
102
|
+
"Tuppence",
|
|
103
|
+
"TuppenceError",
|
|
104
|
+
"TuppenceModel",
|
|
105
|
+
"VERSION",
|
|
106
|
+
"WebhookEndpoint",
|
|
107
|
+
"construct_event",
|
|
108
|
+
"construct_store_event",
|
|
109
|
+
"generate_test_header",
|
|
110
|
+
"models",
|
|
111
|
+
]
|