portals-api 0.1.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (33) hide show
  1. portals_api-0.1.0/LICENSE +21 -0
  2. portals_api-0.1.0/PKG-INFO +218 -0
  3. portals_api-0.1.0/README.md +188 -0
  4. portals_api-0.1.0/pyproject.toml +79 -0
  5. portals_api-0.1.0/setup.cfg +4 -0
  6. portals_api-0.1.0/src/portals/__init__.py +40 -0
  7. portals_api-0.1.0/src/portals/_contract.py +2 -0
  8. portals_api-0.1.0/src/portals/_models.py +71 -0
  9. portals_api-0.1.0/src/portals/_transport.py +293 -0
  10. portals_api-0.1.0/src/portals/_version.py +5 -0
  11. portals_api-0.1.0/src/portals/client.py +197 -0
  12. portals_api-0.1.0/src/portals/exceptions.py +69 -0
  13. portals_api-0.1.0/src/portals/models.py +736 -0
  14. portals_api-0.1.0/src/portals/py.typed +0 -0
  15. portals_api-0.1.0/src/portals/resources/__init__.py +0 -0
  16. portals_api-0.1.0/src/portals/resources/_helpers.py +105 -0
  17. portals_api-0.1.0/src/portals/resources/arena.py +145 -0
  18. portals_api-0.1.0/src/portals/resources/collection_offers.py +210 -0
  19. portals_api-0.1.0/src/portals/resources/collections.py +151 -0
  20. portals_api-0.1.0/src/portals/resources/market.py +127 -0
  21. portals_api-0.1.0/src/portals/resources/nfts.py +446 -0
  22. portals_api-0.1.0/src/portals/resources/users.py +117 -0
  23. portals_api-0.1.0/src/portals/resources/wallet.py +88 -0
  24. portals_api-0.1.0/src/portals_api.egg-info/PKG-INFO +218 -0
  25. portals_api-0.1.0/src/portals_api.egg-info/SOURCES.txt +31 -0
  26. portals_api-0.1.0/src/portals_api.egg-info/dependency_links.txt +1 -0
  27. portals_api-0.1.0/src/portals_api.egg-info/requires.txt +2 -0
  28. portals_api-0.1.0/src/portals_api.egg-info/top_level.txt +1 -0
  29. portals_api-0.1.0/tests/test_contract.py +26 -0
  30. portals_api-0.1.0/tests/test_errors.py +110 -0
  31. portals_api-0.1.0/tests/test_models.py +90 -0
  32. portals_api-0.1.0/tests/test_resources.py +502 -0
  33. portals_api-0.1.0/tests/test_transport.py +152 -0
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Portals API client contributors
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,218 @@
1
+ Metadata-Version: 2.4
2
+ Name: portals-api
3
+ Version: 0.1.0
4
+ Summary: A typed Python client for the Portals Partners API
5
+ Author: Portals API client contributors
6
+ License-Expression: MIT
7
+ Project-URL: Homepage, https://github.com/NicolasKingreen/portals-python
8
+ Project-URL: Source, https://github.com/NicolasKingreen/portals-python
9
+ Project-URL: Issues, https://github.com/NicolasKingreen/portals-python/issues
10
+ Project-URL: Changelog, https://github.com/NicolasKingreen/portals-python/blob/main/CHANGELOG.md
11
+ Project-URL: API documentation, https://docs.portals.tg/developers/api/
12
+ Keywords: portals,telegram,ton,nft,api,asyncio
13
+ Classifier: Development Status :: 3 - Alpha
14
+ Classifier: Framework :: AsyncIO
15
+ Classifier: Intended Audience :: Developers
16
+ Classifier: Operating System :: OS Independent
17
+ Classifier: Programming Language :: Python :: 3
18
+ Classifier: Programming Language :: Python :: 3 :: Only
19
+ Classifier: Programming Language :: Python :: 3.11
20
+ Classifier: Programming Language :: Python :: 3.12
21
+ Classifier: Programming Language :: Python :: 3.13
22
+ Classifier: Programming Language :: Python :: 3.14
23
+ Classifier: Typing :: Typed
24
+ Requires-Python: >=3.11
25
+ Description-Content-Type: text/markdown
26
+ License-File: LICENSE
27
+ Requires-Dist: httpx2<3,>=2.16
28
+ Requires-Dist: pydantic<3,>=2.8
29
+ Dynamic: license-file
30
+
31
+ # Portals API Python client
32
+
33
+ A typed synchronous and asynchronous Python client for the [Portals Partners API](https://docs.portals.tg/developers/api/), the partner-facing programmatic API of the Portals marketplace.
34
+
35
+ ## Features
36
+
37
+ - All 35 operations from the Portals Partners OpenAPI contract
38
+ - Matching `PortalsClient` and `AsyncPortalsClient` APIs
39
+ - Typed Pydantic v2 models for every schema in the contract (strict requests, forward-compatible responses)
40
+ - httpx2 connection pooling and injectable HTTP clients
41
+ - Exact `Decimal` amounts and parsed `datetime` timestamps
42
+ - Typed API errors and rate-limit metadata
43
+ - Python 3.11+ and a `py.typed` marker
44
+
45
+ ## Installation
46
+
47
+ ```bash
48
+ pip install portals-api
49
+ ```
50
+
51
+ Or with [uv](https://docs.astral.sh/uv/):
52
+
53
+ ```bash
54
+ uv add portals-api
55
+ ```
56
+
57
+ From a local checkout:
58
+
59
+ ```bash
60
+ pip install .
61
+ ```
62
+
63
+ The distribution is named `portals-api`; the import package is `portals`.
64
+
65
+ ## Quickstart
66
+
67
+ ### Synchronous
68
+
69
+ ```python
70
+ from portals import PortalsClient
71
+
72
+ with PortalsClient("your-api-key") as client:
73
+ config = client.market.config()
74
+ wallet = client.wallet.get()
75
+ results = client.nfts.search(max_price="20", limit=20)
76
+
77
+ print(config.deposit_wallet)
78
+ print(wallet.balance)
79
+ print(results.total_count)
80
+ ```
81
+
82
+ ### Asynchronous
83
+
84
+ ```python
85
+ import asyncio
86
+
87
+ from portals import AsyncPortalsClient
88
+
89
+
90
+ async def main() -> None:
91
+ async with AsyncPortalsClient("your-api-key") as client:
92
+ wallet = await client.wallet.get()
93
+ state = await client.arena.state()
94
+
95
+ print(wallet.balance)
96
+ print(state.server_time)
97
+
98
+
99
+ asyncio.run(main())
100
+ ```
101
+
102
+ Get an API key from the Portals mini app under **Profile → Partner API**. Keep the key on your server and never commit it to source control.
103
+
104
+ ## Resources
105
+
106
+ | Resource | Operations |
107
+ | --- | --- |
108
+ | `client.arena` | Arena state, inventory, and bets |
109
+ | `client.offers` | Create, update, cancel, inspect, and accept collection offers |
110
+ | `client.collections` | Collection previews, metrics, and attribute floor prices |
111
+ | `client.market` | Market configuration and activity |
112
+ | `client.nfts` | Search, owned NFTs, purchases, listings, transfers, and withdrawals |
113
+ | `client.users` | User existence and authenticated user activity |
114
+ | `client.wallet` | Balance, deposits, TON withdrawals, and withdrawal statuses |
115
+
116
+ Methods return typed models. For example:
117
+
118
+ ```python
119
+ from decimal import Decimal
120
+
121
+ from portals import BuyDetail, PortalsClient
122
+
123
+ with PortalsClient("your-api-key") as client:
124
+ purchase = client.nfts.buy(
125
+ [
126
+ BuyDetail(id="nft-id", price=Decimal("12.50")),
127
+ ]
128
+ )
129
+
130
+ print(purchase.total_purchased)
131
+ print(purchase.total_failed)
132
+ print(purchase.total_spent)
133
+ ```
134
+
135
+ Purchases may partially succeed. Inspect each item in `purchase.purchase_results` rather than assuming the whole request succeeded or failed.
136
+
137
+ ## Amounts and timestamps
138
+
139
+ Pass amounts as decimal strings or `Decimal` instances. Floats are rejected to avoid binary floating-point errors. Amounts are serialized as strings on the wire and exposed as `Decimal` values by response models.
140
+
141
+ RFC 3339 timestamps are exposed as `datetime` instances. Arena timestamps remain integers because the API returns Unix milliseconds.
142
+
143
+ ## Errors
144
+
145
+ ```python
146
+ from portals import APIError, PortalsClient, RateLimitError
147
+
148
+ with PortalsClient("your-api-key") as client:
149
+ try:
150
+ client.market.actions()
151
+ except RateLimitError as exc:
152
+ if exc.rate_limit is not None:
153
+ print(exc.rate_limit.reset_seconds)
154
+ except APIError as exc:
155
+ print(exc.status_code, exc.code, exc.message)
156
+ ```
157
+
158
+ Specific exceptions cover authentication, access denial, rate limits, an unavailable market feed, and unknown Arena bet outcomes. Transport failures, invalid JSON, and response-validation failures have separate exception types.
159
+
160
+ Write operations are not automatically retried. In particular, an Arena bet that returns `504 BET_OUTCOME_UNKNOWN` or fails during network transmission may already have been placed; check `client.arena.state()` before trying again.
161
+
162
+ ## Custom HTTP clients
163
+
164
+ Pass an `httpx2.Client` or `httpx2.AsyncClient` to customize transports, proxies, or test behavior. The library closes only clients it creates itself.
165
+
166
+ ```python
167
+ import httpx2
168
+
169
+ from portals import PortalsClient
170
+
171
+ http_client = httpx2.Client(timeout=10)
172
+ client = PortalsClient("your-api-key", client=http_client)
173
+
174
+ client.close()
175
+ http_client.close()
176
+ ```
177
+
178
+ ## Low-level requests
179
+
180
+ Use `request()` for new endpoints that do not yet have a typed method:
181
+
182
+ ```python
183
+ payload = client.request("GET", "/partners/example", params={"limit": 10})
184
+ ```
185
+
186
+ Pass `response_model=YourModel` to validate the response with Pydantic.
187
+
188
+ ## Development
189
+
190
+ Install the project and development tools with [uv](https://docs.astral.sh/uv/):
191
+
192
+ ```bash
193
+ uv sync
194
+ ```
195
+
196
+ Run the checks used by CI:
197
+
198
+ ```bash
199
+ uv run ruff check .
200
+ uv run ruff format --check .
201
+ uv run mypy
202
+ uv run pytest
203
+ uv build
204
+ ```
205
+
206
+ The OpenAPI contract is vendored at `specs/partners.en.json`. Refresh it and regenerate the
207
+ models, then review the resulting changes:
208
+
209
+ ```bash
210
+ uv run python scripts/update_spec.py
211
+ uv run python scripts/generate_models.py
212
+ uv run ruff check --fix src/portals/models.py
213
+ uv run ruff format src/portals/models.py
214
+ ```
215
+
216
+ ## License
217
+
218
+ MIT
@@ -0,0 +1,188 @@
1
+ # Portals API Python client
2
+
3
+ A typed synchronous and asynchronous Python client for the [Portals Partners API](https://docs.portals.tg/developers/api/), the partner-facing programmatic API of the Portals marketplace.
4
+
5
+ ## Features
6
+
7
+ - All 35 operations from the Portals Partners OpenAPI contract
8
+ - Matching `PortalsClient` and `AsyncPortalsClient` APIs
9
+ - Typed Pydantic v2 models for every schema in the contract (strict requests, forward-compatible responses)
10
+ - httpx2 connection pooling and injectable HTTP clients
11
+ - Exact `Decimal` amounts and parsed `datetime` timestamps
12
+ - Typed API errors and rate-limit metadata
13
+ - Python 3.11+ and a `py.typed` marker
14
+
15
+ ## Installation
16
+
17
+ ```bash
18
+ pip install portals-api
19
+ ```
20
+
21
+ Or with [uv](https://docs.astral.sh/uv/):
22
+
23
+ ```bash
24
+ uv add portals-api
25
+ ```
26
+
27
+ From a local checkout:
28
+
29
+ ```bash
30
+ pip install .
31
+ ```
32
+
33
+ The distribution is named `portals-api`; the import package is `portals`.
34
+
35
+ ## Quickstart
36
+
37
+ ### Synchronous
38
+
39
+ ```python
40
+ from portals import PortalsClient
41
+
42
+ with PortalsClient("your-api-key") as client:
43
+ config = client.market.config()
44
+ wallet = client.wallet.get()
45
+ results = client.nfts.search(max_price="20", limit=20)
46
+
47
+ print(config.deposit_wallet)
48
+ print(wallet.balance)
49
+ print(results.total_count)
50
+ ```
51
+
52
+ ### Asynchronous
53
+
54
+ ```python
55
+ import asyncio
56
+
57
+ from portals import AsyncPortalsClient
58
+
59
+
60
+ async def main() -> None:
61
+ async with AsyncPortalsClient("your-api-key") as client:
62
+ wallet = await client.wallet.get()
63
+ state = await client.arena.state()
64
+
65
+ print(wallet.balance)
66
+ print(state.server_time)
67
+
68
+
69
+ asyncio.run(main())
70
+ ```
71
+
72
+ Get an API key from the Portals mini app under **Profile → Partner API**. Keep the key on your server and never commit it to source control.
73
+
74
+ ## Resources
75
+
76
+ | Resource | Operations |
77
+ | --- | --- |
78
+ | `client.arena` | Arena state, inventory, and bets |
79
+ | `client.offers` | Create, update, cancel, inspect, and accept collection offers |
80
+ | `client.collections` | Collection previews, metrics, and attribute floor prices |
81
+ | `client.market` | Market configuration and activity |
82
+ | `client.nfts` | Search, owned NFTs, purchases, listings, transfers, and withdrawals |
83
+ | `client.users` | User existence and authenticated user activity |
84
+ | `client.wallet` | Balance, deposits, TON withdrawals, and withdrawal statuses |
85
+
86
+ Methods return typed models. For example:
87
+
88
+ ```python
89
+ from decimal import Decimal
90
+
91
+ from portals import BuyDetail, PortalsClient
92
+
93
+ with PortalsClient("your-api-key") as client:
94
+ purchase = client.nfts.buy(
95
+ [
96
+ BuyDetail(id="nft-id", price=Decimal("12.50")),
97
+ ]
98
+ )
99
+
100
+ print(purchase.total_purchased)
101
+ print(purchase.total_failed)
102
+ print(purchase.total_spent)
103
+ ```
104
+
105
+ Purchases may partially succeed. Inspect each item in `purchase.purchase_results` rather than assuming the whole request succeeded or failed.
106
+
107
+ ## Amounts and timestamps
108
+
109
+ Pass amounts as decimal strings or `Decimal` instances. Floats are rejected to avoid binary floating-point errors. Amounts are serialized as strings on the wire and exposed as `Decimal` values by response models.
110
+
111
+ RFC 3339 timestamps are exposed as `datetime` instances. Arena timestamps remain integers because the API returns Unix milliseconds.
112
+
113
+ ## Errors
114
+
115
+ ```python
116
+ from portals import APIError, PortalsClient, RateLimitError
117
+
118
+ with PortalsClient("your-api-key") as client:
119
+ try:
120
+ client.market.actions()
121
+ except RateLimitError as exc:
122
+ if exc.rate_limit is not None:
123
+ print(exc.rate_limit.reset_seconds)
124
+ except APIError as exc:
125
+ print(exc.status_code, exc.code, exc.message)
126
+ ```
127
+
128
+ Specific exceptions cover authentication, access denial, rate limits, an unavailable market feed, and unknown Arena bet outcomes. Transport failures, invalid JSON, and response-validation failures have separate exception types.
129
+
130
+ Write operations are not automatically retried. In particular, an Arena bet that returns `504 BET_OUTCOME_UNKNOWN` or fails during network transmission may already have been placed; check `client.arena.state()` before trying again.
131
+
132
+ ## Custom HTTP clients
133
+
134
+ Pass an `httpx2.Client` or `httpx2.AsyncClient` to customize transports, proxies, or test behavior. The library closes only clients it creates itself.
135
+
136
+ ```python
137
+ import httpx2
138
+
139
+ from portals import PortalsClient
140
+
141
+ http_client = httpx2.Client(timeout=10)
142
+ client = PortalsClient("your-api-key", client=http_client)
143
+
144
+ client.close()
145
+ http_client.close()
146
+ ```
147
+
148
+ ## Low-level requests
149
+
150
+ Use `request()` for new endpoints that do not yet have a typed method:
151
+
152
+ ```python
153
+ payload = client.request("GET", "/partners/example", params={"limit": 10})
154
+ ```
155
+
156
+ Pass `response_model=YourModel` to validate the response with Pydantic.
157
+
158
+ ## Development
159
+
160
+ Install the project and development tools with [uv](https://docs.astral.sh/uv/):
161
+
162
+ ```bash
163
+ uv sync
164
+ ```
165
+
166
+ Run the checks used by CI:
167
+
168
+ ```bash
169
+ uv run ruff check .
170
+ uv run ruff format --check .
171
+ uv run mypy
172
+ uv run pytest
173
+ uv build
174
+ ```
175
+
176
+ The OpenAPI contract is vendored at `specs/partners.en.json`. Refresh it and regenerate the
177
+ models, then review the resulting changes:
178
+
179
+ ```bash
180
+ uv run python scripts/update_spec.py
181
+ uv run python scripts/generate_models.py
182
+ uv run ruff check --fix src/portals/models.py
183
+ uv run ruff format src/portals/models.py
184
+ ```
185
+
186
+ ## License
187
+
188
+ MIT
@@ -0,0 +1,79 @@
1
+ [build-system]
2
+ requires = ["setuptools>=77"]
3
+ build-backend = "setuptools.build_meta"
4
+
5
+ [project]
6
+ name = "portals-api"
7
+ version = "0.1.0"
8
+ description = "A typed Python client for the Portals Partners API"
9
+ readme = "README.md"
10
+ requires-python = ">=3.11"
11
+ license = "MIT"
12
+ authors = [{ name = "Portals API client contributors" }]
13
+ keywords = ["portals", "telegram", "ton", "nft", "api", "asyncio"]
14
+ dependencies = [
15
+ "httpx2>=2.16,<3",
16
+ "pydantic>=2.8,<3",
17
+ ]
18
+ classifiers = [
19
+ "Development Status :: 3 - Alpha",
20
+ "Framework :: AsyncIO",
21
+ "Intended Audience :: Developers",
22
+ "Operating System :: OS Independent",
23
+ "Programming Language :: Python :: 3",
24
+ "Programming Language :: Python :: 3 :: Only",
25
+ "Programming Language :: Python :: 3.11",
26
+ "Programming Language :: Python :: 3.12",
27
+ "Programming Language :: Python :: 3.13",
28
+ "Programming Language :: Python :: 3.14",
29
+ "Typing :: Typed",
30
+ ]
31
+
32
+ [project.urls]
33
+ Homepage = "https://github.com/NicolasKingreen/portals-python"
34
+ Source = "https://github.com/NicolasKingreen/portals-python"
35
+ Issues = "https://github.com/NicolasKingreen/portals-python/issues"
36
+ Changelog = "https://github.com/NicolasKingreen/portals-python/blob/main/CHANGELOG.md"
37
+ "API documentation" = "https://docs.portals.tg/developers/api/"
38
+
39
+ [dependency-groups]
40
+ dev = [
41
+ "mypy>=1.8",
42
+ "pytest>=8.0",
43
+ "pytest-asyncio>=0.24",
44
+ "pytest-cov>=5.0",
45
+ "ruff>=0.8",
46
+ ]
47
+
48
+ [tool.setuptools]
49
+ package-dir = { "" = "src" }
50
+
51
+ [tool.setuptools.packages.find]
52
+ where = ["src"]
53
+
54
+ [tool.setuptools.package-data]
55
+ portals = ["py.typed"]
56
+
57
+ [tool.ruff]
58
+ target-version = "py311"
59
+ line-length = 100
60
+
61
+ [tool.ruff.lint]
62
+ select = ["E", "F", "I", "UP", "B", "RUF"]
63
+ ignore = ["E501"]
64
+
65
+ [tool.ruff.lint.per-file-ignores]
66
+ "src/portals/models.py" = ["RUF022"]
67
+
68
+ [tool.mypy]
69
+ python_version = "3.11"
70
+ strict = true
71
+ plugins = ["pydantic.mypy"]
72
+ files = ["src/portals", "tests"]
73
+
74
+ [tool.pytest.ini_options]
75
+ testpaths = ["tests"]
76
+ pythonpath = ["."]
77
+ asyncio_mode = "auto"
78
+ asyncio_default_fixture_loop_scope = "function"
79
+ addopts = "-ra --strict-markers"
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -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
+ ]
@@ -0,0 +1,2 @@
1
+ # Generated by scripts/generate_models.py from specs/partners.en.json.
2
+ __api_version__ = "1.0.0"
@@ -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