listbee 0.1.1__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- listbee/__init__.py +89 -0
- listbee/_base_client.py +321 -0
- listbee/_client.py +79 -0
- listbee/_constants.py +7 -0
- listbee/_exceptions.py +143 -0
- listbee/_pagination.py +97 -0
- listbee/resources/__init__.py +17 -0
- listbee/resources/account.py +44 -0
- listbee/resources/listings.py +292 -0
- listbee/resources/orders.py +113 -0
- listbee/resources/webhooks.py +287 -0
- listbee/types/__init__.py +54 -0
- listbee/types/account.py +45 -0
- listbee/types/listing.py +160 -0
- listbee/types/order.py +55 -0
- listbee/types/pagination.py +36 -0
- listbee/types/shared.py +129 -0
- listbee/types/webhook.py +86 -0
- listbee/webhooks.py +63 -0
- listbee-0.1.1.dist-info/METADATA +530 -0
- listbee-0.1.1.dist-info/RECORD +23 -0
- listbee-0.1.1.dist-info/WHEEL +4 -0
- listbee-0.1.1.dist-info/licenses/LICENSE +190 -0
listbee/__init__.py
ADDED
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
"""ListBee Python SDK — one API call to sell and deliver digital content.
|
|
2
|
+
|
|
3
|
+
Usage:
|
|
4
|
+
from listbee import ListBee
|
|
5
|
+
|
|
6
|
+
client = ListBee(api_key="lb_...")
|
|
7
|
+
listing = client.listings.create(
|
|
8
|
+
name="SEO Playbook",
|
|
9
|
+
price=2999,
|
|
10
|
+
currency="USD",
|
|
11
|
+
content="https://example.com/ebook.pdf",
|
|
12
|
+
)
|
|
13
|
+
print(listing.url)
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
from listbee._client import AsyncListBee, ListBee
|
|
17
|
+
from listbee._exceptions import (
|
|
18
|
+
APIConnectionError,
|
|
19
|
+
APIStatusError,
|
|
20
|
+
APITimeoutError,
|
|
21
|
+
AuthenticationError,
|
|
22
|
+
ConflictError,
|
|
23
|
+
InternalServerError,
|
|
24
|
+
ListBeeError,
|
|
25
|
+
NotFoundError,
|
|
26
|
+
RateLimitError,
|
|
27
|
+
ValidationError,
|
|
28
|
+
WebhookVerificationError,
|
|
29
|
+
)
|
|
30
|
+
from listbee.webhooks import verify_signature
|
|
31
|
+
from listbee.types import (
|
|
32
|
+
AccountReadiness,
|
|
33
|
+
AccountResponse,
|
|
34
|
+
Blocker,
|
|
35
|
+
BlockerAction,
|
|
36
|
+
BlockerCode,
|
|
37
|
+
BlockerResolve,
|
|
38
|
+
BlurMode,
|
|
39
|
+
ContentType,
|
|
40
|
+
CursorPage,
|
|
41
|
+
FaqItem,
|
|
42
|
+
ListingReadiness,
|
|
43
|
+
ListingResponse,
|
|
44
|
+
ListingStatus,
|
|
45
|
+
OrderResponse,
|
|
46
|
+
OrderStatus,
|
|
47
|
+
Review,
|
|
48
|
+
WebhookEventResponse,
|
|
49
|
+
WebhookEventType,
|
|
50
|
+
WebhookResponse,
|
|
51
|
+
WebhookTestResponse,
|
|
52
|
+
)
|
|
53
|
+
|
|
54
|
+
__all__ = [
|
|
55
|
+
"ListBee",
|
|
56
|
+
"AsyncListBee",
|
|
57
|
+
"ListBeeError",
|
|
58
|
+
"APIStatusError",
|
|
59
|
+
"APIConnectionError",
|
|
60
|
+
"APITimeoutError",
|
|
61
|
+
"AuthenticationError",
|
|
62
|
+
"NotFoundError",
|
|
63
|
+
"ConflictError",
|
|
64
|
+
"ValidationError",
|
|
65
|
+
"RateLimitError",
|
|
66
|
+
"InternalServerError",
|
|
67
|
+
"WebhookVerificationError",
|
|
68
|
+
"verify_signature",
|
|
69
|
+
"AccountReadiness",
|
|
70
|
+
"AccountResponse",
|
|
71
|
+
"Blocker",
|
|
72
|
+
"BlockerAction",
|
|
73
|
+
"BlockerCode",
|
|
74
|
+
"BlockerResolve",
|
|
75
|
+
"BlurMode",
|
|
76
|
+
"ContentType",
|
|
77
|
+
"CursorPage",
|
|
78
|
+
"FaqItem",
|
|
79
|
+
"ListingReadiness",
|
|
80
|
+
"ListingResponse",
|
|
81
|
+
"ListingStatus",
|
|
82
|
+
"OrderResponse",
|
|
83
|
+
"OrderStatus",
|
|
84
|
+
"Review",
|
|
85
|
+
"WebhookEventResponse",
|
|
86
|
+
"WebhookEventType",
|
|
87
|
+
"WebhookResponse",
|
|
88
|
+
"WebhookTestResponse",
|
|
89
|
+
]
|
listbee/_base_client.py
ADDED
|
@@ -0,0 +1,321 @@
|
|
|
1
|
+
"""Base HTTP clients (sync and async) for the ListBee SDK."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import asyncio
|
|
6
|
+
import os
|
|
7
|
+
import random
|
|
8
|
+
import time
|
|
9
|
+
from typing import Any, TypeVar
|
|
10
|
+
|
|
11
|
+
import httpx
|
|
12
|
+
from pydantic import BaseModel
|
|
13
|
+
|
|
14
|
+
from listbee._constants import (
|
|
15
|
+
DEFAULT_BASE_URL,
|
|
16
|
+
DEFAULT_MAX_RETRIES,
|
|
17
|
+
DEFAULT_TIMEOUT,
|
|
18
|
+
INITIAL_RETRY_DELAY,
|
|
19
|
+
MAX_RETRY_DELAY,
|
|
20
|
+
RETRY_STATUS_CODES,
|
|
21
|
+
)
|
|
22
|
+
from listbee._exceptions import (
|
|
23
|
+
APIConnectionError,
|
|
24
|
+
APITimeoutError,
|
|
25
|
+
ListBeeError,
|
|
26
|
+
raise_for_status,
|
|
27
|
+
)
|
|
28
|
+
from listbee._pagination import AsyncCursorPage, SyncCursorPage
|
|
29
|
+
|
|
30
|
+
try:
|
|
31
|
+
from importlib.metadata import version
|
|
32
|
+
|
|
33
|
+
_SDK_VERSION = version("listbee")
|
|
34
|
+
except Exception:
|
|
35
|
+
_SDK_VERSION = "0.0.0"
|
|
36
|
+
|
|
37
|
+
T = TypeVar("T", bound=BaseModel)
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
class BaseClient:
|
|
41
|
+
"""Shared configuration and logic for sync and async clients."""
|
|
42
|
+
|
|
43
|
+
def __init__(
|
|
44
|
+
self,
|
|
45
|
+
*,
|
|
46
|
+
api_key: str | None = None,
|
|
47
|
+
base_url: str = DEFAULT_BASE_URL,
|
|
48
|
+
timeout: float = DEFAULT_TIMEOUT,
|
|
49
|
+
max_retries: int = DEFAULT_MAX_RETRIES,
|
|
50
|
+
) -> None:
|
|
51
|
+
self._api_key = api_key or os.environ.get("LISTBEE_API_KEY")
|
|
52
|
+
self._base_url = base_url.rstrip("/")
|
|
53
|
+
self._timeout = timeout
|
|
54
|
+
self._max_retries = max_retries
|
|
55
|
+
|
|
56
|
+
@property
|
|
57
|
+
def base_url(self) -> str:
|
|
58
|
+
"""The base URL this client sends requests to."""
|
|
59
|
+
return self._base_url
|
|
60
|
+
|
|
61
|
+
def _ensure_api_key(self) -> str:
|
|
62
|
+
if not self._api_key:
|
|
63
|
+
raise ListBeeError("No API key provided. Set api_key= or the LISTBEE_API_KEY environment variable.")
|
|
64
|
+
return self._api_key
|
|
65
|
+
|
|
66
|
+
def _build_headers(self) -> dict[str, str]:
|
|
67
|
+
api_key = self._ensure_api_key()
|
|
68
|
+
return {
|
|
69
|
+
"Authorization": f"Bearer {api_key}",
|
|
70
|
+
"Content-Type": "application/json",
|
|
71
|
+
"User-Agent": f"listbee-python/{_SDK_VERSION}",
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
def _should_retry(self, status_code: int, attempt: int) -> bool:
|
|
75
|
+
"""Return True if the request should be retried."""
|
|
76
|
+
return status_code in RETRY_STATUS_CODES and attempt < self._max_retries
|
|
77
|
+
|
|
78
|
+
def _retry_delay(self, attempt: int, headers: httpx.Headers) -> float:
|
|
79
|
+
"""Return seconds to wait before the next retry attempt.
|
|
80
|
+
|
|
81
|
+
Respects the Retry-After response header when present.
|
|
82
|
+
Falls back to exponential backoff with jitter.
|
|
83
|
+
"""
|
|
84
|
+
retry_after = headers.get("retry-after")
|
|
85
|
+
if retry_after is not None:
|
|
86
|
+
try:
|
|
87
|
+
delay = float(retry_after)
|
|
88
|
+
return min(delay, MAX_RETRY_DELAY)
|
|
89
|
+
except ValueError:
|
|
90
|
+
pass
|
|
91
|
+
|
|
92
|
+
# Exponential backoff: 0.5s, 1s, 2s, … capped at 30s, plus jitter
|
|
93
|
+
delay = INITIAL_RETRY_DELAY * (2**attempt)
|
|
94
|
+
delay = min(delay, MAX_RETRY_DELAY)
|
|
95
|
+
# Add ±25% jitter
|
|
96
|
+
jitter = delay * 0.25 * (random.random() * 2 - 1)
|
|
97
|
+
return max(0.0, delay + jitter)
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
class SyncClient(BaseClient):
|
|
101
|
+
"""Synchronous HTTP client backed by httpx."""
|
|
102
|
+
|
|
103
|
+
def __init__(self, **kwargs: Any) -> None:
|
|
104
|
+
super().__init__(**kwargs)
|
|
105
|
+
self._http_client: httpx.Client | None = None
|
|
106
|
+
|
|
107
|
+
def _get_http_client(self) -> httpx.Client:
|
|
108
|
+
if self._http_client is None:
|
|
109
|
+
self._http_client = httpx.Client(
|
|
110
|
+
base_url=self._base_url,
|
|
111
|
+
timeout=self._timeout,
|
|
112
|
+
)
|
|
113
|
+
return self._http_client
|
|
114
|
+
|
|
115
|
+
def close(self) -> None:
|
|
116
|
+
"""Close the underlying HTTP client and release resources."""
|
|
117
|
+
if self._http_client is not None:
|
|
118
|
+
self._http_client.close()
|
|
119
|
+
self._http_client = None
|
|
120
|
+
|
|
121
|
+
def __enter__(self) -> SyncClient:
|
|
122
|
+
return self
|
|
123
|
+
|
|
124
|
+
def __exit__(self, *args: Any) -> None:
|
|
125
|
+
self.close()
|
|
126
|
+
|
|
127
|
+
def _request(
|
|
128
|
+
self,
|
|
129
|
+
method: str,
|
|
130
|
+
path: str,
|
|
131
|
+
*,
|
|
132
|
+
json: Any = None,
|
|
133
|
+
params: dict[str, Any] | None = None,
|
|
134
|
+
timeout: float | None = None,
|
|
135
|
+
) -> httpx.Response:
|
|
136
|
+
headers = self._build_headers()
|
|
137
|
+
effective_timeout = timeout if timeout is not None else self._timeout
|
|
138
|
+
client = self._get_http_client()
|
|
139
|
+
|
|
140
|
+
attempt = 0
|
|
141
|
+
last_response: httpx.Response | None = None
|
|
142
|
+
|
|
143
|
+
while True:
|
|
144
|
+
try:
|
|
145
|
+
response = client.request(
|
|
146
|
+
method,
|
|
147
|
+
path,
|
|
148
|
+
headers=headers,
|
|
149
|
+
json=json,
|
|
150
|
+
params=params,
|
|
151
|
+
timeout=effective_timeout,
|
|
152
|
+
)
|
|
153
|
+
except httpx.TimeoutException as exc:
|
|
154
|
+
raise APITimeoutError(f"Request timed out: {exc}") from exc
|
|
155
|
+
except httpx.ConnectError as exc:
|
|
156
|
+
raise APIConnectionError(f"Connection error: {exc}") from exc
|
|
157
|
+
|
|
158
|
+
if response.is_error:
|
|
159
|
+
last_response = response
|
|
160
|
+
if self._should_retry(response.status_code, attempt):
|
|
161
|
+
delay = self._retry_delay(attempt, response.headers)
|
|
162
|
+
time.sleep(delay)
|
|
163
|
+
attempt += 1
|
|
164
|
+
continue
|
|
165
|
+
|
|
166
|
+
# Non-retryable error: parse and raise
|
|
167
|
+
try:
|
|
168
|
+
body = response.json()
|
|
169
|
+
except Exception:
|
|
170
|
+
body = {}
|
|
171
|
+
raise_for_status(response.status_code, body, dict(response.headers))
|
|
172
|
+
|
|
173
|
+
return response
|
|
174
|
+
|
|
175
|
+
# Exhausted retries — raise from last response
|
|
176
|
+
# (unreachable in normal flow; kept for type completeness)
|
|
177
|
+
assert last_response is not None # pragma: no cover
|
|
178
|
+
try:
|
|
179
|
+
body = last_response.json()
|
|
180
|
+
except Exception:
|
|
181
|
+
body = {}
|
|
182
|
+
raise_for_status(last_response.status_code, body, dict(last_response.headers)) # pragma: no cover
|
|
183
|
+
|
|
184
|
+
def _get(self, path: str, *, params: dict[str, Any] | None = None, timeout: float | None = None) -> httpx.Response:
|
|
185
|
+
return self._request("GET", path, params=params, timeout=timeout)
|
|
186
|
+
|
|
187
|
+
def _post(self, path: str, *, json: Any = None, timeout: float | None = None) -> httpx.Response:
|
|
188
|
+
return self._request("POST", path, json=json, timeout=timeout)
|
|
189
|
+
|
|
190
|
+
def _put(self, path: str, *, json: Any = None, timeout: float | None = None) -> httpx.Response:
|
|
191
|
+
return self._request("PUT", path, json=json, timeout=timeout)
|
|
192
|
+
|
|
193
|
+
def _delete(self, path: str, *, timeout: float | None = None) -> httpx.Response:
|
|
194
|
+
return self._request("DELETE", path, timeout=timeout)
|
|
195
|
+
|
|
196
|
+
def _get_page(self, path: str, params: dict[str, Any], model: type[T]) -> SyncCursorPage[T]:
|
|
197
|
+
"""Fetch a paginated list response and return a SyncCursorPage."""
|
|
198
|
+
response = self._get(path, params=params)
|
|
199
|
+
body = response.json()
|
|
200
|
+
items = [model.model_validate(item) for item in body.get("data", [])]
|
|
201
|
+
return SyncCursorPage(
|
|
202
|
+
data=items,
|
|
203
|
+
has_more=body.get("has_more", False),
|
|
204
|
+
cursor=body.get("cursor"),
|
|
205
|
+
client=self,
|
|
206
|
+
path=path,
|
|
207
|
+
params=params,
|
|
208
|
+
model=model,
|
|
209
|
+
)
|
|
210
|
+
|
|
211
|
+
|
|
212
|
+
class AsyncClient(BaseClient):
|
|
213
|
+
"""Asynchronous HTTP client backed by httpx.AsyncClient."""
|
|
214
|
+
|
|
215
|
+
def __init__(self, **kwargs: Any) -> None:
|
|
216
|
+
super().__init__(**kwargs)
|
|
217
|
+
self._http_client: httpx.AsyncClient | None = None
|
|
218
|
+
|
|
219
|
+
def _get_http_client(self) -> httpx.AsyncClient:
|
|
220
|
+
if self._http_client is None:
|
|
221
|
+
self._http_client = httpx.AsyncClient(
|
|
222
|
+
base_url=self._base_url,
|
|
223
|
+
timeout=self._timeout,
|
|
224
|
+
)
|
|
225
|
+
return self._http_client
|
|
226
|
+
|
|
227
|
+
async def close(self) -> None:
|
|
228
|
+
"""Close the underlying HTTP client and release resources."""
|
|
229
|
+
if self._http_client is not None:
|
|
230
|
+
await self._http_client.aclose()
|
|
231
|
+
self._http_client = None
|
|
232
|
+
|
|
233
|
+
async def __aenter__(self) -> AsyncClient:
|
|
234
|
+
return self
|
|
235
|
+
|
|
236
|
+
async def __aexit__(self, *args: Any) -> None:
|
|
237
|
+
await self.close()
|
|
238
|
+
|
|
239
|
+
async def _request(
|
|
240
|
+
self,
|
|
241
|
+
method: str,
|
|
242
|
+
path: str,
|
|
243
|
+
*,
|
|
244
|
+
json: Any = None,
|
|
245
|
+
params: dict[str, Any] | None = None,
|
|
246
|
+
timeout: float | None = None,
|
|
247
|
+
) -> httpx.Response:
|
|
248
|
+
headers = self._build_headers()
|
|
249
|
+
effective_timeout = timeout if timeout is not None else self._timeout
|
|
250
|
+
client = self._get_http_client()
|
|
251
|
+
|
|
252
|
+
attempt = 0
|
|
253
|
+
last_response: httpx.Response | None = None
|
|
254
|
+
|
|
255
|
+
while True:
|
|
256
|
+
try:
|
|
257
|
+
response = await client.request(
|
|
258
|
+
method,
|
|
259
|
+
path,
|
|
260
|
+
headers=headers,
|
|
261
|
+
json=json,
|
|
262
|
+
params=params,
|
|
263
|
+
timeout=effective_timeout,
|
|
264
|
+
)
|
|
265
|
+
except httpx.TimeoutException as exc:
|
|
266
|
+
raise APITimeoutError(f"Request timed out: {exc}") from exc
|
|
267
|
+
except httpx.ConnectError as exc:
|
|
268
|
+
raise APIConnectionError(f"Connection error: {exc}") from exc
|
|
269
|
+
|
|
270
|
+
if response.is_error:
|
|
271
|
+
last_response = response
|
|
272
|
+
if self._should_retry(response.status_code, attempt):
|
|
273
|
+
delay = self._retry_delay(attempt, response.headers)
|
|
274
|
+
await asyncio.sleep(delay)
|
|
275
|
+
attempt += 1
|
|
276
|
+
continue
|
|
277
|
+
|
|
278
|
+
try:
|
|
279
|
+
body = response.json()
|
|
280
|
+
except Exception:
|
|
281
|
+
body = {}
|
|
282
|
+
raise_for_status(response.status_code, body, dict(response.headers))
|
|
283
|
+
|
|
284
|
+
return response
|
|
285
|
+
|
|
286
|
+
# Exhausted retries — raise from last response
|
|
287
|
+
assert last_response is not None # pragma: no cover
|
|
288
|
+
try:
|
|
289
|
+
body = last_response.json()
|
|
290
|
+
except Exception:
|
|
291
|
+
body = {}
|
|
292
|
+
raise_for_status(last_response.status_code, body, dict(last_response.headers)) # pragma: no cover
|
|
293
|
+
|
|
294
|
+
async def _get(
|
|
295
|
+
self, path: str, *, params: dict[str, Any] | None = None, timeout: float | None = None
|
|
296
|
+
) -> httpx.Response:
|
|
297
|
+
return await self._request("GET", path, params=params, timeout=timeout)
|
|
298
|
+
|
|
299
|
+
async def _post(self, path: str, *, json: Any = None, timeout: float | None = None) -> httpx.Response:
|
|
300
|
+
return await self._request("POST", path, json=json, timeout=timeout)
|
|
301
|
+
|
|
302
|
+
async def _put(self, path: str, *, json: Any = None, timeout: float | None = None) -> httpx.Response:
|
|
303
|
+
return await self._request("PUT", path, json=json, timeout=timeout)
|
|
304
|
+
|
|
305
|
+
async def _delete(self, path: str, *, timeout: float | None = None) -> httpx.Response:
|
|
306
|
+
return await self._request("DELETE", path, timeout=timeout)
|
|
307
|
+
|
|
308
|
+
async def _get_page(self, path: str, params: dict[str, Any], model: type[T]) -> AsyncCursorPage[T]:
|
|
309
|
+
"""Fetch a paginated list response and return an AsyncCursorPage."""
|
|
310
|
+
response = await self._get(path, params=params)
|
|
311
|
+
body = response.json()
|
|
312
|
+
items = [model.model_validate(item) for item in body.get("data", [])]
|
|
313
|
+
return AsyncCursorPage(
|
|
314
|
+
data=items,
|
|
315
|
+
has_more=body.get("has_more", False),
|
|
316
|
+
cursor=body.get("cursor"),
|
|
317
|
+
client=self,
|
|
318
|
+
path=path,
|
|
319
|
+
params=params,
|
|
320
|
+
model=model,
|
|
321
|
+
)
|
listbee/_client.py
ADDED
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from typing import Any
|
|
4
|
+
|
|
5
|
+
from listbee._base_client import AsyncClient, SyncClient
|
|
6
|
+
from listbee.resources.account import Account, AsyncAccount
|
|
7
|
+
from listbee.resources.listings import AsyncListings, Listings
|
|
8
|
+
from listbee.resources.orders import AsyncOrders, Orders
|
|
9
|
+
from listbee.resources.webhooks import AsyncWebhooks, Webhooks
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class ListBee(SyncClient):
|
|
13
|
+
"""Synchronous ListBee API client.
|
|
14
|
+
|
|
15
|
+
Usage:
|
|
16
|
+
from listbee import ListBee
|
|
17
|
+
|
|
18
|
+
client = ListBee(api_key="lb_...")
|
|
19
|
+
listing = client.listings.create(
|
|
20
|
+
name="SEO Playbook",
|
|
21
|
+
price=2999,
|
|
22
|
+
currency="USD",
|
|
23
|
+
content="https://example.com/ebook.pdf",
|
|
24
|
+
)
|
|
25
|
+
print(listing.url)
|
|
26
|
+
|
|
27
|
+
The client reads LISTBEE_API_KEY from the environment if no api_key is provided.
|
|
28
|
+
Use as a context manager to ensure the HTTP connection is closed:
|
|
29
|
+
|
|
30
|
+
with ListBee() as client:
|
|
31
|
+
client.listings.list()
|
|
32
|
+
|
|
33
|
+
Args:
|
|
34
|
+
api_key: Your ListBee API key (lb_...). Falls back to LISTBEE_API_KEY env var.
|
|
35
|
+
base_url: API base URL. Default: https://api.listbee.so
|
|
36
|
+
timeout: Default request timeout in seconds. Default: 30.0
|
|
37
|
+
max_retries: Max retries on 429/5xx responses. Default: 3
|
|
38
|
+
"""
|
|
39
|
+
|
|
40
|
+
def __init__(self, **kwargs: Any) -> None:
|
|
41
|
+
super().__init__(**kwargs)
|
|
42
|
+
self.listings = Listings(self)
|
|
43
|
+
self.orders = Orders(self)
|
|
44
|
+
self.webhooks = Webhooks(self)
|
|
45
|
+
self.account = Account(self)
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
class AsyncListBee(AsyncClient):
|
|
49
|
+
"""Asynchronous ListBee API client.
|
|
50
|
+
|
|
51
|
+
Usage:
|
|
52
|
+
from listbee import AsyncListBee
|
|
53
|
+
|
|
54
|
+
client = AsyncListBee(api_key="lb_...")
|
|
55
|
+
listing = await client.listings.create(
|
|
56
|
+
name="SEO Playbook",
|
|
57
|
+
price=2999,
|
|
58
|
+
currency="USD",
|
|
59
|
+
content="https://example.com/ebook.pdf",
|
|
60
|
+
)
|
|
61
|
+
|
|
62
|
+
Use as an async context manager:
|
|
63
|
+
|
|
64
|
+
async with AsyncListBee() as client:
|
|
65
|
+
await client.listings.list()
|
|
66
|
+
|
|
67
|
+
Args:
|
|
68
|
+
api_key: Your ListBee API key (lb_...). Falls back to LISTBEE_API_KEY env var.
|
|
69
|
+
base_url: API base URL. Default: https://api.listbee.so
|
|
70
|
+
timeout: Default request timeout in seconds. Default: 30.0
|
|
71
|
+
max_retries: Max retries on 429/5xx responses. Default: 3
|
|
72
|
+
"""
|
|
73
|
+
|
|
74
|
+
def __init__(self, **kwargs: Any) -> None:
|
|
75
|
+
super().__init__(**kwargs)
|
|
76
|
+
self.listings = AsyncListings(self)
|
|
77
|
+
self.orders = AsyncOrders(self)
|
|
78
|
+
self.webhooks = AsyncWebhooks(self)
|
|
79
|
+
self.account = AsyncAccount(self)
|
listbee/_constants.py
ADDED
listbee/_exceptions.py
ADDED
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from datetime import datetime
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class ListBeeError(Exception):
|
|
7
|
+
"""Base exception for all ListBee SDK errors."""
|
|
8
|
+
|
|
9
|
+
def __init__(self, message: str) -> None:
|
|
10
|
+
self.message = message
|
|
11
|
+
super().__init__(message)
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class APIConnectionError(ListBeeError):
|
|
15
|
+
"""Raised when a network error prevents the request from completing."""
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class APITimeoutError(ListBeeError):
|
|
19
|
+
"""Raised when a request times out."""
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class APIStatusError(ListBeeError):
|
|
23
|
+
"""Raised when the API returns an error response (4xx/5xx).
|
|
24
|
+
|
|
25
|
+
Attributes:
|
|
26
|
+
type: URI identifying the error type (points to docs).
|
|
27
|
+
title: Short, stable label for the error category.
|
|
28
|
+
status: HTTP status code.
|
|
29
|
+
detail: Specific explanation of what went wrong.
|
|
30
|
+
code: Machine-readable error code.
|
|
31
|
+
param: Request field that caused the error, if applicable.
|
|
32
|
+
"""
|
|
33
|
+
|
|
34
|
+
def __init__(
|
|
35
|
+
self,
|
|
36
|
+
*,
|
|
37
|
+
type: str,
|
|
38
|
+
title: str,
|
|
39
|
+
status: int,
|
|
40
|
+
detail: str,
|
|
41
|
+
code: str,
|
|
42
|
+
param: str | None = None,
|
|
43
|
+
) -> None:
|
|
44
|
+
self.type = type
|
|
45
|
+
self.title = title
|
|
46
|
+
self.status = status
|
|
47
|
+
self.detail = detail
|
|
48
|
+
self.code = code
|
|
49
|
+
self.param = param
|
|
50
|
+
super().__init__(detail)
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
class AuthenticationError(APIStatusError):
|
|
54
|
+
"""Raised on 401 responses — invalid or missing API key."""
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
class NotFoundError(APIStatusError):
|
|
58
|
+
"""Raised on 404 responses — resource not found."""
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
class ConflictError(APIStatusError):
|
|
62
|
+
"""Raised on 409 responses — resource conflict."""
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
class ValidationError(APIStatusError):
|
|
66
|
+
"""Raised on 422 responses — request validation failed."""
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
class RateLimitError(APIStatusError):
|
|
70
|
+
"""Raised on 429 responses — rate limit exceeded.
|
|
71
|
+
|
|
72
|
+
Additional attributes parsed from response headers:
|
|
73
|
+
limit: Max requests per window.
|
|
74
|
+
remaining: Requests remaining in current window.
|
|
75
|
+
reset: When the current window resets.
|
|
76
|
+
"""
|
|
77
|
+
|
|
78
|
+
def __init__(
|
|
79
|
+
self,
|
|
80
|
+
*,
|
|
81
|
+
type: str,
|
|
82
|
+
title: str,
|
|
83
|
+
status: int,
|
|
84
|
+
detail: str,
|
|
85
|
+
code: str,
|
|
86
|
+
param: str | None = None,
|
|
87
|
+
limit: int | None = None,
|
|
88
|
+
remaining: int | None = None,
|
|
89
|
+
reset: datetime | None = None,
|
|
90
|
+
) -> None:
|
|
91
|
+
super().__init__(type=type, title=title, status=status, detail=detail, code=code, param=param)
|
|
92
|
+
self.limit = limit
|
|
93
|
+
self.remaining = remaining
|
|
94
|
+
self.reset = reset
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
class InternalServerError(APIStatusError):
|
|
98
|
+
"""Raised on 500+ responses — server-side error."""
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
class WebhookVerificationError(ListBeeError):
|
|
102
|
+
"""Raised when webhook signature verification fails."""
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
STATUS_CODE_TO_EXCEPTION: dict[int, type[APIStatusError]] = {
|
|
106
|
+
401: AuthenticationError,
|
|
107
|
+
404: NotFoundError,
|
|
108
|
+
409: ConflictError,
|
|
109
|
+
422: ValidationError,
|
|
110
|
+
429: RateLimitError,
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def raise_for_status(status_code: int, body: dict, headers: dict[str, str]) -> None:
|
|
115
|
+
"""Parse an RFC 9457 error body and raise the appropriate exception."""
|
|
116
|
+
kwargs = {
|
|
117
|
+
"type": body.get("type", ""),
|
|
118
|
+
"title": body.get("title", ""),
|
|
119
|
+
"status": body.get("status", status_code),
|
|
120
|
+
"detail": body.get("detail", ""),
|
|
121
|
+
"code": body.get("code", ""),
|
|
122
|
+
"param": body.get("param"),
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
exc_class = STATUS_CODE_TO_EXCEPTION.get(status_code)
|
|
126
|
+
|
|
127
|
+
if exc_class is RateLimitError:
|
|
128
|
+
limit_str = headers.get("x-ratelimit-limit")
|
|
129
|
+
remaining_str = headers.get("x-ratelimit-remaining")
|
|
130
|
+
reset_str = headers.get("x-ratelimit-reset")
|
|
131
|
+
|
|
132
|
+
kwargs["limit"] = int(limit_str) if limit_str else None
|
|
133
|
+
kwargs["remaining"] = int(remaining_str) if remaining_str else None
|
|
134
|
+
kwargs["reset"] = datetime.fromtimestamp(float(reset_str)) if reset_str else None
|
|
135
|
+
raise RateLimitError(**kwargs)
|
|
136
|
+
|
|
137
|
+
if exc_class is not None:
|
|
138
|
+
raise exc_class(**kwargs)
|
|
139
|
+
|
|
140
|
+
if status_code >= 500:
|
|
141
|
+
raise InternalServerError(**kwargs)
|
|
142
|
+
|
|
143
|
+
raise APIStatusError(**kwargs)
|