wow 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.
- wow/__init__.py +52 -0
- wow/_async/__init__.py +1 -0
- wow/_async/_client.py +578 -0
- wow/_async/_resource.py +73 -0
- wow/_async/resources/__init__.py +1 -0
- wow/_async/resources/account.py +19 -0
- wow/_async/resources/achievement.py +39 -0
- wow/_async/resources/character.py +114 -0
- wow/_async/resources/creature.py +34 -0
- wow/_async/resources/guild.py +29 -0
- wow/_async/resources/item.py +111 -0
- wow/_async/resources/mount.py +82 -0
- wow/_async/resources/pet.py +20 -0
- wow/_async/resources/playable_class.py +25 -0
- wow/_async/resources/playable_race.py +20 -0
- wow/_async/resources/playable_specialization.py +20 -0
- wow/_async/resources/power_type.py +20 -0
- wow/_async/resources/profession.py +31 -0
- wow/_async/resources/quest.py +66 -0
- wow/_async/resources/realm.py +79 -0
- wow/_async/resources/recipe.py +21 -0
- wow/_async/resources/region.py +20 -0
- wow/_async/resources/reputation.py +39 -0
- wow/_async/resources/ruleset.py +20 -0
- wow/_async/resources/talent.py +38 -0
- wow/_async/resources/title.py +20 -0
- wow/_async/resources/toy.py +20 -0
- wow/_base.py +190 -0
- wow/_concurrency.py +20 -0
- wow/_exceptions.py +149 -0
- wow/_response.py +69 -0
- wow/_sync/__init__.py +2 -0
- wow/_sync/_client.py +577 -0
- wow/_sync/_resource.py +74 -0
- wow/_sync/resources/__init__.py +2 -0
- wow/_sync/resources/account.py +20 -0
- wow/_sync/resources/achievement.py +40 -0
- wow/_sync/resources/character.py +115 -0
- wow/_sync/resources/creature.py +35 -0
- wow/_sync/resources/guild.py +30 -0
- wow/_sync/resources/item.py +112 -0
- wow/_sync/resources/mount.py +83 -0
- wow/_sync/resources/pet.py +21 -0
- wow/_sync/resources/playable_class.py +26 -0
- wow/_sync/resources/playable_race.py +21 -0
- wow/_sync/resources/playable_specialization.py +21 -0
- wow/_sync/resources/power_type.py +21 -0
- wow/_sync/resources/profession.py +32 -0
- wow/_sync/resources/quest.py +67 -0
- wow/_sync/resources/realm.py +80 -0
- wow/_sync/resources/recipe.py +22 -0
- wow/_sync/resources/region.py +21 -0
- wow/_sync/resources/reputation.py +40 -0
- wow/_sync/resources/ruleset.py +21 -0
- wow/_sync/resources/talent.py +39 -0
- wow/_sync/resources/title.py +21 -0
- wow/_sync/resources/toy.py +21 -0
- wow/_types.py +59 -0
- wow/models/__init__.py +410 -0
- wow/models/_common.py +144 -0
- wow/models/account.py +60 -0
- wow/models/achievement.py +76 -0
- wow/models/character.py +510 -0
- wow/models/creature.py +32 -0
- wow/models/guild.py +75 -0
- wow/models/item.py +365 -0
- wow/models/mount.py +55 -0
- wow/models/pet.py +39 -0
- wow/models/playable_class.py +25 -0
- wow/models/playable_race.py +24 -0
- wow/models/playable_specialization.py +22 -0
- wow/models/power_type.py +18 -0
- wow/models/profession.py +39 -0
- wow/models/quest.py +156 -0
- wow/models/realm.py +62 -0
- wow/models/recipe.py +34 -0
- wow/models/region.py +20 -0
- wow/models/reputation.py +56 -0
- wow/models/ruleset.py +29 -0
- wow/models/talent.py +125 -0
- wow/models/title.py +27 -0
- wow/models/toy.py +22 -0
- wow/py.typed +0 -0
- wow/resources.py +111 -0
- wow/search.py +113 -0
- wow-0.1.0.dist-info/METADATA +103 -0
- wow-0.1.0.dist-info/RECORD +89 -0
- wow-0.1.0.dist-info/WHEEL +4 -0
- wow-0.1.0.dist-info/licenses/LICENSE +21 -0
wow/__init__.py
ADDED
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
"""Typed sync and async client for the World of Warcraft: Forever Battle.net API.
|
|
2
|
+
|
|
3
|
+
>>> from wow import Client
|
|
4
|
+
>>> with Client(client_id="...", client_secret="...") as client: # doctest: +SKIP
|
|
5
|
+
... client.playable_classes.get(1).name
|
|
6
|
+
'Warrior'
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from wow._async._client import AsyncClient
|
|
10
|
+
from wow._base import OAuthToken
|
|
11
|
+
from wow._exceptions import (
|
|
12
|
+
APIConnectionError,
|
|
13
|
+
APIError,
|
|
14
|
+
APIResponseValidationError,
|
|
15
|
+
APIStatusError,
|
|
16
|
+
APITimeoutError,
|
|
17
|
+
AuthenticationError,
|
|
18
|
+
BadRequestError,
|
|
19
|
+
InternalServerError,
|
|
20
|
+
NotFoundError,
|
|
21
|
+
PermissionDeniedError,
|
|
22
|
+
RateLimitError,
|
|
23
|
+
WowError,
|
|
24
|
+
)
|
|
25
|
+
from wow._response import APIResponse
|
|
26
|
+
from wow._sync._client import Client
|
|
27
|
+
from wow._types import Locale, NamespaceKind, Region
|
|
28
|
+
from wow.search import Not, Range
|
|
29
|
+
|
|
30
|
+
__all__ = [
|
|
31
|
+
"APIConnectionError",
|
|
32
|
+
"APIError",
|
|
33
|
+
"APIResponse",
|
|
34
|
+
"APIResponseValidationError",
|
|
35
|
+
"APIStatusError",
|
|
36
|
+
"APITimeoutError",
|
|
37
|
+
"AsyncClient",
|
|
38
|
+
"AuthenticationError",
|
|
39
|
+
"BadRequestError",
|
|
40
|
+
"Client",
|
|
41
|
+
"InternalServerError",
|
|
42
|
+
"Locale",
|
|
43
|
+
"NamespaceKind",
|
|
44
|
+
"Not",
|
|
45
|
+
"NotFoundError",
|
|
46
|
+
"OAuthToken",
|
|
47
|
+
"PermissionDeniedError",
|
|
48
|
+
"Range",
|
|
49
|
+
"RateLimitError",
|
|
50
|
+
"Region",
|
|
51
|
+
"WowError",
|
|
52
|
+
]
|
wow/_async/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""The client implementation."""
|
wow/_async/_client.py
ADDED
|
@@ -0,0 +1,578 @@
|
|
|
1
|
+
"""The client: configuration, authentication, retries and resource namespaces."""
|
|
2
|
+
|
|
3
|
+
import os
|
|
4
|
+
from dataclasses import dataclass, field, replace
|
|
5
|
+
from functools import cached_property
|
|
6
|
+
from typing import TYPE_CHECKING, Self
|
|
7
|
+
from urllib.parse import urlencode
|
|
8
|
+
|
|
9
|
+
import httpx
|
|
10
|
+
from msgspec import Struct
|
|
11
|
+
|
|
12
|
+
from wow._async.resources.account import AsyncAccount
|
|
13
|
+
from wow._async.resources.achievement import AsyncAchievementCategories, AsyncAchievements
|
|
14
|
+
from wow._async.resources.character import AsyncCharacters
|
|
15
|
+
from wow._async.resources.creature import AsyncCreatureFamilies, AsyncCreatureTypes
|
|
16
|
+
from wow._async.resources.guild import AsyncGuilds
|
|
17
|
+
from wow._async.resources.item import AsyncItemClasses, AsyncItems, AsyncItemSets
|
|
18
|
+
from wow._async.resources.mount import AsyncMounts
|
|
19
|
+
from wow._async.resources.pet import AsyncPets
|
|
20
|
+
from wow._async.resources.playable_class import AsyncPlayableClasses
|
|
21
|
+
from wow._async.resources.playable_race import AsyncPlayableRaces
|
|
22
|
+
from wow._async.resources.playable_specialization import AsyncPlayableSpecializations
|
|
23
|
+
from wow._async.resources.power_type import AsyncPowerTypes
|
|
24
|
+
from wow._async.resources.profession import AsyncProfessions
|
|
25
|
+
from wow._async.resources.quest import AsyncQuestAreas, AsyncQuestCategories, AsyncQuests, AsyncQuestTypes
|
|
26
|
+
from wow._async.resources.realm import AsyncRealms
|
|
27
|
+
from wow._async.resources.recipe import AsyncRecipes
|
|
28
|
+
from wow._async.resources.region import AsyncRegions
|
|
29
|
+
from wow._async.resources.reputation import AsyncReputationFactions, AsyncReputationTiers
|
|
30
|
+
from wow._async.resources.ruleset import AsyncRulesets
|
|
31
|
+
from wow._async.resources.talent import AsyncTalents, AsyncTalentTrees
|
|
32
|
+
from wow._async.resources.title import AsyncTitles
|
|
33
|
+
from wow._async.resources.toy import AsyncToys
|
|
34
|
+
from wow._base import (
|
|
35
|
+
DEFAULT_MAX_RETRIES,
|
|
36
|
+
DEFAULT_TIMEOUT,
|
|
37
|
+
OAUTH_URL,
|
|
38
|
+
TOKEN_EXPIRY_MARGIN,
|
|
39
|
+
USER_AGENT,
|
|
40
|
+
OAuthToken,
|
|
41
|
+
api_url,
|
|
42
|
+
backoff,
|
|
43
|
+
connection_error,
|
|
44
|
+
parse_model,
|
|
45
|
+
parse_token,
|
|
46
|
+
raise_for_status,
|
|
47
|
+
request_params,
|
|
48
|
+
retry_delay,
|
|
49
|
+
should_retry,
|
|
50
|
+
)
|
|
51
|
+
from wow._concurrency import AsyncLock, async_sleep
|
|
52
|
+
from wow._exceptions import WowError
|
|
53
|
+
from wow._response import APIResponse
|
|
54
|
+
from wow._types import DEFAULT_LOCALES, LOCALES, Locale, NamespaceKind, Region, is_locale
|
|
55
|
+
|
|
56
|
+
if TYPE_CHECKING:
|
|
57
|
+
from collections.abc import Iterable, Mapping
|
|
58
|
+
from types import TracebackType
|
|
59
|
+
|
|
60
|
+
__all__ = ["AsyncClient"]
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
@dataclass(frozen=True, slots=True)
|
|
64
|
+
class _Config:
|
|
65
|
+
"""Per-client settings; :meth:`AsyncClient.with_options` copies and changes these."""
|
|
66
|
+
|
|
67
|
+
region: Region
|
|
68
|
+
locale: Locale
|
|
69
|
+
locale_is_default: bool
|
|
70
|
+
access_token: str | None = field(repr=False)
|
|
71
|
+
timeout: httpx.Timeout
|
|
72
|
+
max_retries: int
|
|
73
|
+
base_url: str | None
|
|
74
|
+
oauth_url: str
|
|
75
|
+
|
|
76
|
+
@property
|
|
77
|
+
def api_url(self) -> str:
|
|
78
|
+
return self.base_url or api_url(self.region)
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
@dataclass(slots=True)
|
|
82
|
+
class _Session:
|
|
83
|
+
"""State shared by a client and every copy made with :meth:`AsyncClient.with_options`."""
|
|
84
|
+
|
|
85
|
+
http: httpx.AsyncClient
|
|
86
|
+
client_id: str | None
|
|
87
|
+
client_secret: str | None = field(repr=False)
|
|
88
|
+
app_token: OAuthToken | None = None
|
|
89
|
+
token_lock: AsyncLock = field(default_factory=AsyncLock)
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
class AsyncClient:
|
|
93
|
+
"""Async client for the World of Warcraft: Forever API.
|
|
94
|
+
|
|
95
|
+
Game data needs app credentials from https://develop.battle.net; the client trades them for an access token and
|
|
96
|
+
refreshes it before it expires. Profile routes need a user's token instead, from :meth:`exchange_code`.
|
|
97
|
+
|
|
98
|
+
Use one client for the life of your program and close it when done; it is a context manager::
|
|
99
|
+
|
|
100
|
+
async with AsyncClient(region="eu") as client:
|
|
101
|
+
druid = await client.playable_classes.get(11)
|
|
102
|
+
|
|
103
|
+
Args:
|
|
104
|
+
client_id: App client id. Defaults to the ``BATTLENET_CLIENT_ID`` environment variable.
|
|
105
|
+
client_secret: App client secret. Defaults to ``BATTLENET_CLIENT_SECRET``.
|
|
106
|
+
access_token: A token to send instead of fetching one with the app credentials, such as a user token with
|
|
107
|
+
the ``wow.profile`` scope.
|
|
108
|
+
region: The API region to read.
|
|
109
|
+
locale: The language for names and descriptions. Defaults to the region's main locale.
|
|
110
|
+
timeout: Seconds, or an ``httpx.Timeout``, applied to every request.
|
|
111
|
+
max_retries: Retries for connection errors, timeouts, 408, 429 and 5xx responses. ``0`` disables retrying.
|
|
112
|
+
http_client: An ``httpx.AsyncClient`` to send requests through, for proxies, transports or caching. The
|
|
113
|
+
caller keeps ownership and must close it.
|
|
114
|
+
base_url: Override the API host, for example to point at a mock server.
|
|
115
|
+
oauth_url: Override the OAuth host.
|
|
116
|
+
|
|
117
|
+
Raises:
|
|
118
|
+
WowError: Neither app credentials nor an access token were given.
|
|
119
|
+
ValueError: ``locale`` or ``max_retries`` is invalid.
|
|
120
|
+
"""
|
|
121
|
+
|
|
122
|
+
def __init__(
|
|
123
|
+
self,
|
|
124
|
+
client_id: str | None = None,
|
|
125
|
+
client_secret: str | None = None,
|
|
126
|
+
*,
|
|
127
|
+
access_token: str | None = None,
|
|
128
|
+
region: Region | str = Region.US,
|
|
129
|
+
locale: Locale | None = None,
|
|
130
|
+
timeout: float | httpx.Timeout = DEFAULT_TIMEOUT,
|
|
131
|
+
max_retries: int = DEFAULT_MAX_RETRIES,
|
|
132
|
+
http_client: httpx.AsyncClient | None = None,
|
|
133
|
+
base_url: str | None = None,
|
|
134
|
+
oauth_url: str = OAUTH_URL,
|
|
135
|
+
) -> None:
|
|
136
|
+
client_id = client_id or os.environ.get("BATTLENET_CLIENT_ID")
|
|
137
|
+
client_secret = client_secret or os.environ.get("BATTLENET_CLIENT_SECRET")
|
|
138
|
+
if access_token is None and not (client_id and client_secret):
|
|
139
|
+
msg = (
|
|
140
|
+
"no credentials: pass client_id and client_secret, set BATTLENET_CLIENT_ID and "
|
|
141
|
+
"BATTLENET_CLIENT_SECRET, or pass access_token"
|
|
142
|
+
)
|
|
143
|
+
raise WowError(msg)
|
|
144
|
+
resolved_region = Region(region)
|
|
145
|
+
self._config = _Config(
|
|
146
|
+
region=resolved_region,
|
|
147
|
+
locale=_check_locale(locale or DEFAULT_LOCALES[resolved_region]),
|
|
148
|
+
locale_is_default=locale is None,
|
|
149
|
+
access_token=access_token,
|
|
150
|
+
timeout=timeout if isinstance(timeout, httpx.Timeout) else httpx.Timeout(timeout),
|
|
151
|
+
max_retries=_check_retries(max_retries),
|
|
152
|
+
base_url=base_url.rstrip("/") if base_url else None,
|
|
153
|
+
oauth_url=oauth_url.rstrip("/"),
|
|
154
|
+
)
|
|
155
|
+
self._session = _Session(
|
|
156
|
+
http=http_client or httpx.AsyncClient(headers={"User-Agent": USER_AGENT}),
|
|
157
|
+
client_id=client_id,
|
|
158
|
+
client_secret=client_secret,
|
|
159
|
+
)
|
|
160
|
+
self._owns_http = http_client is None
|
|
161
|
+
|
|
162
|
+
@classmethod
|
|
163
|
+
def _from_parts(cls, config: _Config, session: _Session) -> Self:
|
|
164
|
+
client = cls.__new__(cls)
|
|
165
|
+
client._config = config # noqa: SLF001 - initialising a copy of this class
|
|
166
|
+
client._session = session # noqa: SLF001 - initialising a copy of this class
|
|
167
|
+
client._owns_http = False # noqa: SLF001 - copies never close the shared pool
|
|
168
|
+
return client
|
|
169
|
+
|
|
170
|
+
def __repr__(self) -> str:
|
|
171
|
+
return f"<{type(self).__name__} region={self.region.value} locale={self.locale}>"
|
|
172
|
+
|
|
173
|
+
@property
|
|
174
|
+
def region(self) -> Region:
|
|
175
|
+
"""The region this client reads."""
|
|
176
|
+
return self._config.region
|
|
177
|
+
|
|
178
|
+
@property
|
|
179
|
+
def locale(self) -> Locale:
|
|
180
|
+
"""The locale names and descriptions come back in."""
|
|
181
|
+
return self._config.locale
|
|
182
|
+
|
|
183
|
+
# Resources, alphabetical. Each is created on first use and bound to this client's settings.
|
|
184
|
+
|
|
185
|
+
@cached_property
|
|
186
|
+
def account(self) -> AsyncAccount:
|
|
187
|
+
"""The authenticated user's WoW Forever accounts and characters."""
|
|
188
|
+
return AsyncAccount(self)
|
|
189
|
+
|
|
190
|
+
@cached_property
|
|
191
|
+
def achievement_categories(self) -> AsyncAchievementCategories:
|
|
192
|
+
"""The groups achievements are organized into."""
|
|
193
|
+
return AsyncAchievementCategories(self)
|
|
194
|
+
|
|
195
|
+
@cached_property
|
|
196
|
+
def achievements(self) -> AsyncAchievements:
|
|
197
|
+
"""Achievements such as Explorer and Master of Alterac Valley."""
|
|
198
|
+
return AsyncAchievements(self)
|
|
199
|
+
|
|
200
|
+
@cached_property
|
|
201
|
+
def characters(self) -> AsyncCharacters:
|
|
202
|
+
"""Any character's public profile: gear, stats, talents, professions and more."""
|
|
203
|
+
return AsyncCharacters(self)
|
|
204
|
+
|
|
205
|
+
@cached_property
|
|
206
|
+
def creature_families(self) -> AsyncCreatureFamilies:
|
|
207
|
+
"""Pet families such as Bat and Wolf."""
|
|
208
|
+
return AsyncCreatureFamilies(self)
|
|
209
|
+
|
|
210
|
+
@cached_property
|
|
211
|
+
def creature_types(self) -> AsyncCreatureTypes:
|
|
212
|
+
"""Creature types such as Beast and Dragonkin."""
|
|
213
|
+
return AsyncCreatureTypes(self)
|
|
214
|
+
|
|
215
|
+
@cached_property
|
|
216
|
+
def guilds(self) -> AsyncGuilds:
|
|
217
|
+
"""Guilds and their rosters."""
|
|
218
|
+
return AsyncGuilds(self)
|
|
219
|
+
|
|
220
|
+
@cached_property
|
|
221
|
+
def item_classes(self) -> AsyncItemClasses:
|
|
222
|
+
"""Item classes such as Weapon and Consumable."""
|
|
223
|
+
return AsyncItemClasses(self)
|
|
224
|
+
|
|
225
|
+
@cached_property
|
|
226
|
+
def item_sets(self) -> AsyncItemSets:
|
|
227
|
+
"""Equipment sets."""
|
|
228
|
+
return AsyncItemSets(self)
|
|
229
|
+
|
|
230
|
+
@cached_property
|
|
231
|
+
def items(self) -> AsyncItems:
|
|
232
|
+
"""Items."""
|
|
233
|
+
return AsyncItems(self)
|
|
234
|
+
|
|
235
|
+
@cached_property
|
|
236
|
+
def mounts(self) -> AsyncMounts:
|
|
237
|
+
"""Mounts."""
|
|
238
|
+
return AsyncMounts(self)
|
|
239
|
+
|
|
240
|
+
@cached_property
|
|
241
|
+
def pets(self) -> AsyncPets:
|
|
242
|
+
"""Battle pet species."""
|
|
243
|
+
return AsyncPets(self)
|
|
244
|
+
|
|
245
|
+
@cached_property
|
|
246
|
+
def playable_classes(self) -> AsyncPlayableClasses:
|
|
247
|
+
"""Playable classes such as Warrior and Mage."""
|
|
248
|
+
return AsyncPlayableClasses(self)
|
|
249
|
+
|
|
250
|
+
@cached_property
|
|
251
|
+
def playable_races(self) -> AsyncPlayableRaces:
|
|
252
|
+
"""Playable races such as Human and Orc."""
|
|
253
|
+
return AsyncPlayableRaces(self)
|
|
254
|
+
|
|
255
|
+
@cached_property
|
|
256
|
+
def playable_specializations(self) -> AsyncPlayableSpecializations:
|
|
257
|
+
"""Class specializations such as Fury or Restoration."""
|
|
258
|
+
return AsyncPlayableSpecializations(self)
|
|
259
|
+
|
|
260
|
+
@cached_property
|
|
261
|
+
def power_types(self) -> AsyncPowerTypes:
|
|
262
|
+
"""Power types such as Mana and Rage."""
|
|
263
|
+
return AsyncPowerTypes(self)
|
|
264
|
+
|
|
265
|
+
@cached_property
|
|
266
|
+
def professions(self) -> AsyncProfessions:
|
|
267
|
+
"""Professions such as Tailoring and Cooking."""
|
|
268
|
+
return AsyncProfessions(self)
|
|
269
|
+
|
|
270
|
+
@cached_property
|
|
271
|
+
def quest_areas(self) -> AsyncQuestAreas:
|
|
272
|
+
"""The zones quests take place in."""
|
|
273
|
+
return AsyncQuestAreas(self)
|
|
274
|
+
|
|
275
|
+
@cached_property
|
|
276
|
+
def quest_categories(self) -> AsyncQuestCategories:
|
|
277
|
+
"""Quest groupings such as Epic or Seasonal."""
|
|
278
|
+
return AsyncQuestCategories(self)
|
|
279
|
+
|
|
280
|
+
@cached_property
|
|
281
|
+
def quest_types(self) -> AsyncQuestTypes:
|
|
282
|
+
"""Quest kinds such as Elite, PvP or Raid."""
|
|
283
|
+
return AsyncQuestTypes(self)
|
|
284
|
+
|
|
285
|
+
@cached_property
|
|
286
|
+
def quests(self) -> AsyncQuests:
|
|
287
|
+
"""Individual quests."""
|
|
288
|
+
return AsyncQuests(self)
|
|
289
|
+
|
|
290
|
+
@cached_property
|
|
291
|
+
def realms(self) -> AsyncRealms:
|
|
292
|
+
"""WoW Forever realms."""
|
|
293
|
+
return AsyncRealms(self)
|
|
294
|
+
|
|
295
|
+
@cached_property
|
|
296
|
+
def recipes(self) -> AsyncRecipes:
|
|
297
|
+
"""Profession recipes."""
|
|
298
|
+
return AsyncRecipes(self)
|
|
299
|
+
|
|
300
|
+
@cached_property
|
|
301
|
+
def regions(self) -> AsyncRegions:
|
|
302
|
+
"""Battle.net API regions."""
|
|
303
|
+
return AsyncRegions(self)
|
|
304
|
+
|
|
305
|
+
@cached_property
|
|
306
|
+
def reputation_factions(self) -> AsyncReputationFactions:
|
|
307
|
+
"""Reputation factions such as cities, tribes and armies."""
|
|
308
|
+
return AsyncReputationFactions(self)
|
|
309
|
+
|
|
310
|
+
@cached_property
|
|
311
|
+
def reputation_tiers(self) -> AsyncReputationTiers:
|
|
312
|
+
"""The standing levels a faction's reputation can reach."""
|
|
313
|
+
return AsyncReputationTiers(self)
|
|
314
|
+
|
|
315
|
+
@cached_property
|
|
316
|
+
def rulesets(self) -> AsyncRulesets:
|
|
317
|
+
"""Rulesets such as Normal and PvP, which Forever has in place of realms."""
|
|
318
|
+
return AsyncRulesets(self)
|
|
319
|
+
|
|
320
|
+
@cached_property
|
|
321
|
+
def talent_trees(self) -> AsyncTalentTrees:
|
|
322
|
+
"""Class and specialization talent trees."""
|
|
323
|
+
return AsyncTalentTrees(self)
|
|
324
|
+
|
|
325
|
+
@cached_property
|
|
326
|
+
def talents(self) -> AsyncTalents:
|
|
327
|
+
"""Individual talents across every class and specialization."""
|
|
328
|
+
return AsyncTalents(self)
|
|
329
|
+
|
|
330
|
+
@cached_property
|
|
331
|
+
def titles(self) -> AsyncTitles:
|
|
332
|
+
"""Character titles such as Commander."""
|
|
333
|
+
return AsyncTitles(self)
|
|
334
|
+
|
|
335
|
+
@cached_property
|
|
336
|
+
def toys(self) -> AsyncToys:
|
|
337
|
+
"""Toy-box toys."""
|
|
338
|
+
return AsyncToys(self)
|
|
339
|
+
|
|
340
|
+
def with_options(
|
|
341
|
+
self,
|
|
342
|
+
*,
|
|
343
|
+
region: Region | str | None = None,
|
|
344
|
+
locale: Locale | None = None,
|
|
345
|
+
access_token: str | None = None,
|
|
346
|
+
timeout: float | httpx.Timeout | None = None,
|
|
347
|
+
max_retries: int | None = None,
|
|
348
|
+
) -> Self:
|
|
349
|
+
"""Return a client with some settings changed.
|
|
350
|
+
|
|
351
|
+
The copy shares the connection pool and the cached app token, so it is cheap; make one per region, locale
|
|
352
|
+
or user token as needed. Only the original client closes the pool.
|
|
353
|
+
|
|
354
|
+
Args:
|
|
355
|
+
region: Read another region. The locale follows the region unless one was set explicitly.
|
|
356
|
+
locale: Use another locale.
|
|
357
|
+
access_token: Authenticate as a user, for profile routes.
|
|
358
|
+
timeout: Change the request timeout.
|
|
359
|
+
max_retries: Change the retry budget.
|
|
360
|
+
"""
|
|
361
|
+
config = self._config
|
|
362
|
+
new_region = Region(region) if region is not None else config.region
|
|
363
|
+
if locale is not None:
|
|
364
|
+
new_locale, locale_is_default = _check_locale(locale), False
|
|
365
|
+
elif config.locale_is_default:
|
|
366
|
+
new_locale, locale_is_default = DEFAULT_LOCALES[new_region], True
|
|
367
|
+
else:
|
|
368
|
+
new_locale, locale_is_default = config.locale, False
|
|
369
|
+
changed = replace(
|
|
370
|
+
config,
|
|
371
|
+
region=new_region,
|
|
372
|
+
locale=new_locale,
|
|
373
|
+
locale_is_default=locale_is_default,
|
|
374
|
+
access_token=access_token if access_token is not None else config.access_token,
|
|
375
|
+
timeout=config.timeout
|
|
376
|
+
if timeout is None
|
|
377
|
+
else timeout
|
|
378
|
+
if isinstance(timeout, httpx.Timeout)
|
|
379
|
+
else httpx.Timeout(timeout),
|
|
380
|
+
max_retries=config.max_retries if max_retries is None else _check_retries(max_retries),
|
|
381
|
+
)
|
|
382
|
+
return self._from_parts(changed, self._session)
|
|
383
|
+
|
|
384
|
+
async def aclose(self) -> None:
|
|
385
|
+
"""Close the connection pool, unless it was passed in as ``http_client`` or this client is a copy."""
|
|
386
|
+
if self._owns_http:
|
|
387
|
+
await self._session.http.aclose()
|
|
388
|
+
|
|
389
|
+
async def __aenter__(self) -> Self:
|
|
390
|
+
return self
|
|
391
|
+
|
|
392
|
+
async def __aexit__(
|
|
393
|
+
self,
|
|
394
|
+
exc_type: type[BaseException] | None,
|
|
395
|
+
exc: BaseException | None,
|
|
396
|
+
traceback: TracebackType | None,
|
|
397
|
+
) -> None:
|
|
398
|
+
await self.aclose()
|
|
399
|
+
|
|
400
|
+
async def get(
|
|
401
|
+
self,
|
|
402
|
+
path: str,
|
|
403
|
+
*,
|
|
404
|
+
namespace: NamespaceKind | None = None,
|
|
405
|
+
params: Mapping[str, str | int] | None = None,
|
|
406
|
+
) -> APIResponse:
|
|
407
|
+
"""GET any document, for routes without a typed method or links followed by hand.
|
|
408
|
+
|
|
409
|
+
Args:
|
|
410
|
+
path: A path such as ``/data/wow/playable-class/1``, or a full ``href`` from a document's links. Hrefs
|
|
411
|
+
already carry their namespace.
|
|
412
|
+
namespace: The document family to read. Leave it out for hrefs.
|
|
413
|
+
params: Extra query parameters.
|
|
414
|
+
|
|
415
|
+
Raises:
|
|
416
|
+
ValueError: ``path`` is a URL on another host; the client never sends its token elsewhere.
|
|
417
|
+
APIStatusError: Battle.net answered with an error status.
|
|
418
|
+
APIConnectionError: No response arrived.
|
|
419
|
+
"""
|
|
420
|
+
api = httpx.URL(self._config.api_url)
|
|
421
|
+
if path.startswith(("https://", "http://")):
|
|
422
|
+
url = httpx.URL(path)
|
|
423
|
+
if (url.scheme, url.host, url.port) != (api.scheme, api.host, api.port):
|
|
424
|
+
msg = f"refusing to send credentials to {url.scheme}://{url.host}: links must point at {api}"
|
|
425
|
+
raise ValueError(msg)
|
|
426
|
+
else:
|
|
427
|
+
url = httpx.URL(f"{self._config.api_url}/{path.lstrip('/')}")
|
|
428
|
+
merged = {**dict(url.params), **request_params(namespace, self._config.region, self._config.locale, params)}
|
|
429
|
+
return APIResponse(await self._api_request(str(url.copy_with(query=None)), merged))
|
|
430
|
+
|
|
431
|
+
async def _get_model[ModelT: Struct](
|
|
432
|
+
self,
|
|
433
|
+
namespace: NamespaceKind,
|
|
434
|
+
path: str,
|
|
435
|
+
model: type[ModelT],
|
|
436
|
+
params: Mapping[str, str | int] | None = None,
|
|
437
|
+
) -> ModelT:
|
|
438
|
+
url = f"{self._config.api_url}{path}"
|
|
439
|
+
response = await self._api_request(
|
|
440
|
+
url, request_params(namespace, self._config.region, self._config.locale, params)
|
|
441
|
+
)
|
|
442
|
+
return parse_model(response, model)
|
|
443
|
+
|
|
444
|
+
def authorization_url(
|
|
445
|
+
self,
|
|
446
|
+
redirect_uri: str,
|
|
447
|
+
*,
|
|
448
|
+
state: str,
|
|
449
|
+
scopes: str | Iterable[str] = ("wow.profile",),
|
|
450
|
+
) -> str:
|
|
451
|
+
"""Build the Battle.net login URL for the authorization code flow.
|
|
452
|
+
|
|
453
|
+
Send the user here; Battle.net redirects back to ``redirect_uri`` with ``code`` and ``state`` query
|
|
454
|
+
parameters. Check ``state`` matches, then pass ``code`` to :meth:`exchange_code`.
|
|
455
|
+
|
|
456
|
+
Args:
|
|
457
|
+
redirect_uri: A redirect URL registered for the app.
|
|
458
|
+
state: An unguessable value tied to the user's session, to stop cross-site request forgery.
|
|
459
|
+
scopes: Scopes to request, as one string or several. ``wow.profile`` unlocks the account profile; add
|
|
460
|
+
``openid`` for an ID token.
|
|
461
|
+
"""
|
|
462
|
+
query = urlencode(
|
|
463
|
+
{
|
|
464
|
+
"response_type": "code",
|
|
465
|
+
"client_id": self._require_client_id(),
|
|
466
|
+
"redirect_uri": redirect_uri,
|
|
467
|
+
"scope": scopes if isinstance(scopes, str) else " ".join(scopes),
|
|
468
|
+
"state": state,
|
|
469
|
+
}
|
|
470
|
+
)
|
|
471
|
+
return f"{self._config.oauth_url}/authorize?{query}"
|
|
472
|
+
|
|
473
|
+
async def exchange_code(self, code: str, redirect_uri: str) -> OAuthToken:
|
|
474
|
+
"""Trade an authorization code for the user's access token.
|
|
475
|
+
|
|
476
|
+
Use the token with ``client.with_options(access_token=token.access_token)``.
|
|
477
|
+
|
|
478
|
+
Args:
|
|
479
|
+
code: The ``code`` query parameter Battle.net redirected back with.
|
|
480
|
+
redirect_uri: The same ``redirect_uri`` given to :meth:`authorization_url`.
|
|
481
|
+
|
|
482
|
+
Raises:
|
|
483
|
+
AuthenticationError: The app credentials were rejected.
|
|
484
|
+
BadRequestError: The code is invalid, expired or already used, or ``redirect_uri`` does not match.
|
|
485
|
+
"""
|
|
486
|
+
# Never retried: a code is single-use, so a retry after a lost response would only fail with invalid_grant.
|
|
487
|
+
response = await self._token_request(
|
|
488
|
+
{"grant_type": "authorization_code", "code": code, "redirect_uri": redirect_uri}, max_retries=0
|
|
489
|
+
)
|
|
490
|
+
return parse_token(response)
|
|
491
|
+
|
|
492
|
+
def _require_client_id(self) -> str:
|
|
493
|
+
if not self._session.client_id:
|
|
494
|
+
msg = "this needs app credentials, but the client was created with only an access token"
|
|
495
|
+
raise WowError(msg)
|
|
496
|
+
return self._session.client_id
|
|
497
|
+
|
|
498
|
+
async def _token_request(self, data: Mapping[str, str], *, max_retries: int | None = None) -> httpx.Response:
|
|
499
|
+
client_id = self._require_client_id()
|
|
500
|
+
auth = httpx.BasicAuth(client_id, self._session.client_secret or "")
|
|
501
|
+
url = f"{self._config.oauth_url}/token"
|
|
502
|
+
return await self._send("POST", url, data=data, auth=auth, max_retries=max_retries)
|
|
503
|
+
|
|
504
|
+
async def _app_token(self) -> OAuthToken:
|
|
505
|
+
session = self._session
|
|
506
|
+
token = session.app_token
|
|
507
|
+
if token is not None and not token.expires_within(TOKEN_EXPIRY_MARGIN):
|
|
508
|
+
return token
|
|
509
|
+
async with session.token_lock:
|
|
510
|
+
token = session.app_token
|
|
511
|
+
if token is None or token.expires_within(TOKEN_EXPIRY_MARGIN):
|
|
512
|
+
token = parse_token(await self._token_request({"grant_type": "client_credentials"}))
|
|
513
|
+
session.app_token = token
|
|
514
|
+
return token
|
|
515
|
+
|
|
516
|
+
async def _api_request(self, url: str, params: Mapping[str, str | int]) -> httpx.Response:
|
|
517
|
+
"""GET an API URL with a bearer token, replacing a rejected app token once."""
|
|
518
|
+
token_refreshed = False
|
|
519
|
+
while True:
|
|
520
|
+
app_token = None if self._config.access_token is not None else await self._app_token()
|
|
521
|
+
bearer = app_token.access_token if app_token is not None else self._config.access_token
|
|
522
|
+
response = await self._send("GET", url, params=params, headers={"Authorization": f"Bearer {bearer}"})
|
|
523
|
+
if response.status_code == httpx.codes.UNAUTHORIZED and app_token is not None and not token_refreshed:
|
|
524
|
+
# The app token was revoked or expired early. Drop it unless a concurrent request already replaced
|
|
525
|
+
# it, then fetch (or reuse) a fresh one, once.
|
|
526
|
+
async with self._session.token_lock:
|
|
527
|
+
if self._session.app_token is app_token:
|
|
528
|
+
self._session.app_token = None
|
|
529
|
+
token_refreshed = True
|
|
530
|
+
continue
|
|
531
|
+
raise_for_status(response)
|
|
532
|
+
return response
|
|
533
|
+
|
|
534
|
+
async def _send(
|
|
535
|
+
self,
|
|
536
|
+
method: str,
|
|
537
|
+
url: str,
|
|
538
|
+
*,
|
|
539
|
+
params: Mapping[str, str | int] | None = None,
|
|
540
|
+
data: Mapping[str, str] | None = None,
|
|
541
|
+
headers: Mapping[str, str] | None = None,
|
|
542
|
+
auth: httpx.Auth | None = None,
|
|
543
|
+
max_retries: int | None = None,
|
|
544
|
+
) -> httpx.Response:
|
|
545
|
+
"""Send a request, retrying connection failures, timeouts, 408, 429 and 5xx; never raises for status."""
|
|
546
|
+
config = self._config
|
|
547
|
+
budget = config.max_retries if max_retries is None else max_retries
|
|
548
|
+
retries_done = 0
|
|
549
|
+
while True:
|
|
550
|
+
try:
|
|
551
|
+
response = await self._session.http.request(
|
|
552
|
+
method, url, params=params, data=data, headers=headers, auth=auth, timeout=config.timeout
|
|
553
|
+
)
|
|
554
|
+
except httpx.TransportError as error:
|
|
555
|
+
if retries_done >= budget:
|
|
556
|
+
raise connection_error(method, error) from error
|
|
557
|
+
delay = backoff(retries_done)
|
|
558
|
+
else:
|
|
559
|
+
if not should_retry(response) or retries_done >= budget:
|
|
560
|
+
return response
|
|
561
|
+
if (delay := retry_delay(retries_done, response)) is None:
|
|
562
|
+
return response
|
|
563
|
+
await async_sleep(delay)
|
|
564
|
+
retries_done += 1
|
|
565
|
+
|
|
566
|
+
|
|
567
|
+
def _check_locale(locale: str) -> Locale:
|
|
568
|
+
if not is_locale(locale):
|
|
569
|
+
msg = f"unknown locale {locale!r}; expected one of {', '.join(sorted(LOCALES))}"
|
|
570
|
+
raise ValueError(msg)
|
|
571
|
+
return locale
|
|
572
|
+
|
|
573
|
+
|
|
574
|
+
def _check_retries(max_retries: int) -> int:
|
|
575
|
+
if max_retries < 0:
|
|
576
|
+
msg = f"max_retries must be 0 or more, got {max_retries}"
|
|
577
|
+
raise ValueError(msg)
|
|
578
|
+
return max_retries
|