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 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
@@ -0,0 +1,2 @@
1
+ # Generated by scripts/generate_models.py from specs/partners.en.json.
2
+ __api_version__ = "1.0.0"
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
@@ -0,0 +1,5 @@
1
+ from __future__ import annotations
2
+
3
+ from importlib.metadata import version
4
+
5
+ __version__ = version("portals-api")
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."""