bidkit 0.1.0__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.
Files changed (62) hide show
  1. bidkit/__init__.py +49 -0
  2. bidkit/auth.py +477 -0
  3. bidkit/client.py +212 -0
  4. bidkit/config.py +216 -0
  5. bidkit/errors.py +68 -0
  6. bidkit/generated/__init__.py +1 -0
  7. bidkit/generated/models/__init__.py +1 -0
  8. bidkit/generated/models/buy_browse.py +3162 -0
  9. bidkit/generated/models/buy_deal.py +604 -0
  10. bidkit/generated/models/buy_feed.py +1377 -0
  11. bidkit/generated/models/buy_marketing.py +891 -0
  12. bidkit/generated/models/buy_marketplace_insights.py +432 -0
  13. bidkit/generated/models/buy_offer.py +310 -0
  14. bidkit/generated/models/buy_order.py +1363 -0
  15. bidkit/generated/models/cancellation.py +6 -0
  16. bidkit/generated/models/case.py +99 -0
  17. bidkit/generated/models/commerce_catalog.py +318 -0
  18. bidkit/generated/models/commerce_charity.py +412 -0
  19. bidkit/generated/models/commerce_feedback.py +756 -0
  20. bidkit/generated/models/commerce_identity.py +511 -0
  21. bidkit/generated/models/commerce_media.py +333 -0
  22. bidkit/generated/models/commerce_message.py +390 -0
  23. bidkit/generated/models/commerce_notification.py +399 -0
  24. bidkit/generated/models/commerce_taxonomy.py +511 -0
  25. bidkit/generated/models/commerce_translation.py +121 -0
  26. bidkit/generated/models/commerce_vero.py +560 -0
  27. bidkit/generated/models/developer_analytics.py +116 -0
  28. bidkit/generated/models/developer_client_registration.py +140 -0
  29. bidkit/generated/models/developer_key_management.py +111 -0
  30. bidkit/generated/models/inquiry.py +95 -0
  31. bidkit/generated/models/return_.py +69 -0
  32. bidkit/generated/models/sell_account_v1.py +1929 -0
  33. bidkit/generated/models/sell_account_v2.py +1020 -0
  34. bidkit/generated/models/sell_analytics.py +629 -0
  35. bidkit/generated/models/sell_compliance.py +235 -0
  36. bidkit/generated/models/sell_edelivery_international_shipping.py +1993 -0
  37. bidkit/generated/models/sell_feed.py +853 -0
  38. bidkit/generated/models/sell_finances.py +1348 -0
  39. bidkit/generated/models/sell_fulfillment.py +2645 -0
  40. bidkit/generated/models/sell_inventory.py +2949 -0
  41. bidkit/generated/models/sell_leads.py +234 -0
  42. bidkit/generated/models/sell_listing.py +169 -0
  43. bidkit/generated/models/sell_logistics.py +1027 -0
  44. bidkit/generated/models/sell_marketing.py +3656 -0
  45. bidkit/generated/models/sell_metadata.py +2552 -0
  46. bidkit/generated/models/sell_negotiation.py +414 -0
  47. bidkit/generated/models/sell_recommendation.py +149 -0
  48. bidkit/generated/models/sell_stores.py +214 -0
  49. bidkit/generated/resources.py +19909 -0
  50. bidkit/models.py +43 -0
  51. bidkit/notifications.py +196 -0
  52. bidkit/pagination.py +137 -0
  53. bidkit/py.typed +0 -0
  54. bidkit/resource.py +166 -0
  55. bidkit/retry.py +81 -0
  56. bidkit/signing.py +162 -0
  57. bidkit/transport.py +499 -0
  58. bidkit-0.1.0.dist-info/METADATA +425 -0
  59. bidkit-0.1.0.dist-info/RECORD +62 -0
  60. bidkit-0.1.0.dist-info/WHEEL +4 -0
  61. bidkit-0.1.0.dist-info/licenses/LICENSE +21 -0
  62. bidkit-0.1.0.dist-info/licenses/NOTICE +15 -0
