django-esi 8.0.0a3__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 (40) hide show
  1. {django_esi-8.0.0a3.dist-info → django_esi-8.0.0a4.dist-info}/METADATA +1 -1
  2. {django_esi-8.0.0a3.dist-info → django_esi-8.0.0a4.dist-info}/RECORD +40 -29
  3. esi/__init__.py +2 -2
  4. esi/aiopenapi3/plugins.py +13 -2
  5. esi/locale/cs_CZ/LC_MESSAGES/django.mo +0 -0
  6. esi/locale/cs_CZ/LC_MESSAGES/django.po +53 -0
  7. esi/locale/de/LC_MESSAGES/django.mo +0 -0
  8. esi/locale/de/LC_MESSAGES/django.po +10 -9
  9. esi/locale/en/LC_MESSAGES/django.mo +0 -0
  10. esi/locale/en/LC_MESSAGES/django.po +3 -3
  11. esi/locale/es/LC_MESSAGES/django.mo +0 -0
  12. esi/locale/es/LC_MESSAGES/django.po +12 -10
  13. esi/locale/fr_FR/LC_MESSAGES/django.mo +0 -0
  14. esi/locale/fr_FR/LC_MESSAGES/django.po +18 -10
  15. esi/locale/it_IT/LC_MESSAGES/django.mo +0 -0
  16. esi/locale/it_IT/LC_MESSAGES/django.po +12 -10
  17. esi/locale/ja/LC_MESSAGES/django.mo +0 -0
  18. esi/locale/ja/LC_MESSAGES/django.po +10 -9
  19. esi/locale/ko_KR/LC_MESSAGES/django.mo +0 -0
  20. esi/locale/ko_KR/LC_MESSAGES/django.po +10 -9
  21. esi/locale/nl_NL/LC_MESSAGES/django.mo +0 -0
  22. esi/locale/nl_NL/LC_MESSAGES/django.po +53 -0
  23. esi/locale/pl_PL/LC_MESSAGES/django.mo +0 -0
  24. esi/locale/pl_PL/LC_MESSAGES/django.po +53 -0
  25. esi/locale/ru/LC_MESSAGES/django.mo +0 -0
  26. esi/locale/ru/LC_MESSAGES/django.po +13 -10
  27. esi/locale/sk/LC_MESSAGES/django.mo +0 -0
  28. esi/locale/sk/LC_MESSAGES/django.po +55 -0
  29. esi/locale/uk/LC_MESSAGES/django.mo +0 -0
  30. esi/locale/uk/LC_MESSAGES/django.po +57 -0
  31. esi/locale/zh_Hans/LC_MESSAGES/django.mo +0 -0
  32. esi/locale/zh_Hans/LC_MESSAGES/django.po +10 -9
  33. esi/management/commands/generate_esi_stubs.py +11 -31
  34. esi/models.py +1 -1
  35. esi/openapi_clients.py +161 -44
  36. esi/stubs.pyi +388 -388
  37. esi/tests/test_openapi.json +264 -0
  38. esi/tests/test_openapi.py +248 -1
  39. {django_esi-8.0.0a3.dist-info → django_esi-8.0.0a4.dist-info}/WHEEL +0 -0
  40. {django_esi-8.0.0a3.dist-info → django_esi-8.0.0a4.dist-info}/licenses/LICENSE +0 -0
esi/openapi_clients.py CHANGED
@@ -1,10 +1,11 @@
1
1
  import logging
2
+ import pathlib
2
3
  import warnings
3
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
9
10
  from aiopenapi3.errors import HTTPServerError as base_HTTPServerError
10
11
  from aiopenapi3.errors import HTTPClientError as base_HTTPClientError
@@ -18,10 +19,11 @@ from tenacity import (
18
19
  )
19
20
 
20
21
  from django.core.cache import cache
22
+ from django.utils.text import slugify
21
23
 
22
24
  from esi import app_settings
23
25
  from esi.exceptions import HTTPClientError, HTTPServerError, HTTPNotModified
24
- from esi.aiopenapi3.plugins import Add304ContentType, PatchCompatibilityDatePlugin, Trim204ContentType
26
+ from esi.aiopenapi3.plugins import Add304ContentType, DjangoESIInit, PatchCompatibilityDatePlugin, Trim204ContentType
25
27
  from esi.exceptions import ESIErrorLimitException
26
28
  from esi.models import Token
27
29
  from esi.stubs import ESIClientStub
@@ -30,6 +32,8 @@ from . import __title__, __url__, __version__
30
32
 
31
33
  logger = logging.getLogger(__name__)
32
34
 
