bitculator 1.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.
- bitculator-1.0.0/.gitignore +6 -0
- bitculator-1.0.0/LICENSE +21 -0
- bitculator-1.0.0/PKG-INFO +144 -0
- bitculator-1.0.0/README.md +127 -0
- bitculator-1.0.0/examples/quickstart.py +43 -0
- bitculator-1.0.0/pyproject.toml +30 -0
- bitculator-1.0.0/src/bitculator/__init__.py +37 -0
- bitculator-1.0.0/src/bitculator/_http.py +214 -0
- bitculator-1.0.0/src/bitculator/_version.py +3 -0
- bitculator-1.0.0/src/bitculator/client.py +89 -0
- bitculator-1.0.0/src/bitculator/errors.py +119 -0
- bitculator-1.0.0/src/bitculator/pagination.py +59 -0
- bitculator-1.0.0/src/bitculator/py.typed +0 -0
- bitculator-1.0.0/src/bitculator/resources/__init__.py +24 -0
- bitculator-1.0.0/src/bitculator/resources/_base.py +18 -0
- bitculator-1.0.0/src/bitculator/resources/account.py +81 -0
- bitculator-1.0.0/src/bitculator/resources/coins.py +80 -0
- bitculator-1.0.0/src/bitculator/resources/editorial.py +43 -0
- bitculator-1.0.0/src/bitculator/resources/indicators.py +100 -0
- bitculator-1.0.0/src/bitculator/resources/market.py +79 -0
- bitculator-1.0.0/src/bitculator/resources/markets.py +90 -0
bitculator-1.0.0/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Bitculator
|
|
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,144 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: bitculator
|
|
3
|
+
Version: 1.0.0
|
|
4
|
+
Summary: Official Python SDK for the Bitculator Data API
|
|
5
|
+
Project-URL: Documentation, https://bitculator.com/en/documentation/api/v1
|
|
6
|
+
Project-URL: Repository, https://github.com/bitculator/bitculator-python-sdk
|
|
7
|
+
Author: Bitculator
|
|
8
|
+
License: MIT
|
|
9
|
+
License-File: LICENSE
|
|
10
|
+
Keywords: api,bitculator,crypto,cryptocurrency,market-data,sdk
|
|
11
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
12
|
+
Classifier: Programming Language :: Python :: 3
|
|
13
|
+
Classifier: Topic :: Office/Business :: Financial
|
|
14
|
+
Classifier: Typing :: Typed
|
|
15
|
+
Requires-Python: >=3.9
|
|
16
|
+
Description-Content-Type: text/markdown
|
|
17
|
+
|
|
18
|
+
# Bitculator Data API — Python SDK
|
|
19
|
+
|
|
20
|
+
[bitculator.com](https://bitculator.com) · [API documentation](https://bitculator.com/en/documentation/api/v1) · [Get an API key](https://bitculator.com/user/developer/api)
|
|
21
|
+
[PyPI — `bitculator`](https://pypi.org/project/bitculator/) · [GitHub](https://github.com/Bitculator/bitculator-python-sdk)
|
|
22
|
+
|
|
23
|
+
The official Python SDK for the [Bitculator Data API](https://bitculator.com).
|
|
24
|
+
Zero dependencies (standard-library only), typed, works on Python 3.9+.
|
|
25
|
+
|
|
26
|
+
> Keep your API key server-side. It is a Bearer token with the `data-api` ability
|
|
27
|
+
> and is not meant for client-side embedding.
|
|
28
|
+
|
|
29
|
+
## Install
|
|
30
|
+
|
|
31
|
+
```bash
|
|
32
|
+
pip install bitculator
|
|
33
|
+
```
|
|
34
|
+
|
|
35
|
+
## Quick start
|
|
36
|
+
|
|
37
|
+
```python
|
|
38
|
+
import os
|
|
39
|
+
from bitculator import Bitculator
|
|
40
|
+
|
|
41
|
+
client = Bitculator(os.environ["BITCULATOR_API_KEY"])
|
|
42
|
+
|
|
43
|
+
# Single resource — the {"data": ...} envelope is unwrapped for you.
|
|
44
|
+
bitcoin = client.coins.get("bitcoin")
|
|
45
|
+
print(bitcoin["price"]) # "63520.780763913" — a decimal string, never rounded
|
|
46
|
+
|
|
47
|
+
# A paginated list.
|
|
48
|
+
page = client.coins.list(per_page=50, sort="-marketcap")
|
|
49
|
+
print(page.data, page.meta["total"])
|
|
50
|
+
```
|
|
51
|
+
|
|
52
|
+
## Prices are decimal strings
|
|
53
|
+
|
|
54
|
+
Prices, rates, and supplies come back as **strings** (e.g. `"63520.780763913"`)
|
|
55
|
+
to preserve full precision. Use `decimal.Decimal` if you need to do math — do
|
|
56
|
+
**not** pass them through `float()`, which silently loses precision.
|
|
57
|
+
|
|
58
|
+
## Pagination
|
|
59
|
+
|
|
60
|
+
Every paginated method returns a `Page`. Read one page, or iterate to walk them all:
|
|
61
|
+
|
|
62
|
+
```python
|
|
63
|
+
# One page at a time.
|
|
64
|
+
page = client.exchanges.list(per_page=100)
|
|
65
|
+
while page is not None:
|
|
66
|
+
for exchange in page.data:
|
|
67
|
+
print(exchange["name"])
|
|
68
|
+
page = page.next_page()
|
|
69
|
+
|
|
70
|
+
# Or auto-paginate across every page (iterating a Page walks all pages).
|
|
71
|
+
for coin in client.coins.list(sort="-marketcap"):
|
|
72
|
+
print(coin["symbol"])
|
|
73
|
+
```
|
|
74
|
+
|
|
75
|
+
## Errors
|
|
76
|
+
|
|
77
|
+
Every failure derives from `BitculatorError`; HTTP failures raise a status-specific
|
|
78
|
+
subclass carrying the `{"error": {"code", "message", "details"}}` envelope.
|
|
79
|
+
|
|
80
|
+
```python
|
|
81
|
+
from bitculator import ValidationError, RateLimitError, APIError
|
|
82
|
+
|
|
83
|
+
try:
|
|
84
|
+
client.coins.list(per_page=9999) # over the plan cap -> 422
|
|
85
|
+
except ValidationError as err:
|
|
86
|
+
print(err.code, err.details)
|
|
87
|
+
except RateLimitError as err:
|
|
88
|
+
print("retry after", err.retry_after)
|
|
89
|
+
except APIError as err:
|
|
90
|
+
print(err.status, err.message)
|
|
91
|
+
```
|
|
92
|
+
|
|
93
|
+
| Status | Exception |
|
|
94
|
+
| ------ | --------- |
|
|
95
|
+
| 401 | `AuthenticationError` |
|
|
96
|
+
| 403 | `PermissionDeniedError` (plan does not include this endpoint) |
|
|
97
|
+
| 404 | `NotFoundError` |
|
|
98
|
+
| 422 | `ValidationError` (`.details` holds the field errors) |
|
|
99
|
+
| 429 | `RateLimitError` (`.retry_after`) |
|
|
100
|
+
| 5xx | `ServerError` |
|
|
101
|
+
|
|
102
|
+
Timeouts raise `APITimeoutError`; network failures raise `APIConnectionError`.
|
|
103
|
+
|
|
104
|
+
## Quota
|
|
105
|
+
|
|
106
|
+
Every response carries `X-Quota-*` headers, surfaced on the client after each call:
|
|
107
|
+
|
|
108
|
+
```python
|
|
109
|
+
client.coins.list()
|
|
110
|
+
print(client.quota) # Quota(limit=..., remaining=..., reset=..., raw={...})
|
|
111
|
+
```
|
|
112
|
+
|
|
113
|
+
## Configuration
|
|
114
|
+
|
|
115
|
+
```python
|
|
116
|
+
Bitculator(
|
|
117
|
+
api_key, # required (positional)
|
|
118
|
+
base_url="https://bitculator.com", # default
|
|
119
|
+
timeout=30.0, # seconds, default
|
|
120
|
+
max_retries=2, # auto-retry 429 / 5xx / network; 0 to disable
|
|
121
|
+
)
|
|
122
|
+
```
|
|
123
|
+
|
|
124
|
+
## Passing parameters
|
|
125
|
+
|
|
126
|
+
Query parameters are keyword arguments and mirror the API's names exactly
|
|
127
|
+
(`per_page`, `min_price`, ...). For the reserved word `from` (in `convert`), pass
|
|
128
|
+
`from_` — the SDK maps it back to `from` on the wire:
|
|
129
|
+
|
|
130
|
+
```python
|
|
131
|
+
client.conversion.convert(from_="btc", to="usd", amount=1)
|
|
132
|
+
```
|
|
133
|
+
|
|
134
|
+
## Resources
|
|
135
|
+
|
|
136
|
+
`coins` · `prices` · `markets` · `exchanges` · `wallets` · `global_market` ·
|
|
137
|
+
`sentiment` · `indicators` · `liquidations` · `conversion` · `calculators` ·
|
|
138
|
+
`editorial` · `alarms` · `webhooks` · `meta`
|
|
139
|
+
|
|
140
|
+
Full endpoint reference: <https://bitculator.com/en/documentation/api/v1>.
|
|
141
|
+
|
|
142
|
+
## License
|
|
143
|
+
|
|
144
|
+
MIT
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
# Bitculator Data API — Python SDK
|
|
2
|
+
|
|
3
|
+
[bitculator.com](https://bitculator.com) · [API documentation](https://bitculator.com/en/documentation/api/v1) · [Get an API key](https://bitculator.com/user/developer/api)
|
|
4
|
+
[PyPI — `bitculator`](https://pypi.org/project/bitculator/) · [GitHub](https://github.com/Bitculator/bitculator-python-sdk)
|
|
5
|
+
|
|
6
|
+
The official Python SDK for the [Bitculator Data API](https://bitculator.com).
|
|
7
|
+
Zero dependencies (standard-library only), typed, works on Python 3.9+.
|
|
8
|
+
|
|
9
|
+
> Keep your API key server-side. It is a Bearer token with the `data-api` ability
|
|
10
|
+
> and is not meant for client-side embedding.
|
|
11
|
+
|
|
12
|
+
## Install
|
|
13
|
+
|
|
14
|
+
```bash
|
|
15
|
+
pip install bitculator
|
|
16
|
+
```
|
|
17
|
+
|
|
18
|
+
## Quick start
|
|
19
|
+
|
|
20
|
+
```python
|
|
21
|
+
import os
|
|
22
|
+
from bitculator import Bitculator
|
|
23
|
+
|
|
24
|
+
client = Bitculator(os.environ["BITCULATOR_API_KEY"])
|
|
25
|
+
|
|
26
|
+
# Single resource — the {"data": ...} envelope is unwrapped for you.
|
|
27
|
+
bitcoin = client.coins.get("bitcoin")
|
|
28
|
+
print(bitcoin["price"]) # "63520.780763913" — a decimal string, never rounded
|
|
29
|
+
|
|
30
|
+
# A paginated list.
|
|
31
|
+
page = client.coins.list(per_page=50, sort="-marketcap")
|
|
32
|
+
print(page.data, page.meta["total"])
|
|
33
|
+
```
|
|
34
|
+
|
|
35
|
+
## Prices are decimal strings
|
|
36
|
+
|
|
37
|
+
Prices, rates, and supplies come back as **strings** (e.g. `"63520.780763913"`)
|
|
38
|
+
to preserve full precision. Use `decimal.Decimal` if you need to do math — do
|
|
39
|
+
**not** pass them through `float()`, which silently loses precision.
|
|
40
|
+
|
|
41
|
+
## Pagination
|
|
42
|
+
|
|
43
|
+
Every paginated method returns a `Page`. Read one page, or iterate to walk them all:
|
|
44
|
+
|
|
45
|
+
```python
|
|
46
|
+
# One page at a time.
|
|
47
|
+
page = client.exchanges.list(per_page=100)
|
|
48
|
+
while page is not None:
|
|
49
|
+
for exchange in page.data:
|
|
50
|
+
print(exchange["name"])
|
|
51
|
+
page = page.next_page()
|
|
52
|
+
|
|
53
|
+
# Or auto-paginate across every page (iterating a Page walks all pages).
|
|
54
|
+
for coin in client.coins.list(sort="-marketcap"):
|
|
55
|
+
print(coin["symbol"])
|
|
56
|
+
```
|
|
57
|
+
|
|
58
|
+
## Errors
|
|
59
|
+
|
|
60
|
+
Every failure derives from `BitculatorError`; HTTP failures raise a status-specific
|
|
61
|
+
subclass carrying the `{"error": {"code", "message", "details"}}` envelope.
|
|
62
|
+
|
|
63
|
+
```python
|
|
64
|
+
from bitculator import ValidationError, RateLimitError, APIError
|
|
65
|
+
|
|
66
|
+
try:
|
|
67
|
+
client.coins.list(per_page=9999) # over the plan cap -> 422
|
|
68
|
+
except ValidationError as err:
|
|
69
|
+
print(err.code, err.details)
|
|
70
|
+
except RateLimitError as err:
|
|
71
|
+
print("retry after", err.retry_after)
|
|
72
|
+
except APIError as err:
|
|
73
|
+
print(err.status, err.message)
|
|
74
|
+
```
|
|
75
|
+
|
|
76
|
+
| Status | Exception |
|
|
77
|
+
| ------ | --------- |
|
|
78
|
+
| 401 | `AuthenticationError` |
|
|
79
|
+
| 403 | `PermissionDeniedError` (plan does not include this endpoint) |
|
|
80
|
+
| 404 | `NotFoundError` |
|
|
81
|
+
| 422 | `ValidationError` (`.details` holds the field errors) |
|
|
82
|
+
| 429 | `RateLimitError` (`.retry_after`) |
|
|
83
|
+
| 5xx | `ServerError` |
|
|
84
|
+
|
|
85
|
+
Timeouts raise `APITimeoutError`; network failures raise `APIConnectionError`.
|
|
86
|
+
|
|
87
|
+
## Quota
|
|
88
|
+
|
|
89
|
+
Every response carries `X-Quota-*` headers, surfaced on the client after each call:
|
|
90
|
+
|
|
91
|
+
```python
|
|
92
|
+
client.coins.list()
|
|
93
|
+
print(client.quota) # Quota(limit=..., remaining=..., reset=..., raw={...})
|
|
94
|
+
```
|
|
95
|
+
|
|
96
|
+
## Configuration
|
|
97
|
+
|
|
98
|
+
```python
|
|
99
|
+
Bitculator(
|
|
100
|
+
api_key, # required (positional)
|
|
101
|
+
base_url="https://bitculator.com", # default
|
|
102
|
+
timeout=30.0, # seconds, default
|
|
103
|
+
max_retries=2, # auto-retry 429 / 5xx / network; 0 to disable
|
|
104
|
+
)
|
|
105
|
+
```
|
|
106
|
+
|
|
107
|
+
## Passing parameters
|
|
108
|
+
|
|
109
|
+
Query parameters are keyword arguments and mirror the API's names exactly
|
|
110
|
+
(`per_page`, `min_price`, ...). For the reserved word `from` (in `convert`), pass
|
|
111
|
+
`from_` — the SDK maps it back to `from` on the wire:
|
|
112
|
+
|
|
113
|
+
```python
|
|
114
|
+
client.conversion.convert(from_="btc", to="usd", amount=1)
|
|
115
|
+
```
|
|
116
|
+
|
|
117
|
+
## Resources
|
|
118
|
+
|
|
119
|
+
`coins` · `prices` · `markets` · `exchanges` · `wallets` · `global_market` ·
|
|
120
|
+
`sentiment` · `indicators` · `liquidations` · `conversion` · `calculators` ·
|
|
121
|
+
`editorial` · `alarms` · `webhooks` · `meta`
|
|
122
|
+
|
|
123
|
+
Full endpoint reference: <https://bitculator.com/en/documentation/api/v1>.
|
|
124
|
+
|
|
125
|
+
## License
|
|
126
|
+
|
|
127
|
+
MIT
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Run with: BITCULATOR_API_KEY=... python examples/quickstart.py
|
|
3
|
+
(against local dev, add BITCULATOR_BASE_URL=http://localhost)
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
import os
|
|
7
|
+
|
|
8
|
+
from bitculator import APIError, Bitculator
|
|
9
|
+
|
|
10
|
+
client = Bitculator(
|
|
11
|
+
os.environ.get("BITCULATOR_API_KEY", ""),
|
|
12
|
+
base_url=os.environ.get("BITCULATOR_BASE_URL", "https://bitculator.com"),
|
|
13
|
+
)
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def main() -> None:
|
|
17
|
+
client.meta.ping()
|
|
18
|
+
|
|
19
|
+
# Single resource — the {"data": ...} envelope is unwrapped for you.
|
|
20
|
+
bitcoin = client.coins.get("bitcoin")
|
|
21
|
+
print("BTC price:", bitcoin["price"]) # "63520.780763913" — a decimal string, never rounded
|
|
22
|
+
|
|
23
|
+
# Top 5 coins by marketcap.
|
|
24
|
+
top = client.coins.list(per_page=5, sort="-marketcap")
|
|
25
|
+
for coin in top.data:
|
|
26
|
+
print(f"#{coin['rank']}", coin["symbol"], coin["price"])
|
|
27
|
+
|
|
28
|
+
# Auto-paginate the first ~40 gainers.
|
|
29
|
+
for i, coin in enumerate(client.coins.gainers(per_page=20)):
|
|
30
|
+
print("gainer:", coin["symbol"], coin["change_24h"])
|
|
31
|
+
if i >= 39:
|
|
32
|
+
break
|
|
33
|
+
|
|
34
|
+
# A technical indicator.
|
|
35
|
+
print("RSI(14):", client.indicators.rsi("bitcoin", period=14))
|
|
36
|
+
print("quota:", client.quota)
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
if __name__ == "__main__":
|
|
40
|
+
try:
|
|
41
|
+
main()
|
|
42
|
+
except APIError as err:
|
|
43
|
+
print(f"API {err.status}: {err.code} {err.message}")
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["hatchling"]
|
|
3
|
+
build-backend = "hatchling.build"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "bitculator"
|
|
7
|
+
dynamic = ["version"]
|
|
8
|
+
description = "Official Python SDK for the Bitculator Data API"
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
requires-python = ">=3.9"
|
|
11
|
+
license = { text = "MIT" }
|
|
12
|
+
authors = [{ name = "Bitculator" }]
|
|
13
|
+
keywords = ["bitculator", "crypto", "cryptocurrency", "market-data", "api", "sdk"]
|
|
14
|
+
dependencies = []
|
|
15
|
+
classifiers = [
|
|
16
|
+
"Programming Language :: Python :: 3",
|
|
17
|
+
"License :: OSI Approved :: MIT License",
|
|
18
|
+
"Topic :: Office/Business :: Financial",
|
|
19
|
+
"Typing :: Typed",
|
|
20
|
+
]
|
|
21
|
+
|
|
22
|
+
[project.urls]
|
|
23
|
+
Documentation = "https://bitculator.com/en/documentation/api/v1"
|
|
24
|
+
Repository = "https://github.com/bitculator/bitculator-python-sdk"
|
|
25
|
+
|
|
26
|
+
[tool.hatch.version]
|
|
27
|
+
path = "src/bitculator/_version.py"
|
|
28
|
+
|
|
29
|
+
[tool.hatch.build.targets.wheel]
|
|
30
|
+
packages = ["src/bitculator"]
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
"""Official Python SDK for the Bitculator Data API."""
|
|
2
|
+
|
|
3
|
+
from ._http import Quota
|
|
4
|
+
from ._version import __version__
|
|
5
|
+
from .client import Bitculator
|
|
6
|
+
from .errors import (
|
|
7
|
+
APIConnectionError,
|
|
8
|
+
APIDecodeError,
|
|
9
|
+
APIError,
|
|
10
|
+
APITimeoutError,
|
|
11
|
+
AuthenticationError,
|
|
12
|
+
BitculatorError,
|
|
13
|
+
NotFoundError,
|
|
14
|
+
PermissionDeniedError,
|
|
15
|
+
RateLimitError,
|
|
16
|
+
ServerError,
|
|
17
|
+
ValidationError,
|
|
18
|
+
)
|
|
19
|
+
from .pagination import Page
|
|
20
|
+
|
|
21
|
+
__all__ = [
|
|
22
|
+
"Bitculator",
|
|
23
|
+
"Page",
|
|
24
|
+
"Quota",
|
|
25
|
+
"BitculatorError",
|
|
26
|
+
"APIError",
|
|
27
|
+
"APITimeoutError",
|
|
28
|
+
"APIConnectionError",
|
|
29
|
+
"APIDecodeError",
|
|
30
|
+
"AuthenticationError",
|
|
31
|
+
"PermissionDeniedError",
|
|
32
|
+
"NotFoundError",
|
|
33
|
+
"ValidationError",
|
|
34
|
+
"RateLimitError",
|
|
35
|
+
"ServerError",
|
|
36
|
+
"__version__",
|
|
37
|
+
]
|
|
@@ -0,0 +1,214 @@
|
|
|
1
|
+
"""
|
|
2
|
+
The DRY transport core. Every request in the SDK flows through
|
|
3
|
+
``HttpClient._request``: URL + query building, auth headers, JSON
|
|
4
|
+
(de)serialization, the response envelope, quota capture, retries with backoff,
|
|
5
|
+
timeouts, and error mapping all live here — nowhere else. Uses only the standard
|
|
6
|
+
library (``urllib``) so the SDK has zero runtime dependencies.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import decimal
|
|
12
|
+
import http.client
|
|
13
|
+
import json
|
|
14
|
+
import random
|
|
15
|
+
import time
|
|
16
|
+
import urllib.error
|
|
17
|
+
import urllib.parse
|
|
18
|
+
import urllib.request
|
|
19
|
+
from dataclasses import dataclass
|
|
20
|
+
from typing import Any, Callable, Optional
|
|
21
|
+
|
|
22
|
+
from .errors import APIConnectionError, APIDecodeError, APIError, APITimeoutError, BitculatorError
|
|
23
|
+
from .pagination import Page
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
@dataclass
|
|
27
|
+
class Quota:
|
|
28
|
+
"""Quota headers (``X-Quota-*``) from the most recent response."""
|
|
29
|
+
|
|
30
|
+
limit: Optional[int]
|
|
31
|
+
remaining: Optional[int]
|
|
32
|
+
reset: Optional[int]
|
|
33
|
+
raw: dict
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
class HttpClient:
|
|
37
|
+
def __init__(
|
|
38
|
+
self,
|
|
39
|
+
*,
|
|
40
|
+
api_key: str,
|
|
41
|
+
base_url: str,
|
|
42
|
+
timeout: float,
|
|
43
|
+
max_retries: int,
|
|
44
|
+
user_agent: str,
|
|
45
|
+
on_quota: Callable[[Quota], None],
|
|
46
|
+
opener: Optional[urllib.request.OpenerDirector] = None,
|
|
47
|
+
) -> None:
|
|
48
|
+
self._api_key = api_key
|
|
49
|
+
self._base_url = base_url
|
|
50
|
+
self._timeout = timeout
|
|
51
|
+
self._max_retries = max_retries
|
|
52
|
+
self._user_agent = user_agent
|
|
53
|
+
self._on_quota = on_quota
|
|
54
|
+
self._opener = opener or urllib.request.build_opener()
|
|
55
|
+
|
|
56
|
+
def get(self, path: str, params: Optional[dict] = None) -> Any:
|
|
57
|
+
return _unwrap(self._request("GET", path, params=params))
|
|
58
|
+
|
|
59
|
+
def post(self, path: str, body: Any = None, params: Optional[dict] = None) -> Any:
|
|
60
|
+
return _unwrap(self._request("POST", path, params=params, body=body))
|
|
61
|
+
|
|
62
|
+
def delete(self, path: str, params: Optional[dict] = None) -> Any:
|
|
63
|
+
return _unwrap(self._request("DELETE", path, params=params))
|
|
64
|
+
|
|
65
|
+
def get_page(self, path: str, params: Optional[dict] = None) -> Page:
|
|
66
|
+
envelope = self._request("GET", path, params=params)
|
|
67
|
+
return Page(self, path, params or {}, envelope)
|
|
68
|
+
|
|
69
|
+
def _request(self, method: str, path: str, params: Optional[dict] = None, body: Any = None) -> Any:
|
|
70
|
+
url = self._base_url + path + _query(params)
|
|
71
|
+
headers = {
|
|
72
|
+
"Authorization": f"Bearer {self._api_key}",
|
|
73
|
+
"Accept": "application/json",
|
|
74
|
+
"User-Agent": self._user_agent,
|
|
75
|
+
}
|
|
76
|
+
data: Optional[bytes] = None
|
|
77
|
+
if body is not None:
|
|
78
|
+
try:
|
|
79
|
+
data = json.dumps(_snake_keys(body)).encode("utf-8")
|
|
80
|
+
except (TypeError, ValueError) as exc:
|
|
81
|
+
raise BitculatorError(f"Failed to encode request body: {exc}") from exc
|
|
82
|
+
headers["Content-Type"] = "application/json"
|
|
83
|
+
|
|
84
|
+
# Idempotency gate: GET/DELETE are safe to auto-retry; POST is NOT, so a
|
|
85
|
+
# POST the server may already have processed is never re-sent on a 5xx or
|
|
86
|
+
# a network/timeout error. A 429 means the server rejected it before
|
|
87
|
+
# processing, so it stays retryable for every method.
|
|
88
|
+
is_idempotent = method in ("GET", "DELETE")
|
|
89
|
+
|
|
90
|
+
attempt = 0
|
|
91
|
+
while True:
|
|
92
|
+
request = urllib.request.Request(url, data=data, headers=headers, method=method)
|
|
93
|
+
try:
|
|
94
|
+
with self._opener.open(request, timeout=self._timeout) as response:
|
|
95
|
+
self._on_quota(_parse_quota(response.headers))
|
|
96
|
+
raw = response.read()
|
|
97
|
+
if not raw:
|
|
98
|
+
return None
|
|
99
|
+
try:
|
|
100
|
+
return json.loads(raw)
|
|
101
|
+
except (ValueError, TypeError) as exc:
|
|
102
|
+
raise APIDecodeError(f"Failed to decode response from {path}: {exc}") from exc
|
|
103
|
+
except urllib.error.HTTPError as exc:
|
|
104
|
+
self._on_quota(_parse_quota(exc.headers))
|
|
105
|
+
raw = exc.read()
|
|
106
|
+
payload = _safe_json(raw)
|
|
107
|
+
status = exc.code
|
|
108
|
+
retryable = status == 429 or (status >= 500 and is_idempotent)
|
|
109
|
+
if retryable and attempt < self._max_retries:
|
|
110
|
+
time.sleep(_retry_delay(exc.headers, attempt))
|
|
111
|
+
attempt += 1
|
|
112
|
+
continue
|
|
113
|
+
raise APIError.from_response(status, payload, exc.headers) from None
|
|
114
|
+
except (urllib.error.URLError, http.client.HTTPException, TimeoutError, OSError) as exc:
|
|
115
|
+
if is_idempotent and attempt < self._max_retries:
|
|
116
|
+
time.sleep(_backoff(attempt))
|
|
117
|
+
attempt += 1
|
|
118
|
+
continue
|
|
119
|
+
reason = getattr(exc, "reason", exc)
|
|
120
|
+
if isinstance(reason, TimeoutError) or "timed out" in str(reason).lower():
|
|
121
|
+
raise APITimeoutError(f"Request to {path} timed out after {self._timeout}s") from exc
|
|
122
|
+
raise APIConnectionError(f"Network error requesting {path}: {reason}") from exc
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
# ── helpers (all pure; the single place these concerns are implemented) ──────
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
def _unwrap(envelope: Any) -> Any:
|
|
129
|
+
"""Unwrap the ``{"data": ...}`` envelope; pass through bodies with no data key."""
|
|
130
|
+
if isinstance(envelope, dict) and "data" in envelope:
|
|
131
|
+
return envelope["data"]
|
|
132
|
+
return envelope
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
def _query(params: Optional[dict]) -> str:
|
|
136
|
+
"""Serialize params: drop ``None``, lists→CSV, bools→true/false, trailing ``_`` stripped (``from_``→``from``)."""
|
|
137
|
+
if not params:
|
|
138
|
+
return ""
|
|
139
|
+
pairs = []
|
|
140
|
+
for key, value in params.items():
|
|
141
|
+
if value is None:
|
|
142
|
+
continue
|
|
143
|
+
name = key[:-1] if key.endswith("_") else key
|
|
144
|
+
if isinstance(value, (list, tuple)):
|
|
145
|
+
if not value:
|
|
146
|
+
continue
|
|
147
|
+
pairs.append((name, ",".join(str(item) for item in value)))
|
|
148
|
+
elif isinstance(value, bool):
|
|
149
|
+
pairs.append((name, "true" if value else "false"))
|
|
150
|
+
elif isinstance(value, float):
|
|
151
|
+
# Plain-decimal form (0.0000001, not 1e-07) — keeps the wire format
|
|
152
|
+
# identical across the SDK family.
|
|
153
|
+
pairs.append((name, format(decimal.Decimal(repr(value)), "f")))
|
|
154
|
+
else:
|
|
155
|
+
pairs.append((name, str(value)))
|
|
156
|
+
return "?" + urllib.parse.urlencode(pairs) if pairs else ""
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
def _snake_keys(body: Any) -> Any:
|
|
160
|
+
"""Strip a trailing underscore from body keys (``from_``→``from``); leave the rest as-is."""
|
|
161
|
+
if not isinstance(body, dict):
|
|
162
|
+
return body
|
|
163
|
+
return {(key[:-1] if key.endswith("_") else key): value for key, value in body.items()}
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
def _parse_quota(headers: Any) -> Quota:
|
|
167
|
+
raw = {}
|
|
168
|
+
if headers is not None:
|
|
169
|
+
for key, value in headers.items():
|
|
170
|
+
lowered = key.lower()
|
|
171
|
+
if lowered.startswith("x-quota-"):
|
|
172
|
+
raw[lowered] = value
|
|
173
|
+
|
|
174
|
+
def num(name: str) -> Optional[int]:
|
|
175
|
+
value = raw.get(name)
|
|
176
|
+
if value in (None, ""):
|
|
177
|
+
return None
|
|
178
|
+
try:
|
|
179
|
+
return int(value)
|
|
180
|
+
except (TypeError, ValueError):
|
|
181
|
+
return None
|
|
182
|
+
|
|
183
|
+
return Quota(
|
|
184
|
+
limit=num("x-quota-limit"),
|
|
185
|
+
remaining=num("x-quota-remaining"),
|
|
186
|
+
reset=num("x-quota-reset"),
|
|
187
|
+
raw=raw,
|
|
188
|
+
)
|
|
189
|
+
|
|
190
|
+
|
|
191
|
+
def _retry_delay(headers: Any, attempt: int) -> float:
|
|
192
|
+
retry_after = headers.get("retry-after") if headers is not None else None
|
|
193
|
+
try:
|
|
194
|
+
seconds = int(retry_after)
|
|
195
|
+
if seconds > 0:
|
|
196
|
+
# Cap at 60s: a misbehaving proxy must not block a synchronous call for an hour.
|
|
197
|
+
return float(min(seconds, 60))
|
|
198
|
+
except (TypeError, ValueError):
|
|
199
|
+
pass
|
|
200
|
+
return _backoff(attempt)
|
|
201
|
+
|
|
202
|
+
|
|
203
|
+
def _backoff(attempt: int) -> float:
|
|
204
|
+
"""Exponential backoff with jitter, capped at 8s."""
|
|
205
|
+
return min(8.0, 0.25 * (2 ** attempt)) + random.uniform(0.0, 0.1)
|
|
206
|
+
|
|
207
|
+
|
|
208
|
+
def _safe_json(raw: bytes) -> Any:
|
|
209
|
+
if not raw:
|
|
210
|
+
return None
|
|
211
|
+
try:
|
|
212
|
+
return json.loads(raw)
|
|
213
|
+
except (ValueError, TypeError):
|
|
214
|
+
return None
|