pokewallet 0.1.1__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.

Potentially problematic release.


This version of pokewallet might be problematic. Click here for more details.

@@ -0,0 +1,22 @@
1
+ node_modules/
2
+ dist/
3
+ coverage/
4
+ *.tsbuildinfo
5
+ .env
6
+ .env.*
7
+ .dev.vars
8
+ *.log
9
+ .DS_Store
10
+
11
+ # generated from openapi/pokewallet.yaml at build time
12
+ packages/ts/src/generated/
13
+
14
+ # recorded or scratch output
15
+ .tmp/
16
+ .venv/
17
+ __pycache__/
18
+ *.egg-info/
19
+ .pytest_cache/
20
+ *.pyc
21
+ build/
22
+ .wrangler/
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 PokeWallet
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,73 @@
1
+ Metadata-Version: 2.5
2
+ Name: pokewallet
3
+ Version: 0.1.1
4
+ Summary: Official Python SDK for the PokeWallet API
5
+ Project-URL: Homepage, https://github.com/pokewallet-lab/pokewallet-sdk#readme
6
+ Project-URL: Repository, https://github.com/pokewallet-lab/pokewallet-sdk
7
+ Project-URL: Issues, https://github.com/pokewallet-lab/pokewallet-sdk/issues
8
+ Author: PokeWallet
9
+ License-Expression: MIT
10
+ License-File: LICENSE
11
+ Keywords: card-prices,cardmarket,magic-the-gathering,one-piece,pokemon,pokewallet,sdk,tcg,tcgplayer,trading-cards
12
+ Classifier: Development Status :: 4 - Beta
13
+ Classifier: Intended Audience :: Developers
14
+ Classifier: License :: OSI Approved :: MIT License
15
+ Classifier: Operating System :: OS Independent
16
+ Classifier: Programming Language :: Python :: 3
17
+ Classifier: Programming Language :: Python :: 3 :: Only
18
+ Classifier: Programming Language :: Python :: 3.9
19
+ Classifier: Programming Language :: Python :: 3.10
20
+ Classifier: Programming Language :: Python :: 3.11
21
+ Classifier: Programming Language :: Python :: 3.12
22
+ Classifier: Programming Language :: Python :: 3.13
23
+ Classifier: Topic :: Software Development :: Libraries :: Python Modules
24
+ Classifier: Typing :: Typed
25
+ Requires-Python: >=3.9
26
+ Requires-Dist: httpx>=0.25
27
+ Provides-Extra: test
28
+ Requires-Dist: pytest>=7; extra == 'test'
29
+ Description-Content-Type: text/markdown
30
+
31
+ # pokewallet
32
+
33
+ > Preview: install from the GitHub release files, see [Install](../../README.md#2-install) in the root README, which also has the quickstart, the full method table and the MCP server.
34
+
35
+ Official Python SDK for the [PokeWallet API](https://pokewallet.io): Pokémon, One Piece and ten more card games, with TCGPlayer and CardMarket prices. Python 3.9+, sync and async clients, `httpx` is the only dependency.
36
+
37
+ ```python
38
+ from pokewallet import PokeWallet
39
+
40
+ # Key format: pk_live_xxx. Keep it in the environment, never in source control.
41
+ pw = PokeWallet() # reads POKEWALLET_API_KEY, or pass api_key=...
42
+
43
+ page = pw.pokemon.search("charizard", limit=10)
44
+ op01 = pw.onepiece.set("OP01")
45
+ for card in pw.games.mtg.set_cards("FRA"):
46
+ print(card.name)
47
+ ```
48
+
49
+ Async:
50
+
51
+ ```python
52
+ from pokewallet import AsyncPokeWallet
53
+
54
+ async with AsyncPokeWallet() as pw:
55
+ page = await pw.pokemon.search("pikachu")
56
+ async for card in pw.games.mtg.search_all("lotus"):
57
+ ...
58
+ ```
59
+
60
+ Pokémon, One Piece and the ten games (`pw.games.mtg`, `pw.games.ygo`, ... or `pw.game(code)`) share one interface: `search`, `search_all`, `card`, `sets`, `set`, `set_cards`, `prices`, `statistics`, `completion_value`, `trending`, `top_cards`. Pokémon adds `price_history`.
61
+
62
+ - **Typed errors:** `AuthError`, `PlanRequiredError` (`redeem_url` / `upgrade_url`), `FeatureNotEnabledError`, `RateLimitError`, `AmbiguousSetError` (`matches`), `NotFoundError`, `UnavailableError`, `ValidationError`.
63
+ - **Retries:** only on 429 (when `Retry-After` is present, within `retry_after_cap`, default 30 s, and no quota window is exhausted) and on 503 (up to `max_retries`, default 2). Never on other 4xx or 5xx.
64
+ - **Validation:** `limit` and `page` are checked before the request is sent.
65
+ - **Normalization:** prices are `float | None`; set identifiers are exposed as `set_id` / `set_code`; every object keeps the server value in `raw`. Ambiguous set answers (200, 300 and 409 on the wire) all raise `AmbiguousSetError`.
66
+ - **Key safety:** the key is held in a closure and shown as `pk_***` by `repr`, `str` and errors; clients cannot be pickled.
67
+ - **Images:** `image_url(card_id)` only builds the URL; the image routes need the `X-API-Key` header.
68
+
69
+ The client can be created without a key: `health()` and `list_games()` are public and work without one (the `X-API-Key` header is sent only when a key is set); every other call raises `ValidationError` before any request is made.
70
+
71
+ Every request carries `User-Agent: pokewallet-sdk-py/<version>`.
72
+
73
+ Attribution: if you show data obtained through the PokeWallet API, SDKs or MCP server to other people, credit PokéWallet with visible text "Data by PokéWallet" (or "Powered by PokéWallet") linking to <https://pokewallet.io>; private use needs no attribution. See the [Terms of Use](https://pokewallet.io/terms-conditions). This concerns the data and the service; the MIT license of the code is separate.
@@ -0,0 +1,43 @@
1
+ # pokewallet
2
+
3
+ > Preview: install from the GitHub release files, see [Install](../../README.md#2-install) in the root README, which also has the quickstart, the full method table and the MCP server.
4
+
5
+ Official Python SDK for the [PokeWallet API](https://pokewallet.io): Pokémon, One Piece and ten more card games, with TCGPlayer and CardMarket prices. Python 3.9+, sync and async clients, `httpx` is the only dependency.
6
+
7
+ ```python
8
+ from pokewallet import PokeWallet
9
+
10
+ # Key format: pk_live_xxx. Keep it in the environment, never in source control.
11
+ pw = PokeWallet() # reads POKEWALLET_API_KEY, or pass api_key=...
12
+
13
+ page = pw.pokemon.search("charizard", limit=10)
14
+ op01 = pw.onepiece.set("OP01")
15
+ for card in pw.games.mtg.set_cards("FRA"):
16
+ print(card.name)
17
+ ```
18
+
19
+ Async:
20
+
21
+ ```python
22
+ from pokewallet import AsyncPokeWallet
23
+
24
+ async with AsyncPokeWallet() as pw:
25
+ page = await pw.pokemon.search("pikachu")
26
+ async for card in pw.games.mtg.search_all("lotus"):
27
+ ...
28
+ ```
29
+
30
+ Pokémon, One Piece and the ten games (`pw.games.mtg`, `pw.games.ygo`, ... or `pw.game(code)`) share one interface: `search`, `search_all`, `card`, `sets`, `set`, `set_cards`, `prices`, `statistics`, `completion_value`, `trending`, `top_cards`. Pokémon adds `price_history`.
31
+
32
+ - **Typed errors:** `AuthError`, `PlanRequiredError` (`redeem_url` / `upgrade_url`), `FeatureNotEnabledError`, `RateLimitError`, `AmbiguousSetError` (`matches`), `NotFoundError`, `UnavailableError`, `ValidationError`.
33
+ - **Retries:** only on 429 (when `Retry-After` is present, within `retry_after_cap`, default 30 s, and no quota window is exhausted) and on 503 (up to `max_retries`, default 2). Never on other 4xx or 5xx.
34
+ - **Validation:** `limit` and `page` are checked before the request is sent.
35
+ - **Normalization:** prices are `float | None`; set identifiers are exposed as `set_id` / `set_code`; every object keeps the server value in `raw`. Ambiguous set answers (200, 300 and 409 on the wire) all raise `AmbiguousSetError`.
36
+ - **Key safety:** the key is held in a closure and shown as `pk_***` by `repr`, `str` and errors; clients cannot be pickled.
37
+ - **Images:** `image_url(card_id)` only builds the URL; the image routes need the `X-API-Key` header.
38
+
39
+ The client can be created without a key: `health()` and `list_games()` are public and work without one (the `X-API-Key` header is sent only when a key is set); every other call raises `ValidationError` before any request is made.
40
+
41
+ Every request carries `User-Agent: pokewallet-sdk-py/<version>`.
42
+
43
+ Attribution: if you show data obtained through the PokeWallet API, SDKs or MCP server to other people, credit PokéWallet with visible text "Data by PokéWallet" (or "Powered by PokéWallet") linking to <https://pokewallet.io>; private use needs no attribution. See the [Terms of Use](https://pokewallet.io/terms-conditions). This concerns the data and the service; the MIT license of the code is separate.
@@ -0,0 +1,49 @@
1
+ [build-system]
2
+ requires = ["hatchling"]
3
+ build-backend = "hatchling.build"
4
+
5
+ [project]
6
+ name = "pokewallet"
7
+ version = "0.1.1"
8
+ description = "Official Python SDK for the PokeWallet API"
9
+ readme = "README.md"
10
+ requires-python = ">=3.9"
11
+ license = "MIT"
12
+ license-files = ["LICENSE"]
13
+ keywords = ["pokewallet", "pokemon", "tcg", "trading-cards", "one-piece", "magic-the-gathering", "card-prices", "tcgplayer", "cardmarket", "sdk"]
14
+ authors = [{ name = "PokeWallet" }]
15
+ classifiers = [
16
+ "Development Status :: 4 - Beta",
17
+ "Intended Audience :: Developers",
18
+ "License :: OSI Approved :: MIT License",
19
+ "Operating System :: OS Independent",
20
+ "Programming Language :: Python :: 3",
21
+ "Programming Language :: Python :: 3 :: Only",
22
+ "Programming Language :: Python :: 3.9",
23
+ "Programming Language :: Python :: 3.10",
24
+ "Programming Language :: Python :: 3.11",
25
+ "Programming Language :: Python :: 3.12",
26
+ "Programming Language :: Python :: 3.13",
27
+ "Topic :: Software Development :: Libraries :: Python Modules",
28
+ "Typing :: Typed",
29
+ ]
30
+ dependencies = ["httpx>=0.25"]
31
+
32
+ [project.optional-dependencies]
33
+ test = ["pytest>=7"]
34
+
35
+ [project.urls]
36
+ Homepage = "https://github.com/pokewallet-lab/pokewallet-sdk#readme"
37
+ Repository = "https://github.com/pokewallet-lab/pokewallet-sdk"
38
+ Issues = "https://github.com/pokewallet-lab/pokewallet-sdk/issues"
39
+
40
+ [tool.hatch.build.targets.wheel]
41
+ packages = ["src/pokewallet"]
42
+
43
+ # The source distribution carries the package, its metadata files and nothing else:
44
+ # no tests, no recorded fixtures, no local environments.
45
+ [tool.hatch.build.targets.sdist]
46
+ include = ["/src/pokewallet", "/README.md", "/LICENSE", "/pyproject.toml"]
47
+
48
+ [tool.pytest.ini_options]
49
+ testpaths = ["tests/unit"]
@@ -0,0 +1,24 @@
1
+ """Official Python SDK for the PokeWallet API."""
2
+ from ._core import RateLimitInfo
3
+ from ._images import image_url
4
+ from ._version import VERSION as __version__
5
+ from .client import AsyncPokeWallet, PokeWallet
6
+ from .errors import (
7
+ AmbiguousSetError, AuthError, BadRequestError, FeatureNotEnabledError, GameUnavailableError,
8
+ LimitWindow, NetworkError, NotFoundError, PlanRequiredError, PokeWalletError, RateLimitError,
9
+ ServerError, SetMatch, UnavailableError, ValidationError,
10
+ )
11
+ from .types import (
12
+ GAME_CODES, Card, CardmarketPrice, CompletionValue, Game, Health, HealthCheck, Page, PriceHistory, PriceRow,
13
+ SearchPage, SetPage, SetPrices, SetStatistics, SetSummary, TcgPrice, TopCard,
14
+ TrendingSet,
15
+ )
16
+
17
+ __all__ = [
18
+ "AmbiguousSetError", "AsyncPokeWallet", "AuthError", "BadRequestError", "Card", "CardmarketPrice",
19
+ "CompletionValue", "FeatureNotEnabledError", "GAME_CODES", "Game", "GameUnavailableError", "Health", "HealthCheck", "LimitWindow",
20
+ "NetworkError", "NotFoundError", "Page", "PlanRequiredError", "PokeWallet", "PokeWalletError",
21
+ "PriceHistory", "PriceRow", "RateLimitError", "RateLimitInfo", "SearchPage",
22
+ "ServerError", "SetMatch", "SetPage", "SetPrices", "SetStatistics", "SetSummary", "TcgPrice",
23
+ "TopCard", "TrendingSet", "UnavailableError", "ValidationError", "__version__", "image_url",
24
+ ]
@@ -0,0 +1,138 @@
1
+ """Asynchronous transport and worlds (httpx.AsyncClient)."""
2
+ from __future__ import annotations
3
+
4
+ import asyncio
5
+ from typing import Any, AsyncIterator, Awaitable, Callable, List, Mapping, Optional
6
+
7
+ import httpx
8
+
9
+ from ._core import Core, clean_query
10
+ from ._dialect import CardDialect, OnePieceDialect, PokemonDialect
11
+ from .errors import NetworkError
12
+ from .types import (
13
+ Card, CompletionValue, PriceHistory, SearchPage, SetPage, SetPrices,
14
+ SetStatistics, SetSummary, TopCard, TrendingSet,
15
+ )
16
+
17
+
18
+ class AsyncTransport:
19
+ def __init__(self, core: Core, client: httpx.AsyncClient, sleep: Callable[[float], Awaitable[Any]]) -> None:
20
+ self._core = core
21
+ self._client = client
22
+ self._sleep = sleep
23
+
24
+ async def get(self, path: str, query: Optional[Mapping[str, Any]] = None, *, public: bool = False, accept: Optional[Callable[[Any], bool]] = None) -> Any:
25
+ self._core.require_key(public)
26
+ attempt = 0
27
+ while True:
28
+ response: Optional[httpx.Response] = None
29
+ failure: Optional[str] = None
30
+ try:
31
+ response = await self._client.get(
32
+ self._core.url(path), params=clean_query(query), headers=self._core.headers(), timeout=self._core.timeout
33
+ )
34
+ except httpx.HTTPError as exc:
35
+ failure = self._core.network_error_message(exc)
36
+ if failure is not None:
37
+ # Raised outside the except block so no chained exception can carry the request headers.
38
+ raise NetworkError(failure)
39
+ assert response is not None
40
+ body, err, retry_after = self._core.interpret(response.status_code, response.headers, response.text, path)
41
+ if err is None:
42
+ return body
43
+ # Some non-2xx answers are data, not failures (an "unhealthy" report): return them as is, no retry.
44
+ if accept is not None and accept(body):
45
+ return body
46
+ wait = self._core.wait_before_retry(err, retry_after, attempt)
47
+ if wait is None:
48
+ raise err
49
+ attempt += 1
50
+ await self._sleep(wait)
51
+
52
+
53
+ class AsyncWorld:
54
+ """The one interface shared by Pokémon, One Piece and the ten games."""
55
+
56
+ def __init__(self, transport: AsyncTransport, dialect: Any) -> None:
57
+ self._t = transport
58
+ self._d = dialect
59
+
60
+ async def _get(self, req: Any) -> Any:
61
+ return await self._t.get(req.path, req.query)
62
+
63
+ async def search(self, q: str, *, page: Optional[int] = None, limit: Optional[int] = None) -> SearchPage:
64
+ """One page of search results. ``q`` is 2-100 characters for the ten games."""
65
+ return self._d.parse_search(await self._get(self._d.search(q, page, limit)))
66
+
67
+ def search_all(self, q: str, *, limit: Optional[int] = None, max_items: Optional[int] = None) -> AsyncIterator[Card]:
68
+ """Every result of a search, page after page."""
69
+ return self._walk(lambda p: self.search(q, page=p, limit=limit), max_items)
70
+
71
+ async def card(self, card_id: str) -> Card:
72
+ """Card by id (``pk_…``, ``op_…``, ``<code>_…``, or a 64-hex CardMarket id)."""
73
+ return self._d.parse_card(await self._get(self._d.card(card_id)))
74
+
75
+ async def sets(self, *, language: Optional[str] = None) -> List[SetSummary]:
76
+ """All sets of the world. The API does not paginate this list."""
77
+ return self._d.parse_sets(await self._get(self._d.sets(language)))
78
+
79
+ async def set(self, code: str, *, page: Optional[int] = None, limit: Optional[int] = None, language: Optional[str] = None) -> SetPage:
80
+ """One page of a set's cards. Raises ``AmbiguousSetError`` when the code matches several sets."""
81
+ return self._d.parse_set(await self._get(self._d.set(code, page, limit, language)))
82
+
83
+ def set_cards(self, code: str, *, limit: Optional[int] = None, language: Optional[str] = None, max_items: Optional[int] = None) -> AsyncIterator[Card]:
84
+ """Every card of a set, page after page."""
85
+ return self._walk(lambda p: self.set(code, page=p, limit=limit, language=language), max_items)
86
+
87
+ async def prices(self, code: str, *, source: Optional[str] = None) -> SetPrices:
88
+ """Prices of every card in a set: hundreds to thousands of rows. Plan-gated."""
89
+ return self._d.parse_prices(await self._get(self._d.prices(code, source)))
90
+
91
+ async def statistics(self, code: str, *, variant: Optional[str] = None) -> SetStatistics:
92
+ return self._d.parse_statistics(await self._get(self._d.statistics(code, variant)))
93
+
94
+ async def completion_value(self, code: str) -> CompletionValue:
95
+ return self._d.parse_completion_value(await self._get(self._d.completion_value(code)))
96
+
97
+ async def trending(self, *, period: Optional[str] = None, limit: Optional[int] = None) -> List[TrendingSet]:
98
+ return self._d.parse_trending(await self._get(self._d.trending(period, limit)))
99
+
100
+ async def top_cards(self, *, metric: Optional[str] = None, source: Optional[str] = None, limit: Optional[int] = None) -> List[TopCard]:
101
+ return self._d.parse_top_cards(await self._get(self._d.top_cards(metric, source, limit)))
102
+
103
+ @staticmethod
104
+ async def _walk(fetch: Callable[[int], Awaitable[Any]], max_items: Optional[int]) -> AsyncIterator[Card]:
105
+ page, yielded = 1, 0
106
+ while True:
107
+ res = await fetch(page)
108
+ for item in res.items:
109
+ if max_items is not None and yielded >= max_items:
110
+ return
111
+ yield item
112
+ yielded += 1
113
+ if not res.items or page >= res.total_pages:
114
+ return
115
+ page += 1
116
+
117
+
118
+ class AsyncPokemonWorld(AsyncWorld):
119
+ def __init__(self, transport: AsyncTransport) -> None:
120
+ super().__init__(transport, PokemonDialect())
121
+
122
+ async def card(self, card_id: str, *, set_code: Optional[str] = None) -> Card: # type: ignore[override]
123
+ """Card by id, or by plain card number together with ``set_code``."""
124
+ return self._d.parse_card(await self._get(self._d.card(card_id, set_code)))
125
+
126
+ async def price_history(self, card_id: str) -> PriceHistory:
127
+ """Price snapshots at 7/14/30/60/120 days, per finish (not a time series). Plan-gated."""
128
+ return self._d.parse_price_history(await self._get(self._d.price_history(card_id)))
129
+
130
+
131
+ def build_async_worlds(transport: AsyncTransport):
132
+ from .types import GAME_CODES
133
+
134
+ games = {code: AsyncWorld(transport, CardDialect(f"/{code}", ("7d",), 2)) for code in GAME_CODES}
135
+ return AsyncPokemonWorld(transport), AsyncWorld(transport, OnePieceDialect()), games
136
+
137
+
138
+ default_sleep = asyncio.sleep
@@ -0,0 +1,189 @@
1
+ """I/O-free core shared by the sync and async clients: credentials, headers, response handling, retry rules."""
2
+ from __future__ import annotations
3
+
4
+ import json
5
+ import os
6
+ from dataclasses import dataclass
7
+ from typing import Any, Callable, Mapping, Optional, Tuple
8
+
9
+ from ._error_map import error_from_response
10
+ from .errors import PokeWalletError, RateLimitError, ValidationError
11
+ from ._version import DEFAULT_BASE_URL, DEFAULT_USER_AGENT
12
+
13
+ MASK = "pk_***"
14
+ _GAME_PREFIXES = ("/mtg/", "/ygo/", "/cfv/", "/dbs/", "/fab/", "/dgm/", "/swu/", "/lor/", "/rft/", "/cpk/")
15
+
16
+
17
+ @dataclass
18
+ class RateLimitInfo:
19
+ """Quota counters from the ``X-RateLimit-*`` headers of the last answer."""
20
+
21
+ limit_hour: Optional[int]
22
+ remaining_hour: Optional[int]
23
+ limit_day: Optional[int]
24
+ remaining_day: Optional[int]
25
+
26
+
27
+ class Credentials:
28
+ """Holds the API key in a closure: not in ``vars()``, not in repr/str, not picklable or copyable."""
29
+
30
+ __slots__ = ("_get",)
31
+
32
+ def __init__(self, key: Optional[str]) -> None:
33
+ def get() -> Optional[str]:
34
+ return key
35
+
36
+ object.__setattr__(self, "_get", get)
37
+
38
+ @property
39
+ def present(self) -> bool:
40
+ return self._get() is not None
41
+
42
+ def header_value(self) -> Optional[str]:
43
+ return self._get()
44
+
45
+ def redact(self, value: str) -> str:
46
+ key = self._get()
47
+ return value if key is None else value.replace(key, MASK)
48
+
49
+ def __repr__(self) -> str:
50
+ return f"<Credentials {MASK if self.present else 'none'}>"
51
+
52
+ __str__ = __repr__
53
+
54
+ def __reduce__(self) -> Any:
55
+ raise TypeError("API credentials cannot be pickled or copied")
56
+
57
+ def __setattr__(self, name: str, value: Any) -> None:
58
+ raise AttributeError("credentials are read-only")
59
+
60
+
61
+ KEY_REQUIRED = "An API key is required: pass apiKey or set POKEWALLET_API_KEY"
62
+
63
+
64
+ def read_key(api_key: Optional[str]) -> Optional[str]:
65
+ """The key from the argument or the environment; None when there is none (public endpoints still work)."""
66
+ key = (api_key or os.environ.get("POKEWALLET_API_KEY") or "").strip()
67
+ if not key:
68
+ return None
69
+ if not key.startswith("pk_"):
70
+ raise ValidationError('API keys start with "pk_"')
71
+ return key
72
+
73
+
74
+ def parse_retry_after(value: Optional[str]) -> Optional[float]:
75
+ """Seconds from a ``Retry-After`` value (delta-seconds or HTTP date), or None."""
76
+ if value is None or not value.strip():
77
+ return None
78
+ try:
79
+ n = float(value)
80
+ except ValueError:
81
+ from email.utils import parsedate_to_datetime
82
+ from datetime import datetime, timezone
83
+
84
+ try:
85
+ when = parsedate_to_datetime(value)
86
+ except (TypeError, ValueError):
87
+ return None
88
+ if when.tzinfo is None:
89
+ when = when.replace(tzinfo=timezone.utc)
90
+ return max(0.0, (when - datetime.now(timezone.utc)).total_seconds())
91
+ return n if n >= 0 else None
92
+
93
+
94
+ def _int(headers: Mapping[str, str], name: str) -> Optional[int]:
95
+ value = headers.get(name)
96
+ try:
97
+ return int(value) if value is not None else None
98
+ except ValueError:
99
+ return None
100
+
101
+
102
+ class Core:
103
+ def __init__(
104
+ self,
105
+ api_key: Optional[str],
106
+ base_url: Optional[str],
107
+ user_agent: Optional[str],
108
+ max_retries: int,
109
+ retry_after_cap: float,
110
+ timeout: float,
111
+ ) -> None:
112
+ self.credentials = Credentials(read_key(api_key))
113
+ self.base_url = (base_url or DEFAULT_BASE_URL).rstrip("/")
114
+ self.user_agent = user_agent or DEFAULT_USER_AGENT
115
+ self.max_retries = max_retries
116
+ self.retry_after_cap = retry_after_cap
117
+ self.timeout = timeout
118
+ self.last_rate_limit: Optional[RateLimitInfo] = None
119
+
120
+ def url(self, path: str) -> str:
121
+ return f"{self.base_url}{path}"
122
+
123
+ def headers(self) -> dict:
124
+ key = self.credentials.header_value()
125
+ base = {"User-Agent": self.user_agent, "Accept": "application/json"}
126
+ return base if key is None else {"X-API-Key": key, **base}
127
+
128
+ def require_key(self, public: bool) -> None:
129
+ """Raised before any network call: only the two public endpoints work without a key."""
130
+ if not public and not self.credentials.present:
131
+ raise ValidationError(KEY_REQUIRED)
132
+
133
+ def network_error_message(self, exc: BaseException) -> str:
134
+ return self.credentials.redact(f"Request failed before an answer arrived: {type(exc).__name__}: {exc}")
135
+
136
+ def interpret(
137
+ self, status: int, headers: Mapping[str, str], body_text: str, path: str
138
+ ) -> Tuple[Any, Optional[PokeWalletError], Optional[float]]:
139
+ """Returns (body, error, retry_after). The body never contains the key."""
140
+ body = _parse_json(self.credentials.redact(body_text))
141
+ self._track_quota(headers)
142
+ if 200 <= status < 300:
143
+ return body, None, None
144
+ retry_after = parse_retry_after(headers.get("retry-after"))
145
+ return body, error_from_response(status, body, retry_after, path.startswith(_GAME_PREFIXES)), retry_after
146
+
147
+ def wait_before_retry(self, err: PokeWalletError, retry_after: Optional[float], attempt: int) -> Optional[float]:
148
+ """Seconds to wait before the next attempt, or None when the error must be raised."""
149
+ if attempt >= self.max_retries:
150
+ return None
151
+ if err.status == 429:
152
+ # Never guess a wait: no header, or an exhausted window, means retrying is pointless.
153
+ ok = retry_after is not None and retry_after <= self.retry_after_cap
154
+ if ok and isinstance(err, RateLimitError) and err.exhausted:
155
+ ok = False
156
+ return retry_after if ok else None
157
+ if err.status == 503:
158
+ wait = 1.0 if retry_after is None else retry_after
159
+ return wait if wait <= self.retry_after_cap else None
160
+ return None
161
+
162
+ def _track_quota(self, headers: Mapping[str, str]) -> None:
163
+ info = RateLimitInfo(
164
+ _int(headers, "x-ratelimit-limit-hour"),
165
+ _int(headers, "x-ratelimit-remaining-hour"),
166
+ _int(headers, "x-ratelimit-limit-day"),
167
+ _int(headers, "x-ratelimit-remaining-day"),
168
+ )
169
+ if any(v is not None for v in (info.limit_hour, info.remaining_hour, info.limit_day, info.remaining_day)):
170
+ self.last_rate_limit = info
171
+
172
+ def __repr__(self) -> str:
173
+ return f"Core(base_url={self.base_url!r}, user_agent={self.user_agent!r}, api_key={MASK if self.credentials.present else None!r})"
174
+
175
+
176
+ def _parse_json(text: str) -> Any:
177
+ if not text:
178
+ return None
179
+ try:
180
+ return json.loads(text)
181
+ except ValueError:
182
+ return {"error": text[:200]}
183
+
184
+
185
+ def clean_query(query: Optional[Mapping[str, Any]]) -> dict:
186
+ return {k: v for k, v in (query or {}).items() if v is not None}
187
+
188
+
189
+ Sleep = Callable[[float], Any]