portals-api 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.
- portals/__init__.py +40 -0
- portals/_contract.py +2 -0
- portals/_models.py +71 -0
- portals/_transport.py +293 -0
- portals/_version.py +5 -0
- portals/client.py +197 -0
- portals/exceptions.py +69 -0
- portals/models.py +736 -0
- portals/py.typed +0 -0
- portals/resources/__init__.py +0 -0
- portals/resources/_helpers.py +105 -0
- portals/resources/arena.py +145 -0
- portals/resources/collection_offers.py +210 -0
- portals/resources/collections.py +151 -0
- portals/resources/market.py +127 -0
- portals/resources/nfts.py +446 -0
- portals/resources/users.py +117 -0
- portals/resources/wallet.py +88 -0
- portals_api-0.1.0.dist-info/METADATA +218 -0
- portals_api-0.1.0.dist-info/RECORD +23 -0
- portals_api-0.1.0.dist-info/WHEEL +5 -0
- portals_api-0.1.0.dist-info/licenses/LICENSE +21 -0
- portals_api-0.1.0.dist-info/top_level.txt +1 -0
portals/__init__.py
ADDED
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
"""Typed synchronous and asynchronous Python client for the Portals Partners API."""
|
|
2
|
+
|
|
3
|
+
from . import models as _models
|
|
4
|
+
from ._contract import __api_version__
|
|
5
|
+
from ._models import Amount, RateLimit
|
|
6
|
+
from ._version import __version__
|
|
7
|
+
from .client import AsyncPortalsClient, PortalsClient
|
|
8
|
+
from .exceptions import (
|
|
9
|
+
AccessDeniedError,
|
|
10
|
+
APIError,
|
|
11
|
+
AuthenticationError,
|
|
12
|
+
BetOutcomeUnknownError,
|
|
13
|
+
InvalidResponseError,
|
|
14
|
+
MarketFeedUnavailableError,
|
|
15
|
+
PortalsError,
|
|
16
|
+
RateLimitError,
|
|
17
|
+
ResponseValidationError,
|
|
18
|
+
TransportError,
|
|
19
|
+
)
|
|
20
|
+
from .models import * # noqa: F403
|
|
21
|
+
|
|
22
|
+
__all__ = [
|
|
23
|
+
"AccessDeniedError",
|
|
24
|
+
"APIError",
|
|
25
|
+
"Amount",
|
|
26
|
+
"AsyncPortalsClient",
|
|
27
|
+
"AuthenticationError",
|
|
28
|
+
"BetOutcomeUnknownError",
|
|
29
|
+
"InvalidResponseError",
|
|
30
|
+
"MarketFeedUnavailableError",
|
|
31
|
+
"PortalsClient",
|
|
32
|
+
"PortalsError",
|
|
33
|
+
"RateLimit",
|
|
34
|
+
"RateLimitError",
|
|
35
|
+
"ResponseValidationError",
|
|
36
|
+
"TransportError",
|
|
37
|
+
"__api_version__",
|
|
38
|
+
"__version__",
|
|
39
|
+
*_models.__all__,
|
|
40
|
+
]
|
portals/_contract.py
ADDED
portals/_models.py
ADDED
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from collections.abc import Mapping
|
|
4
|
+
from dataclasses import dataclass
|
|
5
|
+
from decimal import Decimal, InvalidOperation
|
|
6
|
+
from typing import Annotated, Any
|
|
7
|
+
|
|
8
|
+
from pydantic import BaseModel, BeforeValidator, ConfigDict, PlainSerializer
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def _amount(value: Any) -> Decimal:
|
|
12
|
+
if not isinstance(value, (str, Decimal)):
|
|
13
|
+
raise ValueError("Amounts must be decimal strings or Decimal instances")
|
|
14
|
+
try:
|
|
15
|
+
result = Decimal(value)
|
|
16
|
+
except InvalidOperation as exc:
|
|
17
|
+
raise ValueError(f"Invalid decimal amount: {value!r}") from exc
|
|
18
|
+
if not result.is_finite():
|
|
19
|
+
raise ValueError("Amounts must be finite")
|
|
20
|
+
return result
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def _serialize_amount(value: Decimal) -> str:
|
|
24
|
+
return format(value, "f")
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
Amount = Annotated[
|
|
28
|
+
Decimal,
|
|
29
|
+
BeforeValidator(_amount),
|
|
30
|
+
PlainSerializer(_serialize_amount, return_type=str),
|
|
31
|
+
]
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
class PortalsModel(BaseModel):
|
|
35
|
+
"""Common Pydantic base model for Portals API schemas."""
|
|
36
|
+
|
|
37
|
+
model_config = ConfigDict(populate_by_name=True)
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
class PortalsRequest(PortalsModel):
|
|
41
|
+
"""Base model for API request payloads (`extra="forbid"`)."""
|
|
42
|
+
|
|
43
|
+
model_config = ConfigDict(extra="forbid", populate_by_name=True)
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
class PortalsResponse(PortalsModel):
|
|
47
|
+
"""Base model for API response payloads (`extra="allow"`)."""
|
|
48
|
+
|
|
49
|
+
model_config = ConfigDict(extra="allow", populate_by_name=True)
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
@dataclass(frozen=True, slots=True)
|
|
53
|
+
class RateLimit:
|
|
54
|
+
"""Rate-limit state parsed from `X-RateLimit-*` response headers."""
|
|
55
|
+
|
|
56
|
+
limit: int
|
|
57
|
+
remaining: int
|
|
58
|
+
reset_seconds: int
|
|
59
|
+
|
|
60
|
+
@classmethod
|
|
61
|
+
def from_headers(cls, headers: Mapping[str, str]) -> RateLimit | None:
|
|
62
|
+
"""Parse rate-limit headers from an HTTP response, or return `None` if absent."""
|
|
63
|
+
normalized = {key.lower(): value for key, value in headers.items()}
|
|
64
|
+
try:
|
|
65
|
+
return cls(
|
|
66
|
+
limit=int(normalized["x-ratelimit-limit"]),
|
|
67
|
+
remaining=int(normalized["x-ratelimit-remaining"]),
|
|
68
|
+
reset_seconds=int(normalized["x-ratelimit-reset"]),
|
|
69
|
+
)
|
|
70
|
+
except (KeyError, TypeError, ValueError):
|
|
71
|
+
return None
|
portals/_transport.py
ADDED
|
@@ -0,0 +1,293 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import asyncio
|
|
4
|
+
import threading
|
|
5
|
+
from collections.abc import Mapping, Sequence
|
|
6
|
+
from datetime import date, datetime
|
|
7
|
+
from decimal import Decimal
|
|
8
|
+
from enum import Enum
|
|
9
|
+
from functools import cache
|
|
10
|
+
from typing import Any, TypeVar, overload
|
|
11
|
+
|
|
12
|
+
import httpx2
|
|
13
|
+
from pydantic import BaseModel, TypeAdapter, ValidationError
|
|
14
|
+
|
|
15
|
+
from ._models import RateLimit
|
|
16
|
+
from ._version import __version__
|
|
17
|
+
from .exceptions import (
|
|
18
|
+
AccessDeniedError,
|
|
19
|
+
APIError,
|
|
20
|
+
AuthenticationError,
|
|
21
|
+
BetOutcomeUnknownError,
|
|
22
|
+
InvalidResponseError,
|
|
23
|
+
MarketFeedUnavailableError,
|
|
24
|
+
RateLimitError,
|
|
25
|
+
ResponseValidationError,
|
|
26
|
+
TransportError,
|
|
27
|
+
)
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
@cache
|
|
31
|
+
def _adapter(model: Any) -> TypeAdapter[Any]:
|
|
32
|
+
return TypeAdapter(model)
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
_T = TypeVar("_T")
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def dump_model(model: BaseModel) -> dict[str, Any]:
|
|
39
|
+
return model.model_dump(mode="json", by_alias=True, exclude_none=True)
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def prepare_params(params: Mapping[str, Any] | None) -> dict[str, Any]:
|
|
43
|
+
result: dict[str, Any] = {}
|
|
44
|
+
for key, value in (params or {}).items():
|
|
45
|
+
if value is None:
|
|
46
|
+
continue
|
|
47
|
+
if isinstance(value, Sequence) and not isinstance(value, (str, bytes)):
|
|
48
|
+
value = ",".join(str(_param_value(item)) for item in value)
|
|
49
|
+
else:
|
|
50
|
+
value = _param_value(value)
|
|
51
|
+
result[key] = value
|
|
52
|
+
return result
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def _param_value(value: Any) -> Any:
|
|
56
|
+
if isinstance(value, bool):
|
|
57
|
+
return str(value).lower()
|
|
58
|
+
if isinstance(value, Decimal):
|
|
59
|
+
return format(value, "f")
|
|
60
|
+
if isinstance(value, Enum):
|
|
61
|
+
return value.value
|
|
62
|
+
if isinstance(value, (datetime, date)):
|
|
63
|
+
return value.isoformat()
|
|
64
|
+
return value
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
class BaseTransport:
|
|
68
|
+
def __init__(
|
|
69
|
+
self,
|
|
70
|
+
api_key: str,
|
|
71
|
+
*,
|
|
72
|
+
base_url: str,
|
|
73
|
+
timeout: float | httpx2.Timeout,
|
|
74
|
+
) -> None:
|
|
75
|
+
if not api_key.strip():
|
|
76
|
+
raise ValueError("api_key must not be empty")
|
|
77
|
+
self._base_url = base_url.rstrip("/")
|
|
78
|
+
self._timeout = timeout
|
|
79
|
+
self._headers = {
|
|
80
|
+
"Authorization": f"partners {api_key}",
|
|
81
|
+
"Accept": "application/json",
|
|
82
|
+
"User-Agent": f"portals-api-python/{__version__}",
|
|
83
|
+
}
|
|
84
|
+
self.last_rate_limit: RateLimit | None = None
|
|
85
|
+
|
|
86
|
+
def _url(self, path: str) -> str:
|
|
87
|
+
if not path.startswith("/"):
|
|
88
|
+
raise ValueError("path must start with '/'")
|
|
89
|
+
return f"{self._base_url}{path}"
|
|
90
|
+
|
|
91
|
+
def _handle_response(self, response: httpx2.Response, response_model: Any) -> Any:
|
|
92
|
+
self.last_rate_limit = RateLimit.from_headers(response.headers)
|
|
93
|
+
if response.status_code >= 400:
|
|
94
|
+
self._raise_api_error(response)
|
|
95
|
+
if response.status_code == 204 or not response.content:
|
|
96
|
+
return None
|
|
97
|
+
try:
|
|
98
|
+
data = response.json()
|
|
99
|
+
except ValueError as exc:
|
|
100
|
+
raise InvalidResponseError("The Portals API returned invalid JSON") from exc
|
|
101
|
+
if response_model is None:
|
|
102
|
+
return data
|
|
103
|
+
try:
|
|
104
|
+
return _adapter(response_model).validate_python(data)
|
|
105
|
+
except ValidationError as exc:
|
|
106
|
+
raise ResponseValidationError(
|
|
107
|
+
f"The response from {response.request.method} {response.request.url.path} "
|
|
108
|
+
"does not match the expected model",
|
|
109
|
+
response=data,
|
|
110
|
+
) from exc
|
|
111
|
+
|
|
112
|
+
def _raise_api_error(self, response: httpx2.Response) -> None:
|
|
113
|
+
try:
|
|
114
|
+
data = response.json() if response.content else None
|
|
115
|
+
except ValueError:
|
|
116
|
+
data = None
|
|
117
|
+
if isinstance(data, dict):
|
|
118
|
+
message = str(
|
|
119
|
+
data.get("message")
|
|
120
|
+
or f"Portals API request failed with HTTP {response.status_code}"
|
|
121
|
+
)
|
|
122
|
+
code = str(data["code"]) if data.get("code") is not None else None
|
|
123
|
+
else:
|
|
124
|
+
message = response.text.strip() or (
|
|
125
|
+
f"Portals API request failed with HTTP {response.status_code}"
|
|
126
|
+
)
|
|
127
|
+
code = None
|
|
128
|
+
|
|
129
|
+
error_type: type[APIError] = APIError
|
|
130
|
+
if response.status_code == 401:
|
|
131
|
+
error_type = AuthenticationError
|
|
132
|
+
elif response.status_code == 403:
|
|
133
|
+
error_type = AccessDeniedError
|
|
134
|
+
elif response.status_code == 429:
|
|
135
|
+
error_type = RateLimitError
|
|
136
|
+
elif response.status_code == 503 and code == "MARKET_FEED_UNAVAILABLE":
|
|
137
|
+
error_type = MarketFeedUnavailableError
|
|
138
|
+
elif response.status_code == 504 and code == "BET_OUTCOME_UNKNOWN":
|
|
139
|
+
error_type = BetOutcomeUnknownError
|
|
140
|
+
raise error_type(
|
|
141
|
+
message,
|
|
142
|
+
status_code=response.status_code,
|
|
143
|
+
code=code,
|
|
144
|
+
response=data,
|
|
145
|
+
rate_limit=self.last_rate_limit,
|
|
146
|
+
)
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
class SyncTransport(BaseTransport):
|
|
150
|
+
def __init__(
|
|
151
|
+
self,
|
|
152
|
+
api_key: str,
|
|
153
|
+
*,
|
|
154
|
+
base_url: str,
|
|
155
|
+
timeout: float | httpx2.Timeout,
|
|
156
|
+
client: httpx2.Client | None = None,
|
|
157
|
+
) -> None:
|
|
158
|
+
super().__init__(api_key, base_url=base_url, timeout=timeout)
|
|
159
|
+
self._client = client
|
|
160
|
+
self._owns_client = client is None
|
|
161
|
+
self._lock = threading.Lock()
|
|
162
|
+
|
|
163
|
+
def _get_client(self) -> httpx2.Client:
|
|
164
|
+
if self._client is None:
|
|
165
|
+
with self._lock:
|
|
166
|
+
if self._client is None:
|
|
167
|
+
self._client = httpx2.Client(timeout=self._timeout)
|
|
168
|
+
return self._client
|
|
169
|
+
|
|
170
|
+
@overload
|
|
171
|
+
def request(
|
|
172
|
+
self,
|
|
173
|
+
method: str,
|
|
174
|
+
path: str,
|
|
175
|
+
*,
|
|
176
|
+
params: Mapping[str, Any] | None = None,
|
|
177
|
+
json_data: Any = None,
|
|
178
|
+
response_model: None = None,
|
|
179
|
+
) -> Any: ...
|
|
180
|
+
|
|
181
|
+
@overload
|
|
182
|
+
def request(
|
|
183
|
+
self,
|
|
184
|
+
method: str,
|
|
185
|
+
path: str,
|
|
186
|
+
*,
|
|
187
|
+
params: Mapping[str, Any] | None = None,
|
|
188
|
+
json_data: Any = None,
|
|
189
|
+
response_model: type[_T],
|
|
190
|
+
) -> _T: ...
|
|
191
|
+
|
|
192
|
+
def request(
|
|
193
|
+
self,
|
|
194
|
+
method: str,
|
|
195
|
+
path: str,
|
|
196
|
+
*,
|
|
197
|
+
params: Mapping[str, Any] | None = None,
|
|
198
|
+
json_data: Any = None,
|
|
199
|
+
response_model: Any = None,
|
|
200
|
+
) -> Any:
|
|
201
|
+
if isinstance(json_data, BaseModel):
|
|
202
|
+
json_data = dump_model(json_data)
|
|
203
|
+
try:
|
|
204
|
+
response = self._get_client().request(
|
|
205
|
+
method,
|
|
206
|
+
self._url(path),
|
|
207
|
+
params=prepare_params(params),
|
|
208
|
+
json=json_data,
|
|
209
|
+
headers=self._headers,
|
|
210
|
+
timeout=self._timeout,
|
|
211
|
+
)
|
|
212
|
+
except httpx2.HTTPError as exc:
|
|
213
|
+
raise TransportError(f"Could not reach the Portals API: {exc}", original=exc) from exc
|
|
214
|
+
return self._handle_response(response, response_model)
|
|
215
|
+
|
|
216
|
+
def close(self) -> None:
|
|
217
|
+
client, self._client = self._client, None
|
|
218
|
+
if self._owns_client and client is not None:
|
|
219
|
+
client.close()
|
|
220
|
+
|
|
221
|
+
|
|
222
|
+
class AsyncTransport(BaseTransport):
|
|
223
|
+
def __init__(
|
|
224
|
+
self,
|
|
225
|
+
api_key: str,
|
|
226
|
+
*,
|
|
227
|
+
base_url: str,
|
|
228
|
+
timeout: float | httpx2.Timeout,
|
|
229
|
+
client: httpx2.AsyncClient | None = None,
|
|
230
|
+
) -> None:
|
|
231
|
+
super().__init__(api_key, base_url=base_url, timeout=timeout)
|
|
232
|
+
self._client = client
|
|
233
|
+
self._owns_client = client is None
|
|
234
|
+
self._lock = asyncio.Lock()
|
|
235
|
+
|
|
236
|
+
async def _get_client(self) -> httpx2.AsyncClient:
|
|
237
|
+
if self._client is None:
|
|
238
|
+
async with self._lock:
|
|
239
|
+
if self._client is None:
|
|
240
|
+
self._client = httpx2.AsyncClient(timeout=self._timeout)
|
|
241
|
+
return self._client
|
|
242
|
+
|
|
243
|
+
@overload
|
|
244
|
+
async def request(
|
|
245
|
+
self,
|
|
246
|
+
method: str,
|
|
247
|
+
path: str,
|
|
248
|
+
*,
|
|
249
|
+
params: Mapping[str, Any] | None = None,
|
|
250
|
+
json_data: Any = None,
|
|
251
|
+
response_model: None = None,
|
|
252
|
+
) -> Any: ...
|
|
253
|
+
|
|
254
|
+
@overload
|
|
255
|
+
async def request(
|
|
256
|
+
self,
|
|
257
|
+
method: str,
|
|
258
|
+
path: str,
|
|
259
|
+
*,
|
|
260
|
+
params: Mapping[str, Any] | None = None,
|
|
261
|
+
json_data: Any = None,
|
|
262
|
+
response_model: type[_T],
|
|
263
|
+
) -> _T: ...
|
|
264
|
+
|
|
265
|
+
async def request(
|
|
266
|
+
self,
|
|
267
|
+
method: str,
|
|
268
|
+
path: str,
|
|
269
|
+
*,
|
|
270
|
+
params: Mapping[str, Any] | None = None,
|
|
271
|
+
json_data: Any = None,
|
|
272
|
+
response_model: Any = None,
|
|
273
|
+
) -> Any:
|
|
274
|
+
if isinstance(json_data, BaseModel):
|
|
275
|
+
json_data = dump_model(json_data)
|
|
276
|
+
try:
|
|
277
|
+
client = await self._get_client()
|
|
278
|
+
response = await client.request(
|
|
279
|
+
method,
|
|
280
|
+
self._url(path),
|
|
281
|
+
params=prepare_params(params),
|
|
282
|
+
json=json_data,
|
|
283
|
+
headers=self._headers,
|
|
284
|
+
timeout=self._timeout,
|
|
285
|
+
)
|
|
286
|
+
except httpx2.HTTPError as exc:
|
|
287
|
+
raise TransportError(f"Could not reach the Portals API: {exc}", original=exc) from exc
|
|
288
|
+
return self._handle_response(response, response_model)
|
|
289
|
+
|
|
290
|
+
async def aclose(self) -> None:
|
|
291
|
+
client, self._client = self._client, None
|
|
292
|
+
if self._owns_client and client is not None:
|
|
293
|
+
await client.aclose()
|
portals/_version.py
ADDED
portals/client.py
ADDED
|
@@ -0,0 +1,197 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from collections.abc import Mapping
|
|
4
|
+
from types import TracebackType
|
|
5
|
+
from typing import Any, TypeVar, overload
|
|
6
|
+
|
|
7
|
+
import httpx2
|
|
8
|
+
|
|
9
|
+
from ._models import RateLimit
|
|
10
|
+
from ._transport import AsyncTransport, SyncTransport
|
|
11
|
+
from .resources.arena import Arena, AsyncArena
|
|
12
|
+
from .resources.collection_offers import AsyncCollectionOffers, CollectionOffers
|
|
13
|
+
from .resources.collections import AsyncCollections, Collections
|
|
14
|
+
from .resources.market import AsyncMarket, Market
|
|
15
|
+
from .resources.nfts import AsyncNFTs, NFTs
|
|
16
|
+
from .resources.users import AsyncUsers, Users
|
|
17
|
+
from .resources.wallet import AsyncWalletResource, WalletResource
|
|
18
|
+
|
|
19
|
+
_T = TypeVar("_T")
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class PortalsClient:
|
|
23
|
+
"""Synchronous client for the Portals Partners API.
|
|
24
|
+
|
|
25
|
+
Args:
|
|
26
|
+
api_key: Partner API token sent as `Authorization: partners <api_key>`.
|
|
27
|
+
base_url: Base URL for the Portals API.
|
|
28
|
+
timeout: Request timeout in seconds or an `httpx2.Timeout` instance.
|
|
29
|
+
client: Optional pre-configured `httpx2.Client`. When provided, the
|
|
30
|
+
caller retains ownership and is responsible for closing it.
|
|
31
|
+
"""
|
|
32
|
+
|
|
33
|
+
def __init__(
|
|
34
|
+
self,
|
|
35
|
+
api_key: str,
|
|
36
|
+
*,
|
|
37
|
+
base_url: str = "https://portal-market.com",
|
|
38
|
+
timeout: float | httpx2.Timeout = 30.0,
|
|
39
|
+
client: httpx2.Client | None = None,
|
|
40
|
+
) -> None:
|
|
41
|
+
self._transport = SyncTransport(api_key, base_url=base_url, timeout=timeout, client=client)
|
|
42
|
+
self.arena = Arena(self._transport)
|
|
43
|
+
self.offers = CollectionOffers(self._transport)
|
|
44
|
+
self.collections = Collections(self._transport)
|
|
45
|
+
self.market = Market(self._transport)
|
|
46
|
+
self.nfts = NFTs(self._transport)
|
|
47
|
+
self.users = Users(self._transport)
|
|
48
|
+
self.wallet = WalletResource(self._transport)
|
|
49
|
+
|
|
50
|
+
@property
|
|
51
|
+
def last_rate_limit(self) -> RateLimit | None:
|
|
52
|
+
"""Rate-limit metadata parsed from the most recent HTTP response, if present."""
|
|
53
|
+
return self._transport.last_rate_limit
|
|
54
|
+
|
|
55
|
+
@overload
|
|
56
|
+
def request(
|
|
57
|
+
self,
|
|
58
|
+
method: str,
|
|
59
|
+
path: str,
|
|
60
|
+
*,
|
|
61
|
+
params: Mapping[str, Any] | None = None,
|
|
62
|
+
json_data: Any = None,
|
|
63
|
+
response_model: None = None,
|
|
64
|
+
) -> Any: ...
|
|
65
|
+
|
|
66
|
+
@overload
|
|
67
|
+
def request(
|
|
68
|
+
self,
|
|
69
|
+
method: str,
|
|
70
|
+
path: str,
|
|
71
|
+
*,
|
|
72
|
+
params: Mapping[str, Any] | None = None,
|
|
73
|
+
json_data: Any = None,
|
|
74
|
+
response_model: type[_T],
|
|
75
|
+
) -> _T: ...
|
|
76
|
+
|
|
77
|
+
def request(
|
|
78
|
+
self,
|
|
79
|
+
method: str,
|
|
80
|
+
path: str,
|
|
81
|
+
*,
|
|
82
|
+
params: Mapping[str, Any] | None = None,
|
|
83
|
+
json_data: Any = None,
|
|
84
|
+
response_model: Any = None,
|
|
85
|
+
) -> Any:
|
|
86
|
+
"""Send an arbitrary request to the Portals API and optionally validate the response."""
|
|
87
|
+
return self._transport.request(
|
|
88
|
+
method,
|
|
89
|
+
path,
|
|
90
|
+
params=params,
|
|
91
|
+
json_data=json_data,
|
|
92
|
+
response_model=response_model,
|
|
93
|
+
)
|
|
94
|
+
|
|
95
|
+
def close(self) -> None:
|
|
96
|
+
"""Close the underlying HTTP client if it was created by this instance."""
|
|
97
|
+
self._transport.close()
|
|
98
|
+
|
|
99
|
+
def __enter__(self) -> PortalsClient:
|
|
100
|
+
return self
|
|
101
|
+
|
|
102
|
+
def __exit__(
|
|
103
|
+
self,
|
|
104
|
+
exc_type: type[BaseException] | None,
|
|
105
|
+
exc: BaseException | None,
|
|
106
|
+
traceback: TracebackType | None,
|
|
107
|
+
) -> None:
|
|
108
|
+
self.close()
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
class AsyncPortalsClient:
|
|
112
|
+
"""Asynchronous client for the Portals Partners API.
|
|
113
|
+
|
|
114
|
+
Args:
|
|
115
|
+
api_key: Partner API token sent as `Authorization: partners <api_key>`.
|
|
116
|
+
base_url: Base URL for the Portals API.
|
|
117
|
+
timeout: Request timeout in seconds or an `httpx2.Timeout` instance.
|
|
118
|
+
client: Optional pre-configured `httpx2.AsyncClient`. When provided, the
|
|
119
|
+
caller retains ownership and is responsible for closing it.
|
|
120
|
+
"""
|
|
121
|
+
|
|
122
|
+
def __init__(
|
|
123
|
+
self,
|
|
124
|
+
api_key: str,
|
|
125
|
+
*,
|
|
126
|
+
base_url: str = "https://portal-market.com",
|
|
127
|
+
timeout: float | httpx2.Timeout = 30.0,
|
|
128
|
+
client: httpx2.AsyncClient | None = None,
|
|
129
|
+
) -> None:
|
|
130
|
+
self._transport = AsyncTransport(api_key, base_url=base_url, timeout=timeout, client=client)
|
|
131
|
+
self.arena = AsyncArena(self._transport)
|
|
132
|
+
self.offers = AsyncCollectionOffers(self._transport)
|
|
133
|
+
self.collections = AsyncCollections(self._transport)
|
|
134
|
+
self.market = AsyncMarket(self._transport)
|
|
135
|
+
self.nfts = AsyncNFTs(self._transport)
|
|
136
|
+
self.users = AsyncUsers(self._transport)
|
|
137
|
+
self.wallet = AsyncWalletResource(self._transport)
|
|
138
|
+
|
|
139
|
+
@property
|
|
140
|
+
def last_rate_limit(self) -> RateLimit | None:
|
|
141
|
+
"""Rate-limit metadata parsed from the most recent HTTP response, if present."""
|
|
142
|
+
return self._transport.last_rate_limit
|
|
143
|
+
|
|
144
|
+
@overload
|
|
145
|
+
async def request(
|
|
146
|
+
self,
|
|
147
|
+
method: str,
|
|
148
|
+
path: str,
|
|
149
|
+
*,
|
|
150
|
+
params: Mapping[str, Any] | None = None,
|
|
151
|
+
json_data: Any = None,
|
|
152
|
+
response_model: None = None,
|
|
153
|
+
) -> Any: ...
|
|
154
|
+
|
|
155
|
+
@overload
|
|
156
|
+
async def request(
|
|
157
|
+
self,
|
|
158
|
+
method: str,
|
|
159
|
+
path: str,
|
|
160
|
+
*,
|
|
161
|
+
params: Mapping[str, Any] | None = None,
|
|
162
|
+
json_data: Any = None,
|
|
163
|
+
response_model: type[_T],
|
|
164
|
+
) -> _T: ...
|
|
165
|
+
|
|
166
|
+
async def request(
|
|
167
|
+
self,
|
|
168
|
+
method: str,
|
|
169
|
+
path: str,
|
|
170
|
+
*,
|
|
171
|
+
params: Mapping[str, Any] | None = None,
|
|
172
|
+
json_data: Any = None,
|
|
173
|
+
response_model: Any = None,
|
|
174
|
+
) -> Any:
|
|
175
|
+
"""Send an arbitrary request to the Portals API and optionally validate the response."""
|
|
176
|
+
return await self._transport.request(
|
|
177
|
+
method,
|
|
178
|
+
path,
|
|
179
|
+
params=params,
|
|
180
|
+
json_data=json_data,
|
|
181
|
+
response_model=response_model,
|
|
182
|
+
)
|
|
183
|
+
|
|
184
|
+
async def aclose(self) -> None:
|
|
185
|
+
"""Close the underlying async HTTP client if it was created by this instance."""
|
|
186
|
+
await self._transport.aclose()
|
|
187
|
+
|
|
188
|
+
async def __aenter__(self) -> AsyncPortalsClient:
|
|
189
|
+
return self
|
|
190
|
+
|
|
191
|
+
async def __aexit__(
|
|
192
|
+
self,
|
|
193
|
+
exc_type: type[BaseException] | None,
|
|
194
|
+
exc: BaseException | None,
|
|
195
|
+
traceback: TracebackType | None,
|
|
196
|
+
) -> None:
|
|
197
|
+
await self.aclose()
|
portals/exceptions.py
ADDED
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from typing import Any
|
|
4
|
+
|
|
5
|
+
from ._models import RateLimit
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class PortalsError(Exception):
|
|
9
|
+
"""Base exception for all errors raised by the Portals client."""
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class TransportError(PortalsError):
|
|
13
|
+
"""Raised when an underlying network or HTTP transport operation fails."""
|
|
14
|
+
|
|
15
|
+
def __init__(self, message: str, *, original: BaseException | None = None) -> None:
|
|
16
|
+
super().__init__(message)
|
|
17
|
+
self.original = original
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class InvalidResponseError(PortalsError):
|
|
21
|
+
"""Raised when the API returns non-JSON content where JSON was expected."""
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class ResponseValidationError(InvalidResponseError):
|
|
25
|
+
"""Raised when a JSON response payload fails Pydantic model validation."""
|
|
26
|
+
|
|
27
|
+
def __init__(self, message: str, *, response: Any = None) -> None:
|
|
28
|
+
super().__init__(message)
|
|
29
|
+
self.response = response
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
class APIError(PortalsError):
|
|
33
|
+
"""Raised when the Portals API returns an HTTP 4xx or 5xx error response."""
|
|
34
|
+
|
|
35
|
+
def __init__(
|
|
36
|
+
self,
|
|
37
|
+
message: str,
|
|
38
|
+
*,
|
|
39
|
+
status_code: int,
|
|
40
|
+
code: str | None = None,
|
|
41
|
+
response: Any = None,
|
|
42
|
+
rate_limit: RateLimit | None = None,
|
|
43
|
+
) -> None:
|
|
44
|
+
super().__init__(message)
|
|
45
|
+
self.message = message
|
|
46
|
+
self.status_code = status_code
|
|
47
|
+
self.code = code
|
|
48
|
+
self.response = response
|
|
49
|
+
self.rate_limit = rate_limit
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
class AuthenticationError(APIError):
|
|
53
|
+
"""Raised on HTTP 401 responses when the API key is missing or invalid."""
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
class AccessDeniedError(APIError):
|
|
57
|
+
"""Raised on HTTP 403 responses when the API key lacks permission."""
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
class RateLimitError(APIError):
|
|
61
|
+
"""Raised on HTTP 429 responses when the rate limit is exceeded."""
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
class MarketFeedUnavailableError(APIError):
|
|
65
|
+
"""Raised on HTTP 503 `MARKET_FEED_UNAVAILABLE` responses."""
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
class BetOutcomeUnknownError(APIError):
|
|
69
|
+
"""Raised on HTTP 504 `BET_OUTCOME_UNKNOWN` when an Arena bet outcome timed out."""
|