35
+ ETAG_EXPIRY = 60*60*24*7 # 7 days
36
+
33
37
 
34
38
  def _time_to_expiry(expires_header: str) -> int:
35
39
  """Calculate cache TTL from Expires header
@@ -81,9 +85,19 @@ async def http_retry_async() -> AsyncRetrying:
81
85
  )
82
86
 
83
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
+
84
97
  def _load_aiopenapi_client_sync(
85
98
  spec_url: str,
86
99
  compatibility_date: str,
100
+ app_name: str,
87
101
  user_agent: str,
88
102
  tenant: str,
89
103
  spec_file: str | None = None) -> OpenAPI:
@@ -92,6 +106,7 @@ def _load_aiopenapi_client_sync(
92
106
  Args:
93
107
  spec_url (str): _description_
94
108
  compatibility_date (str): _description_
109
+ app_name (str): _description_
95
110
  user_agent (str): _description_
96
111
  tenant (str): _description_
97
112
  spec_file (str | None, optional): _description_. Defaults to None.
@@ -123,21 +138,23 @@ def _load_aiopenapi_client_sync(
123
138
  url=spec_url,
124
139
  path=spec_file,
125
140
  session_factory=session_factory,
141
+ loader=FileSystemLoader(pathlib.Path(spec_file)),
126
142
  use_operation_tags=True,
127
- plugins=[PatchCompatibilityDatePlugin(), Trim204ContentType(), Add304ContentType()]
143
+ plugins=_load_plugins(app_name)
128
144
  )
129
145
  else:
130
146
  return OpenAPI.load_sync(
131
147
  url=spec_url,
132
148
  session_factory=session_factory,
133
149
  use_operation_tags=True,
134
- plugins=[PatchCompatibilityDatePlugin(), Trim204ContentType(), Add304ContentType()]
150
+ plugins=_load_plugins(app_name)
135
151
  )
136
152
 
137
153
 
138
154
  async def _load_aiopenapi_client_async(
139
155
  spec_url: str,
140
156
  compatibility_date: str,
157
+ app_name: str,
141
158
  user_agent: str,
142
159
  tenant: str,
143
160
  spec_file: str | None = None) -> OpenAPI:
@@ -179,18 +196,18 @@ async def _load_aiopenapi_client_async(
179
196
  path=spec_file,
180
197
  session_factory=session_factory,
181
198
  use_operation_tags=True,
182
- plugins=[PatchCompatibilityDatePlugin(), Trim204ContentType(), Add304ContentType()]
199
+ plugins=_load_plugins(app_name)
183
200
  )
184
201
  else:
185
202
  return await OpenAPI.load_async(
186
203
  url=spec_url,
187
204
  session_factory=session_factory,
188
205
  use_operation_tags=True,
189
- plugins=[PatchCompatibilityDatePlugin(), Trim204ContentType(), Add304ContentType()]
206
+ plugins=_load_plugins(app_name)
190
207
  )
191
208
 
192
209
 
193
- 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:
194
211
  """
