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 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
+ ]
@@ -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
@@ -0,0 +1,7 @@
1
+ DEFAULT_BASE_URL = "https://api.listbee.so"
2
+ DEFAULT_TIMEOUT = 30.0
3
+ LISTING_CREATE_TIMEOUT = 120.0
4
+ DEFAULT_MAX_RETRIES = 3
5
+ RETRY_STATUS_CODES = {429, 500, 502, 503, 504}
6
+ MAX_RETRY_DELAY = 30.0
7
+ INITIAL_RETRY_DELAY = 0.5
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)