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.
@@ -0,0 +1,6 @@
1
+ __pycache__/
2
+ *.py[cod]
3
+ dist/
4
+ build/
5
+ *.egg-info/
6
+ .venv/
@@ -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
@@ -0,0 +1,3 @@
1
+ """SDK version, sent in the User-Agent header. Single source of truth — pyproject.toml reads it via [tool.hatch.version]."""
2
+
3
+ __version__ = "1.0.0"