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.
- bidkit/__init__.py +49 -0
- bidkit/auth.py +477 -0
- bidkit/client.py +212 -0
- bidkit/config.py +216 -0
- bidkit/errors.py +68 -0
- bidkit/generated/__init__.py +1 -0
- bidkit/generated/models/__init__.py +1 -0
- bidkit/generated/models/buy_browse.py +3162 -0
- bidkit/generated/models/buy_deal.py +604 -0
- bidkit/generated/models/buy_feed.py +1377 -0
- bidkit/generated/models/buy_marketing.py +891 -0
- bidkit/generated/models/buy_marketplace_insights.py +432 -0
- bidkit/generated/models/buy_offer.py +310 -0
- bidkit/generated/models/buy_order.py +1363 -0
- bidkit/generated/models/cancellation.py +6 -0
- bidkit/generated/models/case.py +99 -0
- bidkit/generated/models/commerce_catalog.py +318 -0
- bidkit/generated/models/commerce_charity.py +412 -0
- bidkit/generated/models/commerce_feedback.py +756 -0
- bidkit/generated/models/commerce_identity.py +511 -0
- bidkit/generated/models/commerce_media.py +333 -0
- bidkit/generated/models/commerce_message.py +390 -0
- bidkit/generated/models/commerce_notification.py +399 -0
- bidkit/generated/models/commerce_taxonomy.py +511 -0
- bidkit/generated/models/commerce_translation.py +121 -0
- bidkit/generated/models/commerce_vero.py +560 -0
- bidkit/generated/models/developer_analytics.py +116 -0
- bidkit/generated/models/developer_client_registration.py +140 -0
- bidkit/generated/models/developer_key_management.py +111 -0
- bidkit/generated/models/inquiry.py +95 -0
- bidkit/generated/models/return_.py +69 -0
- bidkit/generated/models/sell_account_v1.py +1929 -0
- bidkit/generated/models/sell_account_v2.py +1020 -0
- bidkit/generated/models/sell_analytics.py +629 -0
- bidkit/generated/models/sell_compliance.py +235 -0
- bidkit/generated/models/sell_edelivery_international_shipping.py +1993 -0
- bidkit/generated/models/sell_feed.py +853 -0
- bidkit/generated/models/sell_finances.py +1348 -0
- bidkit/generated/models/sell_fulfillment.py +2645 -0
- bidkit/generated/models/sell_inventory.py +2949 -0
- bidkit/generated/models/sell_leads.py +234 -0
- bidkit/generated/models/sell_listing.py +169 -0
- bidkit/generated/models/sell_logistics.py +1027 -0
- bidkit/generated/models/sell_marketing.py +3656 -0
- bidkit/generated/models/sell_metadata.py +2552 -0
- bidkit/generated/models/sell_negotiation.py +414 -0
- bidkit/generated/models/sell_recommendation.py +149 -0
- bidkit/generated/models/sell_stores.py +214 -0
- bidkit/generated/resources.py +19909 -0
- bidkit/models.py +43 -0
- bidkit/notifications.py +196 -0
- bidkit/pagination.py +137 -0
- bidkit/py.typed +0 -0
- bidkit/resource.py +166 -0
- bidkit/retry.py +81 -0
- bidkit/signing.py +162 -0
- bidkit/transport.py +499 -0
- bidkit-0.1.0.dist-info/METADATA +425 -0
- bidkit-0.1.0.dist-info/RECORD +62 -0
- bidkit-0.1.0.dist-info/WHEEL +4 -0
- bidkit-0.1.0.dist-info/licenses/LICENSE +21 -0
- 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
|
+
)
|