django-esi 8.0.0a2__py3-none-any.whl → 8.0.0a4__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.

Files changed (41) hide show
  1. {django_esi-8.0.0a2.dist-info → django_esi-8.0.0a4.dist-info}/METADATA +1 -1
  2. {django_esi-8.0.0a2.dist-info → django_esi-8.0.0a4.dist-info}/RECORD +41 -29
  3. esi/__init__.py +2 -2
  4. esi/aiopenapi3/plugins.py +34 -2
  5. esi/exceptions.py +29 -0
  6. esi/locale/cs_CZ/LC_MESSAGES/django.mo +0 -0
  7. esi/locale/cs_CZ/LC_MESSAGES/django.po +53 -0
  8. esi/locale/de/LC_MESSAGES/django.mo +0 -0
  9. esi/locale/de/LC_MESSAGES/django.po +10 -9
  10. esi/locale/en/LC_MESSAGES/django.mo +0 -0
  11. esi/locale/en/LC_MESSAGES/django.po +5 -5
  12. esi/locale/es/LC_MESSAGES/django.mo +0 -0
  13. esi/locale/es/LC_MESSAGES/django.po +12 -10
  14. esi/locale/fr_FR/LC_MESSAGES/django.mo +0 -0
  15. esi/locale/fr_FR/LC_MESSAGES/django.po +18 -10
  16. esi/locale/it_IT/LC_MESSAGES/django.mo +0 -0
  17. esi/locale/it_IT/LC_MESSAGES/django.po +12 -10
  18. esi/locale/ja/LC_MESSAGES/django.mo +0 -0
  19. esi/locale/ja/LC_MESSAGES/django.po +10 -9
  20. esi/locale/ko_KR/LC_MESSAGES/django.mo +0 -0
  21. esi/locale/ko_KR/LC_MESSAGES/django.po +10 -9
  22. esi/locale/nl_NL/LC_MESSAGES/django.mo +0 -0
  23. esi/locale/nl_NL/LC_MESSAGES/django.po +53 -0
  24. esi/locale/pl_PL/LC_MESSAGES/django.mo +0 -0
  25. esi/locale/pl_PL/LC_MESSAGES/django.po +53 -0
  26. esi/locale/ru/LC_MESSAGES/django.mo +0 -0
  27. esi/locale/ru/LC_MESSAGES/django.po +18 -14
  28. esi/locale/sk/LC_MESSAGES/django.mo +0 -0
  29. esi/locale/sk/LC_MESSAGES/django.po +55 -0
  30. esi/locale/uk/LC_MESSAGES/django.mo +0 -0
  31. esi/locale/uk/LC_MESSAGES/django.po +57 -0
  32. esi/locale/zh_Hans/LC_MESSAGES/django.mo +0 -0
  33. esi/locale/zh_Hans/LC_MESSAGES/django.po +10 -9
  34. esi/management/commands/generate_esi_stubs.py +11 -31
  35. esi/models.py +1 -1
  36. esi/openapi_clients.py +236 -59
  37. esi/stubs.pyi +426 -384
  38. esi/tests/test_openapi.json +264 -0
  39. esi/tests/test_openapi.py +261 -0
  40. {django_esi-8.0.0a2.dist-info → django_esi-8.0.0a4.dist-info}/WHEEL +0 -0
  41. {django_esi-8.0.0a2.dist-info → django_esi-8.0.0a4.dist-info}/licenses/LICENSE +0 -0
esi/openapi_clients.py CHANGED
@@ -1,11 +1,14 @@
1
1
  import logging
2
+ import pathlib
2
3
  import warnings
3
- from datetime import datetime
4
+ from datetime import datetime, date
4
5
  from hashlib import md5
5
6
  from typing import Any
6
7
 
7
- from aiopenapi3 import OpenAPI
8
+ from aiopenapi3 import OpenAPI, FileSystemLoader
8
9
  from aiopenapi3._types import ResponseDataType, ResponseHeadersType
