django-esi 7.0.1__py3-none-any.whl → 8.0.0a2__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,903 @@
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, Trim204ContentType
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(), Trim204ContentType()]
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(), Trim204ContentType()]
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(), Trim204ContentType()]
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(), Trim204ContentType()]
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_body_param(self) -> Token | None:
330
+ """Pop the request body from parameters to be able to check the param validity
331
+ Returns:
332
+ Any | None: the request body
333
+ """
334
+ _body = self._kwargs.pop("body", None)
335
+ if _body and not getattr(self.operation, "requestBody", False):
336
+ raise ValueError("Request Body provided on endpoint with no request body paramater.")
337
+ return _body
338
+
339
+ def _extract_token_param(self) -> Token | None:
340
+ """Pop token from parameters or use the Client wide token if set
341
+ Returns:
342
+ Token | None: The token to use for the request
343
+ """
344
+ _token = self._kwargs.pop("token", None)
345
+ if _token and not getattr(self.operation, "security", False):
346
+ raise ValueError("Token provided on public endpoint")
347
+ return self.token or _token
348
+
349
+ def _has_page_param(self) -> bool:
350
+ """Check if this operation supports Offset Based Pagination.
351
+ Returns:
352
+ bool: True if page parameters are present, False otherwise
353
+ """
354
+ return any(p.name == "page" for p in self.operation.parameters)
355
+
356
+ def _has_cursor_param(self) -> bool:
357
+ """Check if this operation supports Cursor Based Pagination.
358
+ Returns:
359
+ bool: True if cursor parameters are present, False otherwise
360
+ """
361
+ return any(p.name == "before" or p.name == "after" for p in self.operation.parameters)
362
+
363
+ def _get_cache(self, cache_key: str) -> tuple[ResponseHeadersType | None, Any, Response | None]:
364
+ """Retrieve cached response and validate expiry
365
+ Args:
366
+ cache_key (str): The cache key to retrieve
367
+ Returns:
368
+ tuple[ResponseHeadersType | None, Any, Response | None]: The cached response,
369
+ or None if not found or expired
370
+ """
371
+ try:
372
+ cached_response = cache.get(cache_key)
373
+ except Exception as e:
374
+ logger.error(f"Cache retrieve failed {e}", exc_info=True)
375
+ return None, None, None
376
+
377
+ if cached_response:
378
+ logger.debug(f"Cache Hit {self.url}")
379
+ headers, data = self.parse_cached_request(cached_response)
380
+
381
+ expiry = _time_to_expiry(str(headers.get('Expires')))
382
+ if expiry < 0:
383
+ logger.warning("Cache expired by %d seconds, forcing expiry", expiry)
384
+ return None, None, None
385
+ return headers, data, cached_response
386
+
387
+ return None, None, None
388
+
389
+ def _store_cache(self, cache_key: str, response) -> None:
390
+ """ Store the response in cache with ETag and TTL.
391
+ Args:
392
+ cache_key (str): The cache key to store the response under
393
+ response (Response): The response object to cache
394
+ """
395
+ if not app_settings.ESI_CACHE_RESPONSE:
396
+ return
397
+
398
+ if "ETag" in response.headers:
399
+ cache.set(f"{cache_key}_etag", response.headers["ETag"])
400
+
401
+ expires = response.headers.get("Expires")
402
+ ttl = _time_to_expiry(expires) if expires else 0
403
+ if ttl > 0:
404
+ try:
405
+ cache.set(cache_key, response, ttl)
406
+ except Exception as e:
407
+ logger.error(f"Failed to cache {e}", exc_info=True)
408
+
409
+ def _validate_token_scopes(self, token: Token) -> None:
410
+ """Validate that the token provided has the required scopes for this ESI operation.
411
+ """
412
+ token_scopes = set(token.scopes.all().values_list("name", flat=True))
413
+ try:
414
+ required_scopes = set(getattr(getattr(self.operation, "security", [])[0], "root", {}).get("OAuth2", []))
415
+ except KeyError:
416
+ required_scopes = []
417
+ missing_scopes = [x for x in required_scopes if x not in token_scopes]
418
+ if len(missing_scopes) > 0:
419
+ raise ValueError(f"Token Missing Scopes - {missing_scopes}")
420
+
421
+ def parse_cached_request(self, cached_response) -> tuple[ResponseHeadersType, ResponseDataType]:
422
+ req = self.api.createRequest(
423
+ f"{self.operation.tags[0]}.{self.operation.operationId}"
424
+ )
425
+ return req._process_request(cached_response)
426
+
427
+
428
+ class EsiOperation(BaseEsiOperation):
429
+ def _make_request(
430
+ self,
431
+ parameters: dict[str, Any],
432
+ etag: str | None = None) -> RequestBase.Response:
433
+
434
+ reset = cache.get("esi_error_limit_reset")
435
+ if reset is not None:
436
+ # Hard exception here if there is still an open Error Limit
437
+ # developers need to either decorators.wait_for_esi_error_limit_reset()
438
+ # or handle this by pushing their celery tasks back
439
+ raise ESIErrorLimitException(reset=reset)
440
+
441
+ retry = http_retry_sync()
442
+
443
+ def __func():
444
+ req = self.api.createRequest(f"{self.operation.tags[0]}.{self.operation.operationId}")
445
+ if self.token:
446
+ self.api.authenticate(OAuth2=True) # make the lib happy
447
+ if isinstance(self.token, str):
448
+ # Fallback older Django-ESI Behaviour
449
+ # Deprecated
450
+ req.req.headers["Authorization"] = f"Bearer {self.token}"
451
+ warnings.warn(
452
+ "Passing an Access Token string directly is deprecated."
453
+ "Doing so will Skip Validation of Scopes"
454
+ "Please use a Token object instead.",
455
+ DeprecationWarning,
456
+ stacklevel=2
457
+ )
458
+ else:
459
+ self._validate_token_scopes(self.token)
460
+ req.req.headers["Authorization"] = f"Bearer {self.token.valid_access_token()}"
461
+ if etag:
462
+ req.req.headers["If-None-Match"] = etag
463
+ return req.request(data=self.body, parameters=self._unnormalize_parameters(parameters))
464
+ return retry(__func)
465
+
466
+ def result(
467
+ self,
468
+ etag: str | None = None,
469
+ return_response: bool = False,
470
+ use_cache: bool = True,
471
+ **extra) -> tuple[Any, Response] | Any:
472
+ """Executes the request and returns the response from ESI for the current operation.
473
+ Raises:
474
+ ESIErrorLimitException: _description_
475
+ Returns:
476
+ _type_: _description_
477
+ """
478
+
479
+ self.token = self._extract_token_param()
480
+ self.body = self._extract_body_param()
481
+ parameters = self._kwargs | extra
482
+ cache_key = self._cache_key()
483
+ etag_key = f"{cache_key}_etag"
484
+
485
+ if not etag and app_settings.ESI_CACHE_RESPONSE:
486
+ etag = cache.get(etag_key)
487
+
488
+ headers, data, response = self._get_cache(cache_key)
489
+
490
+ if response and use_cache:
491
+ expiry = _time_to_expiry(str(headers.get('Expires')))
492
+ if expiry < 0:
493
+ logger.warning(
494
+ "cache expired by %d seconds, Forcing expiry", expiry
495
+ )
496
+ response = None
497
+ headers = None
498
+ data = None
499
+
500
+ if not response:
501
+ logger.debug(f"Cache Miss {self.url}")
502
+ headers, data, response = self._make_request(parameters, etag)
503
+ if response.status_code == 420:
504
+ reset = response.headers.get("X-RateLimit-Reset", None)
505
+ cache.set("esi_error_limit_reset", reset, timeout=reset)
506
+ raise ESIErrorLimitException(reset=reset)
507
+ # if response.status_code == 304 and app_settings.ESI_CACHE_RESPONSE:
508
+ # cached = cache.get(cache_key)
509
+ # if cached:
510
+ # return (cached, response) if return_response else cached
511
+ # we dont want to do this, if we do this we have to store data longer than ttl. rip ram
512
+ self._store_cache(cache_key, response)
513
+
514
+ return (data, response) if return_response else data
515
+
516
+ def results(
517
+ self,
518
+ etag: str | None = None,
519
+ return_response: bool = False,
520
+ use_cache: bool = True,
521
+ **extra) -> tuple[list[Any], Response | Any | None] | list[Any]:
522
+ all_results: list[Any] = []
523
+ last_response: Response | None = None
524
+ """Executes the request and returns the response from ESI for the current
525
+ operation. Response will include all pages if there are more available.
526
+
527
+ Returns:
528
+ _type_: _description_
529
+ """
530
+ if self._has_page_param():
531
+ current_page = 1
532
+ total_pages = 1
533
+ while current_page <= total_pages:
534
+ self._kwargs["page"] = current_page
535
+ data, response = self.result(etag=etag, return_response=True, **extra)
536
+ last_response = response
537
+ all_results.extend(data if isinstance(data, list) else [data])
538
+ total_pages = int(response.headers.get("X-Pages", 1))
539
+ logger.debug(
540
+ f"ESI Page Fetched {self.url} - {current_page}/{total_pages}"
541
+ )
542
+ current_page += 1
543
+
544
+ elif self._has_cursor_param():
545
+ # Untested, there are no cursor based endpoints in ESI
546
+ params = self._kwargs.copy()
547
+ params.update(extra)
548
+ for cursor_param in ("after", "before"):
549
+ if params.get(cursor_param):
550
+ break
551
+ else:
552
+ cursor_param = "after"
553
+ while True:
554
+ data, response = self.result(etag=etag, return_response=True, use_cache=use_cache, **params)
555
+ last_response = response
556
+ if not data:
557
+ break
558
+ all_results.extend(data if isinstance(data, list) else [data])
559
+ cursor_token = {k.lower(): v for k, v in response.headers.items()}.get(cursor_param)
560
+ if not cursor_token:
561
+ break
562
+ params[cursor_param] = cursor_token
563
+
564
+ else:
565
+ data, response = self.result(etag=etag, return_response=True, use_cache=use_cache, **extra)
566
+ all_results.extend(data if isinstance(data, list) else [data])
567
+ last_response = response
568
+
569
+ return (all_results, last_response) if return_response else all_results
570
+
571
+ def results_localized(
572
+ self,
573
+ languages: list[str] | str | None = None,
574
+ **kwargs) -> dict[str, list[Any]]:
575
+ """Executes the request and returns the response from ESI for all default languages and pages (if any).
576
+ Args:
577
+ languages: (list[str], str, optional) language(s) to return instead of default languages
578
+ Raises:
579
+ ValueError: Invalid or Not Supported Language Code ...
580
+ Returns:
581
+ dict[str, list[Any]]: Dict of all responses with the language code as keys.
582
+ """
583
+ if not languages:
584
+ my_languages = list(app_settings.ESI_LANGUAGES)
585
+ else:
586
+ my_languages = []
587
+ for lang in dict.fromkeys(languages):
588
+ if lang not in app_settings.ESI_LANGUAGES:
589
+ raise ValueError('Invalid or Not Supported Language Code: %s' % lang)
590
+ my_languages.append(lang)
591
+
592
+ return {
593
+ language: self.results(language=language, **kwargs)
594
+ for language in my_languages
595
+ }
596
+
597
+ def required_scopes(self) -> list[str]:
598
+ """Return a simple list of scopes required for an endpoint. #Requires loading and processing a client
599
+ Returns:
600
+ list[str]: List of Scopes Required
601
+ """
602
+ try:
603
+ if not getattr(self.operation, "security", False):
604
+ return [] # No Scopes Required
605
+ else:
606
+ return list(getattr(getattr(self.operation, "security", [])[0], "root", {}).get("OAuth2", []))
607
+ except (IndexError, KeyError):
608
+ return []
609
+
610
+
611
+ class EsiOperationAsync(BaseEsiOperation):
612
+ async def _make_request(
613
+ self,
614
+ parameters: dict[str, Any],
615
+ etag: str | None = None
616
+ ) -> RequestBase.Response:
617
+
618
+ reset = cache.get("esi_error_limit_reset")
619
+ if reset is not None:
620
+ # Hard exception here if there is still an open rate limit
621
+ # developers need to either decorators.wait_for_esi_error_limit_reset()
622
+ # or handle this by pushing their celery tasks back
623
+ raise ESIErrorLimitException(reset=reset)
624
+
625
+ async for attempt in http_retry_async():
626
+ with attempt:
627
+ req = self.api.createRequest(f"{self.operation.tags[0]}.{self.operation.operationId}")
628
+ if self.token:
629
+ self.api.authenticate(OAuth2=True) # make the lib happy
630
+ self._validate_token_scopes(self.token)
631
+ req.req.headers["Authorization"] = f"Bearer {self.token.valid_access_token()}"
632
+ if etag:
633
+ req.req.headers["If-None-Match"] = etag
634
+ return await req.request(parameters=self._unnormalize_parameters(parameters))
635
+ # Should never be reached because AsyncRetrying always yields at least once
636
+ raise RuntimeError("Retry loop exited without performing a request")
637
+
638
+ async def result(
639
+ self,
640
+ etag: str | None = None,
641
+ return_response: bool = False,
642
+ use_cache: bool = True,
643
+ **extra
644
+ ) -> tuple[Any, Response] | Any:
645
+ self.token = self._extract_token_param()
646
+ parameters = self._kwargs | extra
647
+ cache_key = self._cache_key()
648
+ etag_key = f"{cache_key}_etag"
649
+
650
+ if not etag and app_settings.ESI_CACHE_RESPONSE:
651
+ etag = cache.get(etag_key)
652
+
653
+ headers, data, response = self._get_cache(cache_key)
654
+
655
+ if response and use_cache:
656
+ expiry = _time_to_expiry(str(headers.get('Expires')))
657
+ if expiry < 0:
658
+ logger.warning(
659
+ "cache expired by %d seconds, Forcing expiry", expiry
660
+ )
661
+ response = None
662
+ headers = None
663
+ data = None
664
+
665
+ if not response:
666
+ logger.debug(f"Cache Miss {self.url}")
667
+ headers, data, response = await self._make_request(parameters, etag)
668
+ if response.status_code == 420:
669
+ reset = response.headers.get("X-RateLimit-Reset", None)
670
+ cache.set("esi_error_limit_reset", reset, timeout=reset)
671
+ raise ESIErrorLimitException(reset=reset)
672
+ # if response.status_code == 304 and app_settings.ESI_CACHE_RESPONSE:
673
+ # cached = cache.get(cache_key)
674
+ # if cached:
675
+ # return (cached, response) if return_response else cached
676
+ # we dont want to do this, if we do this we have to store data longer than ttl. rip ram
677
+ self._store_cache(cache_key, response)
678
+
679
+ return (data, response) if return_response else data
680
+
681
+ async def results(
682
+ self,
683
+ etag: str | None = None,
684
+ return_response: bool = False,
685
+ use_cache: bool = True,
686
+ **extra
687
+ ) -> tuple[list[Any], Response | Any | None] | list[Any]:
688
+ all_results = []
689
+ last_response = None
690
+
691
+ if self._has_page_param():
692
+ current_page = 1
693
+ total_pages = 1
694
+ while current_page <= total_pages:
695
+ self._kwargs["page"] = current_page
696
+ data, response = await self.result(etag=etag, return_response=True, use_cache=use_cache, **extra)
697
+ last_response = response
698
+ all_results.extend(data if isinstance(data, list) else [data])
699
+ total_pages = int(response.headers.get("X-Pages", 1))
700
+ logger.debug(
701
+ f"ESI Page Fetched {self.url} - {current_page}/{total_pages}"
702
+ )
703
+ current_page += 1
704
+ # elif self._has_cursor_param():
705
+ # TODO
706
+ else:
707
+ data, response = await self.result(etag=etag, return_response=True, use_cache=use_cache, **extra)
708
+ all_results.extend(data if isinstance(data, list) else [data])
709
+ last_response = response
710
+
711
+ return (all_results, last_response) if return_response else all_results
712
+
713
+ def results_localized(
714
+ self,
715
+ languages: list[str] | str | None = None,
716
+ **extra) -> dict[str, list[Any]]:
717
+ """Executes the request and returns the response from ESI for all default languages and pages (if any).
718
+ Args:
719
+ languages: (list[str], str, optional) language(s) to return instead of default languages
720
+ Raises:
721
+ ValueError: Invalid or Not Supported Language Code ...
722
+ Returns:
723
+ dict[str, list[Any]]: Dict of all responses with the language code as keys.
724
+ """
725
+ if not languages:
726
+ my_languages = list(app_settings.ESI_LANGUAGES)
727
+ else:
728
+ my_languages = []
729
+ for lang in dict.fromkeys(languages):
730
+ if lang not in app_settings.ESI_LANGUAGES:
731
+ raise ValueError('Invalid or Not Supported Language Code: %s' % lang)
732
+ my_languages.append(lang)
733
+
734
+ return {
735
+ language: self.results(language=language, **extra)
736
+ for language in my_languages
737
+ }
738
+
739
+ def required_scopes(self) -> list[str]:
740
+ """Return a simple list of scopes required for an endpoint. #Requires loading and processing a client
741
+ Returns:
742
+ list[str]: List of Scopes Required
743
+ """
744
+ try:
745
+ if not getattr(self.operation, "security", False):
746
+ return [] # No Scopes Required
747
+ else:
748
+ return list(getattr(getattr(self.operation, "security", [])[0], "root", {}).get("OAuth2", []))
749
+ except (IndexError, KeyError):
750
+ return []
751
+
752
+
753
+ class ESITag:
754
+ """
755
+ API Tag Wrapper, providing access to Operations within a tag
756
+ Assets, Characters, etc.
757
+ """
758
+
759
+ def __init__(self, operation, api) -> None:
760
+ self._oi = operation._oi
761
+ self._operations = operation._operations
762
+ self.api = api
763
+
764
+ def __getattr__(self, name: str) -> EsiOperation:
765
+ if name not in self._operations:
766
+ raise AttributeError(
767
+ f"Operation '{name}' not found in tag '{self._oi}'. "
768
+ f"Available operations: {', '.join(sorted(self._operations.keys()))}"
769
+ )
770
+ return EsiOperation(self._operations[name], self.api)
771
+
772
+
773
+ class ESITagAsync():
774
+ """
775
+ Async API Tag Wrapper, providing access to Operations within a tag
776
+ Assets, Characters, etc.
777
+ """
778
+
779
+ def __init__(self, operation, api) -> None:
780
+ self._oi = operation._oi
781
+ self._operations = operation._operations
782
+ self.api = api
783
+
784
+ def __getattr__(self, name: str) -> EsiOperationAsync:
785
+ if name not in self._operations:
786
+ raise AttributeError(
787
+ f"Operation '{name}' not found in tag '{self._oi}'. "
788
+ f"Available operations: {', '.join(sorted(self._operations.keys()))}"
789
+ )
790
+ return EsiOperationAsync(self._operations[name], self.api)
791
+
792
+
793
+ class ESIClient(ESIClientStub):
794
+ """
795
+ Base ESI Client, provides access to Tags Assets, Characters, etc.
796
+ or Raw aiopenapi3 via sad smiley ._.
797
+ """
798
+ def __init__(self, api: OpenAPI) -> None:
799
+ self.api = api
800
+ self._tags = set(api._operationindex._tags.keys())
801
+
802
+ def __getattr__(self, tag: str) -> ESITag | OperationIndex:
803
+ # underscore returns the raw aiopenapi3 client
804
+ if tag == "_":
805
+ return self.api._operationindex
806
+
807
+ elif tag in set(self.api._operationindex._tags.keys()):
808
+ return ESITag(self.api._operationindex._tags[tag], self.api)
809
+
810
+ raise AttributeError(
811
+ f"Tag '{tag}' not found. "
812
+ f"Available tags: {', '.join(sorted(self._tags))}"
813
+ )
814
+
815
+
816
+ class ESIClientAsync(ESIClientStub):
817
+ """
818
+ Async Base ESI Client, provides access to Tags Assets, Characters, etc.
819
+ or Raw aiopenapi3 via sad smiley ._.
820
+ """
821
+ def __init__(self, api: OpenAPI) -> None:
822
+ self.api = api
823
+ self._tags = set(api._operationindex._tags.keys())
824
+
825
+ def __getattr__(self, tag: str) -> ESITagAsync | OperationIndex:
826
+ # underscore returns the raw aiopenapi3 client
827
+ if tag == "_":
828
+ return self.api._operationindex
829
+
830
+ elif tag in set(self.api._operationindex._tags.keys()):
831
+ return ESITagAsync(self.api._operationindex._tags[tag], self.api)
832
+
833
+ raise AttributeError(
834
+ f"Tag '{tag}' not found. "
835
+ f"Available tags: {', '.join(sorted(self._tags))}"
836
+ )
837
+
838
+
839
+ class ESIClientProvider:
840
+ """Class for providing a single ESI client instance for a whole app
841
+ Args:
842
+ compatibility_date (str): The compatibility date for the ESI client.
843
+ ua_appname (str): Name of the App for generating a User-Agent,
844
+ ua_version (str): Version of the App for generating a User-Agent,
845
+ ua_url (str, Optional): URL To the Source Code or Documentation for generating a User-Agent,
846
+ spec_file (str, Optional): Absolute path to a OpenApi 3.1 spec file to load.
847
+ tenant (str, Optional): The ESI tenant to use (default: "tranquility").
848
+ Functions:
849
+ client(): ESIClient
850
+ client_async(): ESIClientAsync
851
+ """
852
+
853
+ _client: ESIClient | None = None
854
+ _client_async: ESIClientAsync | None = None
855
+
856
+ def __init__(
857
+ self,
858
+ compatibility_date: str,
859
+ ua_appname: str,
860
+ ua_version: str,
861
+ ua_url: str | None = None,
862
+ spec_file: None | str = None,
863
+ tenant: str = "tranquility",
864
+ **kwargs
865
+ ) -> None:
866
+ self._compatibility_date = compatibility_date
867
+ self._ua_appname = ua_appname
868
+ self._ua_version = ua_version
869
+ self._ua_url = ua_url
870
+ self._spec_file = spec_file
871
+ self._tenant = tenant
872
+ self._kwargs = kwargs
873
+
874
+ @property
875
+ def client(self) -> ESIClient:
876
+ if self._client is None:
877
+ api = esi_client_factory_sync(
878
+ compatibility_date=self._compatibility_date,
879
+ ua_appname=self._ua_appname,
880
+ ua_version=self._ua_version,
881
+ ua_url=self._ua_url,
882
+ spec_file=self._spec_file,
883
+ tenant=self._tenant,
884
+ **self._kwargs)
885
+ self._client = ESIClient(api)
886
+ return self._client
887
+
888
+ @property
889
+ async def client_async(self) -> ESIClientAsync:
890
+ if self._client_async is None:
891
+ api = await esi_client_factory_async(
892
+ compatibility_date=self._compatibility_date,
893
+ ua_appname=self._ua_appname,
894
+ ua_version=self._ua_version,
895
+ ua_url=self._ua_url,
896
+ spec_file=self._spec_file,
897
+ tenant=self._tenant,
898
+ **self._kwargs)
899
+ self._client_async = ESIClientAsync(api)
900
+ return self._client_async
901
+
902
+ def __str__(self) -> str:
903
+ return "ESIClientProvider"