django-esi 8.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 (100) hide show
  1. django_esi-8.1.0.dist-info/METADATA +93 -0
  2. django_esi-8.1.0.dist-info/RECORD +100 -0
  3. django_esi-8.1.0.dist-info/WHEEL +4 -0
  4. django_esi-8.1.0.dist-info/licenses/LICENSE +674 -0
  5. esi/__init__.py +7 -0
  6. esi/admin.py +42 -0
  7. esi/aiopenapi3/client.py +79 -0
  8. esi/aiopenapi3/plugins.py +224 -0
  9. esi/app_settings.py +112 -0
  10. esi/apps.py +11 -0
  11. esi/checks.py +56 -0
  12. esi/clients.py +657 -0
  13. esi/decorators.py +271 -0
  14. esi/errors.py +22 -0
  15. esi/exceptions.py +51 -0
  16. esi/helpers.py +63 -0
  17. esi/locale/cs_CZ/LC_MESSAGES/django.mo +0 -0
  18. esi/locale/cs_CZ/LC_MESSAGES/django.po +53 -0
  19. esi/locale/de/LC_MESSAGES/django.mo +0 -0
  20. esi/locale/de/LC_MESSAGES/django.po +58 -0
  21. esi/locale/en/LC_MESSAGES/django.mo +0 -0
  22. esi/locale/en/LC_MESSAGES/django.po +54 -0
  23. esi/locale/es/LC_MESSAGES/django.mo +0 -0
  24. esi/locale/es/LC_MESSAGES/django.po +59 -0
  25. esi/locale/fr_FR/LC_MESSAGES/django.mo +0 -0
  26. esi/locale/fr_FR/LC_MESSAGES/django.po +59 -0
  27. esi/locale/it_IT/LC_MESSAGES/django.mo +0 -0
  28. esi/locale/it_IT/LC_MESSAGES/django.po +59 -0
  29. esi/locale/ja/LC_MESSAGES/django.mo +0 -0
  30. esi/locale/ja/LC_MESSAGES/django.po +58 -0
  31. esi/locale/ko_KR/LC_MESSAGES/django.mo +0 -0
  32. esi/locale/ko_KR/LC_MESSAGES/django.po +58 -0
  33. esi/locale/nl_NL/LC_MESSAGES/django.mo +0 -0
  34. esi/locale/nl_NL/LC_MESSAGES/django.po +53 -0
  35. esi/locale/pl_PL/LC_MESSAGES/django.mo +0 -0
  36. esi/locale/pl_PL/LC_MESSAGES/django.po +53 -0
  37. esi/locale/ru/LC_MESSAGES/django.mo +0 -0
  38. esi/locale/ru/LC_MESSAGES/django.po +61 -0
  39. esi/locale/sk/LC_MESSAGES/django.mo +0 -0
  40. esi/locale/sk/LC_MESSAGES/django.po +55 -0
  41. esi/locale/uk/LC_MESSAGES/django.mo +0 -0
  42. esi/locale/uk/LC_MESSAGES/django.po +57 -0
  43. esi/locale/zh_Hans/LC_MESSAGES/django.mo +0 -0
  44. esi/locale/zh_Hans/LC_MESSAGES/django.po +58 -0
  45. esi/management/commands/__init__.py +0 -0
  46. esi/management/commands/esi_clear_spec_cache.py +21 -0
  47. esi/management/commands/generate_esi_stubs.py +661 -0
  48. esi/management/commands/migrate_to_ssov2.py +188 -0
  49. esi/managers.py +303 -0
  50. esi/managers.pyi +85 -0
  51. esi/migrations/0001_initial.py +55 -0
  52. esi/migrations/0002_scopes_20161208.py +56 -0
  53. esi/migrations/0003_hide_tokens_from_admin_site.py +23 -0
  54. esi/migrations/0004_remove_unique_access_token.py +18 -0
  55. esi/migrations/0005_remove_token_length_limit.py +23 -0
  56. esi/migrations/0006_remove_url_length_limit.py +18 -0
  57. esi/migrations/0007_fix_mysql_8_migration.py +18 -0
  58. esi/migrations/0008_nullable_refresh_token.py +18 -0
  59. esi/migrations/0009_set_old_tokens_to_sso_v1.py +18 -0
  60. esi/migrations/0010_set_new_tokens_to_sso_v2.py +18 -0
  61. esi/migrations/0011_add_token_indices.py +28 -0
  62. esi/migrations/0012_fix_token_type_choices.py +18 -0
  63. esi/migrations/0013_squashed_0012_fix_token_type_choices.py +57 -0
  64. esi/migrations/__init__.py +0 -0
  65. esi/models.py +349 -0
  66. esi/openapi_clients.py +1225 -0
  67. esi/rate_limiting.py +107 -0
  68. esi/signals.py +21 -0
  69. esi/static/esi/img/EVE_SSO_Login_Buttons_Large_Black.png +0 -0
  70. esi/static/esi/img/EVE_SSO_Login_Buttons_Large_White.png +0 -0
  71. esi/static/esi/img/EVE_SSO_Login_Buttons_Small_Black.png +0 -0
  72. esi/static/esi/img/EVE_SSO_Login_Buttons_Small_White.png +0 -0
  73. esi/stubs.py +2 -0
  74. esi/stubs.pyi +6807 -0
  75. esi/tasks.py +78 -0
  76. esi/templates/esi/select_token.html +116 -0
  77. esi/templatetags/__init__.py +0 -0
  78. esi/templatetags/scope_tags.py +8 -0
  79. esi/tests/__init__.py +134 -0
  80. esi/tests/client_authed_pilot.py +63 -0
  81. esi/tests/client_public_pilot.py +53 -0
  82. esi/tests/factories.py +47 -0
  83. esi/tests/factories_2.py +60 -0
  84. esi/tests/jwt_factory.py +135 -0
  85. esi/tests/test_checks.py +48 -0
  86. esi/tests/test_clients.py +1019 -0
  87. esi/tests/test_decorators.py +578 -0
  88. esi/tests/test_management_command.py +307 -0
  89. esi/tests/test_managers.py +673 -0
  90. esi/tests/test_models.py +403 -0
  91. esi/tests/test_openapi.json +854 -0
  92. esi/tests/test_openapi.py +1017 -0
  93. esi/tests/test_swagger.json +489 -0
  94. esi/tests/test_swagger_full.json +51112 -0
  95. esi/tests/test_tasks.py +116 -0
  96. esi/tests/test_templatetags.py +22 -0
  97. esi/tests/test_views.py +331 -0
  98. esi/tests/threading_pilot.py +69 -0
  99. esi/urls.py +9 -0
  100. esi/views.py +129 -0
