pawpayments 2.0.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.
- pawpayments-2.0.0/.github/workflows/release.yml +27 -0
- pawpayments-2.0.0/.gitignore +14 -0
- pawpayments-2.0.0/LICENSE +21 -0
- pawpayments-2.0.0/PKG-INFO +136 -0
- pawpayments-2.0.0/README.md +109 -0
- pawpayments-2.0.0/pyproject.toml +42 -0
- pawpayments-2.0.0/src/pawpayments/__init__.py +17 -0
- pawpayments-2.0.0/src/pawpayments/_async_client.py +152 -0
- pawpayments-2.0.0/src/pawpayments/_client.py +152 -0
- pawpayments-2.0.0/src/pawpayments/_http.py +72 -0
- pawpayments-2.0.0/src/pawpayments/_models.py +21 -0
- pawpayments-2.0.0/src/pawpayments/_resources.py +122 -0
- pawpayments-2.0.0/src/pawpayments/errors.py +23 -0
- pawpayments-2.0.0/src/pawpayments/py.typed +1 -0
- pawpayments-2.0.0/src/pawpayments/webhook.py +33 -0
- pawpayments-2.0.0/tests/test_live.py +105 -0
- pawpayments-2.0.0/tests/test_negative.py +81 -0
- pawpayments-2.0.0/tests/test_webhook.py +79 -0
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
name: Release
|
|
2
|
+
|
|
3
|
+
on:
|
|
4
|
+
push:
|
|
5
|
+
tags: ['v*']
|
|
6
|
+
|
|
7
|
+
jobs:
|
|
8
|
+
publish:
|
|
9
|
+
runs-on: ubuntu-latest
|
|
10
|
+
permissions:
|
|
11
|
+
contents: write
|
|
12
|
+
id-token: write # required for PyPI Trusted Publishing (no token needed)
|
|
13
|
+
steps:
|
|
14
|
+
- uses: actions/checkout@v4
|
|
15
|
+
|
|
16
|
+
- uses: actions/setup-python@v5
|
|
17
|
+
with:
|
|
18
|
+
python-version: '3.11'
|
|
19
|
+
|
|
20
|
+
- run: pip install --no-cache-dir build
|
|
21
|
+
- run: python -m build
|
|
22
|
+
|
|
23
|
+
- uses: pypa/gh-action-pypi-publish@release/v1
|
|
24
|
+
|
|
25
|
+
- run: gh release create "$GITHUB_REF_NAME" --generate-notes
|
|
26
|
+
env:
|
|
27
|
+
GH_TOKEN: ${{ github.token }}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) PawPayments
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: pawpayments
|
|
3
|
+
Version: 2.0.0
|
|
4
|
+
Summary: Official Python SDK for the PawPayments Native V2 API
|
|
5
|
+
Project-URL: Homepage, https://pawpayments.com
|
|
6
|
+
Project-URL: Documentation, https://docs.pawpayments.com
|
|
7
|
+
Project-URL: Source, https://github.com/pawpayments/python-sdk
|
|
8
|
+
Author-email: PawPayments <support@pawpayments.com>
|
|
9
|
+
License: MIT
|
|
10
|
+
License-File: LICENSE
|
|
11
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
12
|
+
Classifier: Programming Language :: Python :: 3
|
|
13
|
+
Classifier: Programming Language :: Python :: 3.9
|
|
14
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
15
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
18
|
+
Classifier: Topic :: Office/Business :: Financial
|
|
19
|
+
Classifier: Typing :: Typed
|
|
20
|
+
Requires-Python: >=3.9
|
|
21
|
+
Requires-Dist: httpx>=0.24
|
|
22
|
+
Requires-Dist: requests>=2.28
|
|
23
|
+
Provides-Extra: dev
|
|
24
|
+
Requires-Dist: pytest-asyncio>=0.21; extra == 'dev'
|
|
25
|
+
Requires-Dist: pytest>=7; extra == 'dev'
|
|
26
|
+
Description-Content-Type: text/markdown
|
|
27
|
+
|
|
28
|
+
# PawPayments — Python SDK
|
|
29
|
+
|
|
30
|
+
Official Python SDK for the [PawPayments](https://pawpayments.com) Native V2 API.
|
|
31
|
+
Ships both a synchronous client (`PawPayments`, built on `requests`) and an
|
|
32
|
+
asynchronous one (`AsyncPawPayments`, built on `httpx`).
|
|
33
|
+
|
|
34
|
+
## Install
|
|
35
|
+
|
|
36
|
+
```bash
|
|
37
|
+
pip install pawpayments
|
|
38
|
+
```
|
|
39
|
+
|
|
40
|
+
Requires Python **3.9+**.
|
|
41
|
+
|
|
42
|
+
## Quickstart (sync)
|
|
43
|
+
|
|
44
|
+
```python
|
|
45
|
+
from pawpayments import PawPayments
|
|
46
|
+
|
|
47
|
+
paw = PawPayments(api_key="…")
|
|
48
|
+
|
|
49
|
+
invoice = paw.invoices.create(
|
|
50
|
+
amount=25,
|
|
51
|
+
fiat_currency="USD",
|
|
52
|
+
billing_type="STATIC",
|
|
53
|
+
asset="usdt_tron",
|
|
54
|
+
description="Pro plan, 1 month",
|
|
55
|
+
notify_url="https://example.com/paw/webhook",
|
|
56
|
+
)
|
|
57
|
+
print(invoice["payment_url"])
|
|
58
|
+
```
|
|
59
|
+
|
|
60
|
+
## Quickstart (async)
|
|
61
|
+
|
|
62
|
+
```python
|
|
63
|
+
import asyncio
|
|
64
|
+
from pawpayments import AsyncPawPayments
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
async def main():
|
|
68
|
+
async with AsyncPawPayments(api_key="…") as paw:
|
|
69
|
+
invoice = await paw.invoices.create(
|
|
70
|
+
amount=25,
|
|
71
|
+
fiat_currency="USD",
|
|
72
|
+
billing_type="STATIC",
|
|
73
|
+
asset="usdt_tron",
|
|
74
|
+
)
|
|
75
|
+
print(invoice["payment_url"])
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
asyncio.run(main())
|
|
79
|
+
```
|
|
80
|
+
|
|
81
|
+
## Resources
|
|
82
|
+
|
|
83
|
+
| Group | Methods |
|
|
84
|
+
|-------|---------|
|
|
85
|
+
| `paw.assets` | `list()` |
|
|
86
|
+
| `paw.rates` | `get(base=, assets=)` |
|
|
87
|
+
| `paw.balance` | `get()` |
|
|
88
|
+
| `paw.invoices` | `create(**)`, `get(id)`, `list(**)`, `notify(id)` |
|
|
89
|
+
| `paw.payouts` | `create(**, uniq_id=)`, `get(id)`, `list(**)`, `batch(items, uniq_id=)` |
|
|
90
|
+
| `paw.ledger` | `list(type=, ...)` |
|
|
91
|
+
| `paw.notifications` | `list(**)`, `test(url=)` |
|
|
92
|
+
| `paw.permanent` | `create(**)`, `get(id)`, `list(**)`, `deactivate(id)` |
|
|
93
|
+
|
|
94
|
+
`payouts.create` and `payouts.batch` accept an optional `uniq_id` for explicit
|
|
95
|
+
idempotency (UUIDv4). When omitted the SDK generates one with `uuid.uuid4()`.
|
|
96
|
+
A repeated `uniq_id` within 2 hours yields a 409.
|
|
97
|
+
|
|
98
|
+
## Webhook verification
|
|
99
|
+
|
|
100
|
+
```python
|
|
101
|
+
from flask import Flask, request, abort
|
|
102
|
+
from pawpayments import Webhook
|
|
103
|
+
|
|
104
|
+
app = Flask(__name__)
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
@app.post("/paw/webhook")
|
|
108
|
+
def webhook():
|
|
109
|
+
raw = request.get_data()
|
|
110
|
+
sig = request.headers.get("X-Paw-Signature", "")
|
|
111
|
+
if not Webhook.verify_raw_body(raw, sig, API_KEY):
|
|
112
|
+
abort(401)
|
|
113
|
+
payload = Webhook.parse_payload(raw)
|
|
114
|
+
# …handle the invoice update…
|
|
115
|
+
return "", 200
|
|
116
|
+
```
|
|
117
|
+
|
|
118
|
+
## Errors
|
|
119
|
+
|
|
120
|
+
```python
|
|
121
|
+
from pawpayments import PawPaymentsApiError
|
|
122
|
+
|
|
123
|
+
try:
|
|
124
|
+
paw.invoices.create(...)
|
|
125
|
+
except PawPaymentsApiError as exc:
|
|
126
|
+
print(exc.code, exc.http_status, str(exc), exc.details)
|
|
127
|
+
```
|
|
128
|
+
|
|
129
|
+
## Testing
|
|
130
|
+
|
|
131
|
+
- `pytest tests/test_webhook.py` — signature unit tests + PHP-interop parity.
|
|
132
|
+
- `PAW_API_KEY=… pytest tests/test_live.py tests/test_negative.py` — live happy-path + negative-case suites against `https://api.pawpayments.com`. Tests skip when `PAW_API_KEY` is absent.
|
|
133
|
+
|
|
134
|
+
## License
|
|
135
|
+
|
|
136
|
+
MIT
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
# PawPayments — Python SDK
|
|
2
|
+
|
|
3
|
+
Official Python SDK for the [PawPayments](https://pawpayments.com) Native V2 API.
|
|
4
|
+
Ships both a synchronous client (`PawPayments`, built on `requests`) and an
|
|
5
|
+
asynchronous one (`AsyncPawPayments`, built on `httpx`).
|
|
6
|
+
|
|
7
|
+
## Install
|
|
8
|
+
|
|
9
|
+
```bash
|
|
10
|
+
pip install pawpayments
|
|
11
|
+
```
|
|
12
|
+
|
|
13
|
+
Requires Python **3.9+**.
|
|
14
|
+
|
|
15
|
+
## Quickstart (sync)
|
|
16
|
+
|
|
17
|
+
```python
|
|
18
|
+
from pawpayments import PawPayments
|
|
19
|
+
|
|
20
|
+
paw = PawPayments(api_key="…")
|
|
21
|
+
|
|
22
|
+
invoice = paw.invoices.create(
|
|
23
|
+
amount=25,
|
|
24
|
+
fiat_currency="USD",
|
|
25
|
+
billing_type="STATIC",
|
|
26
|
+
asset="usdt_tron",
|
|
27
|
+
description="Pro plan, 1 month",
|
|
28
|
+
notify_url="https://example.com/paw/webhook",
|
|
29
|
+
)
|
|
30
|
+
print(invoice["payment_url"])
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
## Quickstart (async)
|
|
34
|
+
|
|
35
|
+
```python
|
|
36
|
+
import asyncio
|
|
37
|
+
from pawpayments import AsyncPawPayments
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
async def main():
|
|
41
|
+
async with AsyncPawPayments(api_key="…") as paw:
|
|
42
|
+
invoice = await paw.invoices.create(
|
|
43
|
+
amount=25,
|
|
44
|
+
fiat_currency="USD",
|
|
45
|
+
billing_type="STATIC",
|
|
46
|
+
asset="usdt_tron",
|
|
47
|
+
)
|
|
48
|
+
print(invoice["payment_url"])
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
asyncio.run(main())
|
|
52
|
+
```
|
|
53
|
+
|
|
54
|
+
## Resources
|
|
55
|
+
|
|
56
|
+
| Group | Methods |
|
|
57
|
+
|-------|---------|
|
|
58
|
+
| `paw.assets` | `list()` |
|
|
59
|
+
| `paw.rates` | `get(base=, assets=)` |
|
|
60
|
+
| `paw.balance` | `get()` |
|
|
61
|
+
| `paw.invoices` | `create(**)`, `get(id)`, `list(**)`, `notify(id)` |
|
|
62
|
+
| `paw.payouts` | `create(**, uniq_id=)`, `get(id)`, `list(**)`, `batch(items, uniq_id=)` |
|
|
63
|
+
| `paw.ledger` | `list(type=, ...)` |
|
|
64
|
+
| `paw.notifications` | `list(**)`, `test(url=)` |
|
|
65
|
+
| `paw.permanent` | `create(**)`, `get(id)`, `list(**)`, `deactivate(id)` |
|
|
66
|
+
|
|
67
|
+
`payouts.create` and `payouts.batch` accept an optional `uniq_id` for explicit
|
|
68
|
+
idempotency (UUIDv4). When omitted the SDK generates one with `uuid.uuid4()`.
|
|
69
|
+
A repeated `uniq_id` within 2 hours yields a 409.
|
|
70
|
+
|
|
71
|
+
## Webhook verification
|
|
72
|
+
|
|
73
|
+
```python
|
|
74
|
+
from flask import Flask, request, abort
|
|
75
|
+
from pawpayments import Webhook
|
|
76
|
+
|
|
77
|
+
app = Flask(__name__)
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
@app.post("/paw/webhook")
|
|
81
|
+
def webhook():
|
|
82
|
+
raw = request.get_data()
|
|
83
|
+
sig = request.headers.get("X-Paw-Signature", "")
|
|
84
|
+
if not Webhook.verify_raw_body(raw, sig, API_KEY):
|
|
85
|
+
abort(401)
|
|
86
|
+
payload = Webhook.parse_payload(raw)
|
|
87
|
+
# …handle the invoice update…
|
|
88
|
+
return "", 200
|
|
89
|
+
```
|
|
90
|
+
|
|
91
|
+
## Errors
|
|
92
|
+
|
|
93
|
+
```python
|
|
94
|
+
from pawpayments import PawPaymentsApiError
|
|
95
|
+
|
|
96
|
+
try:
|
|
97
|
+
paw.invoices.create(...)
|
|
98
|
+
except PawPaymentsApiError as exc:
|
|
99
|
+
print(exc.code, exc.http_status, str(exc), exc.details)
|
|
100
|
+
```
|
|
101
|
+
|
|
102
|
+
## Testing
|
|
103
|
+
|
|
104
|
+
- `pytest tests/test_webhook.py` — signature unit tests + PHP-interop parity.
|
|
105
|
+
- `PAW_API_KEY=… pytest tests/test_live.py tests/test_negative.py` — live happy-path + negative-case suites against `https://api.pawpayments.com`. Tests skip when `PAW_API_KEY` is absent.
|
|
106
|
+
|
|
107
|
+
## License
|
|
108
|
+
|
|
109
|
+
MIT
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["hatchling>=1.21"]
|
|
3
|
+
build-backend = "hatchling.build"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "pawpayments"
|
|
7
|
+
version = "2.0.0"
|
|
8
|
+
description = "Official Python SDK for the PawPayments Native V2 API"
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
license = { text = "MIT" }
|
|
11
|
+
authors = [{ name = "PawPayments", email = "support@pawpayments.com" }]
|
|
12
|
+
requires-python = ">=3.9"
|
|
13
|
+
classifiers = [
|
|
14
|
+
"License :: OSI Approved :: MIT License",
|
|
15
|
+
"Programming Language :: Python :: 3",
|
|
16
|
+
"Programming Language :: Python :: 3.9",
|
|
17
|
+
"Programming Language :: Python :: 3.10",
|
|
18
|
+
"Programming Language :: Python :: 3.11",
|
|
19
|
+
"Programming Language :: Python :: 3.12",
|
|
20
|
+
"Programming Language :: Python :: 3.13",
|
|
21
|
+
"Topic :: Office/Business :: Financial",
|
|
22
|
+
"Typing :: Typed",
|
|
23
|
+
]
|
|
24
|
+
dependencies = [
|
|
25
|
+
"requests>=2.28",
|
|
26
|
+
"httpx>=0.24",
|
|
27
|
+
]
|
|
28
|
+
|
|
29
|
+
[project.optional-dependencies]
|
|
30
|
+
dev = ["pytest>=7", "pytest-asyncio>=0.21"]
|
|
31
|
+
|
|
32
|
+
[project.urls]
|
|
33
|
+
Homepage = "https://pawpayments.com"
|
|
34
|
+
Documentation = "https://docs.pawpayments.com"
|
|
35
|
+
Source = "https://github.com/pawpayments/python-sdk"
|
|
36
|
+
|
|
37
|
+
[tool.hatch.build.targets.wheel]
|
|
38
|
+
packages = ["src/pawpayments"]
|
|
39
|
+
|
|
40
|
+
[tool.pytest.ini_options]
|
|
41
|
+
testpaths = ["tests"]
|
|
42
|
+
asyncio_mode = "auto"
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
"""Official PawPayments Python SDK."""
|
|
2
|
+
from ._async_client import AsyncPawPayments
|
|
3
|
+
from ._client import PawPayments
|
|
4
|
+
from ._models import ListResult, Pagination
|
|
5
|
+
from .errors import PawPaymentsApiError
|
|
6
|
+
from .webhook import Webhook
|
|
7
|
+
|
|
8
|
+
__all__ = [
|
|
9
|
+
"PawPayments",
|
|
10
|
+
"AsyncPawPayments",
|
|
11
|
+
"PawPaymentsApiError",
|
|
12
|
+
"Webhook",
|
|
13
|
+
"ListResult",
|
|
14
|
+
"Pagination",
|
|
15
|
+
]
|
|
16
|
+
|
|
17
|
+
__version__ = "0.1.0"
|
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
"""Asynchronous client built on `httpx.AsyncClient`."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
from typing import Any, Iterable, Mapping, Optional
|
|
5
|
+
|
|
6
|
+
import httpx
|
|
7
|
+
|
|
8
|
+
from . import _resources as R
|
|
9
|
+
from ._http import base_headers, decode_envelope, unwrap_list, unwrap_result
|
|
10
|
+
from ._models import ListResult
|
|
11
|
+
from .errors import PawPaymentsApiError
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class _AsyncResource:
|
|
15
|
+
def __init__(self, client: "AsyncPawPayments") -> None:
|
|
16
|
+
self._client = client
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class _AsyncAssets(_AsyncResource):
|
|
20
|
+
async def list(self) -> list[dict[str, Any]]:
|
|
21
|
+
return await self._client._dispatch(R.assets_list())
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class _AsyncRates(_AsyncResource):
|
|
25
|
+
async def get(self, *, base: str = "USD", assets: Optional[Iterable[str] | str] = None) -> dict[str, Any]:
|
|
26
|
+
return await self._client._dispatch(R.rates_get(base=base, assets=assets))
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
class _AsyncBalance(_AsyncResource):
|
|
30
|
+
async def get(self) -> dict[str, Any]:
|
|
31
|
+
return await self._client._dispatch(R.balance_get())
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
class _AsyncInvoices(_AsyncResource):
|
|
35
|
+
async def create(self, **params: Any) -> dict[str, Any]:
|
|
36
|
+
return await self._client._dispatch(R.invoices_create(params))
|
|
37
|
+
|
|
38
|
+
async def get(self, order_id: str) -> dict[str, Any]:
|
|
39
|
+
return await self._client._dispatch(R.invoices_get(order_id))
|
|
40
|
+
|
|
41
|
+
async def list(self, **params: Any) -> ListResult:
|
|
42
|
+
return await self._client._dispatch_list(R.invoices_list(**params))
|
|
43
|
+
|
|
44
|
+
async def notify(self, order_id: str) -> dict[str, Any]:
|
|
45
|
+
return await self._client._dispatch(R.invoices_notify(order_id))
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
class _AsyncPayouts(_AsyncResource):
|
|
49
|
+
async def create(self, *, uniq_id: Optional[str] = None, **params: Any) -> dict[str, Any]:
|
|
50
|
+
return await self._client._dispatch(R.payouts_create(params, uniq_id=uniq_id))
|
|
51
|
+
|
|
52
|
+
async def batch(self, items: list[Mapping[str, Any]], *, uniq_id: Optional[str] = None) -> dict[str, Any]:
|
|
53
|
+
return await self._client._dispatch(R.payouts_batch(items, uniq_id=uniq_id))
|
|
54
|
+
|
|
55
|
+
async def get(self, payout_id: str) -> dict[str, Any]:
|
|
56
|
+
return await self._client._dispatch(R.payouts_get(payout_id))
|
|
57
|
+
|
|
58
|
+
async def list(self, **params: Any) -> ListResult:
|
|
59
|
+
return await self._client._dispatch_list(R.payouts_list(**params))
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
class _AsyncLedger(_AsyncResource):
|
|
63
|
+
async def list(self, **params: Any) -> ListResult:
|
|
64
|
+
return await self._client._dispatch_list(R.ledger_list(**params))
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
class _AsyncNotifications(_AsyncResource):
|
|
68
|
+
async def list(self, **params: Any) -> ListResult:
|
|
69
|
+
return await self._client._dispatch_list(R.notifications_list(**params))
|
|
70
|
+
|
|
71
|
+
async def test(self, url: Optional[str] = None) -> dict[str, Any]:
|
|
72
|
+
return await self._client._dispatch(R.notifications_test(url))
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
class _AsyncPermanent(_AsyncResource):
|
|
76
|
+
async def create(self, **params: Any) -> dict[str, Any]:
|
|
77
|
+
return await self._client._dispatch(R.permanent_create(params))
|
|
78
|
+
|
|
79
|
+
async def get(self, address_id: str) -> dict[str, Any]:
|
|
80
|
+
return await self._client._dispatch(R.permanent_get(address_id))
|
|
81
|
+
|
|
82
|
+
async def list(self, **params: Any) -> ListResult:
|
|
83
|
+
return await self._client._dispatch_list(R.permanent_list(**params))
|
|
84
|
+
|
|
85
|
+
async def deactivate(self, address_id: str) -> dict[str, Any]:
|
|
86
|
+
return await self._client._dispatch(R.permanent_deactivate(address_id))
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
class AsyncPawPayments:
|
|
90
|
+
"""Asynchronous PawPayments client (built on `httpx.AsyncClient`)."""
|
|
91
|
+
|
|
92
|
+
def __init__(
|
|
93
|
+
self,
|
|
94
|
+
api_key: str,
|
|
95
|
+
base_url: str = "https://api.pawpayments.com",
|
|
96
|
+
timeout: float = 30.0,
|
|
97
|
+
client: Optional[httpx.AsyncClient] = None,
|
|
98
|
+
) -> None:
|
|
99
|
+
if not api_key:
|
|
100
|
+
raise ValueError("api_key is required")
|
|
101
|
+
self._api_key = api_key
|
|
102
|
+
self._base_url = base_url.rstrip("/")
|
|
103
|
+
self._timeout = timeout
|
|
104
|
+
self._client = client or httpx.AsyncClient(timeout=timeout)
|
|
105
|
+
self._owns_client = client is None
|
|
106
|
+
|
|
107
|
+
self.assets = _AsyncAssets(self)
|
|
108
|
+
self.rates = _AsyncRates(self)
|
|
109
|
+
self.balance = _AsyncBalance(self)
|
|
110
|
+
self.invoices = _AsyncInvoices(self)
|
|
111
|
+
self.payouts = _AsyncPayouts(self)
|
|
112
|
+
self.ledger = _AsyncLedger(self)
|
|
113
|
+
self.notifications = _AsyncNotifications(self)
|
|
114
|
+
self.permanent = _AsyncPermanent(self)
|
|
115
|
+
|
|
116
|
+
@property
|
|
117
|
+
def base_url(self) -> str:
|
|
118
|
+
return self._base_url
|
|
119
|
+
|
|
120
|
+
async def aclose(self) -> None:
|
|
121
|
+
if self._owns_client:
|
|
122
|
+
await self._client.aclose()
|
|
123
|
+
|
|
124
|
+
async def __aenter__(self) -> "AsyncPawPayments":
|
|
125
|
+
return self
|
|
126
|
+
|
|
127
|
+
async def __aexit__(self, *_: object) -> None:
|
|
128
|
+
await self.aclose()
|
|
129
|
+
|
|
130
|
+
async def _dispatch(self, spec: R.RequestSpec) -> Any:
|
|
131
|
+
envelope = await self._send(spec)
|
|
132
|
+
return unwrap_result(envelope)
|
|
133
|
+
|
|
134
|
+
async def _dispatch_list(self, spec: R.RequestSpec) -> ListResult:
|
|
135
|
+
envelope = await self._send(spec)
|
|
136
|
+
return unwrap_list(envelope) # type: ignore[return-value]
|
|
137
|
+
|
|
138
|
+
async def _send(self, spec: R.RequestSpec) -> dict[str, Any]:
|
|
139
|
+
url = self._base_url + spec.path
|
|
140
|
+
headers = base_headers(self._api_key, spec.uniq_id)
|
|
141
|
+
kwargs: dict[str, Any] = {"headers": headers, "timeout": self._timeout}
|
|
142
|
+
if spec.query:
|
|
143
|
+
kwargs["params"] = spec.query
|
|
144
|
+
if spec.body is not None:
|
|
145
|
+
kwargs["json"] = spec.body
|
|
146
|
+
try:
|
|
147
|
+
response = await self._client.request(spec.method, url, **kwargs)
|
|
148
|
+
except httpx.HTTPError as exc:
|
|
149
|
+
raise PawPaymentsApiError(
|
|
150
|
+
f"Network error: {exc}", "NETWORK_ERROR", None
|
|
151
|
+
) from exc
|
|
152
|
+
return decode_envelope(response.status_code, response.content or b"")
|
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
"""Synchronous client built on `requests`."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
from typing import Any, Iterable, Mapping, Optional
|
|
5
|
+
|
|
6
|
+
import requests
|
|
7
|
+
|
|
8
|
+
from . import _resources as R
|
|
9
|
+
from ._http import base_headers, decode_envelope, unwrap_list, unwrap_result
|
|
10
|
+
from ._models import ListResult
|
|
11
|
+
from .errors import PawPaymentsApiError
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class _Resource:
|
|
15
|
+
def __init__(self, client: "PawPayments") -> None:
|
|
16
|
+
self._client = client
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class _Assets(_Resource):
|
|
20
|
+
def list(self) -> list[dict[str, Any]]:
|
|
21
|
+
return self._client._dispatch(R.assets_list())
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class _Rates(_Resource):
|
|
25
|
+
def get(self, *, base: str = "USD", assets: Optional[Iterable[str] | str] = None) -> dict[str, Any]:
|
|
26
|
+
return self._client._dispatch(R.rates_get(base=base, assets=assets))
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
class _Balance(_Resource):
|
|
30
|
+
def get(self) -> dict[str, Any]:
|
|
31
|
+
return self._client._dispatch(R.balance_get())
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
class _Invoices(_Resource):
|
|
35
|
+
def create(self, **params: Any) -> dict[str, Any]:
|
|
36
|
+
return self._client._dispatch(R.invoices_create(params))
|
|
37
|
+
|
|
38
|
+
def get(self, order_id: str) -> dict[str, Any]:
|
|
39
|
+
return self._client._dispatch(R.invoices_get(order_id))
|
|
40
|
+
|
|
41
|
+
def list(self, **params: Any) -> ListResult:
|
|
42
|
+
return self._client._dispatch_list(R.invoices_list(**params))
|
|
43
|
+
|
|
44
|
+
def notify(self, order_id: str) -> dict[str, Any]:
|
|
45
|
+
return self._client._dispatch(R.invoices_notify(order_id))
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
class _Payouts(_Resource):
|
|
49
|
+
def create(self, *, uniq_id: Optional[str] = None, **params: Any) -> dict[str, Any]:
|
|
50
|
+
return self._client._dispatch(R.payouts_create(params, uniq_id=uniq_id))
|
|
51
|
+
|
|
52
|
+
def batch(self, items: list[Mapping[str, Any]], *, uniq_id: Optional[str] = None) -> dict[str, Any]:
|
|
53
|
+
return self._client._dispatch(R.payouts_batch(items, uniq_id=uniq_id))
|
|
54
|
+
|
|
55
|
+
def get(self, payout_id: str) -> dict[str, Any]:
|
|
56
|
+
return self._client._dispatch(R.payouts_get(payout_id))
|
|
57
|
+
|
|
58
|
+
def list(self, **params: Any) -> ListResult:
|
|
59
|
+
return self._client._dispatch_list(R.payouts_list(**params))
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
class _Ledger(_Resource):
|
|
63
|
+
def list(self, **params: Any) -> ListResult:
|
|
64
|
+
return self._client._dispatch_list(R.ledger_list(**params))
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
class _Notifications(_Resource):
|
|
68
|
+
def list(self, **params: Any) -> ListResult:
|
|
69
|
+
return self._client._dispatch_list(R.notifications_list(**params))
|
|
70
|
+
|
|
71
|
+
def test(self, url: Optional[str] = None) -> dict[str, Any]:
|
|
72
|
+
return self._client._dispatch(R.notifications_test(url))
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
class _Permanent(_Resource):
|
|
76
|
+
def create(self, **params: Any) -> dict[str, Any]:
|
|
77
|
+
return self._client._dispatch(R.permanent_create(params))
|
|
78
|
+
|
|
79
|
+
def get(self, address_id: str) -> dict[str, Any]:
|
|
80
|
+
return self._client._dispatch(R.permanent_get(address_id))
|
|
81
|
+
|
|
82
|
+
def list(self, **params: Any) -> ListResult:
|
|
83
|
+
return self._client._dispatch_list(R.permanent_list(**params))
|
|
84
|
+
|
|
85
|
+
def deactivate(self, address_id: str) -> dict[str, Any]:
|
|
86
|
+
return self._client._dispatch(R.permanent_deactivate(address_id))
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
class PawPayments:
|
|
90
|
+
"""Synchronous PawPayments client (built on `requests`)."""
|
|
91
|
+
|
|
92
|
+
def __init__(
|
|
93
|
+
self,
|
|
94
|
+
api_key: str,
|
|
95
|
+
base_url: str = "https://api.pawpayments.com",
|
|
96
|
+
timeout: float = 30.0,
|
|
97
|
+
session: Optional[requests.Session] = None,
|
|
98
|
+
) -> None:
|
|
99
|
+
if not api_key:
|
|
100
|
+
raise ValueError("api_key is required")
|
|
101
|
+
self._api_key = api_key
|
|
102
|
+
self._base_url = base_url.rstrip("/")
|
|
103
|
+
self._timeout = timeout
|
|
104
|
+
self._session = session or requests.Session()
|
|
105
|
+
self._owns_session = session is None
|
|
106
|
+
|
|
107
|
+
self.assets = _Assets(self)
|
|
108
|
+
self.rates = _Rates(self)
|
|
109
|
+
self.balance = _Balance(self)
|
|
110
|
+
self.invoices = _Invoices(self)
|
|
111
|
+
self.payouts = _Payouts(self)
|
|
112
|
+
self.ledger = _Ledger(self)
|
|
113
|
+
self.notifications = _Notifications(self)
|
|
114
|
+
self.permanent = _Permanent(self)
|
|
115
|
+
|
|
116
|
+
@property
|
|
117
|
+
def base_url(self) -> str:
|
|
118
|
+
return self._base_url
|
|
119
|
+
|
|
120
|
+
def close(self) -> None:
|
|
121
|
+
if self._owns_session:
|
|
122
|
+
self._session.close()
|
|
123
|
+
|
|
124
|
+
def __enter__(self) -> "PawPayments":
|
|
125
|
+
return self
|
|
126
|
+
|
|
127
|
+
def __exit__(self, *_: object) -> None:
|
|
128
|
+
self.close()
|
|
129
|
+
|
|
130
|
+
def _dispatch(self, spec: R.RequestSpec) -> Any:
|
|
131
|
+
envelope = self._send(spec)
|
|
132
|
+
return unwrap_result(envelope)
|
|
133
|
+
|
|
134
|
+
def _dispatch_list(self, spec: R.RequestSpec) -> ListResult:
|
|
135
|
+
envelope = self._send(spec)
|
|
136
|
+
return unwrap_list(envelope) # type: ignore[return-value]
|
|
137
|
+
|
|
138
|
+
def _send(self, spec: R.RequestSpec) -> dict[str, Any]:
|
|
139
|
+
url = self._base_url + spec.path
|
|
140
|
+
headers = base_headers(self._api_key, spec.uniq_id)
|
|
141
|
+
kwargs: dict[str, Any] = {"headers": headers, "timeout": self._timeout}
|
|
142
|
+
if spec.query:
|
|
143
|
+
kwargs["params"] = spec.query
|
|
144
|
+
if spec.body is not None:
|
|
145
|
+
kwargs["json"] = spec.body
|
|
146
|
+
try:
|
|
147
|
+
response = self._session.request(spec.method, url, **kwargs)
|
|
148
|
+
except requests.RequestException as exc:
|
|
149
|
+
raise PawPaymentsApiError(
|
|
150
|
+
f"Network error: {exc}", "NETWORK_ERROR", None
|
|
151
|
+
) from exc
|
|
152
|
+
return decode_envelope(response.status_code, response.content or b"")
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
"""Shared envelope/error decoding used by both sync and async transports."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import json
|
|
5
|
+
from typing import Any, Optional
|
|
6
|
+
|
|
7
|
+
from .errors import PawPaymentsApiError
|
|
8
|
+
|
|
9
|
+
DEFAULT_USER_AGENT = "pawpayments-python-sdk/0.1.0"
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def base_headers(api_key: str, uniq_id: Optional[str] = None) -> dict[str, str]:
|
|
13
|
+
headers = {
|
|
14
|
+
"x-api-key": api_key,
|
|
15
|
+
"Accept": "application/json",
|
|
16
|
+
"User-Agent": DEFAULT_USER_AGENT,
|
|
17
|
+
}
|
|
18
|
+
if uniq_id:
|
|
19
|
+
headers["x-uniq-id"] = uniq_id
|
|
20
|
+
return headers
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def decode_envelope(status: int, raw: bytes) -> dict[str, Any]:
|
|
24
|
+
if not raw:
|
|
25
|
+
if status >= 400:
|
|
26
|
+
raise PawPaymentsApiError(f"HTTP {status}", "UNKNOWN", status)
|
|
27
|
+
return {}
|
|
28
|
+
try:
|
|
29
|
+
decoded = json.loads(raw.decode("utf-8"))
|
|
30
|
+
except (UnicodeDecodeError, json.JSONDecodeError) as exc:
|
|
31
|
+
raise PawPaymentsApiError(
|
|
32
|
+
f"Invalid JSON response (HTTP {status})", "INVALID_RESPONSE", status
|
|
33
|
+
) from exc
|
|
34
|
+
if not isinstance(decoded, dict):
|
|
35
|
+
raise PawPaymentsApiError(
|
|
36
|
+
f"Invalid JSON response (HTTP {status})", "INVALID_RESPONSE", status
|
|
37
|
+
)
|
|
38
|
+
if status >= 400 or decoded.get("ok") is False:
|
|
39
|
+
err = decoded.get("error")
|
|
40
|
+
if isinstance(err, dict):
|
|
41
|
+
code = str(err.get("code") or "UNKNOWN")
|
|
42
|
+
message = str(err.get("message") or f"HTTP {status}")
|
|
43
|
+
details = err.get("details") if isinstance(err.get("details"), list) else None
|
|
44
|
+
else:
|
|
45
|
+
code = str(decoded.get("code") or err or "UNKNOWN")
|
|
46
|
+
message = str(decoded.get("message") or err or f"HTTP {status}")
|
|
47
|
+
details = None
|
|
48
|
+
raise PawPaymentsApiError(message, code, status, details)
|
|
49
|
+
return decoded
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def unwrap_result(envelope: dict[str, Any]) -> Any:
|
|
53
|
+
if "result" in envelope:
|
|
54
|
+
return envelope["result"]
|
|
55
|
+
if "data" in envelope:
|
|
56
|
+
return envelope["data"]
|
|
57
|
+
return envelope
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def unwrap_list(envelope: dict[str, Any]) -> dict[str, Any]:
|
|
61
|
+
items = envelope.get("result")
|
|
62
|
+
if items is None:
|
|
63
|
+
items = envelope.get("data") or []
|
|
64
|
+
if not isinstance(items, list):
|
|
65
|
+
items = []
|
|
66
|
+
pagination = envelope.get("pagination") or {
|
|
67
|
+
"page": 1,
|
|
68
|
+
"per_page": len(items),
|
|
69
|
+
"total": len(items),
|
|
70
|
+
"pages": 1,
|
|
71
|
+
}
|
|
72
|
+
return {"items": items, "pagination": pagination}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
"""Lightweight typed result wrappers.
|
|
2
|
+
|
|
3
|
+
These are TypedDicts so the SDK can stay dependency-light (no pydantic).
|
|
4
|
+
At runtime they are just plain dicts; the typing exists for editor
|
|
5
|
+
support only.
|
|
6
|
+
"""
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
from typing import Any, TypedDict
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class Pagination(TypedDict):
|
|
13
|
+
page: int
|
|
14
|
+
per_page: int
|
|
15
|
+
total: int
|
|
16
|
+
pages: int
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class ListResult(TypedDict):
|
|
20
|
+
items: list[dict[str, Any]]
|
|
21
|
+
pagination: Pagination
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
"""Route specifications shared between the sync and async clients.
|
|
2
|
+
|
|
3
|
+
Each resource method builds a ``RequestSpec`` describing how to call the
|
|
4
|
+
HTTP API. The two transport implementations (`_client.PawPayments` and
|
|
5
|
+
`_async_client.AsyncPawPayments`) wire the spec into their respective
|
|
6
|
+
HTTP libraries so we don't repeat URL/query/body wiring twice.
|
|
7
|
+
"""
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import uuid
|
|
11
|
+
from dataclasses import dataclass, field
|
|
12
|
+
from typing import Any, Iterable, Mapping, Optional
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
@dataclass
|
|
16
|
+
class RequestSpec:
|
|
17
|
+
method: str
|
|
18
|
+
path: str
|
|
19
|
+
query: dict[str, Any] = field(default_factory=dict)
|
|
20
|
+
body: Optional[Any] = None
|
|
21
|
+
uniq_id: Optional[str] = None
|
|
22
|
+
paginated: bool = False
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def _drop_none(d: Mapping[str, Any]) -> dict[str, Any]:
|
|
26
|
+
return {k: v for k, v in d.items() if v is not None}
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def _join(value: Any) -> Any:
|
|
30
|
+
if isinstance(value, (list, tuple, set)) and not isinstance(value, str):
|
|
31
|
+
return ",".join(str(v) for v in value)
|
|
32
|
+
return value
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def assets_list() -> RequestSpec:
|
|
36
|
+
return RequestSpec("GET", "/api/v2/assets")
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def rates_get(*, base: str = "USD", assets: Optional[Iterable[str] | str] = None) -> RequestSpec:
|
|
40
|
+
return RequestSpec(
|
|
41
|
+
"GET",
|
|
42
|
+
"/api/v2/rates",
|
|
43
|
+
query=_drop_none({"base": base, "assets": _join(assets) if assets else None}),
|
|
44
|
+
)
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def balance_get() -> RequestSpec:
|
|
48
|
+
return RequestSpec("GET", "/api/v2/balance")
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def invoices_create(params: Mapping[str, Any]) -> RequestSpec:
|
|
52
|
+
return RequestSpec("POST", "/api/v2/invoices", body=dict(params))
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def invoices_get(order_id: str) -> RequestSpec:
|
|
56
|
+
return RequestSpec("GET", f"/api/v2/invoices/{order_id}")
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def invoices_list(**params: Any) -> RequestSpec:
|
|
60
|
+
if "order_ids" in params:
|
|
61
|
+
params["order_ids"] = _join(params["order_ids"])
|
|
62
|
+
return RequestSpec("GET", "/api/v2/invoices", query=_drop_none(params), paginated=True)
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def invoices_notify(order_id: str) -> RequestSpec:
|
|
66
|
+
return RequestSpec("POST", f"/api/v2/invoices/{order_id}/notify")
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def payouts_create(params: Mapping[str, Any], uniq_id: Optional[str] = None) -> RequestSpec:
|
|
70
|
+
return RequestSpec(
|
|
71
|
+
"POST",
|
|
72
|
+
"/api/v2/payouts",
|
|
73
|
+
body=dict(params),
|
|
74
|
+
uniq_id=uniq_id or str(uuid.uuid4()),
|
|
75
|
+
)
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def payouts_batch(items: list[Mapping[str, Any]], uniq_id: Optional[str] = None) -> RequestSpec:
|
|
79
|
+
return RequestSpec(
|
|
80
|
+
"POST",
|
|
81
|
+
"/api/v2/payouts/batch",
|
|
82
|
+
body={"items": [dict(i) for i in items]},
|
|
83
|
+
uniq_id=uniq_id or str(uuid.uuid4()),
|
|
84
|
+
)
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def payouts_get(payout_id: str) -> RequestSpec:
|
|
88
|
+
return RequestSpec("GET", f"/api/v2/payouts/{payout_id}")
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def payouts_list(**params: Any) -> RequestSpec:
|
|
92
|
+
if "order_ids" in params:
|
|
93
|
+
params["order_ids"] = _join(params["order_ids"])
|
|
94
|
+
return RequestSpec("GET", "/api/v2/payouts", query=_drop_none(params), paginated=True)
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def ledger_list(**params: Any) -> RequestSpec:
|
|
98
|
+
return RequestSpec("GET", "/api/v2/ledger", query=_drop_none(params), paginated=True)
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def notifications_list(**params: Any) -> RequestSpec:
|
|
102
|
+
return RequestSpec("GET", "/api/v2/notifications", query=_drop_none(params), paginated=True)
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def notifications_test(url: Optional[str] = None) -> RequestSpec:
|
|
106
|
+
return RequestSpec("POST", "/api/v2/notifications/test", body={"url": url} if url else {})
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def permanent_create(params: Mapping[str, Any]) -> RequestSpec:
|
|
110
|
+
return RequestSpec("POST", "/api/v2/permanent", body=dict(params))
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def permanent_get(address_id: str) -> RequestSpec:
|
|
114
|
+
return RequestSpec("GET", f"/api/v2/permanent/{address_id}")
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def permanent_list(**params: Any) -> RequestSpec:
|
|
118
|
+
return RequestSpec("GET", "/api/v2/permanent", query=_drop_none(params), paginated=True)
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def permanent_deactivate(address_id: str) -> RequestSpec:
|
|
122
|
+
return RequestSpec("DELETE", f"/api/v2/permanent/{address_id}")
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from typing import Any, Optional
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class PawPaymentsApiError(Exception):
|
|
7
|
+
def __init__(
|
|
8
|
+
self,
|
|
9
|
+
message: str,
|
|
10
|
+
code: str = "UNKNOWN",
|
|
11
|
+
http_status: Optional[int] = None,
|
|
12
|
+
details: Optional[list[dict[str, Any]]] = None,
|
|
13
|
+
) -> None:
|
|
14
|
+
super().__init__(message)
|
|
15
|
+
self.code = code
|
|
16
|
+
self.http_status = http_status
|
|
17
|
+
self.details = details
|
|
18
|
+
|
|
19
|
+
def __repr__(self) -> str:
|
|
20
|
+
return (
|
|
21
|
+
f"PawPaymentsApiError(code={self.code!r}, http_status={self.http_status!r}, "
|
|
22
|
+
f"message={str(self)!r})"
|
|
23
|
+
)
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import hashlib
|
|
4
|
+
import hmac
|
|
5
|
+
import json
|
|
6
|
+
from typing import Any, Union
|
|
7
|
+
|
|
8
|
+
_BodyT = Union[str, bytes, bytearray, memoryview]
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def _to_bytes(body: _BodyT) -> bytes:
|
|
12
|
+
if isinstance(body, (bytes, bytearray, memoryview)):
|
|
13
|
+
return bytes(body)
|
|
14
|
+
return body.encode("utf-8")
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class Webhook:
|
|
18
|
+
@staticmethod
|
|
19
|
+
def verify_raw_body(raw_body: _BodyT, header_signature: str, api_key: str) -> bool:
|
|
20
|
+
if not header_signature:
|
|
21
|
+
return False
|
|
22
|
+
expected = hmac.new(api_key.encode("utf-8"), _to_bytes(raw_body), hashlib.sha256).hexdigest()
|
|
23
|
+
return hmac.compare_digest(expected, header_signature)
|
|
24
|
+
|
|
25
|
+
@staticmethod
|
|
26
|
+
def parse_payload(raw_body: _BodyT) -> dict[str, Any]:
|
|
27
|
+
try:
|
|
28
|
+
data = json.loads(_to_bytes(raw_body).decode("utf-8"))
|
|
29
|
+
except (UnicodeDecodeError, json.JSONDecodeError) as exc:
|
|
30
|
+
raise ValueError("Invalid webhook payload: not valid JSON") from exc
|
|
31
|
+
if not isinstance(data, dict):
|
|
32
|
+
raise ValueError("Invalid webhook payload: not valid JSON")
|
|
33
|
+
return data
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
import os
|
|
2
|
+
|
|
3
|
+
import pytest
|
|
4
|
+
|
|
5
|
+
from pawpayments import AsyncPawPayments, PawPayments
|
|
6
|
+
|
|
7
|
+
API_KEY = os.environ.get("PAW_API_KEY")
|
|
8
|
+
BASE = os.environ.get("PAW_BASE", "https://api.pawpayments.com")
|
|
9
|
+
|
|
10
|
+
pytestmark = pytest.mark.skipif(not API_KEY, reason="PAW_API_KEY not set")
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
@pytest.fixture
|
|
14
|
+
def client():
|
|
15
|
+
with PawPayments(api_key=API_KEY, base_url=BASE) as c:
|
|
16
|
+
yield c
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
@pytest.fixture
|
|
20
|
+
async def async_client():
|
|
21
|
+
async with AsyncPawPayments(api_key=API_KEY, base_url=BASE) as c:
|
|
22
|
+
yield c
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def test_assets_list(client):
|
|
26
|
+
assets = client.assets.list()
|
|
27
|
+
assert isinstance(assets, list)
|
|
28
|
+
assert assets
|
|
29
|
+
assert any(a.get("enabled", True) for a in assets)
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def test_rates_get(client):
|
|
33
|
+
rates = client.rates.get(base="USD")
|
|
34
|
+
assert rates["base"] == "USD"
|
|
35
|
+
assert isinstance(rates["rates"], dict)
|
|
36
|
+
assert rates["rates"]
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def test_balance_get(client):
|
|
40
|
+
balance = client.balance.get()
|
|
41
|
+
assert isinstance(balance, dict)
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def test_invoices_create_get_list(client):
|
|
45
|
+
invoice = client.invoices.create(
|
|
46
|
+
amount=25,
|
|
47
|
+
fiat_currency="USD",
|
|
48
|
+
billing_type="STATIC",
|
|
49
|
+
asset="usdt_tron",
|
|
50
|
+
description="Python SDK live test",
|
|
51
|
+
ttl=1800,
|
|
52
|
+
)
|
|
53
|
+
assert invoice["order_id"]
|
|
54
|
+
fetched = client.invoices.get(invoice["order_id"])
|
|
55
|
+
assert fetched["order_id"] == invoice["order_id"]
|
|
56
|
+
page = client.invoices.list(page=1, per_page=5)
|
|
57
|
+
assert page["pagination"]["page"] == 1
|
|
58
|
+
assert isinstance(page["items"], list)
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def test_payouts_list_readonly(client):
|
|
62
|
+
page = client.payouts.list(page=1, per_page=5)
|
|
63
|
+
assert page["pagination"]["page"] == 1
|
|
64
|
+
assert isinstance(page["items"], list)
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def test_ledger_list(client):
|
|
68
|
+
page = client.ledger.list(page=1, per_page=5)
|
|
69
|
+
assert page["pagination"]["page"] == 1
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def test_notifications_list(client):
|
|
73
|
+
page = client.notifications.list(page=1, per_page=5)
|
|
74
|
+
assert isinstance(page["items"], list)
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def test_notifications_test_probe(client):
|
|
78
|
+
probe = client.notifications.test("https://httpbin.org/status/200")
|
|
79
|
+
assert probe["url"] == "https://httpbin.org/status/200"
|
|
80
|
+
assert "delivered" in probe
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def test_permanent_list(client):
|
|
84
|
+
page = client.permanent.list(page=1, per_page=5)
|
|
85
|
+
assert page["pagination"]["page"] == 1
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
async def test_async_assets_list(async_client):
|
|
89
|
+
assets = await async_client.assets.list()
|
|
90
|
+
assert isinstance(assets, list)
|
|
91
|
+
assert assets
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
async def test_async_invoices_round_trip(async_client):
|
|
95
|
+
invoice = await async_client.invoices.create(
|
|
96
|
+
amount=15,
|
|
97
|
+
fiat_currency="USD",
|
|
98
|
+
billing_type="STATIC",
|
|
99
|
+
asset="usdt_tron",
|
|
100
|
+
description="Async Python SDK live test",
|
|
101
|
+
ttl=1800,
|
|
102
|
+
)
|
|
103
|
+
assert invoice["order_id"]
|
|
104
|
+
fetched = await async_client.invoices.get(invoice["order_id"])
|
|
105
|
+
assert fetched["order_id"] == invoice["order_id"]
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
import os
|
|
2
|
+
|
|
3
|
+
import pytest
|
|
4
|
+
|
|
5
|
+
from pawpayments import PawPayments, PawPaymentsApiError
|
|
6
|
+
|
|
7
|
+
API_KEY = os.environ.get("PAW_API_KEY")
|
|
8
|
+
BASE = os.environ.get("PAW_BASE", "https://api.pawpayments.com")
|
|
9
|
+
|
|
10
|
+
pytestmark = pytest.mark.skipif(not API_KEY, reason="PAW_API_KEY not set")
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
@pytest.fixture
|
|
14
|
+
def client():
|
|
15
|
+
with PawPayments(api_key=API_KEY, base_url=BASE) as c:
|
|
16
|
+
yield c
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def test_bad_api_key_returns_4xx():
|
|
20
|
+
with PawPayments(api_key="definitely-not-valid", base_url=BASE) as bad:
|
|
21
|
+
with pytest.raises(PawPaymentsApiError) as exc:
|
|
22
|
+
bad.balance.get()
|
|
23
|
+
assert exc.value.http_status is not None
|
|
24
|
+
assert 400 <= exc.value.http_status < 500
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def test_invoices_create_empty_body_rejected(client):
|
|
28
|
+
with pytest.raises(PawPaymentsApiError) as exc:
|
|
29
|
+
client.invoices.create()
|
|
30
|
+
assert 400 <= (exc.value.http_status or 0) < 500
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def test_invoices_create_unknown_asset_rejected(client):
|
|
34
|
+
with pytest.raises(PawPaymentsApiError) as exc:
|
|
35
|
+
client.invoices.create(
|
|
36
|
+
amount=5,
|
|
37
|
+
fiat_currency="USD",
|
|
38
|
+
billing_type="STATIC",
|
|
39
|
+
asset="doge_doge",
|
|
40
|
+
)
|
|
41
|
+
assert 400 <= (exc.value.http_status or 0) < 500
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def test_invoices_create_negative_amount_rejected(client):
|
|
45
|
+
with pytest.raises(PawPaymentsApiError) as exc:
|
|
46
|
+
client.invoices.create(
|
|
47
|
+
amount=-1,
|
|
48
|
+
fiat_currency="USD",
|
|
49
|
+
billing_type="STATIC",
|
|
50
|
+
asset="usdt_tron",
|
|
51
|
+
)
|
|
52
|
+
assert 400 <= (exc.value.http_status or 0) < 500
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def test_invoices_get_unknown_id(client):
|
|
56
|
+
with pytest.raises(PawPaymentsApiError) as exc:
|
|
57
|
+
client.invoices.get("ffffffffffffffffffffffff")
|
|
58
|
+
assert 400 <= (exc.value.http_status or 0) < 500
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def test_payout_without_balance_or_ip(client):
|
|
62
|
+
with pytest.raises(PawPaymentsApiError) as exc:
|
|
63
|
+
client.payouts.create(
|
|
64
|
+
address="TTestAddress1234567890",
|
|
65
|
+
amount=10,
|
|
66
|
+
fiat_currency="USD",
|
|
67
|
+
asset="usdt_tron",
|
|
68
|
+
)
|
|
69
|
+
assert 400 <= (exc.value.http_status or 0) < 500
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def test_payouts_get_unknown_id(client):
|
|
73
|
+
with pytest.raises(PawPaymentsApiError) as exc:
|
|
74
|
+
client.payouts.get("ffffffffffffffffffffffff")
|
|
75
|
+
assert 400 <= (exc.value.http_status or 0) < 500
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def test_permanent_create_bad_family(client):
|
|
79
|
+
with pytest.raises(PawPaymentsApiError) as exc:
|
|
80
|
+
client.permanent.create(user_id="live-test-user", family="not_a_family")
|
|
81
|
+
assert 400 <= (exc.value.http_status or 0) < 500
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
import hashlib
|
|
2
|
+
import hmac
|
|
3
|
+
import shutil
|
|
4
|
+
import subprocess
|
|
5
|
+
|
|
6
|
+
import pytest
|
|
7
|
+
|
|
8
|
+
from pawpayments import Webhook
|
|
9
|
+
|
|
10
|
+
API_KEY = "test_api_key_abc123"
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def _sig(message: bytes | str) -> str:
|
|
14
|
+
if isinstance(message, str):
|
|
15
|
+
message = message.encode()
|
|
16
|
+
return hmac.new(API_KEY.encode(), message, hashlib.sha256).hexdigest()
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def test_verify_raw_body_valid():
|
|
20
|
+
body = '{"order_id":"abc","status":"success"}'
|
|
21
|
+
assert Webhook.verify_raw_body(body, _sig(body), API_KEY) is True
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def test_verify_raw_body_bad_signature():
|
|
25
|
+
body = '{"order_id":"abc","status":"success"}'
|
|
26
|
+
assert Webhook.verify_raw_body(body, "deadbeef", API_KEY) is False
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def test_verify_raw_body_tampered():
|
|
30
|
+
body = '{"order_id":"abc","status":"success"}'
|
|
31
|
+
sig = _sig(body)
|
|
32
|
+
assert Webhook.verify_raw_body('{"order_id":"abc","status":"cancelled"}', sig, API_KEY) is False
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def test_verify_raw_body_bytes_input():
|
|
36
|
+
body = '{"order_id":"abc"}'
|
|
37
|
+
assert Webhook.verify_raw_body(body.encode(), _sig(body), API_KEY) is True
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def test_verify_raw_body_empty_signature():
|
|
41
|
+
assert Webhook.verify_raw_body("{}", "", API_KEY) is False
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def test_parse_payload_object():
|
|
45
|
+
body = '{"order_id":"abc","status":"success","amount":100.5}'
|
|
46
|
+
payload = Webhook.parse_payload(body)
|
|
47
|
+
assert payload["order_id"] == "abc"
|
|
48
|
+
assert payload["amount"] == 100.5
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def test_parse_payload_invalid_json():
|
|
52
|
+
with pytest.raises(ValueError):
|
|
53
|
+
Webhook.parse_payload("not json")
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def test_parse_payload_array_rejected():
|
|
57
|
+
with pytest.raises(ValueError):
|
|
58
|
+
Webhook.parse_payload("[1,2,3]")
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
@pytest.mark.skipif(shutil.which("php") is None, reason="PHP not installed")
|
|
62
|
+
def test_php_interop_round_trip(tmp_path):
|
|
63
|
+
"""PHP `Webhook::verifyRawBody` from the official PHP SDK must accept
|
|
64
|
+
our Python-generated signature unchanged."""
|
|
65
|
+
body = '{"order_id":"abc","status":"success"}'
|
|
66
|
+
sig = _sig(body)
|
|
67
|
+
php_code = (
|
|
68
|
+
"$body = file_get_contents($argv[1]);"
|
|
69
|
+
"$expected = hash_hmac('sha256', $body, $argv[3]);"
|
|
70
|
+
"echo hash_equals($expected, $argv[2]) ? 'OK' : 'BAD';"
|
|
71
|
+
)
|
|
72
|
+
body_file = tmp_path / "body.json"
|
|
73
|
+
body_file.write_text(body)
|
|
74
|
+
proc = subprocess.run(
|
|
75
|
+
["php", "-r", php_code, str(body_file), sig, API_KEY],
|
|
76
|
+
capture_output=True,
|
|
77
|
+
timeout=5,
|
|
78
|
+
)
|
|
79
|
+
assert proc.stdout.strip() == b"OK", proc.stdout + proc.stderr
|