10
+ from aiopenapi3.errors import HTTPServerError as base_HTTPServerError
11
+ from aiopenapi3.errors import HTTPClientError as base_HTTPClientError
9
12
  from aiopenapi3.request import OperationIndex, RequestBase
10
13
  from httpx import (
11
14
  AsyncClient, Client, HTTPStatusError, RequestError, Response, Timeout,
@@ -16,9 +19,11 @@ from tenacity import (
16
19
  )
17
20
 
18
21
  from django.core.cache import cache
22
+ from django.utils.text import slugify
19
23
 
20
24
  from esi import app_settings
21
- from esi.aiopenapi3.plugins import PatchCompatibilityDatePlugin, Trim204ContentType
25
+ from esi.exceptions import HTTPClientError, HTTPServerError, HTTPNotModified
26
+ from esi.aiopenapi3.plugins import Add304ContentType, DjangoESIInit, PatchCompatibilityDatePlugin, Trim204ContentType
22
27
  from esi.exceptions import ESIErrorLimitException
23
28
  from esi.models import Token
24
29
  from esi.stubs import ESIClientStub
@@ -27,6 +32,8 @@ from . import __title__, __url__, __version__
27
32
 
28
33
  logger = logging.getLogger(__name__)
29
34
 
35
+ ETAG_EXPIRY = 60*60*24*7 # 7 days
36
+
30
37
 
31
38
  def _time_to_expiry(expires_header: str) -> int:
32
39
  """Calculate cache TTL from Expires header
@@ -78,9 +85,19 @@ async def http_retry_async() -> AsyncRetrying:
78
85
  )
79
86
 
80
87
 
88
+ def _load_plugins(app_name):
89
+ """Load the plugins to make ESI work with this lib.
90
+
91
+ Args:
92
+ app_name (str): app name to use for internal etags
93
+ """
94
+ return [PatchCompatibilityDatePlugin(), Trim204ContentType(), Add304ContentType(), DjangoESIInit(app_name)]
95
+
96
+
81
97
  def _load_aiopenapi_client_sync(
82
98
  spec_url: str,
83
99
  compatibility_date: str,
100
+ app_name: str,
84
101
  user_agent: str,
85
102
  tenant: str,
86
103
  spec_file: str | None = None) -> OpenAPI:
@@ -89,6 +106,7 @@ def _load_aiopenapi_client_sync(
89
106
  Args:
90
107
  spec_url (str): _description_
91
108
  compatibility_date (str): _description_
109
+ app_name (str): _description_
92
110
  user_agent (str): _description_
93
111
  tenant (str): _description_
94
112
  spec_file (str | None, optional): _description_. Defaults to None.
@@ -120,21 +138,23 @@ def _load_aiopenapi_client_sync(
120
138
  url=spec_url,
121
139
  path=spec_file,
122
140
  session_factory=session_factory,
141
+ loader=FileSystemLoader(pathlib.Path(spec_file)),
123
142
  use_operation_tags=True,
124
- plugins=[PatchCompatibilityDatePlugin(), Trim204ContentType()]
143
+ plugins=_load_plugins(app_name)
125
144
  )
126
145
  else:
127
146
  return OpenAPI.load_sync(
128
147
  url=spec_url,
129
148
  session_factory=session_factory,
130
149
  use_operation_tags=True,
131
- plugins=[PatchCompatibilityDatePlugin(), Trim204ContentType()]
150
+ plugins=_load_plugins(app_name)
132
151
  )
133
152
 
134
153
 
135
154
  async def _load_aiopenapi_client_async(
136
155
  spec_url: str,
137
156
  compatibility_date: str,
157
+ app_name: str,
138
158
  user_agent: str,
139
159
  tenant: str,
140
160
  spec_file: str | None = None) -> OpenAPI:
@@ -176,18 +196,18 @@ async def _load_aiopenapi_client_async(
176
196
  path=spec_file,
177
197
  session_factory=session_factory,
178
198
  use_operation_tags=True,
179
- plugins=[PatchCompatibilityDatePlugin(), Trim204ContentType()]
199
+ plugins=_load_plugins(app_name)
180
200
  )
181
201
  else:
182
202
  return await OpenAPI.load_async(
183
203
  url=spec_url,
184
204
  session_factory=session_factory,
185
205
  use_operation_tags=True,
186
- plugins=[PatchCompatibilityDatePlugin(), Trim204ContentType()]
206
+ plugins=_load_plugins(app_name)
187
207
  )
188
208
 
189
209
 
190
- def _build_user_agent(ua_appname: str, ua_version: str, ua_url: str | None) -> str:
210
+ def _build_user_agent(ua_appname: str, ua_version: str, ua_url: str | None = None) -> str:
191
211
  """
192
212
  AppName/1.2.3 (foo@example.com; +https://gitlab.com/) Django-ESI/1.2.3 (+https://gitlab.com/allianceauth/django-esi)
193
213
  Contact Email will be inserted from app_settings.
@@ -228,7 +248,7 @@ def esi_client_factory_sync(
228
248
  """
229
249
  user_agent = _build_user_agent(ua_appname, ua_version, ua_url)
230
250
  spec_url = _get_spec_url()
231
- return _load_aiopenapi_client_sync(spec_url, compatibility_date, user_agent, tenant, spec_file)
251
+ return _load_aiopenapi_client_sync(spec_url, compatibility_date, ua_appname, user_agent, tenant, spec_file)
232
252
 
233
253
 
234
254
  async def esi_client_factory_async(
@@ -250,11 +270,11 @@ async def esi_client_factory_async(
250
270
  """
251
271
  user_agent = _build_user_agent(ua_appname, ua_version, ua_url)
252
272
  spec_url = _get_spec_url()
253
- return await _load_aiopenapi_client_async(spec_url, compatibility_date, user_agent, tenant, spec_file)
273
+ return await _load_aiopenapi_client_async(spec_url, compatibility_date, ua_appname, user_agent, tenant, spec_file)
254
274
 
255
275
 
256
276
  class BaseEsiOperation():
257
- def __init__(self, operation, api) -> None:
277
+ def __init__(self, operation, api: OpenAPI) -> None:
258
278
  self.method, self.url, self.operation, self.extra = operation
259
279
  self.api = api
260
280
  self.token: Token | None = None
@@ -317,12 +337,25 @@ class BaseEsiOperation():
317
337
 
318
338
  return normalized
319
339
 
340
+ def _etag_key(self) -> str:
341
+ """Generate a key name used to cache etag responses based on app_name and cache_key
342
+ Returns:
343
+ str: Key
344
+ """
345
+ # ignore the token this will break the cache
346
+ return f"{slugify(self.api.app_name)}_etag_{self._cache_key()}"
347
+
320
348
  def _cache_key(self) -> str:
321
349
  """Generate a key name used to cache responses based on method, url, args, kwargs
322
350
  Returns:
323
- str: Key name
351
+ str: Key
324
352
  """
325
- data = (self.method + self.url + str(self._args) + str(self._kwargs)).encode('utf-8')
353
+ # ignore the token this will break the cache
354
+ ignore_keys = [
355
+ "token",
356
+ ]
357
+ _kwargs = {key: value for key, value in self._kwargs.items() if key not in ignore_keys}
358
+ data = (self.method + self.url + str(self._args) + str(_kwargs)).encode('utf-8')
326
359
  str_hash = md5(data).hexdigest() # nosec B303
327
360
  return f'esi_{str_hash}'
328
361
 
@@ -360,7 +393,7 @@ class BaseEsiOperation():
360
393
  """
361
394
  return any(p.name == "before" or p.name == "after" for p in self.operation.parameters)
362
395
 
363
- def _get_cache(self, cache_key: str) -> tuple[ResponseHeadersType | None, Any, Response | None]:
396
+ def _get_cache(self, cache_key: str, etag: str) -> tuple[ResponseHeadersType | None, Any, Response | None]:
364
397
  """Retrieve cached response and validate expiry
365
398
  Args:
366
399
  cache_key (str): The cache key to retrieve
@@ -368,6 +401,9 @@ class BaseEsiOperation():
368
401
  tuple[ResponseHeadersType | None, Any, Response | None]: The cached response,
369
402
  or None if not found or expired
370
403
  """
404
+ if not app_settings.ESI_CACHE_RESPONSE:
405
+ return None, None, None
406
+
371
407
  try:
372
408
  cached_response = cache.get(cache_key)
373
409
  except Exception as e:
@@ -376,18 +412,46 @@ class BaseEsiOperation():
376
412
 
377
413
  if cached_response:
378
414
  logger.debug(f"Cache Hit {self.url}")
379
- headers, data = self.parse_cached_request(cached_response)
415
+ expiry = _time_to_expiry(str(cached_response.headers.get('Expires')))
380
416
 
381
- expiry = _time_to_expiry(str(headers.get('Expires')))
417
+ # force check to ensure cache isn't expired
382
418
  if expiry < 0:
383
419
  logger.warning("Cache expired by %d seconds, forcing expiry", expiry)
384
420
  return None, None, None
421
+
422
+ # check if etag is same before building models from cache
423
+ if etag:
424
+ if cached_response.headers.get('Expires') == etag:
425
+ # refresh/store the etag's TTL
426
+ self._store_etag(cached_response.headers)
427
+ raise HTTPNotModified(
428
+ status_code=304,
429
+ headers=cached_response.headers
430
+ )
431
+
432
+ # build models
433
+ headers, data = self.parse_cached_request(cached_response)
385
434
  return headers, data, cached_response
386
435
 
387
436
  return None, None, None
388
437
 
438
+ def _store_etag(self, headers: dict):
439
+ """
440
+ Store response etag in cache for 7 days
441
+ """
442
+ if "ETag" in headers:
443
+ cache.set(self._etag_key(), headers["ETag"], timeout=ETAG_EXPIRY)
444
+
445
+ def _clear_etag(self):
446
+ """ Delete the cached etag for this operation.
447
+ """
448
+ try:
449
+ cache.delete(self._etag_key())
450
+ except Exception as e:
451
+ logger.error(f"Failed to delete etag {e}", exc_info=True)
452
+
389
453
  def _store_cache(self, cache_key: str, response) -> None:
390
- """ Store the response in cache with ETag and TTL.
454
+ """ Store the response in cache for expiry TTL.
391
455
  Args:
392
456
  cache_key (str): The cache key to store the response under
393
457
  response (Response): The response object to cache
@@ -395,9 +459,6 @@ class BaseEsiOperation():
395
459
  if not app_settings.ESI_CACHE_RESPONSE:
396
460
  return
397
461
 
398
- if "ETag" in response.headers:
399
- cache.set(f"{cache_key}_etag", response.headers["ETag"])
400
-
401
462
  expires = response.headers.get("Expires")
402
463
  ttl = _time_to_expiry(expires) if expires else 0
403
464
  if ttl > 0:
@@ -406,6 +467,14 @@ class BaseEsiOperation():
406
467
  except Exception as e:
407
468
  logger.error(f"Failed to cache {e}", exc_info=True)
408
469
 
470
+ def _clear_cache(self):
471
+ """ Delete the cached data for this operation.
472
+ """
473
+ try:
474
+ cache.delete(self._cache_key())
475
+ except Exception as e:
476
+ logger.error(f"Failed to delete cache {e}", exc_info=True)
477
+
409
478
  def _validate_token_scopes(self, token: Token) -> None:
410
479
  """Validate that the token provided has the required scopes for this ESI operation.
411
480
  """
@@ -465,8 +534,9 @@ class EsiOperation(BaseEsiOperation):
465
534
 
466
535
  def result(
467
536
  self,
468
- etag: str | None = None,
537
+ use_etag: bool = True,
469
538
  return_response: bool = False,
539
+ force_refresh: bool = False,
470
540
  use_cache: bool = True,
471
541
  **extra) -> tuple[Any, Response] | Any:
472
542
  """Executes the request and returns the response from ESI for the current operation.
@@ -480,12 +550,17 @@ class EsiOperation(BaseEsiOperation):
480
550
  self.body = self._extract_body_param()
481
551
  parameters = self._kwargs | extra
482
552
  cache_key = self._cache_key()
483
- etag_key = f"{cache_key}_etag"
553
+ etag_key = self._etag_key()
554
+ etag = None
484
555
 
485
- if not etag and app_settings.ESI_CACHE_RESPONSE:
556
+ if force_refresh:
557
+ self._clear_cache()
558
+ self._clear_etag()
559
+
560
+ if use_etag:
486
561
  etag = cache.get(etag_key)
487
562
 
488
- headers, data, response = self._get_cache(cache_key)
563
+ headers, data, response = self._get_cache(cache_key, etag=etag) if use_cache else (None, None, None)
489
564
 
490
565
  if response and use_cache:
491
566
  expiry = _time_to_expiry(str(headers.get('Expires')))
@@ -499,24 +574,47 @@ class EsiOperation(BaseEsiOperation):
499
574
 
500
575
  if not response:
501
576
  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)
577
+ try:
578
+ headers, data, response = self._make_request(parameters, etag)
579
+ if response.status_code == 420:
580
+ reset = response.headers.get("X-RateLimit-Reset", None)
581
+ cache.set("esi_error_limit_reset", reset, timeout=reset)
582
+ raise ESIErrorLimitException(reset=reset)
583
+
584
+ # Shim our exceptions into Django-ESI
585
+ except base_HTTPServerError as e:
586
+ raise HTTPServerError(
587
+ status_code=e.status_code,
588
+ headers=e.headers,
589
+ data=e.data
590
+ )
591
+
592
+ except base_HTTPClientError as e:
593
+ raise HTTPClientError(
594
+ status_code=e.status_code,
595
+ headers=e.headers,
596
+ data=e.data
597
+ )
598
+
599
+ # Throw a 304 exception for catching.
600
+ if response.status_code == 304:
601
+ # refresh/store the etag's TTL
602
+ self._store_etag(response.headers)
603
+ raise HTTPNotModified(
604
+ status_code=304,
605
+ headers=response.headers
606
+ )
607
+
608
+ # last step store cache we dont want to catch the 304 `None` resonses
609
+ self._store_cache(cache_key, response)
513
610
 
514
611
  return (data, response) if return_response else data
515
612
 
516
613
  def results(
517
614
  self,
518
- etag: str | None = None,
615
+ use_etag: bool = True,
519
616
  return_response: bool = False,
617
+ force_refresh: bool = False,
520
618
  use_cache: bool = True,
521
619
  **extra) -> tuple[list[Any], Response | Any | None] | list[Any]:
522
620
  all_results: list[Any] = []
@@ -532,7 +630,12 @@ class EsiOperation(BaseEsiOperation):
532
630
  total_pages = 1
533
631
  while current_page <= total_pages:
534
632
  self._kwargs["page"] = current_page
535
- data, response = self.result(etag=etag, return_response=True, **extra)
633
+ data, response = self.result(
634
+ use_etag=use_etag,
635
+ return_response=True,
636
+ force_refresh=force_refresh,
637
+ **extra
638
+ )
536
639
  last_response = response
537
640
  all_results.extend(data if isinstance(data, list) else [data])
538
641
  total_pages = int(response.headers.get("X-Pages", 1))
@@ -551,7 +654,13 @@ class EsiOperation(BaseEsiOperation):
551
654
  else:
552
655
  cursor_param = "after"
553
656
  while True:
554
- data, response = self.result(etag=etag, return_response=True, use_cache=use_cache, **params)
657
+ data, response = self.result(
658
+ use_etag=use_etag,
659
+ return_response=True,
660
+ force_refresh=force_refresh,
661
+ use_cache=use_cache,
662
+ **params
663
+ )
555
664
  last_response = response
556
665
  if not data:
557
666
  break
@@ -562,7 +671,13 @@ class EsiOperation(BaseEsiOperation):
562
671
  params[cursor_param] = cursor_token
563
672
 
564
673
  else:
565
- data, response = self.result(etag=etag, return_response=True, use_cache=use_cache, **extra)
674
+ data, response = self.result(
675
+ use_etag=use_etag,
676
+ return_response=True,
677
+ force_refresh=force_refresh,
678
+ use_cache=use_cache,
679
+ **extra
680
+ )
566
681
  all_results.extend(data if isinstance(data, list) else [data])
567
682
  last_response = response
568
683
 
@@ -571,7 +686,8 @@ class EsiOperation(BaseEsiOperation):
571
686
  def results_localized(
572
687
  self,
573
688
  languages: list[str] | str | None = None,
574
- **kwargs) -> dict[str, list[Any]]:
689
+ **kwargs
690
+ ) -> dict[str, list[Any]]:
575
691
  """Executes the request and returns the response from ESI for all default languages and pages (if any).
576
692
  Args:
577
693
  languages: (list[str], str, optional) language(s) to return instead of default languages
@@ -590,7 +706,7 @@ class EsiOperation(BaseEsiOperation):
590
706
  my_languages.append(lang)
591
707
 
592
708
  return {
593
- language: self.results(language=language, **kwargs)
709
+ language: self.results(accept_language=language, **kwargs)
594
710
  for language in my_languages
595
711
  }
596
712
 
@@ -664,17 +780,37 @@ class EsiOperationAsync(BaseEsiOperation):
664
780
 
665
781
  if not response:
666
782
  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)
783
+ try:
784
+ headers, data, response = await self._make_request(parameters, etag)
785
+ if response.status_code == 420:
786
+ reset = response.headers.get("X-RateLimit-Reset", None)
787
+ cache.set("esi_error_limit_reset", reset, timeout=reset)
788
+ raise ESIErrorLimitException(reset=reset)
789
+ self._store_cache(cache_key, response)
790
+ self._store_etag(response.headers)
791
+ # Shim our exceptions into Django-ESI
792
+ except base_HTTPServerError as e:
793
+ raise HTTPServerError(
794
+ status_code=e.status_code,
795
+ headers=e.headers,
796
+ data=e.data
797
+ )
798
+
799
+ except base_HTTPClientError as e:
800
+ raise HTTPClientError(
801
+ status_code=e.status_code,
802
+ headers=e.headers,
803
+ data=e.data
804
+ )
805
+
806
+ # Throw a 304 exception for catching.
807
+ if response.status_code == 304:
808
+ # refresh/store the etag's TTL
809
+ self._store_etag(response.headers)
810
+ raise HTTPNotModified(
811
+ status_code=304,
812
+ headers=response.headers
813
+ )
678
814
 
679
815
  return (data, response) if return_response else data
680
816
 
@@ -732,7 +868,7 @@ class EsiOperationAsync(BaseEsiOperation):
732
868
  my_languages.append(lang)
733
869
 
734
870
  return {
735
- language: self.results(language=language, **extra)
871
+ language: self.results(accept_language=language, **kwargs)
736
872
  for language in my_languages
737
873
  }
738
874
 
@@ -795,6 +931,7 @@ class ESIClient(ESIClientStub):
795
931
  Base ESI Client, provides access to Tags Assets, Characters, etc.
796
932
  or Raw aiopenapi3 via sad smiley ._.
797
933
  """
934
+
798
935
  def __init__(self, api: OpenAPI) -> None:
799
936
  self.api = api
800
937
  self._tags = set(api._operationindex._tags.keys())
@@ -804,7 +941,11 @@ class ESIClient(ESIClientStub):
804
941
  if tag == "_":
805
942
  return self.api._operationindex
806
943
 
807
- elif tag in set(self.api._operationindex._tags.keys()):
944
+ # convert pythonic Planetary_Interaction to Planetary Interaction
945
+ if "_" in tag:
946
+ tag = tag.replace("_", " ")
947
+
948
+ if tag in set(self.api._operationindex._tags.keys()):
808
949
  return ESITag(self.api._operationindex._tags[tag], self.api)
809
950
 
810
951
  raise AttributeError(
@@ -812,12 +953,36 @@ class ESIClient(ESIClientStub):
812
953
  f"Available tags: {', '.join(sorted(self._tags))}"
813
954
  )
814
955
 
956
+ def purge_all_etags(self):
957
+ """ Delete all stored etags from the cache for this application
958
+
959
+ TODO: consider making this more config agnostic
960
+ """
961
+ try:
962
+ # new lib
963
+ from django_redis import get_redis_connection
964
+ _client = get_redis_connection("default")
965
+ except (NotImplementedError, ModuleNotFoundError):
966
+ # old lib
967
+ from django.core.cache import caches
968
+ default_cache = caches['default']
969
+ _client = default_cache.get_master_client()
970
+
971
+ keys = _client.keys(f":?:{slugify(self.api.app_name)}_etag_*")
972
+ if keys:
973
+ deleted = _client.delete(*keys)
974
+
975
+ logger.info(f"Deleted {deleted} etag keys")
976
+
977
+ return deleted
978
+
815
979
 
816
980
  class ESIClientAsync(ESIClientStub):
817
981
  """
818
982
  Async Base ESI Client, provides access to Tags Assets, Characters, etc.
819
983
  or Raw aiopenapi3 via sad smiley ._.
820
984
  """
985
+
821
986
  def __init__(self, api: OpenAPI) -> None:
822
987
  self.api = api
823
988
  self._tags = set(api._operationindex._tags.keys())
@@ -827,7 +992,11 @@ class ESIClientAsync(ESIClientStub):
827
992
  if tag == "_":
828
993
  return self.api._operationindex
829
994
 
830
- elif tag in set(self.api._operationindex._tags.keys()):
995
+ # convert pythonic Planetary_Interaction to Planetary Interaction
996
+ if "_" in tag:
997
+ tag = tag.replace("_", " ")
998
+
999
+ if tag in set(self.api._operationindex._tags.keys()):
831
1000
  return ESITagAsync(self.api._operationindex._tags[tag], self.api)
832
1001
 
833
1002
  raise AttributeError(
@@ -839,7 +1008,7 @@ class ESIClientAsync(ESIClientStub):
839
1008
  class ESIClientProvider:
840
1009
  """Class for providing a single ESI client instance for a whole app
841
1010
  Args:
842
- compatibility_date (str): The compatibility date for the ESI client.
1011
+ compatibility_date (str | date): The compatibility date for the ESI client.
843
1012
  ua_appname (str): Name of the App for generating a User-Agent,
844
1013
  ua_version (str): Version of the App for generating a User-Agent,
845
1014
  ua_url (str, Optional): URL To the Source Code or Documentation for generating a User-Agent,
@@ -855,7 +1024,7 @@ class ESIClientProvider:
855
1024
 
856
1025
  def __init__(
857
1026
  self,
858
- compatibility_date: str,
1027
+ compatibility_date: str | date,
859
1028
  ua_appname: str,
860
1029
  ua_version: str,
861
1030
  ua_url: str | None = None,
@@ -863,7 +1032,10 @@ class ESIClientProvider:
863
1032
  tenant: str = "tranquility",
864
1033
  **kwargs
865
1034
  ) -> None:
866
- self._compatibility_date = compatibility_date
1035
+ if type(compatibility_date) is date:
1036
+ self._compatibility_date = self._date_to_string(compatibility_date)
1037
+ else:
1038
+ self._compatibility_date = compatibility_date
867
1039
  self._ua_appname = ua_appname
868
1040
  self._ua_version = ua_version
869
1041
  self._ua_url = ua_url
@@ -899,5 +1071,10 @@ class ESIClientProvider:
899
1071
  self._client_async = ESIClientAsync(api)
900
1072
  return self._client_async
901
1073
 
1074
+ @classmethod
1075
+ def _date_to_string(cls, compatibility_date: date) -> str:
1076
+ """Turns a date object in a compatibility_date string"""
1077
+ return f"{compatibility_date.year}-{compatibility_date.month:02}-{compatibility_date.day:02}"
1078
+
902
1079
  def __str__(self) -> str:
903
- return "ESIClientProvider"
1080
+ return f"ESIClientProvider - {self._ua_appname} ({self._ua_version})"