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/client.py
ADDED
|
@@ -0,0 +1,212 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from collections.abc import Mapping
|
|
4
|
+
from contextlib import AbstractAsyncContextManager, AbstractContextManager
|
|
5
|
+
from typing import TYPE_CHECKING, Any
|
|
6
|
+
|
|
7
|
+
import httpx
|
|
8
|
+
|
|
9
|
+
from .auth import EbayAuth, OAuthTokens, TokenCache
|
|
10
|
+
from .config import DEFAULT_TIMEOUT, EbayConfig
|
|
11
|
+
from .errors import EbayConfigError
|
|
12
|
+
from .transport import AsyncEbayTransport, EbayTransport
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def _authorization_url(
|
|
16
|
+
config: EbayConfig,
|
|
17
|
+
*,
|
|
18
|
+
state: str | None,
|
|
19
|
+
prompt: str | None,
|
|
20
|
+
scopes: tuple[str, ...] | None,
|
|
21
|
+
) -> str:
|
|
22
|
+
if not config.app_id or not config.ru_name:
|
|
23
|
+
raise EbayConfigError("app_id and ru_name are required to build an authorization URL")
|
|
24
|
+
return EbayAuth.authorization_url(
|
|
25
|
+
app_id=config.app_id,
|
|
26
|
+
ru_name=config.ru_name,
|
|
27
|
+
scopes=scopes or config.scopes,
|
|
28
|
+
sandbox=config.sandbox,
|
|
29
|
+
state=state,
|
|
30
|
+
prompt=prompt,
|
|
31
|
+
)
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def _overridden_config(config: EbayConfig, overrides: Mapping[str, Any]) -> EbayConfig:
|
|
35
|
+
"""Re-validate the explicitly-set fields plus ``overrides`` into a new config."""
|
|
36
|
+
data = {name: getattr(config, name) for name in config.model_fields_set}
|
|
37
|
+
return EbayConfig(**{**data, **overrides})
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
if TYPE_CHECKING:
|
|
41
|
+
from .generated.resources import (
|
|
42
|
+
AsyncBuyNamespace,
|
|
43
|
+
AsyncCommerceNamespace,
|
|
44
|
+
AsyncDeveloperNamespace,
|
|
45
|
+
AsyncPostOrderNamespace,
|
|
46
|
+
AsyncSellNamespace,
|
|
47
|
+
BuyNamespace,
|
|
48
|
+
CommerceNamespace,
|
|
49
|
+
DeveloperNamespace,
|
|
50
|
+
PostOrderNamespace,
|
|
51
|
+
SellNamespace,
|
|
52
|
+
)
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
class EbayClient:
|
|
56
|
+
buy: BuyNamespace
|
|
57
|
+
commerce: CommerceNamespace
|
|
58
|
+
developer: DeveloperNamespace
|
|
59
|
+
post_order: PostOrderNamespace
|
|
60
|
+
sell: SellNamespace
|
|
61
|
+
|
|
62
|
+
def __init__(
|
|
63
|
+
self,
|
|
64
|
+
config: EbayConfig | Mapping[str, Any] | None = None,
|
|
65
|
+
*,
|
|
66
|
+
http_client: httpx.Client | None = None,
|
|
67
|
+
token_cache: TokenCache | None = None,
|
|
68
|
+
) -> None:
|
|
69
|
+
self.config = (
|
|
70
|
+
config if isinstance(config, EbayConfig) else EbayConfig.model_validate(config or {})
|
|
71
|
+
)
|
|
72
|
+
self._owns_http = http_client is None
|
|
73
|
+
self.http = http_client or httpx.Client(timeout=self.config.timeout or DEFAULT_TIMEOUT)
|
|
74
|
+
self.auth = EbayAuth(self.config, token_cache)
|
|
75
|
+
self._transport = EbayTransport(self.config, self.auth, self.http)
|
|
76
|
+
|
|
77
|
+
from .generated.resources import install_sync_namespaces
|
|
78
|
+
|
|
79
|
+
install_sync_namespaces(self)
|
|
80
|
+
|
|
81
|
+
@classmethod
|
|
82
|
+
def from_env(cls) -> EbayClient:
|
|
83
|
+
return cls(EbayConfig.from_env())
|
|
84
|
+
|
|
85
|
+
def with_options(self, **overrides: Any) -> EbayClient:
|
|
86
|
+
"""A scoped client with config fields overridden, e.g.::
|
|
87
|
+
|
|
88
|
+
client.with_options(timeout=5.0, max_retries=0).sell.inventory.get_offers(...)
|
|
89
|
+
|
|
90
|
+
The returned client shares this client's HTTP connection pool and token cache
|
|
91
|
+
(no new connections, no re-auth); closing it is a no-op, and any
|
|
92
|
+
:class:`EbayConfig` field (``timeout``, ``max_retries``, ``marketplace_id``,
|
|
93
|
+
``retry_backoff``, ...) can be overridden.
|
|
94
|
+
"""
|
|
95
|
+
return type(self)(
|
|
96
|
+
_overridden_config(self.config, overrides),
|
|
97
|
+
http_client=self.http,
|
|
98
|
+
token_cache=self.auth.cache,
|
|
99
|
+
)
|
|
100
|
+
|
|
101
|
+
def authorization_url(
|
|
102
|
+
self,
|
|
103
|
+
*,
|
|
104
|
+
state: str | None = None,
|
|
105
|
+
prompt: str | None = None,
|
|
106
|
+
scopes: tuple[str, ...] | None = None,
|
|
107
|
+
) -> str:
|
|
108
|
+
"""Build the eBay consent URL to send a user to (authorization-code flow)."""
|
|
109
|
+
return _authorization_url(self.config, state=state, prompt=prompt, scopes=scopes)
|
|
110
|
+
|
|
111
|
+
def exchange_code(self, code: str, *, ru_name: str | None = None) -> OAuthTokens:
|
|
112
|
+
"""Exchange a consent ``code`` for tokens and authenticate this client with them.
|
|
113
|
+
|
|
114
|
+
Note: this mutates ``self.config`` — the returned ``refresh_token`` is stored on
|
|
115
|
+
the config so subsequent calls run as the newly authorized user.
|
|
116
|
+
"""
|
|
117
|
+
tokens = self.auth.exchange_code(self.http, code, ru_name=ru_name)
|
|
118
|
+
self.auth.seed_tokens(tokens)
|
|
119
|
+
return tokens
|
|
120
|
+
|
|
121
|
+
def close(self) -> None:
|
|
122
|
+
# Only close the transport the SDK created; never an injected, caller-owned client.
|
|
123
|
+
if self._owns_http:
|
|
124
|
+
self.http.close()
|
|
125
|
+
|
|
126
|
+
def __enter__(self) -> EbayClient:
|
|
127
|
+
return self
|
|
128
|
+
|
|
129
|
+
def __exit__(self, *_exc: object) -> None:
|
|
130
|
+
self.close()
|
|
131
|
+
|
|
132
|
+
def _request(self, **kwargs: Any) -> Any:
|
|
133
|
+
return self._transport.request(**kwargs)
|
|
134
|
+
|
|
135
|
+
def _stream(self, **kwargs: Any) -> AbstractContextManager[httpx.Response]:
|
|
136
|
+
return self._transport.stream(**kwargs)
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
class AsyncEbayClient:
|
|
140
|
+
buy: AsyncBuyNamespace
|
|
141
|
+
commerce: AsyncCommerceNamespace
|
|
142
|
+
developer: AsyncDeveloperNamespace
|
|
143
|
+
post_order: AsyncPostOrderNamespace
|
|
144
|
+
sell: AsyncSellNamespace
|
|
145
|
+
|
|
146
|
+
def __init__(
|
|
147
|
+
self,
|
|
148
|
+
config: EbayConfig | Mapping[str, Any] | None = None,
|
|
149
|
+
*,
|
|
150
|
+
http_client: httpx.AsyncClient | None = None,
|
|
151
|
+
token_cache: TokenCache | None = None,
|
|
152
|
+
) -> None:
|
|
153
|
+
self.config = (
|
|
154
|
+
config if isinstance(config, EbayConfig) else EbayConfig.model_validate(config or {})
|
|
155
|
+
)
|
|
156
|
+
self._owns_http = http_client is None
|
|
157
|
+
self.http = http_client or httpx.AsyncClient(timeout=self.config.timeout or DEFAULT_TIMEOUT)
|
|
158
|
+
self.auth = EbayAuth(self.config, token_cache)
|
|
159
|
+
self._transport = AsyncEbayTransport(self.config, self.auth, self.http)
|
|
160
|
+
|
|
161
|
+
from .generated.resources import install_async_namespaces
|
|
162
|
+
|
|
163
|
+
install_async_namespaces(self)
|
|
164
|
+
|
|
165
|
+
@classmethod
|
|
166
|
+
def from_env(cls) -> AsyncEbayClient:
|
|
167
|
+
return cls(EbayConfig.from_env())
|
|
168
|
+
|
|
169
|
+
def with_options(self, **overrides: Any) -> AsyncEbayClient:
|
|
170
|
+
"""A scoped client with config fields overridden — see :meth:`EbayClient.with_options`."""
|
|
171
|
+
return type(self)(
|
|
172
|
+
_overridden_config(self.config, overrides),
|
|
173
|
+
http_client=self.http,
|
|
174
|
+
token_cache=self.auth.cache,
|
|
175
|
+
)
|
|
176
|
+
|
|
177
|
+
def authorization_url(
|
|
178
|
+
self,
|
|
179
|
+
*,
|
|
180
|
+
state: str | None = None,
|
|
181
|
+
prompt: str | None = None,
|
|
182
|
+
scopes: tuple[str, ...] | None = None,
|
|
183
|
+
) -> str:
|
|
184
|
+
"""Build the eBay consent URL to send a user to (authorization-code flow)."""
|
|
185
|
+
return _authorization_url(self.config, state=state, prompt=prompt, scopes=scopes)
|
|
186
|
+
|
|
187
|
+
async def exchange_code(self, code: str, *, ru_name: str | None = None) -> OAuthTokens:
|
|
188
|
+
"""Exchange a consent ``code`` for tokens and authenticate this client with them.
|
|
189
|
+
|
|
190
|
+
Note: this mutates ``self.config`` — the returned ``refresh_token`` is stored on
|
|
191
|
+
the config so subsequent calls run as the newly authorized user.
|
|
192
|
+
"""
|
|
193
|
+
tokens = await self.auth.async_exchange_code(self.http, code, ru_name=ru_name)
|
|
194
|
+
self.auth.seed_tokens(tokens)
|
|
195
|
+
return tokens
|
|
196
|
+
|
|
197
|
+
async def close(self) -> None:
|
|
198
|
+
# Only close the transport the SDK created; never an injected, caller-owned client.
|
|
199
|
+
if self._owns_http:
|
|
200
|
+
await self.http.aclose()
|
|
201
|
+
|
|
202
|
+
async def __aenter__(self) -> AsyncEbayClient:
|
|
203
|
+
return self
|
|
204
|
+
|
|
205
|
+
async def __aexit__(self, *_exc: object) -> None:
|
|
206
|
+
await self.close()
|
|
207
|
+
|
|
208
|
+
async def _request(self, **kwargs: Any) -> Any:
|
|
209
|
+
return await self._transport.request(**kwargs)
|
|
210
|
+
|
|
211
|
+
def _stream(self, **kwargs: Any) -> AbstractAsyncContextManager[httpx.Response]:
|
|
212
|
+
return self._transport.stream(**kwargs)
|
bidkit/config.py
ADDED
|
@@ -0,0 +1,216 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import logging
|
|
5
|
+
import os
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
from typing import Any, Literal
|
|
8
|
+
|
|
9
|
+
from pydantic import BaseModel, ConfigDict, Field, SecretStr, ValidationError
|
|
10
|
+
|
|
11
|
+
SecretValue = SecretStr | str
|
|
12
|
+
|
|
13
|
+
logger = logging.getLogger("bidkit.config")
|
|
14
|
+
|
|
15
|
+
# Timeout for HTTP clients the SDK creates itself when EbayConfig.timeout is unset.
|
|
16
|
+
DEFAULT_TIMEOUT = 30.0
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class EbaySigningConfig(BaseModel):
|
|
20
|
+
"""Credentials for eBay digital-signature (message signing).
|
|
21
|
+
|
|
22
|
+
Required by the Finances API and several payout/refund operations. ``jwe`` is the
|
|
23
|
+
encrypted public key returned by the Key Management API; ``private_key`` is the
|
|
24
|
+
matching PEM (or bare base64 PKCS#8) private key; ``digest`` selects the
|
|
25
|
+
content-digest algorithm.
|
|
26
|
+
"""
|
|
27
|
+
|
|
28
|
+
model_config = ConfigDict(extra="forbid")
|
|
29
|
+
|
|
30
|
+
jwe: str
|
|
31
|
+
private_key: SecretValue
|
|
32
|
+
digest: Literal["sha256", "sha512"] = "sha256"
|
|
33
|
+
# Escape hatch: sign every request instead of only the operations eBay
|
|
34
|
+
# requires signatures for, in case eBay expands the signed-API list before
|
|
35
|
+
# the SDK catches up.
|
|
36
|
+
sign_all: bool = False
|
|
37
|
+
|
|
38
|
+
@property
|
|
39
|
+
def private_key_value(self) -> str:
|
|
40
|
+
value = _secret_value(self.private_key)
|
|
41
|
+
if value is None:
|
|
42
|
+
raise ValueError("signing private_key is empty")
|
|
43
|
+
return value
|
|
44
|
+
|
|
45
|
+
@classmethod
|
|
46
|
+
def from_key_file(cls, path: str | Path) -> EbaySigningConfig:
|
|
47
|
+
"""Load signing material from an ebay-cli style ``signing-key.json``."""
|
|
48
|
+
data = json.loads(Path(path).expanduser().read_text())
|
|
49
|
+
private_key = data.get("privateKeyPem") or data.get("privateKey")
|
|
50
|
+
if not data.get("jwe") or not private_key:
|
|
51
|
+
raise ValueError(f"Signing key file {path} is missing 'jwe' and/or a private key")
|
|
52
|
+
# ebay-cli files sometimes put the KEY algorithm (e.g. "ED25519") in
|
|
53
|
+
# "cipher"; only sha256/sha512 are content-digest choices, anything else
|
|
54
|
+
# falls back to the default.
|
|
55
|
+
raw_digest = str(data.get("cipher") or "").lower()
|
|
56
|
+
digest: Literal["sha256", "sha512"] = "sha512" if raw_digest == "sha512" else "sha256"
|
|
57
|
+
return cls(jwe=data["jwe"], private_key=private_key, digest=digest)
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
class EbayConfig(BaseModel):
|
|
61
|
+
model_config = ConfigDict(extra="forbid")
|
|
62
|
+
|
|
63
|
+
app_id: str | None = None
|
|
64
|
+
cert_id: SecretValue | None = None
|
|
65
|
+
dev_id: str | None = None
|
|
66
|
+
ru_name: str | None = None
|
|
67
|
+
sandbox: bool = False
|
|
68
|
+
|
|
69
|
+
marketplace_id: str = "EBAY_US"
|
|
70
|
+
accept_language: str | None = "en-US"
|
|
71
|
+
content_language: str | None = "en-US"
|
|
72
|
+
|
|
73
|
+
access_token: SecretValue | None = None
|
|
74
|
+
refresh_token: SecretValue | None = None
|
|
75
|
+
scopes: tuple[str, ...] = ("https://api.ebay.com/oauth/api_scope",)
|
|
76
|
+
auto_refresh: bool = True
|
|
77
|
+
|
|
78
|
+
signing: EbaySigningConfig | None = None
|
|
79
|
+
|
|
80
|
+
# None means "use the HTTP client's own timeout" (30 s for SDK-created clients);
|
|
81
|
+
# a set value is applied per request and wins over an injected client's default.
|
|
82
|
+
timeout: float | None = Field(default=None, gt=0)
|
|
83
|
+
base_url_override: str | None = None
|
|
84
|
+
|
|
85
|
+
# Retry policy for transient responses (429 + transient 5xx) and connection errors.
|
|
86
|
+
max_retries: int = Field(default=2, ge=0)
|
|
87
|
+
retry_statuses: tuple[int, ...] = (429, 500, 502, 503, 504)
|
|
88
|
+
retry_backoff: float = Field(default=0.5, ge=0)
|
|
89
|
+
retry_max_backoff: float = Field(default=60.0, ge=0)
|
|
90
|
+
respect_retry_after: bool = True
|
|
91
|
+
|
|
92
|
+
@classmethod
|
|
93
|
+
def from_env(cls, prefix: str = "EBAY_") -> EbayConfig:
|
|
94
|
+
def value(name: str) -> str | None:
|
|
95
|
+
raw = os.getenv(prefix + name)
|
|
96
|
+
return raw if raw not in (None, "") else None
|
|
97
|
+
|
|
98
|
+
scopes = value("SCOPES")
|
|
99
|
+
data: dict[str, Any] = {
|
|
100
|
+
"app_id": value("APP_ID"),
|
|
101
|
+
"cert_id": value("CERT_ID"),
|
|
102
|
+
"dev_id": value("DEV_ID"),
|
|
103
|
+
"ru_name": value("RU_NAME"),
|
|
104
|
+
"marketplace_id": value("MARKETPLACE_ID") or "EBAY_US",
|
|
105
|
+
"accept_language": value("ACCEPT_LANGUAGE") or "en-US",
|
|
106
|
+
"content_language": value("CONTENT_LANGUAGE") or "en-US",
|
|
107
|
+
"access_token": value("ACCESS_TOKEN"),
|
|
108
|
+
"refresh_token": value("REFRESH_TOKEN"),
|
|
109
|
+
"base_url_override": value("BASE_URL"),
|
|
110
|
+
}
|
|
111
|
+
sandbox = value("SANDBOX")
|
|
112
|
+
if sandbox is not None:
|
|
113
|
+
data["sandbox"] = sandbox.strip().lower() in {"1", "true", "yes", "on"}
|
|
114
|
+
if scopes:
|
|
115
|
+
data["scopes"] = tuple(scope for scope in scopes.split() if scope)
|
|
116
|
+
|
|
117
|
+
signing_key_file = value("SIGNING_KEY_FILE")
|
|
118
|
+
signing_jwe = value("SIGNING_JWE")
|
|
119
|
+
if signing_key_file:
|
|
120
|
+
data["signing"] = EbaySigningConfig.from_key_file(signing_key_file)
|
|
121
|
+
else:
|
|
122
|
+
signing_private_key = value("SIGNING_PRIVATE_KEY")
|
|
123
|
+
if signing_jwe and signing_private_key:
|
|
124
|
+
data["signing"] = EbaySigningConfig.model_validate(
|
|
125
|
+
{
|
|
126
|
+
"jwe": signing_jwe,
|
|
127
|
+
"private_key": signing_private_key,
|
|
128
|
+
"digest": value("SIGNING_DIGEST") or "sha256",
|
|
129
|
+
}
|
|
130
|
+
)
|
|
131
|
+
|
|
132
|
+
return cls(**{key: val for key, val in data.items() if val is not None})
|
|
133
|
+
|
|
134
|
+
@classmethod
|
|
135
|
+
def from_file(
|
|
136
|
+
cls,
|
|
137
|
+
path: str | Path = "~/.config/ebay-cli/config.json",
|
|
138
|
+
*,
|
|
139
|
+
signing_key_file: str | Path | None = None,
|
|
140
|
+
) -> EbayConfig:
|
|
141
|
+
"""Load an ebay-cli style ``config.json``.
|
|
142
|
+
|
|
143
|
+
Credentials live under a ``credentials`` object (or at the top level) with the
|
|
144
|
+
aliases ebay-cli uses: ``app_id``/``client_id``, ``cert_id``/``client_secret``,
|
|
145
|
+
``ru_name``/``redirect_uri``, ``granted_scopes``/``scopes``. Top-level
|
|
146
|
+
``environment`` ("sandbox"/"production") and ``marketplace_default`` map to
|
|
147
|
+
``sandbox`` and ``marketplace_id``. A ``signing-key.json`` next to the config is
|
|
148
|
+
picked up automatically; pass ``signing_key_file`` to point elsewhere.
|
|
149
|
+
"""
|
|
150
|
+
config_path = Path(path).expanduser()
|
|
151
|
+
raw = json.loads(config_path.read_text())
|
|
152
|
+
creds = raw.get("credentials", raw)
|
|
153
|
+
if not isinstance(creds, dict):
|
|
154
|
+
raise ValueError(f"Config file {config_path} has no usable 'credentials' object")
|
|
155
|
+
|
|
156
|
+
def alias(*names: str) -> Any:
|
|
157
|
+
return next((creds[name] for name in names if creds.get(name)), None)
|
|
158
|
+
|
|
159
|
+
scopes = alias("granted_scopes", "scopes")
|
|
160
|
+
data: dict[str, Any] = {
|
|
161
|
+
"app_id": alias("app_id", "client_id"),
|
|
162
|
+
"cert_id": alias("cert_id", "client_secret"),
|
|
163
|
+
"dev_id": alias("dev_id"),
|
|
164
|
+
"ru_name": alias("ru_name", "redirect_uri"),
|
|
165
|
+
"refresh_token": alias("refresh_token"),
|
|
166
|
+
"marketplace_id": raw.get("marketplace_default"),
|
|
167
|
+
}
|
|
168
|
+
if raw.get("environment"):
|
|
169
|
+
data["sandbox"] = raw["environment"] == "sandbox"
|
|
170
|
+
if scopes:
|
|
171
|
+
data["scopes"] = tuple(scopes.split()) if isinstance(scopes, str) else tuple(scopes)
|
|
172
|
+
|
|
173
|
+
if signing_key_file is not None:
|
|
174
|
+
# Explicitly requested: let a broken file raise.
|
|
175
|
+
data["signing"] = EbaySigningConfig.from_key_file(signing_key_file)
|
|
176
|
+
else:
|
|
177
|
+
sibling = config_path.with_name("signing-key.json")
|
|
178
|
+
if sibling.exists():
|
|
179
|
+
# Auto-detected: best effort, since most flows never need signing
|
|
180
|
+
# and a stub or foreign-format file must not break config loading.
|
|
181
|
+
try:
|
|
182
|
+
data["signing"] = EbaySigningConfig.from_key_file(sibling)
|
|
183
|
+
except (ValueError, ValidationError) as exc:
|
|
184
|
+
logger.warning("ignoring unusable signing key file %s: %s", sibling, exc)
|
|
185
|
+
|
|
186
|
+
return cls(**{key: val for key, val in data.items() if val is not None})
|
|
187
|
+
|
|
188
|
+
def api_root(self, subdomain: str = "api") -> str:
|
|
189
|
+
if self.base_url_override:
|
|
190
|
+
return self.base_url_override.rstrip("/")
|
|
191
|
+
sandbox = ".sandbox" if self.sandbox else ""
|
|
192
|
+
return f"https://{subdomain}{sandbox}.ebay.com"
|
|
193
|
+
|
|
194
|
+
@property
|
|
195
|
+
def oauth_token_url(self) -> str:
|
|
196
|
+
return f"{self.api_root('api')}/identity/v1/oauth2/token"
|
|
197
|
+
|
|
198
|
+
@property
|
|
199
|
+
def client_secret(self) -> str | None:
|
|
200
|
+
return _secret_value(self.cert_id)
|
|
201
|
+
|
|
202
|
+
@property
|
|
203
|
+
def bearer_token(self) -> str | None:
|
|
204
|
+
return _secret_value(self.access_token)
|
|
205
|
+
|
|
206
|
+
@property
|
|
207
|
+
def refresh_token_value(self) -> str | None:
|
|
208
|
+
return _secret_value(self.refresh_token)
|
|
209
|
+
|
|
210
|
+
|
|
211
|
+
def _secret_value(value: SecretValue | None) -> str | None:
|
|
212
|
+
if value is None:
|
|
213
|
+
return None
|
|
214
|
+
if isinstance(value, SecretStr):
|
|
215
|
+
return value.get_secret_value()
|
|
216
|
+
return value
|
bidkit/errors.py
ADDED
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from typing import Any
|
|
4
|
+
|
|
5
|
+
import httpx
|
|
6
|
+
import orjson
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class EbaySDKError(Exception):
|
|
10
|
+
"""Base exception for all SDK-level failures."""
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class EbayConfigError(EbaySDKError):
|
|
14
|
+
"""Raised when SDK configuration is incomplete or invalid."""
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class EbayTransportError(EbaySDKError):
|
|
18
|
+
"""Raised for network-level failures before eBay returns a response."""
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class EbayAuthError(EbaySDKError):
|
|
22
|
+
"""Raised when OAuth token acquisition or refresh fails."""
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class EbayAPIError(EbaySDKError):
|
|
26
|
+
def __init__(
|
|
27
|
+
self,
|
|
28
|
+
message: str,
|
|
29
|
+
*,
|
|
30
|
+
status_code: int,
|
|
31
|
+
response: httpx.Response | None = None,
|
|
32
|
+
payload: Any = None,
|
|
33
|
+
request_id: str | None = None,
|
|
34
|
+
) -> None:
|
|
35
|
+
super().__init__(message)
|
|
36
|
+
self.status_code = status_code
|
|
37
|
+
self.response = response
|
|
38
|
+
self.payload = payload
|
|
39
|
+
self.request_id = request_id
|
|
40
|
+
|
|
41
|
+
@classmethod
|
|
42
|
+
def from_response(cls, response: httpx.Response) -> EbayAPIError:
|
|
43
|
+
payload: Any
|
|
44
|
+
try:
|
|
45
|
+
payload = orjson.loads(response.content)
|
|
46
|
+
except orjson.JSONDecodeError:
|
|
47
|
+
payload = response.text
|
|
48
|
+
|
|
49
|
+
message = response.reason_phrase or f"eBay API returned HTTP {response.status_code}"
|
|
50
|
+
if isinstance(payload, dict):
|
|
51
|
+
errors = payload.get("errors")
|
|
52
|
+
if isinstance(errors, list) and errors:
|
|
53
|
+
first = errors[0]
|
|
54
|
+
if isinstance(first, dict):
|
|
55
|
+
message = first.get("message") or first.get("longMessage") or message
|
|
56
|
+
elif payload.get("error_description"):
|
|
57
|
+
message = str(payload["error_description"])
|
|
58
|
+
elif payload.get("message"):
|
|
59
|
+
message = str(payload["message"])
|
|
60
|
+
|
|
61
|
+
return cls(
|
|
62
|
+
message,
|
|
63
|
+
status_code=response.status_code,
|
|
64
|
+
response=response,
|
|
65
|
+
payload=payload,
|
|
66
|
+
request_id=response.headers.get("x-ebay-c-request-id")
|
|
67
|
+
or response.headers.get("x-ebay-request-id"),
|
|
68
|
+
)
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Generated OpenAPI clients and models."""
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Generated Pydantic model modules."""
|