django-esi 7.0.0b1__py3-none-any.whl → 8.0.0a1__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.

Potentially problematic release.


This version of django-esi might be problematic. Click here for more details.

esi/openapi_clients.py ADDED
@@ -0,0 +1,845 @@
1
+ import logging
2
+ import warnings
3
+ from datetime import datetime
4
+ from hashlib import md5
5
+ from typing import Any
6
+
7
+ from aiopenapi3 import OpenAPI
8
+ from aiopenapi3._types import ResponseDataType, ResponseHeadersType
9
+ from aiopenapi3.request import OperationIndex, RequestBase
10
+ from httpx import (
11
+ AsyncClient, Client, HTTPStatusError, RequestError, Response, Timeout,
12
+ )
13
+ from tenacity import (
14
+ AsyncRetrying, Retrying, retry_if_exception, stop_after_attempt,
15
+ wait_combine, wait_exponential,
16
+ )
17
+
18
+ from django.core.cache import cache
19
+
20
+ from esi import app_settings
21
+ from esi.aiopenapi3.plugins import PatchCompatibilityDatePlugin
22
+ from esi.exceptions import ESIErrorLimitException
23
+ from esi.models import Token
24
+ from esi.stubs import ESIClientStub
25
+
26
+ from . import __title__, __url__, __version__
27
+
28
+ logger = logging.getLogger(__name__)
29
+
30
+
31
+ def _time_to_expiry(expires_header: str) -> int:
32
+ """Calculate cache TTL from Expires header
33
+ Args:
34
+ expires_header (str): The value of the Expires header '%a, %d %b %Y %H:%M:%S %Z'
35
+ Returns:
36
+ int: The cache TTL in seconds
37
+ """
38
+ try:
39
+ expires_dt = datetime.strptime(str(expires_header), '%a, %d %b %Y %H:%M:%S %Z')
40
+ return max(int((expires_dt - datetime.utcnow()).total_seconds()), 0)
41
+ except ValueError:
42
+ return 0
43
+
44
+
45
+ def _httpx_exceptions(exc: BaseException) -> bool:
46
+ """
47
+ Helper function for HTTP Retries, what various exceptions and status codes should we retry on.
48
+ ESI has some weird behaviours
49
+ """
50
+ if isinstance(exc, ESIErrorLimitException):
51
+ return False
52
+ if isinstance(exc, RequestError):
53
+ return True
54
+ if isinstance(exc, HTTPStatusError) and getattr(exc.response, "status_code", None) in {502, 503, 504}:
55
+ return True
56
+ return False
57
+
58
+
59
+ def http_retry_sync() -> Retrying:
60
+ return Retrying(
61
+ retry=retry_if_exception(_httpx_exceptions),
62
+ wait=wait_combine(
63
+ wait_exponential(multiplier=1, min=1, max=10),
64
+ ),
65
+ stop=stop_after_attempt(3),
66
+ reraise=True,
67
+ )
68
+
69
+
70
+ async def http_retry_async() -> AsyncRetrying:
71
+ return AsyncRetrying(
72
+ retry=retry_if_exception(_httpx_exceptions),
73
+ wait=wait_combine(
74
+ wait_exponential(multiplier=1, min=1, max=10),
75
+ ),
76
+ stop=stop_after_attempt(3),
77
+ reraise=True,
78
+ )
79
+
80
+
81
+ def _load_aiopenapi_client_sync(
82
+ spec_url: str,
83
+ compatibility_date: str,
84
+ user_agent: str,
85
+ tenant: str,
86
+ spec_file: str | None = None) -> OpenAPI:
87
+ """Create an OpenAPI3 Client from Spec
88
+
89
+ Args:
90
+ spec_url (str): _description_
91
+ compatibility_date (str): _description_
92
+ user_agent (str): _description_
93
+ tenant (str): _description_
94
+ spec_file (str | None, optional): _description_. Defaults to None.
95
+
96
+ Returns:
97
+ OpenAPI: aiopenapi3 Client Class
98
+ """
99
+ headers = {
100
+ "User-Agent": user_agent,
101
+ "X-Tenant": tenant,
102
+ "X-Compatibility-Date": compatibility_date
103
+ }
104
+
105
+ def session_factory(**kwargs) -> Client:
106
+ kwargs.pop("headers", None)
107
+ return Client(
108
+ headers=headers,
109
+ timeout=Timeout(
110
+ connect=app_settings.ESI_REQUESTS_CONNECT_TIMEOUT,
111
+ read=app_settings.ESI_REQUESTS_READ_TIMEOUT,
112
+ write=app_settings.ESI_REQUESTS_WRITE_TIMEOUT,
113
+ pool=app_settings.ESI_REQUESTS_POOL_TIMEOUT
114
+ ),
115
+ http2=True,
116
+ **kwargs
117
+ )
118
+ if spec_file:
119
+ return OpenAPI.load_file(
120
+ url=spec_url,
121
+ path=spec_file,
122
+ session_factory=session_factory,
123
+ use_operation_tags=True,
124
+ plugins=[PatchCompatibilityDatePlugin()]
125
+ )
126
+ else:
127
+ return OpenAPI.load_sync(
128
+ url=spec_url,
129
+ session_factory=session_factory,
130
+ use_operation_tags=True,
131
+ plugins=[PatchCompatibilityDatePlugin()]
132
+ )
133
+
134
+
135
+ async def _load_aiopenapi_client_async(
136
+ spec_url: str,
137
+ compatibility_date: str,
138
+ user_agent: str,
139
+ tenant: str,
140
+ spec_file: str | None = None) -> OpenAPI:
141
+ """Create an OpenAPI3 Client from Spec Async
142
+
143
+ Args:
144
+ spec_url (str): _description_
145
+ compatibility_date (str): _description_
146
+ user_agent (str): _description_
147
+ tenant (str): _description_
148
+ spec_file (str | None, optional): _description_. Defaults to None.
149
+
150
+ Returns:
151
+ OpenAPI: aiopenapi3 Client Class
152
+ """
153
+ headers = {
154
+ "User-Agent": user_agent,
155
+ "X-Tenant": tenant,
156
+ "X-Compatibility-Date": compatibility_date
157
+ }
158
+
159
+ def session_factory(**kwargs) -> AsyncClient:
160
+ kwargs.pop("headers", None)
161
+ return AsyncClient(
162
+ headers=headers,
163
+ timeout=Timeout(
164
+ connect=app_settings.ESI_REQUESTS_CONNECT_TIMEOUT,
165
+ read=app_settings.ESI_REQUESTS_READ_TIMEOUT,
166
+ write=app_settings.ESI_REQUESTS_WRITE_TIMEOUT,
167
+ pool=app_settings.ESI_REQUESTS_POOL_TIMEOUT
168
+ ),
169
+ http2=True,
170
+ **kwargs
171
+ )
172
+ if spec_file:
173
+ # TODO find a async way to load from file?
174
+ return OpenAPI.load_file(
175
+ url=spec_url,
176
+ path=spec_file,
177
+ session_factory=session_factory,
178
+ use_operation_tags=True,
179
+ plugins=[PatchCompatibilityDatePlugin()]
180
+ )
181
+ else:
182
+ return await OpenAPI.load_async(
183
+ url=spec_url,
184
+ session_factory=session_factory,
185
+ use_operation_tags=True,
186
+ plugins=[PatchCompatibilityDatePlugin()]
187
+ )
188
+
189
+
190
+ def _build_user_agent(ua_appname: str, ua_version: str, ua_url: str | None) -> str:
191
+ """
192
+ AppName/1.2.3 (foo@example.com; +https://gitlab.com/) Django-ESI/1.2.3 (+https://gitlab.com/allianceauth/django-esi)
193
+ Contact Email will be inserted from app_settings.
194
+ Args:
195
+ ua_appname (str): Application Name, PascalCase
196
+ ua_version (str): Application Version, SemVer
197
+ ua_url (str | None): Application URL (Optional)
198
+ Returns:
199
+ str: User-Agent string
200
+ """
201
+ return (
202
+ f"{ua_appname}/{ua_version} "
203
+ f"({app_settings.ESI_USER_CONTACT_EMAIL}{f'; +{ua_url})' if ua_url else ')'} "
204
+ f"{__title__}/{__version__} (+{__url__})"
205
+ )
206
+
207
+
208
+ def _get_spec_url() -> str:
209
+ return f"{app_settings.ESI_API_URL}meta/openapi.json"
210
+
211
+
212
+ def esi_client_factory_sync(
213
+ compatibility_date: str,
214
+ ua_appname: str, ua_version: str, ua_url: str | None = None,
215
+ spec_file: str | None = None,
216
+ tenant: str = "tranquility",
217
+ **kwargs) -> OpenAPI:
218
+ """Generate a new OpenAPI ESI client.
219
+ Args:
220
+ compatibility_date (str): "YYYY-MM-DD" The latest version of ESI your client is tested with
221
+ ua_appname (str): Application Name, PascalCase
222
+ ua_version (str): Application Version, SemVer
223
+ ua_url (str, optional): Application URL (Optional). Defaults to None.
224
+ spec_file (str | None, optional): Specification file path (Optional). Defaults to None.
225
+ tenant (str, optional): Tenant ID (Optional). Defaults to "tranquility".
226
+ Returns:
227
+ OpenAPI: OpenAPI ESI Client
228
+ """
229
+ user_agent = _build_user_agent(ua_appname, ua_version, ua_url)
230
+ spec_url = _get_spec_url()
231
+ return _load_aiopenapi_client_sync(spec_url, compatibility_date, user_agent, tenant, spec_file)
232
+
233
+
234
+ async def esi_client_factory_async(
235
+ compatibility_date: str,
236
+ ua_appname: str, ua_version: str, ua_url: str | None = None,
237
+ spec_file: str | None = None,
238
+ tenant: str = "tranquility",
239
+ **kwargs) -> OpenAPI:
240
+ """Generate a new OpenAPI ESI client.
241
+ Args:
242
+ compatibility_date (str): "YYYY-MM-DD" The latest version of ESI your client is tested with
243
+ ua_appname (str): Application Name, PascalCase
244
+ ua_version (str): Application Version, SemVer
245
+ ua_url (str | None, optional): Application URL (Optional). Defaults to None.
246
+ spec_file (str | None, optional): Specification file path (Optional). Defaults to None.
247
+ tenant (str, optional): Tenant ID (Optional). Defaults to "tranquility".
248
+ Returns:
249
+ OpenAPI: OpenAPI ESI Client
250
+ """
251
+ user_agent = _build_user_agent(ua_appname, ua_version, ua_url)
252
+ spec_url = _get_spec_url()
253
+ return await _load_aiopenapi_client_async(spec_url, compatibility_date, user_agent, tenant, spec_file)
254
+
255
+
256
+ class BaseEsiOperation():
257
+ def __init__(self, operation, api) -> None:
258
+ self.method, self.url, self.operation, self.extra = operation
259
+ self.api = api
260
+ self.token: Token | None = None
261
+ self._args = []
262
+ self._kwargs = {}
263
+
264
+ def __call__(self, *args, **kwargs) -> "BaseEsiOperation":
265
+ self._args = args
266
+ self._kwargs = kwargs
267
+ return self
268
+
269
+ def _unnormalize_parameters(self, params: dict[str, Any]) -> dict[str, Any]:
270
+ """UN-Normalize Pythonic parameter names back to OpenAPI names.
271
+
272
+ Converts pythonic keys like "Accept_Language" to "Accept-Language" when/if
273
+ a non pythonic (usually) hyphenated form exists in the operation's parameter list. Performs
274
+ case-insensitive matching and only rewrites when there's a known
275
+ parameter with hyphens, leaving normal snake_case params (e.g.
276
+ "type_id") untouched.
277
+ Args:
278
+ params: Raw parameters collected from the call
279
+ Returns:
280
+ dict: Parameters with keys aligned to the OpenAPI spec
281
+ """
282
+ try:
283
+ spec_param_names = [p.name for p in getattr(self.operation, "parameters", [])]
284
+ except Exception:
285
+ spec_param_names = []
286
+
287
+ # Exact and case-insensitive lookup maps
288
+ spec_param_set = set(spec_param_names)
289
+ spec_param_map_ci = {n.lower(): n for n in spec_param_names}
290
+
291
+ normalized: dict[str, Any] = {}
292
+ for k, v in params.items():
293
+ # Fast path: exact match
294
+ if k in spec_param_set:
295
+ normalized[k] = v
296
+ continue
297
+
298
+ # Try hyphen variant
299
+ k_dash = k.replace("_", "-")
300
+ if k_dash in spec_param_set:
301
+ normalized[k_dash] = v
302
+ continue
303
+
304
+ # Case-insensitive fallbacks
305
+ kl = k.lower()
306
+ if kl in spec_param_map_ci:
307
+ normalized[spec_param_map_ci[kl]] = v
308
+ continue
309
+
310
+ k_dash_l = k_dash.lower()
311
+ if k_dash_l in spec_param_map_ci:
312
+ normalized[spec_param_map_ci[k_dash_l]] = v
313
+ continue
314
+
315
+ # Unknown to the spec; pass through as-is (aiopenapi3 will validate)
316
+ normalized[k] = v
317
+
318
+ return normalized
319
+
320
+ def _cache_key(self) -> str:
321
+ """Generate a key name used to cache responses based on method, url, args, kwargs
322
+ Returns:
323
+ str: Key name
324
+ """
325
+ data = (self.method + self.url + str(self._args) + str(self._kwargs)).encode('utf-8')
326
+ str_hash = md5(data).hexdigest() # nosec B303
327
+ return f'esi_{str_hash}'
328
+
329
+ def _extract_token_param(self) -> Token | None:
330
+ """Pop token from parameters or use the Client wide token if set
331
+ Returns:
332
+ Token | None: The token to use for the request
333
+ """
334
+ _token = self._kwargs.pop("token", None)
335
+ if _token and not getattr(self.operation, "security", False):
336
+ raise ValueError("Token provided on public endpoint")
337
+ return self.token or _token
338
+
339
+ def _has_page_param(self) -> bool:
340
+ """Check if this operation supports Offset Based Pagination.
341
+ Returns:
342
+ bool: True if page parameters are present, False otherwise
343
+ """
344
+ return any(p.name == "page" for p in self.operation.parameters)
345
+
346
+ def _has_cursor_param(self) -> bool:
347
+ """Check if this operation supports Cursor Based Pagination.
348
+ Returns:
349
+ bool: True if cursor parameters are present, False otherwise
350
+ """
351
+ return any(p.name == "before" or p.name == "after" for p in self.operation.parameters)
352
+
353
+ def _get_cache(self, cache_key: str) -> tuple[ResponseHeadersType | None, Any, Response | None]:
354
+ """Retrieve cached response and validate expiry
355
+ Args:
356
+ cache_key (str): The cache key to retrieve
357
+ Returns:
358
+ tuple[ResponseHeadersType | None, Any, Response | None]: The cached response,
359
+ or None if not found or expired
360
+ """
361
+ try:
362
+ cached_response = cache.get(cache_key)
363
+ except Exception as e:
364
+ logger.error(f"Cache retrieve failed {e}", exc_info=True)
365
+ return None, None, None
366
+
367
+ if cached_response:
368
+ logger.debug(f"Cache Hit {self.url}")
369
+ headers, data = self.parse_cached_request(cached_response)
370
+
371
+ expiry = _time_to_expiry(str(headers.get('Expires')))
372
+ if expiry < 0:
373
+ logger.warning("Cache expired by %d seconds, forcing expiry", expiry)
374
+ return None, None, None
375
+ return headers, data, cached_response
376
+
377
+ return None, None, None
378
+
379
+ def _store_cache(self, cache_key: str, response) -> None:
380
+ """ Store the response in cache with ETag and TTL.
381
+ Args:
382
+ cache_key (str): The cache key to store the response under
383
+ response (Response): The response object to cache
384
+ """
385
+ if not app_settings.ESI_CACHE_RESPONSE:
386
+ return
387
+
388
+ if "ETag" in response.headers:
389
+ cache.set(f"{cache_key}_etag", response.headers["ETag"])
390
+
391
+ expires = response.headers.get("Expires")
392
+ ttl = _time_to_expiry(expires) if expires else 0
393
+ if ttl > 0:
394
+ try:
395
+ cache.set(cache_key, response, ttl)
396
+ except Exception as e:
397
+ logger.error(f"Failed to cache {e}", exc_info=True)
398
+
399
+ def _validate_token_scopes(self, token: Token) -> None:
400
+ """Validate that the token provided has the required scopes for this ESI operation.
401
+ """
402
+ token_scopes = set(token.scopes.all().values_list("name", flat=True))
403
+ try:
404
+ required_scopes = set(getattr(getattr(self.operation, "security", [])[0], "root", {}).get("OAuth2", []))
405
+ except KeyError:
406
+ required_scopes = []
407
+ missing_scopes = [x for x in required_scopes if x not in token_scopes]
408
+ if len(missing_scopes) > 0:
409
+ raise ValueError(f"Token Missing Scopes - {missing_scopes}")
410
+
411
+ def parse_cached_request(self, cached_response) -> tuple[ResponseHeadersType, ResponseDataType]:
412
+ req = self.api.createRequest(
413
+ f"{self.operation.tags[0]}.{self.operation.operationId}"
414
+ )
415
+ return req._process_request(cached_response)
416
+
417
+
418
+ class EsiOperation(BaseEsiOperation):
419
+ def _make_request(
420
+ self,
421
+ parameters: dict[str, Any],
422
+ etag: str | None = None) -> RequestBase.Response:
423
+
424
+ reset = cache.get("esi_error_limit_reset")
425
+ if reset is not None:
426
+ # Hard exception here if there is still an open Error Limit
427
+ # developers need to either decorators.wait_for_esi_error_limit_reset()
428
+ # or handle this by pushing their celery tasks back
429
+ raise ESIErrorLimitException(reset=reset)
430
+
431
+ retry = http_retry_sync()
432
+
433
+ def __func():
434
+ req = self.api.createRequest(f"{self.operation.tags[0]}.{self.operation.operationId}")
435
+ if self.token:
436
+ self.api.authenticate(OAuth2=True) # make the lib happy
437
+ if isinstance(self.token, str):
438
+ # Fallback older Django-ESI Behaviour
439
+ # Deprecated
440
+ req.req.headers["Authorization"] = f"Bearer {self.token}"
441
+ warnings.warn(
442
+ "Passing an Access Token string directly is deprecated."
443
+ "Doing so will Skip Validation of Scopes"
444
+ "Please use a Token object instead.",
445
+ DeprecationWarning,
446
+ stacklevel=2
447
+ )
448
+ else:
449
+ self._validate_token_scopes(self.token)
450
+ req.req.headers["Authorization"] = f"Bearer {self.token.valid_access_token()}"
451
+ if etag:
452
+ req.req.headers["If-None-Match"] = etag
453
+ return req.request(parameters=self._unnormalize_parameters(parameters))
454
+ return retry(__func)
455
+
456
+ def result(
457
+ self,
458
+ etag: str | None = None,
459
+ return_response: bool = False,
460
+ use_cache: bool = True,
461
+ **extra) -> tuple[Any, Response] | Any:
462
+ """Executes the request and returns the response from ESI for the current operation.
463
+ Raises:
464
+ ESIErrorLimitException: _description_
465
+ Returns:
466
+ _type_: _description_
467
+ """
468
+
469
+ self.token = self._extract_token_param()
470
+ parameters = self._kwargs | extra
471
+ cache_key = self._cache_key()
472
+ etag_key = f"{cache_key}_etag"
473
+
474
+ if not etag and app_settings.ESI_CACHE_RESPONSE:
475
+ etag = cache.get(etag_key)
476
+
477
+ headers, data, response = self._get_cache(cache_key)
478
+
479
+ if response and use_cache:
480
+ expiry = _time_to_expiry(str(headers.get('Expires')))
481
+ if expiry < 0:
482
+ logger.warning(
483
+ "cache expired by %d seconds, Forcing expiry", expiry
484
+ )
485
+ response = None
486
+ headers = None
487
+ data = None
488
+
489
+ if not response:
490
+ logger.debug(f"Cache Miss {self.url}")
491
+ headers, data, response = self._make_request(parameters, etag)
492
+ if response.status_code == 420:
493
+ reset = response.headers.get("X-RateLimit-Reset", None)
494
+ cache.set("esi_error_limit_reset", reset, timeout=reset)
495
+ raise ESIErrorLimitException(reset=reset)
496
+ # if response.status_code == 304 and app_settings.ESI_CACHE_RESPONSE:
497
+ # cached = cache.get(cache_key)
498
+ # if cached:
499
+ # return (cached, response) if return_response else cached
500
+ # we dont want to do this, if we do this we have to store data longer than ttl. rip ram
501
+ self._store_cache(cache_key, response)
502
+
503
+ return (data, response) if return_response else data
504
+
505
+ def results(
506
+ self,
507
+ etag: str | None = None,
508
+ return_response: bool = False,
509
+ use_cache: bool = True,
510
+ **extra) -> tuple[list[Any], Response | Any | None] | list[Any]:
511
+ all_results = []
512
+ last_response = None
513
+ """Executes the request and returns the response from ESI for the current
514
+ operation. Response will include all pages if there are more available.
515
+
516
+ Returns:
517
+ _type_: _description_
518
+ """
519
+ if self._has_page_param():
520
+ current_page = 1
521
+ total_pages = 1
522
+ while current_page <= total_pages:
523
+ self._kwargs["page"] = current_page
524
+ data, response = self.result(etag=etag, return_response=True, **extra)
525
+ last_response = response
526
+ all_results.extend(data if isinstance(data, list) else [data])
527
+ total_pages = int(response.headers.get("X-Pages", 1))
528
+ logger.debug(
529
+ f"ESI Page Fetched {self.url} - {current_page}/{total_pages}"
530
+ )
531
+ current_page += 1
532
+
533
+ elif self._has_cursor_param():
534
+ # Untested, there are no cursor based endpoints in ESI
535
+ params = self._kwargs.copy()
536
+ params.update(extra)
537
+ for cursor_param in ("after", "before"):
538
+ if params.get(cursor_param):
539
+ break
540
+ else:
541
+ cursor_param = "after"
542
+ while True:
543
+ data, response = self.result(etag=etag, return_response=True, use_cache=use_cache, **params)
544
+ last_response = response
545
+ if not data:
546
+ break
547
+ all_results.extend(data if isinstance(data, list) else [data])
548
+ cursor_token = {k.lower(): v for k, v in response.headers.items()}.get(cursor_param)
549
+ if not cursor_token:
550
+ break
551
+ params[cursor_param] = cursor_token
552
+
553
+ else:
554
+ data, response = self.result(etag=etag, return_response=True, use_cache=use_cache, **extra)
555
+ all_results.extend(data if isinstance(data, list) else [data])
556
+ last_response = response
557
+
558
+ return (all_results, last_response) if return_response else all_results
559
+
560
+ def results_localized(self, languages: str | list[str] = "en", **kwargs) -> list[Any]:
561
+ # We can either push Accept-Language up to the library level
562
+ # OR we insert the parameter into each request here
563
+ raise NotImplementedError()
564
+
565
+ def required_scopes(self) -> list[str]:
566
+ """Return a simple list of scopes required for an endpoint. #Requires loading and processing a client
567
+ Returns:
568
+ list[str]: List of Scopes Required
569
+ """
570
+ try:
571
+ if not getattr(self.operation, "security", False):
572
+ return [] # No Scopes Required
573
+ else:
574
+ return list(getattr(getattr(self.operation, "security", [])[0], "root", {}).get("OAuth2", []))
575
+ except (IndexError, KeyError):
576
+ return []
577
+
578
+
579
+ class EsiOperationAsync(BaseEsiOperation):
580
+ async def _make_request(
581
+ self,
582
+ parameters: dict[str, Any],
583
+ etag: str | None = None
584
+ ) -> RequestBase.Response:
585
+
586
+ reset = cache.get("esi_error_limit_reset")
587
+ if reset is not None:
588
+ # Hard exception here if there is still an open rate limit
589
+ # developers need to either decorators.wait_for_esi_error_limit_reset()
590
+ # or handle this by pushing their celery tasks back
591
+ raise ESIErrorLimitException(reset=reset)
592
+
593
+ async for attempt in http_retry_async():
594
+ with attempt:
595
+ req = self.api.createRequest(f"{self.operation.tags[0]}.{self.operation.operationId}")
596
+ if self.token:
597
+ self.api.authenticate(OAuth2=True) # make the lib happy
598
+ self._validate_token_scopes(self.token)
599
+ req.req.headers["Authorization"] = f"Bearer {self.token.valid_access_token()}"
600
+ if etag:
601
+ req.req.headers["If-None-Match"] = etag
602
+ return await req.request(parameters=self._unnormalize_parameters(parameters))
603
+ # Should never be reached because AsyncRetrying always yields at least once
604
+ raise RuntimeError("Retry loop exited without performing a request")
605
+
606
+ async def result(
607
+ self,
608
+ etag: str | None = None,
609
+ return_response: bool = False,
610
+ use_cache: bool = True,
611
+ **extra
612
+ ) -> tuple[Any, Response] | Any:
613
+ self.token = self._extract_token_param()
614
+ parameters = self._kwargs | extra
615
+ cache_key = self._cache_key()
616
+ etag_key = f"{cache_key}_etag"
617
+
618
+ if not etag and app_settings.ESI_CACHE_RESPONSE:
619
+ etag = cache.get(etag_key)
620
+
621
+ headers, data, response = self._get_cache(cache_key)
622
+
623
+ if response and use_cache:
624
+ expiry = _time_to_expiry(str(headers.get('Expires')))
625
+ if expiry < 0:
626
+ logger.warning(
627
+ "cache expired by %d seconds, Forcing expiry", expiry
628
+ )
629
+ response = None
630
+ headers = None
631
+ data = None
632
+
633
+ if not response:
634
+ logger.debug(f"Cache Miss {self.url}")
635
+ headers, data, response = await self._make_request(parameters, etag)
636
+ if response.status_code == 420:
637
+ reset = response.headers.get("X-RateLimit-Reset", None)
638
+ cache.set("esi_error_limit_reset", reset, timeout=reset)
639
+ raise ESIErrorLimitException(reset=reset)
640
+ # if response.status_code == 304 and app_settings.ESI_CACHE_RESPONSE:
641
+ # cached = cache.get(cache_key)
642
+ # if cached:
643
+ # return (cached, response) if return_response else cached
644
+ # we dont want to do this, if we do this we have to store data longer than ttl. rip ram
645
+ self._store_cache(cache_key, response)
646
+
647
+ return (data, response) if return_response else data
648
+
649
+ async def results(
650
+ self,
651
+ etag: str | None = None,
652
+ return_response: bool = False,
653
+ use_cache: bool = True,
654
+ **extra
655
+ ) -> tuple[list[Any], Response | Any | None] | list[Any]:
656
+ all_results = []
657
+ last_response = None
658
+
659
+ if self._has_page_param():
660
+ current_page = 1
661
+ total_pages = 1
662
+ while current_page <= total_pages:
663
+ self._kwargs["page"] = current_page
664
+ data, response = await self.result(etag=etag, return_response=True, use_cache=use_cache, **extra)
665
+ last_response = response
666
+ all_results.extend(data if isinstance(data, list) else [data])
667
+ total_pages = int(response.headers.get("X-Pages", 1))
668
+ logger.debug(
669
+ f"ESI Page Fetched {self.url} - {current_page}/{total_pages}"
670
+ )
671
+ current_page += 1
672
+ # elif self._has_cursor_param():
673
+ # TODO
674
+ else:
675
+ data, response = await self.result(etag=etag, return_response=True, use_cache=use_cache, **extra)
676
+ all_results.extend(data if isinstance(data, list) else [data])
677
+ last_response = response
678
+
679
+ return (all_results, last_response) if return_response else all_results
680
+
681
+ async def results_localized(self, languages: str | list[str] = "en", **kwargs) -> list[Any]:
682
+ raise NotImplementedError()
683
+
684
+ def required_scopes(self) -> list[str]:
685
+ """Return a simple list of scopes required for an endpoint. #Requires loading and processing a client
686
+ Returns:
687
+ list[str]: List of Scopes Required
688
+ """
689
+ try:
690
+ if not getattr(self.operation, "security", False):
691
+ return [] # No Scopes Required
692
+ else:
693
+ return list(getattr(getattr(self.operation, "security", [])[0], "root", {}).get("OAuth2", []))
694
+ except (IndexError, KeyError):
695
+ return []
696
+
697
+
698
+ class ESITag:
699
+ """
700
+ API Tag Wrapper, providing access to Operations within a tag
701
+ Assets, Characters, etc.
702
+ """
703
+
704
+ def __init__(self, operation, api) -> None:
705
+ self._oi = operation._oi
706
+ self._operations = operation._operations
707
+ self.api = api
708
+
709
+ def __getattr__(self, name: str) -> EsiOperation:
710
+ if name not in self._operations:
711
+ raise AttributeError(
712
+ f"Operation '{name}' not found in tag '{self._oi}'. "
713
+ f"Available operations: {', '.join(sorted(self._operations.keys()))}"
714
+ )
715
+ return EsiOperation(self._operations[name], self.api)
716
+
717
+
718
+ class ESITagAsync():
719
+ """
720
+ Async API Tag Wrapper, providing access to Operations within a tag
721
+ Assets, Characters, etc.
722
+ """
723
+
724
+ def __init__(self, operation, api) -> None:
725
+ self._oi = operation._oi
726
+ self._operations = operation._operations
727
+ self.api = api
728
+
729
+ def __getattr__(self, name: str) -> EsiOperationAsync:
730
+ if name not in self._operations:
731
+ raise AttributeError(
732
+ f"Operation '{name}' not found in tag '{self._oi}'. "
733
+ f"Available operations: {', '.join(sorted(self._operations.keys()))}"
734
+ )
735
+ return EsiOperationAsync(self._operations[name], self.api)
736
+
737
+
738
+ class ESIClient(ESIClientStub):
739
+ """
740
+ Base ESI Client, provides access to Tags Assets, Characters, etc.
741
+ or Raw aiopenapi3 via sad smiley ._.
742
+ """
743
+ def __init__(self, api: OpenAPI) -> None:
744
+ self.api = api
745
+ self._tags = set(api._operationindex._tags.keys())
746
+
747
+ def __getattr__(self, tag: str) -> ESITag | OperationIndex:
748
+ # underscore returns the raw aiopenapi3 client
749
+ if tag == "_":
750
+ return self.api._operationindex
751
+
752
+ elif tag in set(self.api._operationindex._tags.keys()):
753
+ return ESITag(self.api._operationindex._tags[tag], self.api)
754
+
755
+ raise AttributeError(
756
+ f"Tag '{tag}' not found. "
757
+ f"Available tags: {', '.join(sorted(self._tags))}"
758
+ )
759
+
760
+
761
+ class ESIClientAsync(ESIClientStub):
762
+ """
763
+ Async Base ESI Client, provides access to Tags Assets, Characters, etc.
764
+ or Raw aiopenapi3 via sad smiley ._.
765
+ """
766
+ def __init__(self, api: OpenAPI) -> None:
767
+ self.api = api
768
+ self._tags = set(api._operationindex._tags.keys())
769
+
770
+ def __getattr__(self, tag: str) -> ESITagAsync | OperationIndex:
771
+ # underscore returns the raw aiopenapi3 client
772
+ if tag == "_":
773
+ return self.api._operationindex
774
+
775
+ elif tag in set(self.api._operationindex._tags.keys()):
776
+ return ESITagAsync(self.api._operationindex._tags[tag], self.api)
777
+
778
+ raise AttributeError(
779
+ f"Tag '{tag}' not found. "
780
+ f"Available tags: {', '.join(sorted(self._tags))}"
781
+ )
782
+
783
+
784
+ class ESIClientProvider:
785
+ """Class for providing a single ESI client instance for a whole app
786
+ Args:
787
+ compatibility_date (str): The compatibility date for the ESI client.
788
+ ua_appname (str): Name of the App for generating a User-Agent,
789
+ ua_version (str): Version of the App for generating a User-Agent,
790
+ ua_url (str, Optional): URL To the Source Code or Documentation for generating a User-Agent,
791
+ spec_file (str, Optional): Absolute path to a OpenApi 3.1 spec file to load.
792
+ tenant (str, Optional): The ESI tenant to use (default: "tranquility").
793
+ Functions:
794
+ client(): ESIClient
795
+ client_async(): ESIClientAsync
796
+ """
797
+
798
+ def __init__(
799
+ self,
800
+ compatibility_date: str,
801
+ ua_appname: str,
802
+ ua_version: str,
803
+ ua_url: str | None = None,
804
+ spec_file: None | str = None,
805
+ tenant: str = "tranquility",
806
+ **kwargs
807
+ ) -> None:
808
+ self._compatibility_date = compatibility_date
809
+ self._ua_appname = ua_appname
810
+ self._ua_version = ua_version
811
+ self._ua_url = ua_url
812
+ self._spec_file = spec_file
813
+ self._tenant = tenant
814
+ self._kwargs = kwargs
815
+
816
+ @property
817
+ def client(self) -> ESIClient:
818
+ if self._client is None:
819
+ api = esi_client_factory_sync(
820
+ compatibility_date=self._compatibility_date,
821
+ ua_appname=self._ua_appname,
822
+ ua_version=self._ua_version,
823
+ ua_url=self._ua_url,
824
+ spec_file=self._spec_file,
825
+ tenant=self._tenant,
826
+ **self._kwargs)
827
+ self._client = ESIClient(api)
828
+ return self._client
829
+
830
+ @property
831
+ async def client_async(self) -> ESIClientAsync:
832
+ if self._client_async is None:
833
+ api = await esi_client_factory_async(
834
+ compatibility_date=self._compatibility_date,
835
+ ua_appname=self._ua_appname,
836
+ ua_version=self._ua_version,
837
+ ua_url=self._ua_url,
838
+ spec_file=self._spec_file,
839
+ tenant=self._tenant,
840
+ **self._kwargs)
841
+ self._client_async = ESIClientAsync(api)
842
+ return self._client_async
843
+
844
+ def __str__(self) -> str:
845
+ return "ESIClientProvider"