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.
Files changed (89) hide show
  1. wow/__init__.py +52 -0
  2. wow/_async/__init__.py +1 -0
  3. wow/_async/_client.py +578 -0
  4. wow/_async/_resource.py +73 -0
  5. wow/_async/resources/__init__.py +1 -0
  6. wow/_async/resources/account.py +19 -0
  7. wow/_async/resources/achievement.py +39 -0
  8. wow/_async/resources/character.py +114 -0
  9. wow/_async/resources/creature.py +34 -0
  10. wow/_async/resources/guild.py +29 -0
  11. wow/_async/resources/item.py +111 -0
  12. wow/_async/resources/mount.py +82 -0
  13. wow/_async/resources/pet.py +20 -0
  14. wow/_async/resources/playable_class.py +25 -0
  15. wow/_async/resources/playable_race.py +20 -0
  16. wow/_async/resources/playable_specialization.py +20 -0
  17. wow/_async/resources/power_type.py +20 -0
  18. wow/_async/resources/profession.py +31 -0
  19. wow/_async/resources/quest.py +66 -0
  20. wow/_async/resources/realm.py +79 -0
  21. wow/_async/resources/recipe.py +21 -0
  22. wow/_async/resources/region.py +20 -0
  23. wow/_async/resources/reputation.py +39 -0
  24. wow/_async/resources/ruleset.py +20 -0
  25. wow/_async/resources/talent.py +38 -0
  26. wow/_async/resources/title.py +20 -0
  27. wow/_async/resources/toy.py +20 -0
  28. wow/_base.py +190 -0
  29. wow/_concurrency.py +20 -0
  30. wow/_exceptions.py +149 -0
  31. wow/_response.py +69 -0
  32. wow/_sync/__init__.py +2 -0
  33. wow/_sync/_client.py +577 -0
  34. wow/_sync/_resource.py +74 -0
  35. wow/_sync/resources/__init__.py +2 -0
  36. wow/_sync/resources/account.py +20 -0
  37. wow/_sync/resources/achievement.py +40 -0
  38. wow/_sync/resources/character.py +115 -0
  39. wow/_sync/resources/creature.py +35 -0
  40. wow/_sync/resources/guild.py +30 -0
  41. wow/_sync/resources/item.py +112 -0
  42. wow/_sync/resources/mount.py +83 -0
  43. wow/_sync/resources/pet.py +21 -0
  44. wow/_sync/resources/playable_class.py +26 -0
  45. wow/_sync/resources/playable_race.py +21 -0
  46. wow/_sync/resources/playable_specialization.py +21 -0
  47. wow/_sync/resources/power_type.py +21 -0
  48. wow/_sync/resources/profession.py +32 -0
  49. wow/_sync/resources/quest.py +67 -0
  50. wow/_sync/resources/realm.py +80 -0
  51. wow/_sync/resources/recipe.py +22 -0
  52. wow/_sync/resources/region.py +21 -0
  53. wow/_sync/resources/reputation.py +40 -0
  54. wow/_sync/resources/ruleset.py +21 -0
  55. wow/_sync/resources/talent.py +39 -0
  56. wow/_sync/resources/title.py +21 -0
  57. wow/_sync/resources/toy.py +21 -0
  58. wow/_types.py +59 -0
  59. wow/models/__init__.py +410 -0
  60. wow/models/_common.py +144 -0
  61. wow/models/account.py +60 -0
  62. wow/models/achievement.py +76 -0
  63. wow/models/character.py +510 -0
  64. wow/models/creature.py +32 -0
  65. wow/models/guild.py +75 -0
  66. wow/models/item.py +365 -0
  67. wow/models/mount.py +55 -0
  68. wow/models/pet.py +39 -0
  69. wow/models/playable_class.py +25 -0
  70. wow/models/playable_race.py +24 -0
  71. wow/models/playable_specialization.py +22 -0
  72. wow/models/power_type.py +18 -0
  73. wow/models/profession.py +39 -0
  74. wow/models/quest.py +156 -0
  75. wow/models/realm.py +62 -0
  76. wow/models/recipe.py +34 -0
  77. wow/models/region.py +20 -0
  78. wow/models/reputation.py +56 -0
  79. wow/models/ruleset.py +29 -0
  80. wow/models/talent.py +125 -0
  81. wow/models/title.py +27 -0
  82. wow/models/toy.py +22 -0
  83. wow/py.typed +0 -0
  84. wow/resources.py +111 -0
  85. wow/search.py +113 -0
  86. wow-0.1.0.dist-info/METADATA +103 -0
  87. wow-0.1.0.dist-info/RECORD +89 -0
  88. wow-0.1.0.dist-info/WHEEL +4 -0
  89. 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