bidkit/__init__.py ADDED
@@ -0,0 +1,49 @@
1
+ import logging
2
+ from importlib.metadata import PackageNotFoundError, version
3
+
4
+ from .auth import FileTokenCache, InMemoryTokenCache, OAuthTokens, TokenCache
5
+ from .client import AsyncEbayClient, EbayClient
6
+ from .config import EbayConfig, EbaySigningConfig
7
+ from .errors import (
8
+ EbayAPIError,
9
+ EbayAuthError,
10
+ EbayConfigError,
11
+ EbaySDKError,
12
+ EbayTransportError,
13
+ )
14
+ from .notifications import (
15
+ AsyncNotificationVerifier,
16
+ NotificationVerifier,
17
+ challenge_response,
18
+ )
19
+ from .pagination import paginate, paginate_async
20
+
21
+ try:
22
+ __version__ = version("bidkit")
23
+ except PackageNotFoundError: # running from a source tree without an installed dist
24
+ __version__ = "0.1.0"
25
+
26
+ # Library logging convention: silent unless the application opts in, e.g.
27
+ # logging.getLogger("bidkit").setLevel(logging.DEBUG)
28
+ logging.getLogger("bidkit").addHandler(logging.NullHandler())
29
+
30
+ __all__ = [
31
+ "AsyncEbayClient",
32
+ "AsyncNotificationVerifier",
33
+ "EbayAPIError",
34
+ "EbayAuthError",
35
+ "EbayClient",
36
+ "EbayConfig",
37
+ "EbayConfigError",
38
+ "EbaySDKError",
39
+ "EbaySigningConfig",
40
+ "EbayTransportError",
41
+ "FileTokenCache",
42
+ "InMemoryTokenCache",
43
+ "NotificationVerifier",
44
+ "OAuthTokens",
45
+ "TokenCache",
46
+ "challenge_response",
47
+ "paginate",
48
+ "paginate_async",
49
+ ]
bidkit/auth.py ADDED
@@ -0,0 +1,477 @@
1
+ from __future__ import annotations
2
+
3
+ import asyncio
4
+ import hashlib
5
+ import logging
6
+ import os
7
+ import threading
8
+ from collections.abc import Iterator
9
+ from contextlib import contextmanager
10
+ from dataclasses import dataclass
11
+ from datetime import UTC, datetime, timedelta
12
+ from pathlib import Path
13
+ from typing import Any, Protocol
14
+ from urllib.parse import urlencode
15
+
16
+ import httpx
17
+ import orjson
18
+ from pydantic import BaseModel, ValidationError, field_validator
19
+
20
+ from .config import EbayConfig
21
+ from .errors import EbayAuthError, EbayConfigError
22
+
23
+ logger = logging.getLogger("bidkit.auth")
24
+
25
+
26
+ class TokenData(BaseModel):
27
+ access_token: str
28
+ expires_at: datetime
29
+ token_type: str = "Bearer"
30
+
31
+ @field_validator("expires_at")
32
+ @classmethod
33
+ def _ensure_timezone(cls, value: datetime) -> datetime:
34
+ # Foreign/hand-edited cache files may carry naive timestamps; comparing those
35
+ # against aware datetimes raises TypeError, so pin them to UTC instead.
36
+ return value if value.tzinfo else value.replace(tzinfo=UTC)
37
+
38
+ @property
39
+ def is_stale(self) -> bool:
40
+ return self.expires_at <= datetime.now(UTC) + timedelta(minutes=5)
41
+
42
+
43
+ class OAuthTokens(BaseModel):
44
+ """Result of an authorization-code exchange.
45
+
46
+ ``refresh_token`` is the long-lived credential to persist and pass back as
47
+ ``EbayConfig.refresh_token``; ``access_token`` is the short-lived user token.
48
+ """
49
+
50
+ access_token: str
51
+ token_expiry: datetime
52
+ refresh_token: str | None = None
53
+ refresh_token_expiry: datetime | None = None
54
+ token_type: str = "User Access Token"
55
+
56
+ def to_token_data(self) -> TokenData:
57
+ return TokenData(
58
+ access_token=self.access_token,
59
+ expires_at=self.token_expiry,
60
+ token_type=self.token_type,
61
+ )
62
+
63
+
64
+ class TokenCache(Protocol):
65
+ def get(self, key: str) -> TokenData | None: ...
66
+
67
+ def set(self, key: str, token: TokenData) -> None: ...
68
+
69
+
70
+ @dataclass
71
+ class InMemoryTokenCache:
72
+ _tokens: dict[str, TokenData]
73
+
74
+ def __init__(self) -> None:
75
+ self._tokens = {}
76
+
77
+ def get(self, key: str) -> TokenData | None:
78
+ return self._tokens.get(key)
79
+
80
+ def set(self, key: str, token: TokenData) -> None:
81
+ self._tokens[key] = token
82
+
83
+
84
+ class FileTokenCache:
85
+ """Persist access tokens across processes in a 0600 JSON file.
86
+
87
+ Drop-in :class:`TokenCache`::
88
+
89
+ client = EbayClient(config, token_cache=FileTokenCache())
90
+
91
+ Defaults to ``$XDG_CACHE_HOME/bidkit/tokens.json`` (``~/.cache/bidkit/tokens.json``).
92
+ Each entry maps an :meth:`EbayAuth._cache_key` (which never contains token values) to
93
+ the token data; the file itself holds live access tokens, hence the restrictive mode.
94
+ Writes are atomic and expired entries are pruned on every write; a corrupt or foreign
95
+ file is treated as empty rather than raising.
96
+ """
97
+
98
+ def __init__(self, path: str | Path | None = None) -> None:
99
+ if path is None:
100
+ cache_home = os.environ.get("XDG_CACHE_HOME") or "~/.cache"
101
+ path = Path(cache_home) / "bidkit" / "tokens.json"
102
+ self.path = Path(path).expanduser()
103
+ self._lock = threading.Lock()
104
+ # (st_mtime_ns, st_size) -> parsed entries; avoids re-reading the file on the
105
+ # per-request hot path (auth consults the cache before every request).
106
+ self._snapshot: tuple[tuple[int, int], dict[str, Any]] | None = None
107
+
108
+ def get(self, key: str) -> TokenData | None:
109
+ with self._lock:
110
+ entry = self._load().get(key)
111
+ if not isinstance(entry, dict):
112
+ return None
113
+ try:
114
+ return TokenData.model_validate(entry)
115
+ except ValidationError:
116
+ return None
117
+
118
+ def set(self, key: str, token: TokenData) -> None:
119
+ # flock (best effort) makes the read-modify-write atomic across processes,
120
+ # not just across threads; without it concurrent processes overwrite each
121
+ # other's freshly minted tokens.
122
+ with self._lock, self._file_lock():
123
+ self._snapshot = None
124
+ entries = self._load()
125
+ entries[key] = token.model_dump(mode="json")
126
+ entries = {k: v for k, v in entries.items() if self._is_live(v)}
127
+ self._write(entries)
128
+
129
+ @contextmanager
130
+ def _file_lock(self) -> Iterator[None]:
131
+ try:
132
+ import fcntl
133
+ except ImportError: # pragma: no cover - Windows; thread lock still applies
134
+ yield
135
+ return
136
+ self.path.parent.mkdir(mode=0o700, parents=True, exist_ok=True)
137
+ fd = os.open(self.path.with_name(self.path.name + ".lock"), os.O_CREAT | os.O_RDWR, 0o600)
138
+ try:
139
+ fcntl.flock(fd, fcntl.LOCK_EX)
140
+ yield
141
+ finally:
142
+ fcntl.flock(fd, fcntl.LOCK_UN)
143
+ os.close(fd)
144
+
145
+ @staticmethod
146
+ def _is_live(entry: Any) -> bool:
147
+ try:
148
+ return TokenData.model_validate(entry).expires_at > datetime.now(UTC)
149
+ except ValidationError:
150
+ return False
151
+
152
+ def _load(self) -> dict[str, Any]:
153
+ try:
154
+ stat = self.path.stat()
155
+ stamp = (stat.st_mtime_ns, stat.st_size)
156
+ if self._snapshot is not None and self._snapshot[0] == stamp:
157
+ return dict(self._snapshot[1])
158
+ data = orjson.loads(self.path.read_bytes())
159
+ except (OSError, orjson.JSONDecodeError):
160
+ self._snapshot = None
161
+ return {}
162
+ entries = data if isinstance(data, dict) else {}
163
+ self._snapshot = (stamp, dict(entries))
164
+ return entries
165
+
166
+ def _write(self, entries: dict[str, Any]) -> None:
167
+ self.path.parent.mkdir(mode=0o700, parents=True, exist_ok=True)
168
+ tmp = self.path.with_name(self.path.name + f".{os.getpid()}.tmp")
169
+ fd = os.open(tmp, os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600)
170
+ try:
171
+ with os.fdopen(fd, "wb") as handle:
172
+ handle.write(orjson.dumps(entries, option=orjson.OPT_INDENT_2))
173
+ os.replace(tmp, self.path) # atomic; inherits the 0600 mode
174
+ except BaseException:
175
+ tmp.unlink(missing_ok=True)
176
+ raise
177
+
178
+
179
+ class EbayAuth:
180
+ def __init__(self, config: EbayConfig, cache: TokenCache | None = None) -> None:
181
+ self.config = config
182
+ self.cache = cache or InMemoryTokenCache()
183
+ # Serialize token refreshes per cache key so concurrent callers hitting a stale
184
+ # token trigger a single refresh instead of a stampede on the OAuth endpoint. The
185
+ # sync and async paths use their own primitive; _locks_guard protects the lazy
186
+ # creation of both maps.
187
+ self._locks_guard = threading.Lock()
188
+ self._sync_locks: dict[str, threading.Lock] = {}
189
+ self._async_locks: dict[str, asyncio.Lock] = {}
190
+
191
+ @staticmethod
192
+ def authorization_url(
193
+ *,
194
+ app_id: str,
195
+ ru_name: str,
196
+ scopes: tuple[str, ...],
197
+ sandbox: bool = False,
198
+ state: str | None = None,
199
+ prompt: str | None = None,
200
+ ) -> str:
201
+ host = "auth.sandbox.ebay.com" if sandbox else "auth.ebay.com"
202
+ query = {
203
+ "client_id": app_id,
204
+ "redirect_uri": ru_name,
205
+ "response_type": "code",
206
+ "scope": " ".join(scopes),
207
+ }
208
+ if state:
209
+ query["state"] = state
210
+ if prompt:
211
+ query["prompt"] = prompt
212
+ return f"https://{host}/oauth2/authorize?{urlencode(query)}"
213
+
214
+ def exchange_code(
215
+ self,
216
+ client: httpx.Client,
217
+ code: str,
218
+ *,
219
+ ru_name: str | None = None,
220
+ ) -> OAuthTokens:
221
+ """Exchange an authorization code for user access + refresh tokens."""
222
+ response = client.post(
223
+ self.config.oauth_token_url,
224
+ data=self._exchange_data(code, ru_name),
225
+ auth=self._client_credentials(),
226
+ headers={"Content-Type": "application/x-www-form-urlencoded"},
227
+ )
228
+ return self._parse_oauth_tokens(response)
229
+
230
+ async def async_exchange_code(
231
+ self,
232
+ client: httpx.AsyncClient,
233
+ code: str,
234
+ *,
235
+ ru_name: str | None = None,
236
+ ) -> OAuthTokens:
237
+ response = await client.post(
238
+ self.config.oauth_token_url,
239
+ data=self._exchange_data(code, ru_name),
240
+ auth=self._client_credentials(),
241
+ headers={"Content-Type": "application/x-www-form-urlencoded"},
242
+ )
243
+ return self._parse_oauth_tokens(response)
244
+
245
+ def _exchange_data(self, code: str, ru_name: str | None) -> dict[str, str]:
246
+ redirect_uri = ru_name or self.config.ru_name
247
+ if not redirect_uri:
248
+ raise EbayConfigError("ru_name is required to exchange an authorization code")
249
+ return {
250
+ "grant_type": "authorization_code",
251
+ "code": code,
252
+ "redirect_uri": redirect_uri,
253
+ }
254
+
255
+ def authorization_header(
256
+ self,
257
+ client: httpx.Client,
258
+ *,
259
+ scheme: str = "Bearer",
260
+ ) -> dict[str, str]:
261
+ return {"Authorization": f"{scheme} {self.access_token(client)}"}
262
+
263
+ async def async_authorization_header(
264
+ self,
265
+ client: httpx.AsyncClient,
266
+ *,
267
+ scheme: str = "Bearer",
268
+ ) -> dict[str, str]:
269
+ return {"Authorization": f"{scheme} {await self.async_access_token(client)}"}
270
+
271
+ def access_token(self, client: httpx.Client) -> str:
272
+ static_token = self.config.bearer_token
273
+ if static_token:
274
+ return static_token
275
+
276
+ key = self._cache_key()
277
+ cached = self.cache.get(key)
278
+ if cached and not cached.is_stale:
279
+ return cached.access_token
280
+
281
+ with self._sync_lock(key):
282
+ # Re-check inside the lock: another thread may have refreshed while we waited.
283
+ cached = self.cache.get(key)
284
+ if cached and not cached.is_stale:
285
+ return cached.access_token
286
+ token = (
287
+ self._refresh_user_token(client)
288
+ if self.config.refresh_token
289
+ else self._client_token(client)
290
+ )
291
+ self.cache.set(key, token)
292
+ self._log_token_acquired(token)
293
+ return token.access_token
294
+
295
+ async def async_access_token(self, client: httpx.AsyncClient) -> str:
296
+ static_token = self.config.bearer_token
297
+ if static_token:
298
+ return static_token
299
+
300
+ key = self._cache_key()
301
+ cached = self.cache.get(key)
302
+ if cached and not cached.is_stale:
303
+ return cached.access_token
304
+
305
+ async with self._async_lock(key):
306
+ # Re-check inside the lock: another coroutine may have refreshed while we waited.
307
+ cached = self.cache.get(key)
308
+ if cached and not cached.is_stale:
309
+ return cached.access_token
310
+ token = (
311
+ await self._async_refresh_user_token(client)
312
+ if self.config.refresh_token
313
+ else await self._async_client_token(client)
314
+ )
315
+ self.cache.set(key, token)
316
+ self._log_token_acquired(token)
317
+ return token.access_token
318
+
319
+ def seed_tokens(self, tokens: OAuthTokens) -> None:
320
+ """Make freshly exchanged tokens usable by subsequent requests.
321
+
322
+ Stores the refresh token on the config (so the cache key resolves to the new
323
+ grant) and primes the cache with the short-lived access token.
324
+ """
325
+ if tokens.refresh_token:
326
+ self.config.refresh_token = tokens.refresh_token
327
+ self.cache.set(self._cache_key(), tokens.to_token_data())
328
+
329
+ def _log_token_acquired(self, token: TokenData) -> None:
330
+ """Log token acquisition without ever touching the token values themselves."""
331
+ if not logger.isEnabledFor(logging.INFO):
332
+ return
333
+ expires_in = int((token.expires_at - datetime.now(UTC)).total_seconds())
334
+ if self.config.refresh_token:
335
+ digest = hashlib.sha256((self.config.refresh_token_value or "").encode()).hexdigest()
336
+ logger.info(
337
+ "refreshed user token for refresh:%s… (expires in %d s)",
338
+ digest[:8],
339
+ expires_in,
340
+ extra={"grant": "user", "expires_in": expires_in},
341
+ )
342
+ else:
343
+ logger.info(
344
+ "minted client token (expires in %d s)",
345
+ expires_in,
346
+ extra={"grant": "client", "expires_in": expires_in},
347
+ )
348
+
349
+ def _sync_lock(self, key: str) -> threading.Lock:
350
+ with self._locks_guard:
351
+ lock = self._sync_locks.get(key)
352
+ if lock is None:
353
+ lock = self._sync_locks[key] = threading.Lock()
354
+ return lock
355
+
356
+ def _async_lock(self, key: str) -> asyncio.Lock:
357
+ with self._locks_guard:
358
+ lock = self._async_locks.get(key)
359
+ if lock is None:
360
+ lock = self._async_locks[key] = asyncio.Lock()
361
+ return lock
362
+
363
+ def _cache_key(self) -> str:
364
+ # Identify the exact grant so a shared cache never returns another tenant's token:
365
+ # app credentials + the specific refresh token (hashed, not stored) + env + scopes.
366
+ if self.config.refresh_token:
367
+ digest = hashlib.sha256((self.config.refresh_token_value or "").encode()).hexdigest()
368
+ grant = f"refresh:{digest[:16]}"
369
+ else:
370
+ grant = "client"
371
+ env = "sandbox" if self.config.sandbox else "production"
372
+ app_id = self.config.app_id or "-"
373
+ return f"{grant}:{app_id}:{env}:{' '.join(self.config.scopes)}"
374
+
375
+ def _client_token(self, client: httpx.Client) -> TokenData:
376
+ client_auth = self._client_credentials()
377
+ response = client.post(
378
+ self.config.oauth_token_url,
379
+ data={
380
+ "grant_type": "client_credentials",
381
+ "scope": " ".join(self.config.scopes),
382
+ },
383
+ auth=client_auth,
384
+ headers={"Content-Type": "application/x-www-form-urlencoded"},
385
+ )
386
+ return self._parse_token_response(response)
387
+
388
+ async def _async_client_token(self, client: httpx.AsyncClient) -> TokenData:
389
+ client_auth = self._client_credentials()
390
+ response = await client.post(
391
+ self.config.oauth_token_url,
392
+ data={
393
+ "grant_type": "client_credentials",
394
+ "scope": " ".join(self.config.scopes),
395
+ },
396
+ auth=client_auth,
397
+ headers={"Content-Type": "application/x-www-form-urlencoded"},
398
+ )
399
+ return self._parse_token_response(response)
400
+
401
+ def _refresh_user_token(self, client: httpx.Client) -> TokenData:
402
+ client_auth = self._client_credentials()
403
+ refresh_token = self._refresh_token()
404
+ response = client.post(
405
+ self.config.oauth_token_url,
406
+ data={
407
+ "grant_type": "refresh_token",
408
+ "refresh_token": refresh_token,
409
+ "scope": " ".join(self.config.scopes),
410
+ },
411
+ auth=client_auth,
412
+ headers={"Content-Type": "application/x-www-form-urlencoded"},
413
+ )
414
+ return self._parse_token_response(response)
415
+
416
+ async def _async_refresh_user_token(self, client: httpx.AsyncClient) -> TokenData:
417
+ client_auth = self._client_credentials()
418
+ refresh_token = self._refresh_token()
419
+ response = await client.post(
420
+ self.config.oauth_token_url,
421
+ data={
422
+ "grant_type": "refresh_token",
423
+ "refresh_token": refresh_token,
424
+ "scope": " ".join(self.config.scopes),
425
+ },
426
+ auth=client_auth,
427
+ headers={"Content-Type": "application/x-www-form-urlencoded"},
428
+ )
429
+ return self._parse_token_response(response)
430
+
431
+ def _client_credentials(self) -> tuple[str, str]:
432
+ client_secret = self.config.client_secret
433
+ if not self.config.app_id or not client_secret:
434
+ raise EbayConfigError("app_id and cert_id are required to obtain OAuth tokens")
435
+ return self.config.app_id, client_secret
436
+
437
+ def _refresh_token(self) -> str:
438
+ refresh_token = self.config.refresh_token_value
439
+ if not refresh_token:
440
+ raise EbayConfigError("refresh_token is required to refresh a user token")
441
+ return refresh_token
442
+
443
+ def _decode_token_response(self, response: httpx.Response) -> dict:
444
+ if response.status_code >= 400:
445
+ try:
446
+ detail = orjson.loads(response.content)
447
+ except orjson.JSONDecodeError:
448
+ detail = response.text
449
+ raise EbayAuthError(f"OAuth token request failed: {detail}")
450
+
451
+ payload = orjson.loads(response.content)
452
+ if not payload.get("access_token"):
453
+ raise EbayAuthError("OAuth token response did not include access_token")
454
+ return payload
455
+
456
+ def _parse_token_response(self, response: httpx.Response) -> TokenData:
457
+ payload = self._decode_token_response(response)
458
+ expires_in = int(payload.get("expires_in", 7200))
459
+ return TokenData(
460
+ access_token=payload["access_token"],
461
+ expires_at=datetime.now(UTC) + timedelta(seconds=expires_in),
462
+ token_type=payload.get("token_type", "Bearer"),
463
+ )
464
+
465
+ def _parse_oauth_tokens(self, response: httpx.Response) -> OAuthTokens:
466
+ payload = self._decode_token_response(response)
467
+ now = datetime.now(UTC)
468
+ refresh_expiry = None
469
+ if payload.get("refresh_token_expires_in") is not None:
470
+ refresh_expiry = now + timedelta(seconds=int(payload["refresh_token_expires_in"]))
471
+ return OAuthTokens(
472
+ access_token=payload["access_token"],
473
+ token_expiry=now + timedelta(seconds=int(payload.get("expires_in", 7200))),
474
+ refresh_token=payload.get("refresh_token"),
475
+ refresh_token_expiry=refresh_expiry,
476
+ token_type=payload.get("token_type", "User Access Token"),
477
+ )