195
212
  AppName/1.2.3 (foo@example.com; +https://gitlab.com/) Django-ESI/1.2.3 (+https://gitlab.com/allianceauth/django-esi)
196
213
  Contact Email will be inserted from app_settings.
@@ -231,7 +248,7 @@ def esi_client_factory_sync(
231
248
  """
232
249
  user_agent = _build_user_agent(ua_appname, ua_version, ua_url)
233
250
  spec_url = _get_spec_url()
234
- 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)
235
252
 
236
253
 
237
254
  async def esi_client_factory_async(
@@ -253,11 +270,11 @@ async def esi_client_factory_async(
253
270
  """
254
271
  user_agent = _build_user_agent(ua_appname, ua_version, ua_url)
255
272
  spec_url = _get_spec_url()
256
- 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)
257
274
 
258
275
 
259
276
  class BaseEsiOperation():
260
- def __init__(self, operation, api) -> None:
277
+ def __init__(self, operation, api: OpenAPI) -> None:
261
278
  self.method, self.url, self.operation, self.extra = operation
262
279
  self.api = api
263
280
  self.token: Token | None = None
@@ -320,16 +337,24 @@ class BaseEsiOperation():
320
337
 
321
338
  return normalized
322
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
+
323
348
  def _cache_key(self) -> str:
324
349
  """Generate a key name used to cache responses based on method, url, args, kwargs
325
350
  Returns:
326
- str: Key name
351
+ str: Key
327
352
  """
328
353
  # ignore the token this will break the cache
329
354
  ignore_keys = [
330
355
  "token",
331
356
  ]
332
- _kwargs = { key:value for key, value in self._kwargs.items() if key not in ignore_keys }
357
+ _kwargs = {key: value for key, value in self._kwargs.items() if key not in ignore_keys}
333
358
  data = (self.method + self.url + str(self._args) + str(_kwargs)).encode('utf-8')
334
359
  str_hash = md5(data).hexdigest() # nosec B303
335
360
  return f'esi_{str_hash}'
@@ -376,6 +401,9 @@ class BaseEsiOperation():
376
401
  tuple[ResponseHeadersType | None, Any, Response | None]: The cached response,
377
402
  or None if not found or expired
378
403
  """
404
+ if not app_settings.ESI_CACHE_RESPONSE:
405
+ return None, None, None
406
+
379
407
  try:
380
408
  cached_response = cache.get(cache_key)
381
409
  except Exception as e:
@@ -394,6 +422,8 @@ class BaseEsiOperation():
394
422
  # check if etag is same before building models from cache
395
423
  if etag:
396
424
  if cached_response.headers.get('Expires') == etag:
425
+ # refresh/store the etag's TTL
426
+ self._store_etag(cached_response.headers)
397
427
  raise HTTPNotModified(
398
428
  status_code=304,
399
429
  headers=cached_response.headers
@@ -405,8 +435,23 @@ class BaseEsiOperation():
405
435
 
406
436
  return None, None, None
407
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
+
408
453
  def _store_cache(self, cache_key: str, response) -> None:
409
- """ Store the response in cache with ETag and TTL.
454
+ """ Store the response in cache for expiry TTL.
410
455
  Args:
411
456
  cache_key (str): The cache key to store the response under
412
457
  response (Response): The response object to cache
@@ -414,9 +459,6 @@ class BaseEsiOperation():
414
459
  if not app_settings.ESI_CACHE_RESPONSE:
415
460
  return
416
461
 
417
- if "ETag" in response.headers:
418
- cache.set(f"{cache_key}_etag", response.headers["ETag"])
419
-
420
462
  expires = response.headers.get("Expires")
421
463
  ttl = _time_to_expiry(expires) if expires else 0
422
464
  if ttl > 0:
@@ -425,6 +467,14 @@ class BaseEsiOperation():
425
467
  except Exception as e:
426
468
  logger.error(f"Failed to cache {e}", exc_info=True)
427
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
+
428
478
  def _validate_token_scopes(self, token: Token) -> None:
429
479
  """Validate that the token provided has the required scopes for this ESI operation.
430
480
  """
@@ -484,8 +534,9 @@ class EsiOperation(BaseEsiOperation):
484
534
 
485
535
  def result(
486
536
  self,
487
- etag: str | None = None,
537
+ use_etag: bool = True,
488
538
  return_response: bool = False,
539
+ force_refresh: bool = False,
489
540
  use_cache: bool = True,
490
541
  **extra) -> tuple[Any, Response] | Any:
491
542
  """Executes the request and returns the response from ESI for the current operation.
@@ -499,9 +550,14 @@ class EsiOperation(BaseEsiOperation):
499
550
  self.body = self._extract_body_param()
500
551
  parameters = self._kwargs | extra
501
552
  cache_key = self._cache_key()
502
- etag_key = f"{cache_key}_etag"
553
+ etag_key = self._etag_key()
554
+ etag = None
503
555
 
504
- 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:
505
561
  etag = cache.get(etag_key)
506
562
 
507
563
  headers, data, response = self._get_cache(cache_key, etag=etag) if use_cache else (None, None, None)
@@ -524,13 +580,6 @@ class EsiOperation(BaseEsiOperation):
524
580
  reset = response.headers.get("X-RateLimit-Reset", None)
525
581
  cache.set("esi_error_limit_reset", reset, timeout=reset)
526
582
  raise ESIErrorLimitException(reset=reset)
527
- self._store_cache(cache_key, response)
528
-
529
- # if response.status_code == 304 and app_settings.ESI_CACHE_RESPONSE:
530
- # cached = cache.get(cache_key)
531
- # if cached:
532
- # return (cached, response) if return_response else cached
533
- # we dont want to do this, if we do this we have to store data longer than ttl. rip ram
534
583
 
535
584
  # Shim our exceptions into Django-ESI
536
585
  except base_HTTPServerError as e:
@@ -549,17 +598,23 @@ class EsiOperation(BaseEsiOperation):
549
598
 
550
599
  # Throw a 304 exception for catching.
551
600
  if response.status_code == 304:
601
+ # refresh/store the etag's TTL
602
+ self._store_etag(response.headers)
552
603
  raise HTTPNotModified(
553
604
  status_code=304,
554
605
  headers=response.headers
555
606
  )
556
607
 
608
+ # last step store cache we dont want to catch the 304 `None` resonses
609
+ self._store_cache(cache_key, response)
610
+
557
611
  return (data, response) if return_response else data
558
612
 
559
613
  def results(
560
614
  self,
561
- etag: str | None = None,
615
+ use_etag: bool = True,
562
616
  return_response: bool = False,
617
+ force_refresh: bool = False,
563
618
  use_cache: bool = True,
564
619
  **extra) -> tuple[list[Any], Response | Any | None] | list[Any]:
565
620
  all_results: list[Any] = []
@@ -575,7 +630,12 @@ class EsiOperation(BaseEsiOperation):
575
630
  total_pages = 1
576
631
  while current_page <= total_pages:
577
632
  self._kwargs["page"] = current_page
578
- 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
+ )
579
639
  last_response = response