esi/openapi_clients.py ADDED
@@ -0,0 +1,1225 @@
1
+ import logging
2
+ import pathlib
3
+ import warnings
4
+ import datetime as dt
5
+ from hashlib import md5
6
+ from timeit import default_timer
7
+ from typing import Any
8
+
9
+ from aiopenapi3 import OpenAPI, FileSystemLoader
10
+ from aiopenapi3._types import ResponseDataType, ResponseHeadersType
11
+ from aiopenapi3.errors import HTTPServerError as base_HTTPServerError
12
+ from aiopenapi3.errors import HTTPClientError as base_HTTPClientError
13
+ from aiopenapi3.request import OperationIndex, RequestBase
14
+ from esi.aiopenapi3.client import SpecCachingClient
15
+ from esi.rate_limiting import ESIRateLimitBucket, ESIRateLimits, interval_to_seconds
16
+ from httpx import (
17
+ AsyncClient, Client, HTTPStatusError, RequestError, Response, Timeout,
18
+ )
19
+ from tenacity import (
20
+ AsyncRetrying, Retrying, retry_if_exception, stop_after_attempt,
21
+ wait_combine, wait_exponential,
22
+ )
23
+
24
+ from django.core.cache import cache
25
+ from django.utils.text import slugify
26
+
27
+ from esi import app_settings
28
+ from esi.exceptions import HTTPClientError, HTTPServerError, HTTPNotModified
29
+ from esi.aiopenapi3.plugins import (
30
+ Add304ContentType, DjangoESIInit, PatchCompatibilityDatePlugin,
31
+ Trim204ContentType, MinifySpec
32
+ )
33
+ from esi.exceptions import ESIErrorLimitException
34
+ from esi.models import Token
35
+ from esi.signals import esi_request_statistics
36
+ from esi.stubs import ESIClientStub
37
+
38
+ from . import __title__, __url__, __version__
39
+ from .helpers import pascal_case_string
40
+
41
+ logger = logging.getLogger(__name__)
42
+
43
+ ETAG_EXPIRY = 60 * 60 * 24 * 7 # 7 days
44
+
45
+
46
+ def _time_to_expiry(expires_header: str) -> int:
47
+ """Calculate cache TTL from Expires header
48
+ Args:
49
+ expires_header (str): The value of the Expires header '%a, %d %b %Y %H:%M:%S %Z'
50
+ Returns:
51
+ int: The cache TTL in seconds
52
+ """
53
+ try:
54
+ expires_dt = dt.datetime.strptime(str(expires_header), '%a, %d %b %Y %H:%M:%S %Z')
55
+ if expires_dt.tzinfo is None:
56
+ expires_dt = expires_dt.replace(tzinfo=dt.timezone.utc)
57
+ return max(int((expires_dt - dt.datetime.now(dt.timezone.utc)).total_seconds()), 0)
58
+ except ValueError:
59
+ return 0
60
+
61
+
62
+ def _httpx_exceptions(exc: BaseException) -> bool:
63
+ """
64
+ Helper function for HTTP Retries, what various exceptions and status codes should we retry on.
65
+ ESI has some weird behaviours
66
+ """
67
+ if isinstance(exc, ESIErrorLimitException):
68
+ return False
69
+ if isinstance(exc, RequestError):
70
+ return True
71
+ if isinstance(exc, HTTPStatusError) and getattr(exc.response, "status_code", None) in {502, 503, 504}:
72
+ return True
73
+ return False
74
+
75
+
76
+ def http_retry_sync() -> Retrying:
77
+ return Retrying(
78
+ retry=retry_if_exception(_httpx_exceptions),
79
+ wait=wait_combine(
80
+ wait_exponential(multiplier=1, min=1, max=10),
81
+ ),
82
+ stop=stop_after_attempt(3),
83
+ reraise=True,
84
+ )
85
+
86
+
87
+ async def http_retry_async() -> AsyncRetrying: # pragma: no cover
88
+ return AsyncRetrying(
89
+ retry=retry_if_exception(_httpx_exceptions),
90
+ wait=wait_combine(
91
+ wait_exponential(multiplier=1, min=1, max=10),
92
+ ),
93
+ stop=stop_after_attempt(3),
94
+ reraise=True,
95
+ )
96
+
97
+
98
+ def _load_plugins(app_name, tags: list[str] = [], operations: list[str] = []):
99
+ """Load the plugins to make ESI work with this lib.
100
+
101
+ Args:
102
+ app_name (str): app name to use for internal etags
103
+ """
104
+ return [
105
+ PatchCompatibilityDatePlugin(),
106
+ Trim204ContentType(),
107
+ Add304ContentType(),
108
+ DjangoESIInit(app_name),
109
+ MinifySpec(tags, operations)
110
+ ]
111
+
112
+
113
+ def _load_aiopenapi_client_sync(
114
+ spec_url: str,
115
+ compatibility_date: str,
116
+ app_name: str,
117
+ user_agent: str,
118
+ tenant: str,
119
+ spec_file: str | None = None,
120
+ tags: list[str] = [],
121
+ operations: list[str] = []) -> OpenAPI:
122
+ """Create an OpenAPI3 Client from Spec
123
+
124
+ Args:
125
+ spec_url (str): _description_
126
+ compatibility_date (str): _description_
127
+ app_name (str): _description_
128
+ user_agent (str): _description_
129
+ tenant (str): _description_
130
+ spec_file (str | None, optional): _description_. Defaults to None.
131
+
132
+ Returns:
133
+ OpenAPI: aiopenapi3 Client Class
134
+ """
135
+ headers = {
136
+ "User-Agent": user_agent,
137
+ "X-Tenant": tenant,
138
+ "X-Compatibility-Date": compatibility_date
139
+ }
140
+
141
+ def session_factory(**kwargs) -> Client:
142
+ kwargs.pop("headers", None)
143
+ return SpecCachingClient(
144
+ headers=headers,
145
+ timeout=Timeout(
146
+ connect=app_settings.ESI_REQUESTS_CONNECT_TIMEOUT,
147
+ read=app_settings.ESI_REQUESTS_READ_TIMEOUT,
148
+ write=app_settings.ESI_REQUESTS_WRITE_TIMEOUT,
149
+ pool=app_settings.ESI_REQUESTS_POOL_TIMEOUT
150
+ ),
151
+ http2=True,
152
+ **kwargs
153
+ )
154
+ if spec_file:
155
+ return OpenAPI.load_file(
156
+ url=spec_url,
157
+ path=spec_file,
158
+ session_factory=session_factory,
159
+ loader=FileSystemLoader(pathlib.Path(spec_file)),
160
+ use_operation_tags=True,
161
+ plugins=_load_plugins(app_name, tags, operations)
162
+ )
163
+ else:
164
+ return OpenAPI.load_sync(
165
+ url=spec_url,
166
+ session_factory=session_factory,
167
+ use_operation_tags=True,
168
+ plugins=_load_plugins(app_name, tags, operations)
169
+ )
170
+
171
+
172
+ async def _load_aiopenapi_client_async(
173
+ spec_url: str,
174
+ compatibility_date: str,
175
+ app_name: str,
176
+ user_agent: str,
177
+ tenant: str,
178
+ spec_file: str | None = None) -> OpenAPI: # pragma: no cover
179
+ """Create an OpenAPI3 Client from Spec Async
180
+
181
+ Args:
182
+ spec_url (str): _description_
183
+ compatibility_date (str): _description_
184
+ user_agent (str): _description_
185
+ tenant (str): _description_
186
+ spec_file (str | None, optional): _description_. Defaults to None.
187
+
188
+ Returns:
189
+ OpenAPI: aiopenapi3 Client Class
190
+ """
191
+ headers = {
192
+ "User-Agent": user_agent,
193
+ "X-Tenant": tenant,
194
+ "X-Compatibility-Date": compatibility_date
195
+ }
196
+
197
+ def session_factory(**kwargs) -> AsyncClient:
198
+ kwargs.pop("headers", None)
199
+ return AsyncClient(
200
+ headers=headers,
201
+ timeout=Timeout(
202
+ connect=app_settings.ESI_REQUESTS_CONNECT_TIMEOUT,
203
+ read=app_settings.ESI_REQUESTS_READ_TIMEOUT,
204
+ write=app_settings.ESI_REQUESTS_WRITE_TIMEOUT,
205
+ pool=app_settings.ESI_REQUESTS_POOL_TIMEOUT
206
+ ),
207
+ http2=True,
208
+ **kwargs
209
+ )
210
+ if spec_file:
211
+ # TODO find a async way to load from file?
212
+ return OpenAPI.load_file(
213
+ url=spec_url,
214
+ path=spec_file,
215
+ session_factory=session_factory,
216
+ use_operation_tags=True,
217
+ plugins=_load_plugins(app_name)
218
+ )
219
+ else:
220
+ return await OpenAPI.load_async(
221
+ url=spec_url,
222
+ session_factory=session_factory,
223
+ use_operation_tags=True,
224
+ plugins=_load_plugins(app_name)
225
+ )
226
+
227
+
228
+ def _build_user_agent(ua_appname: str, ua_version: str, ua_url: str | None = None) -> str:
229
+ """
230
+ AppName/1.2.3 (foo@example.com; +https://gitlab.com/) Django-ESI/1.2.3 (+https://gitlab.com/allianceauth/django-esi)
231
+ Contact Email will be inserted from app_settings.
232
+ Args:
233
+ ua_appname (str): Application Name, PascalCase
234
+ ua_version (str): Application Version, SemVer
235
+ ua_url (str | None): Application URL (Optional)
236
+ Returns:
237
+ str: User-Agent string
238
+ """
239
+
240
+ # Enforce PascalCase for `ua_appname` and strip whitespace
241
+ sanitized_ua_appname = pascal_case_string(ua_appname)
242
+ sanitized_appname = pascal_case_string(__title__)
243
+
244
+ return (
245
+ f"{sanitized_ua_appname}/{ua_version} "
246
+ f"({app_settings.ESI_USER_CONTACT_EMAIL}{f'; +{ua_url})' if ua_url else ')'} "
247
+ f"{sanitized_appname}/{__version__} (+{__url__})"
248
+ )
249
+
250
+
251
+ def _get_spec_url() -> str:
252
+ return f"{app_settings.ESI_API_URL}meta/openapi.json"
253
+
254
+
255
+ def esi_client_factory_sync(
256
+ compatibility_date: str,
257
+ ua_appname: str, ua_version: str, ua_url: str | None = None,
258
+ spec_file: str | None = None,
259
+ tenant: str = "tranquility",
260
+ tags: list[str] = [], operations: list[str] = [],
261
+ **kwargs) -> OpenAPI:
262
+ """Generate a new OpenAPI ESI client.
263
+ Args:
264
+ compatibility_date (str): "YYYY-MM-DD" The latest version of ESI your client is tested with
265
+ ua_appname (str): Application Name, PascalCase
266
+ ua_version (str): Application Version, SemVer
267
+ ua_url (str, optional): Application URL (Optional). Defaults to None.
268
+ spec_file (str | None, optional): Specification file path (Optional). Defaults to None.
269
+ tenant (str, optional): Tenant ID (Optional). Defaults to "tranquility".
270
+ Returns:
271
+ OpenAPI: OpenAPI ESI Client
272
+ """
273
+ user_agent = _build_user_agent(ua_appname, ua_version, ua_url)
274
+ spec_url = _get_spec_url()
275
+ return _load_aiopenapi_client_sync(
276
+ spec_url,
277
+ compatibility_date,
278
+ ua_appname,
279
+ user_agent,
280
+ tenant,
281
+ spec_file,
282
+ tags,
283
+ operations
284
+ )
285
+
286
+
287
+ async def esi_client_factory_async(
288
+ compatibility_date: str,
289
+ ua_appname: str, ua_version: str, ua_url: str | None = None,
290
+ spec_file: str | None = None,
291
+ tenant: str = "tranquility",
292
+ **kwargs) -> OpenAPI: # pragma: no cover
293
+ """Generate a new OpenAPI ESI client.
294
+ Args:
295
+ compatibility_date (str): "YYYY-MM-DD" The latest version of ESI your client is tested with
296
+ ua_appname (str): Application Name, PascalCase
297
+ ua_version (str): Application Version, SemVer
298
+ ua_url (str | None, optional): Application URL (Optional). Defaults to None.
299
+ spec_file (str | None, optional): Specification file path (Optional). Defaults to None.
300
+ tenant (str, optional): Tenant ID (Optional). Defaults to "tranquility".
301
+ Returns:
302
+ OpenAPI: OpenAPI ESI Client
303
+ """
304
+ user_agent = _build_user_agent(ua_appname, ua_version, ua_url)
305
+ spec_url = _get_spec_url()
306
+ return await _load_aiopenapi_client_async(spec_url, compatibility_date, ua_appname, user_agent, tenant, spec_file)
307
+
308
+
309
+ class BaseEsiOperation():
310
+ def __init__(self, operation, api: OpenAPI) -> None:
311
+ self.method, self.url, self.operation, self.extra = operation
312
+ self.api = api
313
+ self.token: Token | None = None
314
+ self.bucket: ESIRateLimitBucket | None = None
315
+
316
+ self._args = []
317
+ self._kwargs = {}
318
+
319
+ self._set_bucket()
320
+
321
+ def __call__(self, *args, **kwargs) -> "BaseEsiOperation":
322
+ self._args = args
323
+ self._kwargs = kwargs
324
+ return self
325
+
326
+ def _unnormalize_parameters(self, params: dict[str, Any]) -> dict[str, Any]:
327
+ """UN-Normalize Pythonic parameter names back to OpenAPI names.
328
+
329
+ Converts pythonic keys like "Accept_Language" to "Accept-Language" when/if
330
+ a non pythonic (usually) hyphenated form exists in the operation's parameter list. Performs
331
+ case-insensitive matching and only rewrites when there's a known
332
+ parameter with hyphens, leaving normal snake_case params (e.g.
333
+ "type_id") untouched.
334
+ Args:
335
+ params: Raw parameters collected from the call
336
+ Returns:
337
+ dict: Parameters with keys aligned to the OpenAPI spec
338
+ """
339
+ try:
340
+ spec_param_names = [p.name for p in getattr(self.operation, "parameters", [])]
341
+ except Exception:
342
+ spec_param_names = []
343
+
344
+ # Exact and case-insensitive lookup maps
345
+ spec_param_set = set(spec_param_names)
346
+ spec_param_map_ci = {n.lower(): n for n in spec_param_names}
347
+
348
+ normalized: dict[str, Any] = {}
349
+ for k, v in params.items():
350
+ # Fast path: exact match
351
+ if k in spec_param_set:
352
+ normalized[k] = v
353
+ continue
354
+
355
+ # Try hyphen variant
356
+ k_dash = k.replace("_", "-")
357
+ if k_dash in spec_param_set:
358
+ normalized[k_dash] = v
359
+ continue
360
+
361
+ # Case-insensitive fallbacks
362
+ kl = k.lower()
363
+ if kl in spec_param_map_ci:
364
+ normalized[spec_param_map_ci[kl]] = v
365
+ continue
366
+
367
+ k_dash_l = k_dash.lower()
368
+ if k_dash_l in spec_param_map_ci:
369
+ normalized[spec_param_map_ci[k_dash_l]] = v
370
+ continue
371
+
372
+ # Unknown to the spec; pass through as-is (aiopenapi3 will validate)
373
+ normalized[k] = v
374
+
375
+ return normalized
376
+
377
+ def _etag_key(self) -> str:
378
+ """Generate a key name used to cache etag responses based on app_name and cache_key
379
+ Returns:
380
+ str: Key
381
+ """
382
+ # ignore the token this will break the cache
383
+ return f"{slugify(self.api.app_name)}_etag_{self._cache_key()}" # type: ignore app_name is added by a plugin
384
+
385
+ def _cache_key(self) -> str:
386
+ """Generate a key name used to cache responses based on method, url, args, kwargs
387
+ Returns:
388
+ str: Key
389
+ """
390
+ # ignore the token this will break the cache
391
+ ignore_keys = [
392
+ "token",
393
+ ]
394
+ _kwargs = {key: value for key, value in self._kwargs.items() if key not in ignore_keys}
395
+ data = (self.method + self.url + str(self._args) + str(_kwargs)).encode('utf-8')
396
+ str_hash = md5(data).hexdigest() # nosec B303
397
+ return f'esi_{str_hash}'
398
+
399
+ def _extract_body_param(self) -> Token | None:
400
+ """Pop the request body from parameters to be able to check the param validity
401
+ Returns:
402
+ Any | None: the request body
403
+ """
404
+ _body = self._kwargs.pop("body", None)
405
+ if _body and not getattr(self.operation, "requestBody", False):
406
+ raise ValueError("Request Body provided on endpoint with no request body parameter.")
407
+ return _body
408
+
409
+ def _extract_token_param(self) -> Token | None:
410
+ """Pop token from parameters or use the Client wide token if set
411
+ Returns:
412
+ Token | None: The token to use for the request
413
+ """
414
+ _token = self._kwargs.pop("token", None)
415
+ if _token and not getattr(self.operation, "security", False):
416
+ raise ValueError("Token provided on public endpoint")
417
+ return self.token or _token
418
+
419
+ def _has_page_param(self) -> bool:
420
+ """Check if this operation supports Offset Based Pagination.
421
+ Returns:
422
+ bool: True if page parameters are present, False otherwise
423
+ """
424
+ return any(p.name == "page" for p in self.operation.parameters)
425
+
426
+ def _has_cursor_param(self) -> bool:
427
+ """Check if this operation supports Cursor Based Pagination.
428
+ Returns:
429
+ bool: True if cursor parameters are present, False otherwise
430
+ """
431
+ return any(p.name == "before" or p.name == "after" for p in self.operation.parameters)
432
+
433
+ def _get_cache(self, cache_key: str, etag: str | None) -> tuple[ResponseHeadersType | None, Any, Response | None]:
434
+ """Retrieve cached response and validate expiry
435
+ Args:
436
+ cache_key (str): The cache key to retrieve
437
+ Returns:
438
+ tuple[ResponseHeadersType | None, Any, Response | None]: The cached response,
439
+ or None if not found or expired
440
+ """
441
+ if not app_settings.ESI_CACHE_RESPONSE:
442
+ return None, None, None
443
+
444
+ try:
445
+ cached_response = cache.get(cache_key)
446
+ except Exception as e:
447
+ logger.error(f"Cache retrieve failed {e}", exc_info=True)
448
+ return None, None, None
449
+
450
+ if cached_response:
451
+ logger.debug(f"Cache Hit {self.url}")
452
+ expiry = _time_to_expiry(str(cached_response.headers.get('Expires')))
453
+
454
+ # force check to ensure cache isn't expired
455
+ if expiry < 0:
456
+ logger.warning("Cache expired by %d seconds, forcing expiry", expiry)
457
+ return None, None, None
458
+
459
+ # check if etag is same before building models from cache
460
+ if etag:
461
+ if cached_response.headers.get('ETag') == etag:
462
+ # refresh/store the etag's TTL
463
+ self._send_signal(
464
+ status_code=0, # this is a cached response less a 304
465
+ headers=cached_response.headers,
466
+ latency=0
467
+ )
468
+ self._store_etag(cached_response.headers)
469
+ raise HTTPNotModified(
470
+ status_code=304,
471
+ headers=cached_response.headers
472
+ )
473
+
474
+ # build models
475
+ headers, data = self.parse_cached_request(cached_response)
476
+ return headers, data, cached_response
477
+
478
+ return None, None, None
479
+
480
+ def _store_etag(self, headers: dict):
481
+ """
482
+ Store response etag in cache for 7 days
483
+ """
484
+ if "ETag" in headers:
485
+ cache.set(self._etag_key(), headers["ETag"], timeout=ETAG_EXPIRY)
486
+
487
+ def _clear_etag(self):
488
+ """ Delete the cached etag for this operation.
489
+ """
490
+ try:
491
+ cache.delete(self._etag_key())
492
+ except Exception as e:
493
+ logger.error(f"Failed to delete etag {e}", exc_info=True)
494
+
495
+ def _store_cache(self, cache_key: str, response) -> None:
496
+ """ Store the response in cache for expiry TTL.
497
+ Args:
498
+ cache_key (str): The cache key to store the response under
499
+ response (Response): The response object to cache
500
+ """
501
+ if not app_settings.ESI_CACHE_RESPONSE:
502
+ return
503
+
504
+ expires = response.headers.get("Expires")
505
+ ttl = _time_to_expiry(expires) if expires else 0
506
+ if ttl > 0:
507
+ try:
508
+ cache.set(cache_key, response, ttl)
509
+ except Exception as e:
510
+ logger.error(f"Failed to cache {e}", exc_info=True)
511
+
512
+ def _clear_cache(self):
513
+ """ Delete the cached data for this operation.
514
+ """
515
+ try:
516
+ cache.delete(self._cache_key())
517
+ except Exception as e:
518
+ logger.error(f"Failed to delete cache {e}", exc_info=True)
519
+
520
+ def _validate_token_scopes(self, token: Token) -> None:
521
+ """Validate that the token provided has the required scopes for this ESI operation.
522
+ """
523
+ token_scopes = set(token.scopes.all().values_list("name", flat=True))
524
+ try:
525
+ required_scopes = set(getattr(getattr(self.operation, "security", [])[0], "root", {}).get("OAuth2", []))
526
+ except KeyError:
527
+ required_scopes = []
528
+ missing_scopes = [x for x in required_scopes if x not in token_scopes]
529
+ if len(missing_scopes) > 0:
530
+ raise ValueError(f"Token Missing Scopes - {missing_scopes}")
531
+
532
+ def parse_cached_request(self, cached_response) -> tuple[ResponseHeadersType, ResponseDataType]:
533
+ req = self.api.createRequest(
534
+ f"{self.operation.tags[0]}.{self.operation.operationId}"
535
+ )
536
+ return req._process_request(cached_response)
537
+
538
+ def _set_bucket(self):
539
+ """Setup the rate bucket"""
540
+ _rate_limit = getattr(self.operation, "extensions", {}).get("rate-limit", False)
541
+ if _rate_limit:
542
+ _key = _rate_limit["group"]
543
+ if self.token:
544
+ _key = f"{_key}:{self.token.character_id}"
545
+ self.bucket = ESIRateLimitBucket(
546
+ _key,
547
+ _rate_limit["max-tokens"],
548
+ interval_to_seconds(_rate_limit["window-size"])
549
+ )
550
+
551
+ def _send_signal(self, status_code: int, headers: dict = {}, latency: float = 0) -> None:
552
+ """
553
+ Dispatch the esi request statistics signal
554
+ """
555
+ esi_request_statistics.send(
556
+ sender=self.__class__,
557
+ operation=self.operation.operationId,
558
+ status_code=status_code,
559
+ headers=headers,
560
+ latency=latency,
561
+ bucket=self.bucket.slug if self.bucket else ""
562
+ )
563
+
564
+
565
+ class EsiOperation(BaseEsiOperation):
566
+ def __skip__process__headers__(
567
+ self, result, headers: dict[str, str], expected_response
568
+ ):
569
+ """Return all headers always"""
570
+ return headers
571
+
572
+ def _make_request(
573
+ self,
574
+ parameters: dict[str, Any],
575
+ etag: str | None = None) -> RequestBase.Response:
576
+
577
+ reset = cache.get("esi_error_limit_reset")
578
+ if reset is not None:
579
+ # Hard exception here if there is still an open Error Limit
580
+ # developers need to either decorators.wait_for_esi_error_limit_reset()
581
+ # or handle this by pushing their celery tasks back
582
+ raise ESIErrorLimitException(reset=reset)
583
+
584
+ if self.bucket:
585
+ """Check Rate Limit"""
586
+ ESIRateLimits.check_bucket(self.bucket)
587
+
588
+ retry = http_retry_sync()
589
+
590
+ def __func():
591
+ req = self.api.createRequest(f"{self.operation.tags[0]}.{self.operation.operationId}")
592
+
593
+ # We want all headers from ESI
594
+ # don't check/parse them against the spec and return them all
595
+ # TODO Investigate if this is a bug with aiopenapi or a spec compliance issue
596
+ req._process__headers = self.__skip__process__headers__
597
+
598
+ if self.token:
599
+ self.api.authenticate(OAuth2=True) # make the lib happy
600
+ if isinstance(self.token, str):
601
+ # Fallback older Django-ESI Behaviour
602
+ # Deprecated
603
+ req.req.headers["Authorization"] = f"Bearer {self.token}"
604
+ warnings.warn(
605
+ "Passing an Access Token string directly is deprecated."
606
+ "Doing so will Skip Validation of Scopes"
607
+ "Please use a Token object instead.",
608
+ DeprecationWarning,
609
+ stacklevel=2
610
+ )
611
+ else:
612
+ self._validate_token_scopes(self.token)
613
+ req.req.headers["Authorization"] = f"Bearer {self.token.valid_access_token()}"
614
+ if etag:
615
+ req.req.headers["If-None-Match"] = etag
616
+ _response = req.request(data=self.body, parameters=self._unnormalize_parameters(parameters))
617
+
618
+ if self.bucket and "x-ratelimit-remaining" in _response.result.headers:
619
+ logger.debug(
620
+ "ESI Rate-Limit: "
621
+ f"'{_response.result.headers.get('x-ratelimit-group')}' - "
622
+ f"Used {_response.result.headers.get('x-ratelimit-used')} - "
623
+ f"{_response.result.headers.get('x-ratelimit-remaining')} / "
624
+ f"({_response.result.headers.get('x-ratelimit-limit')})"
625
+ )
626
+ ESIRateLimits.set_bucket(
627
+ self.bucket,
628
+ _response.result.headers.get("x-ratelimit-remaining")
629
+ )
630
+
631
+ return _response
632
+ return retry(__func)
633
+
634
+ def result(
635
+ self,
636
+ use_etag: bool = True,
637
+ return_response: bool = False,
638
+ force_refresh: bool = False,
639
+ use_cache: bool = True,
640
+ **extra) -> tuple[Any, Response] | Any:
641
+ """Executes the request and returns the response from ESI for the current operation.
642
+ Raises:
643
+ ESIErrorLimitException: _description_
644
+ ESIBucketLimitException: _description_
645
+ Returns:
646
+ _type_: _description_
647
+ """
648
+ _t = default_timer()
649
+ self.token = self._extract_token_param()
650
+
651
+ if self.token:
652
+ self._set_bucket()
653
+
654
+ self.body = self._extract_body_param()
655
+ parameters = self._kwargs | extra
656
+ cache_key = self._cache_key()
657
+ etag_key = self._etag_key()
658
+ etag = None
659
+
660
+ if force_refresh:
661
+ self._clear_cache()
662
+ self._clear_etag()
663
+
664
+ if use_etag:
665
+ etag = cache.get(etag_key)
666
+
667
+ headers, data, response = self._get_cache(cache_key, etag=etag) if use_cache else (None, None, None)
668
+
669
+ if response and use_cache:
670
+ expiry = _time_to_expiry(str(headers.get('Expires')))
671
+ if expiry < 0:
672
+ logger.warning(
673
+ "cache expired by %d seconds, Forcing expiry", expiry
674
+ )
675
+ response = None
676
+ headers = None
677
+ data = None
678
+
679
+ if not response:
680
+ logger.debug(f"Cache Miss {self.url}")
681
+ try:
682
+ headers, data, response = self._make_request(parameters, etag)
683
+ # Shim our exceptions into Django-ESI
684
+ except base_HTTPServerError as e:
685
+ self._send_signal(
686
+ status_code=e.status_code,
687
+ headers=e.headers,
688
+ latency=default_timer() - _t
689
+ )
690
+ raise HTTPServerError(
691
+ status_code=e.status_code,
692
+ headers=e.headers,
693
+ data=e.data
694
+ )
695
+
696
+ except base_HTTPClientError as e:
697
+ self._send_signal(
698
+ status_code=e.status_code,
699
+ headers=e.headers,
700
+ latency=default_timer() - _t
701
+ )
702
+
703
+ if e.status_code == 420:
704
+ reset = e.headers.get("X-RateLimit-Reset", None)
705
+ if reset:
706
+ reset = int(reset)
707
+ cache.set("esi_error_limit_reset", reset, timeout=reset)
708
+ raise ESIErrorLimitException(reset=reset)
709
+
710
+ raise HTTPClientError(
711
+ status_code=e.status_code,
712
+ headers=e.headers,
713
+ data=e.data
714
+ )
715
+
716
+ self._send_signal(
717
+ status_code=response.status_code,
718
+ headers=response.headers,
719
+ latency=default_timer() - _t
720
+ )
721
+
722
+ # store the ETAG in cache
723
+ self._store_etag(response.headers)
724
+
725
+ # Throw a 304 exception for catching.
726
+ if response.status_code == 304:
727
+ # refresh/store the etag's TTL
728
+ raise HTTPNotModified(
729
+ status_code=304,
730
+ headers=response.headers
731
+ )
732
+
733
+ # last step store cache after 304 logic, we dont want to catch the 304 `None` responses
734
+ self._store_cache(cache_key, response)
735
+
736
+ else:
737
+ # send signal for cached data too
738
+ self._send_signal(
739
+ status_code=0,
740
+ headers=response.headers,
741
+ latency=default_timer() - _t
742
+ )
743
+
744
+ return (data, response) if return_response else data
745
+
746
+ def results(
747
+ self,
748
+ use_etag: bool = True,
749
+ return_response: bool = False,
750
+ force_refresh: bool = False,
751
+ use_cache: bool = True,
752
+ **extra) -> tuple[list[Any], Response | Any | None] | list[Any]:
753
+ all_results: list[Any] = []
754
+ last_response: Response | None = None
755
+ """Executes the request and returns the response from ESI for the current
756
+ operation. Response will include all pages if there are more available.
757
+
758
+ Returns:
759
+ _type_: _description_
760
+ """
761
+ if self._has_page_param():
762
+ current_page = 1
763
+ total_pages = 1
764
+ while current_page <= total_pages:
765
+ self._kwargs["page"] = current_page
766
+ data, response = self.result(
767
+ use_etag=use_etag,
768
+ return_response=True,
769
+ force_refresh=force_refresh,
770
+ **extra
771
+ )
772
+ last_response = response
773
+ all_results.extend(data if isinstance(data, list) else [data])
774
+ total_pages = int(response.headers.get("X-Pages", 1))
775
+ logger.debug(
776
+ f"ESI Page Fetched {self.url} - {current_page}/{total_pages}"
777
+ )
778
+ current_page += 1
779
+
780
+ elif self._has_cursor_param():
781
+ # Untested, there are no cursor based endpoints in ESI
782
+ params = self._kwargs.copy()
783
+ params.update(extra)
784
+ for cursor_param in ("after", "before"):
785
+ if params.get(cursor_param):
786
+ break
787
+ else:
788
+ cursor_param = "after"
789
+ while True:
790
+ data, response = self.result(
791
+ use_etag=use_etag,
792
+ return_response=True,
793
+ force_refresh=force_refresh,
794
+ use_cache=use_cache,
795
+ **params
796
+ )
797
+ last_response = response
798
+ if not data:
799
+ break
800
+ all_results.extend(data if isinstance(data, list) else [data])
801
+ cursor_token = {k.lower(): v for k, v in response.headers.items()}.get(cursor_param)
802
+ if not cursor_token:
803
+ break
804
+ params[cursor_param] = cursor_token
805
+
806
+ else:
807
+ data, response = self.result(
808
+ use_etag=use_etag,
809
+ return_response=True,
810
+ force_refresh=force_refresh,
811
+ use_cache=use_cache,
812
+ **extra
813
+ )
814
+ all_results.extend(data if isinstance(data, list) else [data])
815
+ last_response = response
816
+
817
+ return (all_results, last_response) if return_response else all_results
818
+
819
+ def results_localized(
820
+ self,
821
+ languages: list[str] | str | None = None,
822
+ **kwargs
823
+ ) -> dict[str, list[Any]]:
824
+ """Executes the request and returns the response from ESI for all default languages and pages (if any).
825
+ Args:
826
+ languages: (list[str], str, optional) language(s) to return instead of default languages
827
+ Raises:
828
+ ValueError: Invalid or Not Supported Language Code ...
829
+ Returns:
830
+ dict[str, list[Any]]: Dict of all responses with the language code as keys.
831
+ """
832
+ if not languages:
833
+ my_languages = list(app_settings.ESI_LANGUAGES)
834
+ else:
835
+ my_languages = []
836
+ for lang in dict.fromkeys(languages):
837
+ if lang not in app_settings.ESI_LANGUAGES:
838
+ raise ValueError('Invalid or Not Supported Language Code: %s' % lang)
839
+ my_languages.append(lang)
840
+
841
+ return {
842
+ language: self.results(accept_language=language, **kwargs)
843
+ for language in my_languages
844
+ }
845
+
846
+ def required_scopes(self) -> list[str]:
847
+ """Return a simple list of scopes required for an endpoint. #Requires loading and processing a client
848
+ Returns:
849
+ list[str]: List of Scopes Required
850
+ """
851
+ try:
852
+ if not getattr(self.operation, "security", False):
853
+ return [] # No Scopes Required
854
+ else:
855
+ return list(getattr(getattr(self.operation, "security", [])[0], "root", {}).get("OAuth2", []))
856
+ except (IndexError, KeyError):
857
+ return []
858
+
859
+
860
+ class EsiOperationAsync(BaseEsiOperation): # pragma: no cover
861
+ async def _make_request(
862
+ self,
863
+ parameters: dict[str, Any],
864
+ etag: str | None = None
865
+ ) -> RequestBase.Response:
866
+
867
+ reset = cache.get("esi_error_limit_reset")
868
+ if reset is not None:
869
+ # Hard exception here if there is still an open rate limit
870
+ # developers need to either decorators.wait_for_esi_error_limit_reset()
871
+ # or handle this by pushing their celery tasks back
872
+ raise ESIErrorLimitException(reset=reset)
873
+
874
+ async for attempt in http_retry_async():
875
+ with attempt:
876
+ req = self.api.createRequest(f"{self.operation.tags[0]}.{self.operation.operationId}")
877
+ if self.token:
878
+ self.api.authenticate(OAuth2=True) # make the lib happy
879
+ self._validate_token_scopes(self.token)
880
+ req.req.headers["Authorization"] = f"Bearer {self.token.valid_access_token()}"
881
+ if etag:
882
+ req.req.headers["If-None-Match"] = etag
883
+ return req.request(parameters=self._unnormalize_parameters(parameters))
884
+ # Should never be reached because AsyncRetrying always yields at least once
885
+ raise RuntimeError("Retry loop exited without performing a request")
886
+
887
+ async def result(
888
+ self,
889
+ etag: str | None = None,
890
+ return_response: bool = False,
891
+ use_cache: bool = True,
892
+ **extra
893
+ ) -> tuple[Any, Response] | Any:
894
+ self.token = self._extract_token_param()
895
+ parameters = self._kwargs | extra
896
+ cache_key = self._cache_key()
897
+ etag_key = f"{cache_key}_etag"
898
+
899
+ if not etag and app_settings.ESI_CACHE_RESPONSE:
900
+ etag = cache.get(etag_key)
901
+
902
+ headers, data, response = self._get_cache(cache_key, etag)
903
+
904
+ if response and use_cache:
905
+ expiry = _time_to_expiry(str(headers.get('Expires')))
906
+ if expiry < 0:
907
+ logger.warning(
908
+ "cache expired by %d seconds, Forcing expiry", expiry
909
+ )
910
+ response = None
911
+ headers = None
912
+ data = None
913
+
914
+ if not response:
915
+ logger.debug(f"Cache Miss {self.url}")
916
+ try:
917
+ headers, data, response = await self._make_request(parameters, etag)
918
+ if response.status_code == 420:
919
+ reset = response.headers.get("X-RateLimit-Reset", None)
920
+ cache.set("esi_error_limit_reset", reset, timeout=reset)
921
+ raise ESIErrorLimitException(reset=reset)
922
+ self._store_cache(cache_key, response)
923
+ self._store_etag(response.headers)
924
+ # Shim our exceptions into Django-ESI
925
+ except base_HTTPServerError as e:
926
+ raise HTTPServerError(
927
+ status_code=e.status_code,
928
+ headers=e.headers,
929
+ data=e.data
930
+ )
931
+
932
+ except base_HTTPClientError as e:
933
+ raise HTTPClientError(
934
+ status_code=e.status_code,
935
+ headers=e.headers,
936
+ data=e.data
937
+ )
938
+
939
+ # Throw a 304 exception for catching.
940
+ if response.status_code == 304:
941
+ # refresh/store the etag's TTL
942
+ self._store_etag(response.headers)
943
+ raise HTTPNotModified(
944
+ status_code=304,
945
+ headers=response.headers
946
+ )
947
+
948
+ return (data, response) if return_response else data
949
+
950
+ async def results(
951
+ self,
952
+ etag: str | None = None,
953
+ return_response: bool = False,
954
+ use_cache: bool = True,
955
+ **extra
956
+ ) -> tuple[list[Any], Response | Any | None] | list[Any]:
957
+ all_results = []
958
+ last_response = None
959
+
960
+ if self._has_page_param():
961
+ current_page = 1
962
+ total_pages = 1
963
+ while current_page <= total_pages:
964
+ self._kwargs["page"] = current_page
965
+ data, response = await self.result(etag=etag, return_response=True, use_cache=use_cache, **extra)
966
+ last_response = response
967
+ all_results.extend(data if isinstance(data, list) else [data])
968
+ total_pages = int(response.headers.get("X-Pages", 1))
969
+ logger.debug(
970
+ f"ESI Page Fetched {self.url} - {current_page}/{total_pages}"
971
+ )
972
+ current_page += 1
973
+ # elif self._has_cursor_param():
974
+ # TODO
975
+ else:
976
+ data, response = await self.result(etag=etag, return_response=True, use_cache=use_cache, **extra)
977
+ all_results.extend(data if isinstance(data, list) else [data])
978
+ last_response = response
979
+
980
+ return (all_results, last_response) if return_response else all_results
981
+
982
+ def results_localized(
983
+ self,
984
+ languages: list[str] | str | None = None,
985
+ **extra) -> dict[str, list[Any]]:
986
+ """Executes the request and returns the response from ESI for all default languages and pages (if any).
987
+ Args:
988
+ languages: (list[str], str, optional) language(s) to return instead of default languages
989
+ Raises:
990
+ ValueError: Invalid or Not Supported Language Code ...
991
+ Returns:
992
+ dict[str, list[Any]]: Dict of all responses with the language code as keys.
993
+ """
994
+ if not languages:
995
+ my_languages = list(app_settings.ESI_LANGUAGES)
996
+ else:
997
+ my_languages = []
998
+ for lang in dict.fromkeys(languages):
999
+ if lang not in app_settings.ESI_LANGUAGES:
1000
+ raise ValueError('Invalid or Not Supported Language Code: %s' % lang)
1001
+ my_languages.append(lang)
1002
+
1003
+ return {
1004
+ language: self.results(accept_language=language, **extra)
1005
+ for language in my_languages
1006
+ }
1007
+
1008
+ def required_scopes(self) -> list[str]:
1009
+ """Return a simple list of scopes required for an endpoint. #Requires loading and processing a client
1010
+ Returns:
1011
+ list[str]: List of Scopes Required
1012
+ """
1013
+ try:
1014
+ if not getattr(self.operation, "security", False):
1015
+ return [] # No Scopes Required
1016
+ else:
1017
+ return list(getattr(getattr(self.operation, "security", [])[0], "root", {}).get("OAuth2", []))
1018
+ except (IndexError, KeyError):
1019
+ return []
1020
+
1021
+
1022
+ class ESITag:
1023
+ """
1024
+ API Tag Wrapper, providing access to Operations within a tag
1025
+ Assets, Characters, etc.
1026
+ """
1027
+
1028
+ def __init__(self, operation, api) -> None:
1029
+ self._oi = operation._oi
1030
+ self._operations = operation._operations
1031
+ self.api = api
1032
+
1033
+ def __getattr__(self, name: str) -> EsiOperation:
1034
+ if name not in self._operations:
1035
+ raise AttributeError(
1036
+ f"Operation '{name}' not found in tag '{self._oi}'. "
1037
+ f"Available operations: {', '.join(sorted(self._operations.keys()))}"
1038
+ )
1039
+ return EsiOperation(self._operations[name], self.api)
1040
+
1041
+
1042
+ class ESITagAsync(): # pragma: no cover
1043
+ """
1044
+ Async API Tag Wrapper, providing access to Operations within a tag
1045
+ Assets, Characters, etc.
1046
+ """
1047
+
1048
+ def __init__(self, operation, api) -> None:
1049
+ self._oi = operation._oi
1050
+ self._operations = operation._operations
1051
+ self.api = api
1052
+
1053
+ def __getattr__(self, name: str) -> EsiOperationAsync:
1054
+ if name not in self._operations:
1055
+ raise AttributeError(
1056
+ f"Operation '{name}' not found in tag '{self._oi}'. "
1057
+ f"Available operations: {', '.join(sorted(self._operations.keys()))}"
1058
+ )
1059
+ return EsiOperationAsync(self._operations[name], self.api)
1060
+
1061
+
1062
+ class ESIClient(ESIClientStub):
1063
+ """
1064
+ Base ESI Client, provides access to Tags Assets, Characters, etc.
1065
+ or Raw aiopenapi3 via sad smiley ._.
1066
+ """
1067
+
1068
+ def __init__(self, api: OpenAPI) -> None:
1069
+ self.api = api
1070
+ self._tags = set(api._operationindex._tags.keys())
1071
+
1072
+ def __getattr__(self, tag: str) -> ESITag | OperationIndex:
1073
+ # underscore returns the raw aiopenapi3 client
1074
+ if tag == "_":
1075
+ return self.api._operationindex
1076
+
1077
+ # convert pythonic Planetary_Interaction to Planetary Interaction
1078
+ if "_" in tag:
1079
+ tag = tag.replace("_", " ")
1080
+
1081
+ if tag in set(self.api._operationindex._tags.keys()):
1082
+ return ESITag(self.api._operationindex._tags[tag], self.api)
1083
+
1084
+ raise AttributeError(
1085
+ f"Tag '{tag}' not found. "
1086
+ f"Available tags: {', '.join(sorted(self._tags))}"
1087
+ )
1088
+
1089
+ def purge_all_etags(self):
1090
+ """ Delete all stored etags from the cache for this application
1091
+
1092
+ TODO: consider making this more config agnostic
1093
+ """
1094
+ try:
1095
+ # new lib
1096
+ from django_redis import get_redis_connection
1097
+ _client = get_redis_connection("default")
1098
+ except (NotImplementedError, ModuleNotFoundError):
1099
+ # old lib
1100
+ from django.core.cache import caches
1101
+ default_cache = caches['default']
1102
+ _client = default_cache.get_master_client() # type: ignore
1103
+
1104
+ keys = _client.keys(f":?:{slugify(self.api.app_name)}_etag_*") # type: ignore app_name is added by a plugin
1105
+ if keys:
1106
+ deleted = _client.delete(*keys)
1107
+
1108
+ logger.info(f"Deleted {deleted} etag keys")
1109
+
1110
+ return deleted
1111
+
1112
+
1113
+ class ESIClientAsync(ESIClientStub): # pragma: no cover
1114
+ """
1115
+ Async Base ESI Client, provides access to Tags Assets, Characters, etc.
1116
+ or Raw aiopenapi3 via sad smiley ._.
1117
+ """
1118
+
1119
+ def __init__(self, api: OpenAPI) -> None:
1120
+ self.api = api
1121
+ self._tags = set(api._operationindex._tags.keys())
1122
+
1123
+ def __getattr__(self, tag: str) -> ESITagAsync | OperationIndex:
1124
+ # underscore returns the raw aiopenapi3 client
1125
+ if tag == "_":
1126
+ return self.api._operationindex
1127
+
1128
+ # convert pythonic Planetary_Interaction to Planetary Interaction
1129
+ if "_" in tag:
1130
+ tag = tag.replace("_", " ")
1131
+
1132
+ if tag in set(self.api._operationindex._tags.keys()):
1133
+ return ESITagAsync(self.api._operationindex._tags[tag], self.api)
1134
+
1135
+ raise AttributeError(
1136
+ f"Tag '{tag}' not found. "
1137
+ f"Available tags: {', '.join(sorted(self._tags))}"
1138
+ )
1139
+
1140
+
1141
+ class ESIClientProvider:
1142
+ """Class for providing a single ESI client instance for a whole app
1143
+ * Note that one of either `tags` or `operations` must be provided to reduce memory footprint of the client
1144
+ * When `DEBUG=False`, not supplying either will raise an AttributeError.
1145
+ Args:
1146
+ compatibility_date (str | date): The compatibility date for the ESI client.
1147
+ ua_appname (str): Name of the App for generating a User-Agent,
1148
+ ua_version (str): Version of the App for generating a User-Agent,
1149
+ ua_url (str, Optional): URL To the Source Code or Documentation for generating a User-Agent,
1150
+ spec_file (str, Optional): Absolute path to a OpenApi 3.1 spec file to load.
1151
+ tenant (str, Optional): The ESI tenant to use (default: "tranquility").
1152
+ operations (list[str], Optional*): List of operations to filter the spec down.
1153
+ tags (list[str], Optional*): List of tags to filter the spec down.
1154
+ Functions:
1155
+ client(): ESIClient
1156
+ client_async(): ESIClientAsync
1157
+ """
1158
+
1159
+ _client: ESIClient | None = None
1160
+ _client_async: ESIClientAsync | None = None
1161
+
1162
+ def __init__(
1163
+ self,
1164
+ compatibility_date: str | dt.date,
1165
+ ua_appname: str,
1166
+ ua_version: str,
1167
+ ua_url: str | None = None,
1168
+ spec_file: None | str = None,
1169
+ tenant: str = "tranquility",
1170
+ operations: list[str] = [],
1171
+ tags: list[str] = [],
1172
+ **kwargs
1173
+ ) -> None:
1174
+ if type(compatibility_date) is dt.date:
1175
+ self._compatibility_date: str = self._date_to_string(compatibility_date)
1176
+ else:
1177
+ self._compatibility_date: str = str(compatibility_date)
1178
+ self._ua_appname = ua_appname
1179
+ self._ua_version = ua_version
1180
+ self._ua_url = ua_url
1181
+ self._spec_file = spec_file
1182
+ self._tenant = tenant
1183
+ self._kwargs = kwargs
1184
+ self._operations = operations
1185
+ self._tags = tags
1186
+
1187
+ @property
1188
+ def client(self) -> ESIClient:
1189
+ if self._client is None:
1190
+ api = esi_client_factory_sync(
1191
+ compatibility_date=self._compatibility_date,
1192
+ ua_appname=self._ua_appname,
1193
+ ua_version=self._ua_version,
1194
+ ua_url=self._ua_url,
1195
+ spec_file=self._spec_file,
1196
+ tenant=self._tenant,
1197
+ operations=self._operations,
1198
+ tags=self._tags,
1199
+ **self._kwargs)
1200
+ self._client = ESIClient(api)
1201
+ return self._client
1202
+
1203
+ @property
1204
+ async def client_async(self) -> ESIClientAsync: # pragma: no cover
1205
+ if self._client_async is None:
1206
+ api = await esi_client_factory_async(
1207
+ compatibility_date=self._compatibility_date,
1208
+ ua_appname=self._ua_appname,
1209
+ ua_version=self._ua_version,
1210
+ ua_url=self._ua_url,
1211
+ spec_file=self._spec_file,
1212
+ tenant=self._tenant,
1213
+ operations=self._operations,
1214
+ tags=self._tags,
1215
+ **self._kwargs)
1216
+ self._client_async = ESIClientAsync(api)
1217
+ return self._client_async
1218
+
1219
+ @classmethod
1220
+ def _date_to_string(cls, compatibility_date: dt.date) -> str:
1221
+ """Turns a date object in a compatibility_date string"""
1222
+ return f"{compatibility_date.year}-{compatibility_date.month:02}-{compatibility_date.day:02}"
1223
+
1224
+ def __str__(self) -> str:
1225
+ return f"ESIClientProvider - {self._ua_appname} ({self._ua_version})"