580
640
  all_results.extend(data if isinstance(data, list) else [data])
581
641
  total_pages = int(response.headers.get("X-Pages", 1))
@@ -594,7 +654,13 @@ class EsiOperation(BaseEsiOperation):
594
654
  else:
595
655
  cursor_param = "after"
596
656
  while True:
597
- 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
+ )
598
664
  last_response = response
599
665
  if not data:
600
666
  break
@@ -605,7 +671,13 @@ class EsiOperation(BaseEsiOperation):
605
671
  params[cursor_param] = cursor_token
606
672
 
607
673
  else:
608
- 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
+ )
609
681
  all_results.extend(data if isinstance(data, list) else [data])
610
682
  last_response = response
611
683
 
@@ -614,7 +686,8 @@ class EsiOperation(BaseEsiOperation):
614
686
  def results_localized(
615
687
  self,
616
688
  languages: list[str] | str | None = None,
617
- **kwargs) -> dict[str, list[Any]]:
689
+ **kwargs
690
+ ) -> dict[str, list[Any]]:
618
691
  """Executes the request and returns the response from ESI for all default languages and pages (if any).
619
692
  Args:
620
693
  languages: (list[str], str, optional) language(s) to return instead of default languages
@@ -707,17 +780,37 @@ class EsiOperationAsync(BaseEsiOperation):
707
780
 
708
781
  if not response:
709
782
  logger.debug(f"Cache Miss {self.url}")
710
- headers, data, response = await self._make_request(parameters, etag)
711
- if response.status_code == 420:
712
- reset = response.headers.get("X-RateLimit-Reset", None)
713
- cache.set("esi_error_limit_reset", reset, timeout=reset)
714
- raise ESIErrorLimitException(reset=reset)
715
- # if response.status_code == 304 and app_settings.ESI_CACHE_RESPONSE:
716
- # cached = cache.get(cache_key)
717
- # if cached:
718
- # return (cached, response) if return_response else cached
719
- # we dont want to do this, if we do this we have to store data longer than ttl. rip ram
720
- 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
+ )
721
814
 
722
815
  return (data, response) if return_response else data
723
816
 
@@ -838,6 +931,7 @@ class ESIClient(ESIClientStub):
838
931
  Base ESI Client, provides access to Tags Assets, Characters, etc.
839
932
  or Raw aiopenapi3 via sad smiley ._.
840
933
  """
934
+
841
935
  def __init__(self, api: OpenAPI) -> None:
842
936
  self.api = api
843
937
  self._tags = set(api._operationindex._tags.keys())
@@ -859,12 +953,36 @@ class ESIClient(ESIClientStub):
859
953
  f"Available tags: {', '.join(sorted(self._tags))}"
860
954
  )
861
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
+
862
979
 
863
980
  class ESIClientAsync(ESIClientStub):
864
981
  """
865
982
  Async Base ESI Client, provides access to Tags Assets, Characters, etc.
866
983
  or Raw aiopenapi3 via sad smiley ._.
867
984
  """
985
+
868
986
  def __init__(self, api: OpenAPI) -> None:
869
987
  self.api = api
870
988
  self._tags = set(api._operationindex._tags.keys())
@@ -958,6 +1076,5 @@ class ESIClientProvider:
958
1076
  """Turns a date object in a compatibility_date string"""
959
1077
  return f"{compatibility_date.year}-{compatibility_date.month:02}-{compatibility_date.day:02}"
960
1078
 
961
-
962
1079
  def __str__(self) -> str:
963
- return "ESIClientProvider"
1080
+ return f"ESIClientProvider - {self._ua_appname} ({self._ua_version})"