django-esi 7.0.1__tar.gz → 8.0.0a1__tar.gz

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 (85) hide show
  1. {django_esi-7.0.1 → django_esi-8.0.0a1}/PKG-INFO +4 -2
  2. {django_esi-7.0.1 → django_esi-8.0.0a1}/esi/__init__.py +2 -1
  3. {django_esi-7.0.1 → django_esi-8.0.0a1}/esi/admin.py +4 -3
  4. django_esi-8.0.0a1/esi/aiopenapi3/plugins.py +78 -0
  5. {django_esi-7.0.1 → django_esi-8.0.0a1}/esi/app_settings.py +14 -1
  6. {django_esi-7.0.1 → django_esi-8.0.0a1}/esi/checks.py +1 -1
  7. {django_esi-7.0.1 → django_esi-8.0.0a1}/esi/clients.py +13 -14
  8. {django_esi-7.0.1 → django_esi-8.0.0a1}/esi/decorators.py +35 -3
  9. django_esi-8.0.0a1/esi/exceptions.py +18 -0
  10. django_esi-8.0.0a1/esi/helpers.py +25 -0
  11. django_esi-8.0.0a1/esi/management/commands/generate_esi_stubs.py +211 -0
  12. {django_esi-7.0.1 → django_esi-8.0.0a1}/esi/managers.py +18 -17
  13. django_esi-8.0.0a1/esi/managers.pyi +82 -0
  14. {django_esi-7.0.1 → django_esi-8.0.0a1}/esi/models.py +9 -16
  15. django_esi-8.0.0a1/esi/openapi_clients.py +845 -0
  16. django_esi-8.0.0a1/esi/rate_limiting.py +78 -0
  17. django_esi-8.0.0a1/esi/stubs.py +2 -0
  18. django_esi-8.0.0a1/esi/stubs.pyi +3913 -0
  19. {django_esi-7.0.1 → django_esi-8.0.0a1}/esi/tasks.py +1 -2
  20. {django_esi-7.0.1 → django_esi-8.0.0a1}/esi/tests/jwt_factory.py +3 -4
  21. {django_esi-7.0.1 → django_esi-8.0.0a1}/esi/urls.py +0 -1
  22. {django_esi-7.0.1 → django_esi-8.0.0a1}/esi/views.py +4 -3
  23. {django_esi-7.0.1 → django_esi-8.0.0a1}/pyproject.toml +3 -1
  24. {django_esi-7.0.1 → django_esi-8.0.0a1}/LICENSE +0 -0
  25. {django_esi-7.0.1 → django_esi-8.0.0a1}/README.md +0 -0
  26. {django_esi-7.0.1 → django_esi-8.0.0a1}/esi/apps.py +0 -0
  27. {django_esi-7.0.1 → django_esi-8.0.0a1}/esi/errors.py +0 -0
  28. {django_esi-7.0.1 → django_esi-8.0.0a1}/esi/locale/de/LC_MESSAGES/django.mo +0 -0
  29. {django_esi-7.0.1 → django_esi-8.0.0a1}/esi/locale/de/LC_MESSAGES/django.po +0 -0
  30. {django_esi-7.0.1 → django_esi-8.0.0a1}/esi/locale/en/LC_MESSAGES/django.mo +0 -0
  31. {django_esi-7.0.1 → django_esi-8.0.0a1}/esi/locale/en/LC_MESSAGES/django.po +0 -0
  32. {django_esi-7.0.1 → django_esi-8.0.0a1}/esi/locale/es/LC_MESSAGES/django.mo +0 -0
  33. {django_esi-7.0.1 → django_esi-8.0.0a1}/esi/locale/es/LC_MESSAGES/django.po +0 -0
  34. {django_esi-7.0.1 → django_esi-8.0.0a1}/esi/locale/fr_FR/LC_MESSAGES/django.mo +0 -0
  35. {django_esi-7.0.1 → django_esi-8.0.0a1}/esi/locale/fr_FR/LC_MESSAGES/django.po +0 -0
  36. {django_esi-7.0.1 → django_esi-8.0.0a1}/esi/locale/it_IT/LC_MESSAGES/django.mo +0 -0
  37. {django_esi-7.0.1 → django_esi-8.0.0a1}/esi/locale/it_IT/LC_MESSAGES/django.po +0 -0
  38. {django_esi-7.0.1 → django_esi-8.0.0a1}/esi/locale/ja/LC_MESSAGES/django.mo +0 -0
  39. {django_esi-7.0.1 → django_esi-8.0.0a1}/esi/locale/ja/LC_MESSAGES/django.po +0 -0
  40. {django_esi-7.0.1 → django_esi-8.0.0a1}/esi/locale/ko_KR/LC_MESSAGES/django.mo +0 -0
  41. {django_esi-7.0.1 → django_esi-8.0.0a1}/esi/locale/ko_KR/LC_MESSAGES/django.po +0 -0
  42. {django_esi-7.0.1 → django_esi-8.0.0a1}/esi/locale/ru/LC_MESSAGES/django.mo +0 -0
  43. {django_esi-7.0.1 → django_esi-8.0.0a1}/esi/locale/ru/LC_MESSAGES/django.po +0 -0
  44. {django_esi-7.0.1 → django_esi-8.0.0a1}/esi/locale/zh_Hans/LC_MESSAGES/django.mo +0 -0
  45. {django_esi-7.0.1 → django_esi-8.0.0a1}/esi/locale/zh_Hans/LC_MESSAGES/django.po +0 -0
  46. {django_esi-7.0.1 → django_esi-8.0.0a1}/esi/management/commands/__init__.py +0 -0
  47. {django_esi-7.0.1 → django_esi-8.0.0a1}/esi/management/commands/migrate_to_ssov2.py +0 -0
  48. {django_esi-7.0.1 → django_esi-8.0.0a1}/esi/migrations/0001_initial.py +0 -0
  49. {django_esi-7.0.1 → django_esi-8.0.0a1}/esi/migrations/0002_scopes_20161208.py +0 -0
  50. {django_esi-7.0.1 → django_esi-8.0.0a1}/esi/migrations/0003_hide_tokens_from_admin_site.py +0 -0
  51. {django_esi-7.0.1 → django_esi-8.0.0a1}/esi/migrations/0004_remove_unique_access_token.py +0 -0
  52. {django_esi-7.0.1 → django_esi-8.0.0a1}/esi/migrations/0005_remove_token_length_limit.py +0 -0
  53. {django_esi-7.0.1 → django_esi-8.0.0a1}/esi/migrations/0006_remove_url_length_limit.py +0 -0
  54. {django_esi-7.0.1 → django_esi-8.0.0a1}/esi/migrations/0007_fix_mysql_8_migration.py +0 -0
  55. {django_esi-7.0.1 → django_esi-8.0.0a1}/esi/migrations/0008_nullable_refresh_token.py +0 -0
  56. {django_esi-7.0.1 → django_esi-8.0.0a1}/esi/migrations/0009_set_old_tokens_to_sso_v1.py +0 -0
  57. {django_esi-7.0.1 → django_esi-8.0.0a1}/esi/migrations/0010_set_new_tokens_to_sso_v2.py +0 -0
  58. {django_esi-7.0.1 → django_esi-8.0.0a1}/esi/migrations/0011_add_token_indices.py +0 -0
  59. {django_esi-7.0.1 → django_esi-8.0.0a1}/esi/migrations/0012_fix_token_type_choices.py +0 -0
  60. {django_esi-7.0.1 → django_esi-8.0.0a1}/esi/migrations/0013_squashed_0012_fix_token_type_choices.py +0 -0
  61. {django_esi-7.0.1 → django_esi-8.0.0a1}/esi/migrations/__init__.py +0 -0
  62. {django_esi-7.0.1 → django_esi-8.0.0a1}/esi/static/esi/img/EVE_SSO_Login_Buttons_Large_Black.png +0 -0
  63. {django_esi-7.0.1 → django_esi-8.0.0a1}/esi/static/esi/img/EVE_SSO_Login_Buttons_Large_White.png +0 -0
  64. {django_esi-7.0.1 → django_esi-8.0.0a1}/esi/static/esi/img/EVE_SSO_Login_Buttons_Small_Black.png +0 -0
  65. {django_esi-7.0.1 → django_esi-8.0.0a1}/esi/static/esi/img/EVE_SSO_Login_Buttons_Small_White.png +0 -0
  66. {django_esi-7.0.1 → django_esi-8.0.0a1}/esi/templates/esi/select_token.html +0 -0
  67. {django_esi-7.0.1 → django_esi-8.0.0a1}/esi/templatetags/__init__.py +0 -0
  68. {django_esi-7.0.1 → django_esi-8.0.0a1}/esi/templatetags/scope_tags.py +0 -0
  69. {django_esi-7.0.1 → django_esi-8.0.0a1}/esi/tests/__init__.py +0 -0
  70. {django_esi-7.0.1 → django_esi-8.0.0a1}/esi/tests/client_authed_pilot.py +0 -0
  71. {django_esi-7.0.1 → django_esi-8.0.0a1}/esi/tests/client_public_pilot.py +0 -0
  72. {django_esi-7.0.1 → django_esi-8.0.0a1}/esi/tests/factories.py +0 -0
  73. {django_esi-7.0.1 → django_esi-8.0.0a1}/esi/tests/factories_2.py +0 -0
  74. {django_esi-7.0.1 → django_esi-8.0.0a1}/esi/tests/test_checks.py +0 -0
  75. {django_esi-7.0.1 → django_esi-8.0.0a1}/esi/tests/test_clients.py +0 -0
  76. {django_esi-7.0.1 → django_esi-8.0.0a1}/esi/tests/test_decorators.py +0 -0
  77. {django_esi-7.0.1 → django_esi-8.0.0a1}/esi/tests/test_management_command.py +0 -0
  78. {django_esi-7.0.1 → django_esi-8.0.0a1}/esi/tests/test_managers.py +0 -0
  79. {django_esi-7.0.1 → django_esi-8.0.0a1}/esi/tests/test_models.py +0 -0
  80. {django_esi-7.0.1 → django_esi-8.0.0a1}/esi/tests/test_swagger.json +0 -0
  81. {django_esi-7.0.1 → django_esi-8.0.0a1}/esi/tests/test_swagger_full.json +0 -0
  82. {django_esi-7.0.1 → django_esi-8.0.0a1}/esi/tests/test_tasks.py +0 -0
  83. {django_esi-7.0.1 → django_esi-8.0.0a1}/esi/tests/test_templatetags.py +0 -0
  84. {django_esi-7.0.1 → django_esi-8.0.0a1}/esi/tests/test_views.py +0 -0
  85. {django_esi-7.0.1 → django_esi-8.0.0a1}/esi/tests/threading_pilot.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: django-esi
3
- Version: 7.0.1
3
+ Version: 8.0.0a1
4
4
  Summary: Django app for accessing the EVE Swagger Interface (ESI).
5
5
  Author-email: Alliance Auth <adarnof@gmail.com>
6
6
  Requires-Python: >=3.8
@@ -24,14 +24,16 @@ Classifier: Programming Language :: Python :: 3.13
24
24
  Classifier: Topic :: Internet :: WWW/HTTP
25
25
  Classifier: Topic :: Internet :: WWW/HTTP :: Dynamic Content
26
26
  License-File: LICENSE
27
+ Requires-Dist: aiopenapi3
27
28
  Requires-Dist: bravado>=10.6,<12
28
- Requires-Dist: brotli
29
29
  Requires-Dist: celery>=4.0.2
30
30
  Requires-Dist: django>=4.2,<6
31
+ Requires-Dist: httpx[http2, brotli, zstd]
31
32
  Requires-Dist: jsonschema<4
32
33
  Requires-Dist: python-jose>=3.3
33
34
  Requires-Dist: requests>=2.26
34
35
  Requires-Dist: requests-oauthlib>=0.8
36
+ Requires-Dist: tenacity
35
37
  Requires-Dist: tqdm>=4.62.3
36
38
  Requires-Dist: myst-parser ; extra == "docs"
37
39
  Requires-Dist: sphinx ; extra == "docs"
@@ -1,5 +1,6 @@
1
1
  """Django app for accessing the EVE Swagger Interface (ESI)."""
2
2
 
3
- __version__ = '7.0.1'
3
+ __version__ = '8.0.0a1'
4
4
  __title__ = 'Django-ESI'
5
5
  __url__ = 'https://gitlab.com/allianceauth/django-esi'
6
+ __build_date__ = "2025-08-26"
@@ -1,7 +1,8 @@
1
1
  from django.contrib import admin
2
2
  from django.contrib.auth import get_user_model
3
+ from django.db.models import QuerySet
3
4
 
4
- from .models import Token, Scope, CallbackRedirect
5
+ from .models import CallbackRedirect, Scope, Token
5
6
 
6
7
  admin.site.register(CallbackRedirect)
7
8
 
@@ -13,14 +14,14 @@ class ScopeAdmin(admin.ModelAdmin):
13
14
 
14
15
  @admin.register(Token)
15
16
  class TokenAdmin(admin.ModelAdmin):
16
- def get_queryset(self, request):
17
+ def get_queryset(self, request) -> QuerySet["Token"]:
17
18
  qs = super().get_queryset(request)
18
19
  return qs.select_related('user').prefetch_related('scopes')
19
20
 
20
21
  @admin.display(
21
22
  description='Scopes'
22
23
  )
23
- def get_scopes(self, obj):
24
+ def get_scopes(self, obj) -> str:
24
25
  return ", ".join([x.name for x in obj.scopes.all()])
25
26
 
26
27
  User = get_user_model()
@@ -0,0 +1,78 @@
1
+ from aiopenapi3.plugin import Document
2
+
3
+
4
+ class RemoveSecurityParameter(Document):
5
+ """
6
+ Removes the whole OAuth2 securityScheme
7
+ """
8
+ def parsed(self, ctx: Document.Context) -> Document.Context:
9
+ print("RemoveSecurityParameterPlugin: Removing OAuth2 securityScheme")
10
+ spec = ctx.document
11
+ oauth2 = spec.get("components", {}).get("securitySchemes", {}).pop("OAuth2", None)
12
+ # Patch all paths
13
+ for path_item in spec.get("paths", {}).values():
14
+ for method_name in ("get", "post", "put", "delete", "patch", "options", "head"):
15
+ method = path_item.get(method_name)
16
+ if not method:
17
+ continue
18
+ method.pop("security", None)
19
+
20
+ return ctx
21
+
22
+
23
+ class TrimSecurityParameter(Document):
24
+ """TrimSecurityParameter
25
+ Trims out of Spec OAuth2 attributes. CCP have fixed this.
26
+ Leaving in place in case we need a quick reference again.
27
+ """
28
+ def parsed(self, ctx: Document.Context) -> Document.Context:
29
+ print("TrimSecurityParameter: Trimming out of spec attributes")
30
+ spec = ctx.document
31
+ oauth2 = spec.get("components", {}).get("securitySchemes", {}).get("OAuth2")
32
+ if oauth2 and oauth2.get("type") == "oauth2":
33
+ oauth2.pop("in", None)
34
+ oauth2.pop("name", None)
35
+ return ctx
36
+
37
+
38
+ class PatchCompatibilityDatePlugin(Document):
39
+ """
40
+ Makes the X-Compatibility-Date header optional
41
+
42
+ This is because WE specifically add it in the library to the HTTP requests,
43
+ but without this, it will be a required parameter during request generation before it hits the HTTP library.
44
+ """
45
+ def parsed(self, ctx: Document.Context) -> Document.Context:
46
+ print("PatchCompatibilityDatePlugin: making compatibility date optional")
47
+ spec = ctx.document
48
+
49
+ def patch_param(param):
50
+ # Follow $ref if present
51
+ if "$ref" in param:
52
+ ref = param["$ref"]
53
+ parts = ref.split("/")
54
+ if parts[1] == "components" and parts[2] == "parameters":
55
+ param_name = parts[-1]
56
+ comp_param = spec.get("components", {}).get("parameters", {}).get(param_name)
57
+ if comp_param and comp_param.get("name") == "X-Compatibility-Date":
58
+ comp_param["required"] = False
59
+ else:
60
+ # Inline parameter
61
+ if (param.get("name") == "X-Compatibility-Date" and param.get("in") == "header"):
62
+ param["required"] = False
63
+
64
+ # Patch all paths
65
+ for path_item in spec.get("paths", {}).values():
66
+ for method_name in ("get", "post", "put", "delete", "patch", "options", "head"):
67
+ method = path_item.get(method_name)
68
+ if not method:
69
+ continue
70
+ for param in method.get("parameters", []):
71
+ patch_param(param)
72
+
73
+ # Patch global parameters in components
74
+ for param_name, param in spec.get("components", {}).get("parameters", {}).items():
75
+ if param.get("name") == "X-Compatibility-Date" and param.get("in") == "header":
76
+ param["required"] = False
77
+
78
+ return ctx
@@ -66,8 +66,21 @@ ESI_REQUESTS_CONNECT_TIMEOUT = getattr(settings, 'ESI_REQUESTS_CONNECT_TIMEOUT',
66
66
  Can temporarily overwritten with by passing ``timeout`` with ``result()``
67
67
  """
68
68
 
69
- ESI_REQUESTS_READ_TIMEOUT = getattr(settings, 'ESI_REQUESTS_READ_TIMEOUT', 30)
69
+ ESI_REQUESTS_READ_TIMEOUT = getattr(settings, 'ESI_REQUESTS_READ_TIMEOUT', 10)
70
70
  """Default read timeouts for all requests to ESI.
71
+ This should be a maximum of 10s as ESI cuts all requests to the monolith off @ 10s.
72
+
73
+ Can temporarily overwritten with by passing ``timeout`` with ``result()``
74
+ """
75
+
76
+ ESI_REQUESTS_WRITE_TIMEOUT = getattr(settings, 'ESI_REQUESTS_WRITE_TIMEOUT', 5)
77
+ """Default write timeouts for all requests to ESI.
78
+
79
+ Can temporarily overwritten with by passing ``timeout`` with ``result()``
80
+ """
81
+
82
+ ESI_REQUESTS_POOL_TIMEOUT = getattr(settings, 'ESI_REQUESTS_POOL_TIMEOUT', 5)
83
+ """Default pool timeouts for all requests to ESI.
71
84
 
72
85
  Can temporarily overwritten with by passing ``timeout`` with ``result()``
73
86
  """
@@ -1,5 +1,5 @@
1
- from django.core.checks import Error, Warning, register, Tags
2
1
  from django.conf import settings
2
+ from django.core.checks import Error, Tags, Warning, register
3
3
 
4
4
 
5
5
  @register(Tags.security)
@@ -1,28 +1,27 @@
1
- from datetime import datetime
2
- from hashlib import md5
3
1
  import json
4
2
  import logging
3
+ import warnings
4
+ from datetime import datetime
5
+ from hashlib import md5
5
6
  from time import sleep
7
+ from typing import Any
6
8
  from urllib import parse as urlparse
7
- from typing import Any, Union, Tuple
8
- import warnings
9
9
 
10
- from bravado.client import SwaggerClient
11
10
  from bravado import requests_client
11
+ from bravado.client import SwaggerClient
12
12
  from bravado.exception import (
13
- HTTPBadGateway, HTTPGatewayTimeout, HTTPServiceUnavailable
13
+ HTTPBadGateway, HTTPGatewayTimeout, HTTPServiceUnavailable,
14
14
  )
15
- from bravado_core.response import IncomingResponse
16
- from bravado.swagger_model import Loader
17
15
  from bravado.http_future import HttpFuture
18
- from bravado_core.spec import Spec, CONFIG_DEFAULTS
16
+ from bravado.swagger_model import Loader
17
+ from bravado_core.response import IncomingResponse
18
+ from bravado_core.spec import CONFIG_DEFAULTS, Spec
19
19
  from requests.adapters import HTTPAdapter
20
20
 
21
21
  from django.core.cache import cache
22
22
 
23
+ from . import __title__, __url__, __version__, app_settings
23
24
  from .errors import TokenExpiredError
24
- from . import app_settings, __version__, __title__, __url__
25
-
26
25
 
27
26
  logger = logging.getLogger(__name__)
28
27
 
@@ -75,7 +74,7 @@ class CachingHttpFuture(HttpFuture):
75
74
  except ValueError:
76
75
  return 0
77
76
 
78
- def results(self, **kwargs) -> Union[Any, Tuple[Any, IncomingResponse]]:
77
+ def results(self, **kwargs) -> Any | tuple[Any, IncomingResponse]:
79
78
  """Executes the request and returns the response from ESI for the current
80
79
  route. Response will include all pages if there are more available.
81
80
 
@@ -143,7 +142,7 @@ class CachingHttpFuture(HttpFuture):
143
142
  for language in my_languages
144
143
  }
145
144
 
146
- def result(self, **kwargs) -> Union[Any, Tuple[Any, IncomingResponse]]:
145
+ def result(self, **kwargs) -> Any | tuple[Any, IncomingResponse]:
147
146
  """Executes the request and returns the response from ESI. Response will
148
147
  include the requested / first page only if there are more pages available.
149
148
 
@@ -222,7 +221,7 @@ class CachingHttpFuture(HttpFuture):
222
221
 
223
222
  return super().result(**kwargs)
224
223
 
225
- def _result_with_retries(self, **kwargs) -> Tuple[Any, IncomingResponse]:
224
+ def _result_with_retries(self, **kwargs) -> tuple[Any, IncomingResponse]:
226
225
  """Execute request and retry on certain HTTP errors.
227
226
 
228
227
  ``kwargs`` are passed through to super().result()
@@ -1,14 +1,18 @@
1
+ import functools
1
2
  import logging
3
+ import time
2
4
  from functools import wraps
3
- from typing import Union
4
5
 
5
- from .models import Token, CallbackRedirect
6
+ from django.core.cache import cache
6
7
 
8
+ from esi.rate_limiting import ESIRateLimitBucket, ESIRateLimits
9
+
10
+ from .models import CallbackRedirect, Token
7
11
 
8
12
  logger = logging.getLogger(__name__)
9
13
 
10
14
 
11
- def _check_callback(request) -> Union[Token, None]:
15
+ def _check_callback(request) -> Token | None:
12
16
  # ensure session installed in database
13
17
  if not request.session.exists(request.session.session_key):
14
18
  logger.debug("Creating new session for %s", request.user)
@@ -221,3 +225,31 @@ def single_use_token(scopes='', new=False):
221
225
  return _wrapped_view
222
226
 
223
227
  return decorator
228
+
229
+
230
+ def wait_for_esi_errorlimit_reset(cache_key="esi_error_limit_reset", poll_interval=1):
231
+ def decorator(func):
232
+ def wrapper(*args, **kwargs):
233
+ reset = cache.get(cache_key)
234
+ if reset is not None:
235
+ print(f"ESI Error Limited, waiting {reset}s before retrying...")
236
+ while cache.get(cache_key):
237
+ time.sleep(poll_interval)
238
+ return func(*args, **kwargs)
239
+ return wrapper
240
+ return decorator
241
+
242
+
243
+ def esi_rate_limiter_bucketed(
244
+ bucket: ESIRateLimitBucket,
245
+ raise_on_limit: bool = True,
246
+ ):
247
+ # TODO Investigate esi cache hits.
248
+
249
+ def decorator(func):
250
+ @functools.wraps(func)
251
+ def wrapper(*args, **kwargs):
252
+ ESIRateLimits.check_bucket(bucket, raise_on_limit)
253
+ return func(*args, **kwargs)
254
+ return wrapper
255
+ return decorator
@@ -0,0 +1,18 @@
1
+ class ESIErrorLimitException(Exception):
2
+ """ESI Global Error Limit Exceeded
3
+ https://developers.eveonline.com/docs/services/esi/best-practices/#error-limit
4
+ """
5
+ def __init__(self, reset=None, *args, **kwargs) -> None:
6
+ self.reset = reset
7
+ msg = kwargs.get("message") or (
8
+ f"ESI Error limited. Reset in {reset} seconds." if reset else "ESI Error limited."
9
+ )
10
+ super().__init__(msg, *args)
11
+
12
+
13
+ class ESIBucketLimitException(Exception):
14
+ """Endpoint (Bucket) Specific Rate Limit Exceeded"""
15
+ def __init__(self, bucket, *args, **kwargs) -> None:
16
+ self.bucket = bucket
17
+ msg = kwargs.get("message") or f"ESI bucket limit reached for {bucket}."
18
+ super().__init__(msg, *args)
@@ -0,0 +1,25 @@
1
+ from esi.models import Token
2
+
3
+
4
+ def get_token(character_id: int, scopes: list) -> Token:
5
+ """Helper method to get a valid token for a specific character with specific scopes.
6
+
7
+ Args:
8
+ character_id: Character to filter on.
9
+ scopes: array of ESI scope strings to search for.
10
+
11
+ Returns:
12
+ Matching Token
13
+ """
14
+ qs = (
15
+ Token.objects
16
+ .filter(character_id=character_id)
17
+ .require_scopes(scopes)
18
+ .require_valid()
19
+ )
20
+ token = qs.first()
21
+ if token is None:
22
+ raise Token.DoesNotExist(
23
+ f"No valid token found for character_id={character_id} with required scopes."
24
+ )
25
+ return token
@@ -0,0 +1,211 @@
1
+ import os
2
+ import re
3
+
4
+ from aiopenapi3 import OpenAPI
5
+ from httpx import Client, Timeout
6
+
7
+ from django.core.management.base import BaseCommand
8
+
9
+ from esi import __title__, __url__, __version__, app_settings, stubs, __build_date__
10
+ from esi.aiopenapi3.plugins import PatchCompatibilityDatePlugin
11
+
12
+ # OpenAPI Types to Python
13
+ TYPE_MAP = {
14
+ "integer": "int",
15
+ "number": "float",
16
+ "string": "str",
17
+ "boolean": "bool",
18
+ "array": "list[Any]",
19
+ "object": "dict[str, Any]",
20
+ }
21
+
22
+
23
+ def sanitize_class_name(name: str) -> str:
24
+ """Convert a tag into a valid Python class name."""
25
+ sanitized = re.sub(r'[^0-9a-zA-Z_]', '_', name.strip())
26
+ if sanitized and not sanitized[0].isalpha():
27
+ sanitized = f"_{sanitized}"
28
+ return sanitized
29
+
30
+
31
+ def sanitize_operation_class(name: str) -> str:
32
+ return re.sub(r'[^0-9a-zA-Z_]', '', name[0].upper() + name[1:] + "Operation")
33
+
34
+
35
+ def schema_to_type(schema) -> str:
36
+ """Convert an OpenAPI schema to a Python type hint."""
37
+ if not schema:
38
+ return "Any"
39
+ schema_type = getattr(schema, "type", None)
40
+ if schema_type == "array":
41
+ items_schema = getattr(schema, "items", None)
42
+ inner = schema_to_type(items_schema)
43
+ return f"list[{inner}]"
44
+ if schema_type == "object":
45
+ return "dict[str, Any]"
46
+ return TYPE_MAP.get(schema_type, "Any")
47
+
48
+
49
+ class Command(BaseCommand):
50
+ help = "Generate ESI Stubs from the current ESI spec with correct type hints."
51
+
52
+ def add_arguments(self, parser):
53
+ parser.add_argument(
54
+ "--output",
55
+ default=None,
56
+ help="Custom output path for the generated ESI stub file (default: stubs.pyi next to openapi_clients.py).",
57
+ )
58
+ parser.add_argument(
59
+ "--compatibility_date",
60
+ default=__build_date__,
61
+ help="Compatibility Date to build ESI Stubs from.",
62
+ )
63
+
64
+ def handle(self, *args, **options):
65
+ self.stdout.write("Starting ESI stub generation...")
66
+
67
+ headers = {
68
+ "User-Agent": f"{__title__}/{__version__} (+{__url__})",
69
+ "X-Tenant": "tranquility",
70
+ "X-Compatibility-Date": options["compatibility_date"]
71
+ }
72
+
73
+ def session_factory(**kwargs) -> Client:
74
+ kwargs.pop("headers", None)
75
+ return Client(
76
+ headers=headers,
77
+ timeout=Timeout(
78
+ connect=app_settings.ESI_REQUESTS_CONNECT_TIMEOUT,
79
+ read=app_settings.ESI_REQUESTS_READ_TIMEOUT,
80
+ write=app_settings.ESI_REQUESTS_WRITE_TIMEOUT,
81
+ pool=app_settings.ESI_REQUESTS_POOL_TIMEOUT
82
+ ),
83
+ **kwargs
84
+ )
85
+
86
+ spec_url = f"{app_settings.ESI_API_URL}meta/openapi.json"
87
+ stub_api = OpenAPI.load_sync(
88
+ url=spec_url,
89
+ session_factory=session_factory,
90
+ use_operation_tags=True,
91
+ plugins=[PatchCompatibilityDatePlugin()],
92
+ )
93
+
94
+ base_path = os.path.dirname(stubs.__file__)
95
+ output_path = options["output"] or os.path.join(base_path, "stubs.pyi")
96
+
97
+ self.stdout.write(f"Outputting to: {output_path}")
98
+
99
+ spec_root = stub_api._root
100
+ with open(output_path, "w", encoding="utf-8") as f:
101
+ # File headers
102
+ f.write("# flake8: noqa=E501\n")
103
+ f.write("# Auto Generated do not edit\n")
104
+ # Python Imports
105
+ f.write("from typing import Any, Optional\n\n")
106
+ f.write("from esi.openapi_clients import EsiOperation\n")
107
+ f.write("from esi.models import Token\n\n\n")
108
+
109
+ operation_classes = {}
110
+
111
+ for tag in sorted(stub_api._operationindex._tags.keys()):
112
+ # ESI Operation
113
+ # The various methods called on an ESI Operation
114
+ # result(), Results(), Results_Localized() etc. all live here
115
+ ops = stub_api._operationindex._tags[tag]
116
+ for nm, op in sorted(ops._operations.items()):
117
+ op_obj = op[2]
118
+ docstring = (op_obj.description or op_obj.summary or "").replace("\n", " ").strip()
119
+ op_class_name = sanitize_operation_class(nm)
120
+
121
+ response_type = "Any"
122
+ try:
123
+ resp_200 = op_obj.responses.get("200")
124
+ if resp_200 and "application/json" in resp_200.content:
125
+ response_type = schema_to_type(resp_200.content["application/json"].schema_)
126
+ except Exception:
127
+ response_type = "Any"
128
+
129
+ results_type = response_type if response_type.startswith("list[") else f"list[{response_type}]"
130
+
131
+ if op_class_name not in operation_classes:
132
+ f.write(f"class {op_class_name}(EsiOperation):\n")
133
+ f.write(" \"\"\"EsiOperation, use result(), results() or results_localized()\"\"\"\n")
134
+
135
+ # result()
136
+ f.write(f" def result(self, etag: str | None = None, return_response: bool = False, use_cache: bool = True, **extra) -> {response_type}:\n") # noqa: E501
137
+ f.write(f" \"\"\"{docstring}\"\"\"\n") if docstring else None
138
+ f.write(" ...\n\n")
139
+
140
+ # results()
141
+ f.write(f" def results(self, etag: str | None = None, return_response: bool = False, use_cache: bool = True, **extra) -> {results_type}:\n") # noqa: E501
142
+ f.write(f" \"\"\"{docstring}\"\"\"\n") if docstring else None
143
+ f.write(" ...\n\n")
144
+
145
+ # results_localized()
146
+ f.write(f" def results_localized(self, languages: str | list[str] = 'en', **kwargs) -> {results_type}:\n")
147
+ f.write(f" \"\"\"{docstring}\"\"\"\n") if docstring else None
148
+ f.write(" ...\n\n\n")
149
+
150
+ operation_classes[op_class_name] = True
151
+
152
+ f.write("class ESIClientStub:\n")
153
+ for tag in sorted(stub_api._operationindex._tags.keys()):
154
+ # ESI ESIClientStub
155
+ # The various ESI Tags and Operations
156
+ ops = stub_api._operationindex._tags[tag]
157
+ class_name = f"_{sanitize_class_name(tag)}"
158
+ f.write(f" class {class_name}:\n")
159
+ for nm, op in sorted(ops._operations.items()):
160
+ op_obj = op[2]
161
+ effective_security = (
162
+ getattr(op_obj, "security", None) or getattr(spec_root, "security", None)
163
+ )
164
+
165
+ def _has_oauth2(sec) -> bool:
166
+ data = sec if isinstance(sec, dict) else getattr(sec, "root", None)
167
+ if not isinstance(data, dict):
168
+ return False
169
+ return any(k.lower() == "oauth2" for k in data)
170
+
171
+ needs_oauth = any(_has_oauth2(s) for s in (effective_security or []))
172
+
173
+ params = ["self"]
174
+ optional_params = []
175
+ for p in getattr(op_obj, "parameters", []):
176
+ required = getattr(p, "required", False)
177
+ schema_type_value = getattr(getattr(p, "schema_", None), "type", None)
178
+ if isinstance(schema_type_value, str):
179
+ param_type = TYPE_MAP.get(schema_type_value, "Any")
180
+ else:
181
+ param_type = "Any"
182
+ default = ""
183
+ if not required:
184
+ param_type = f"Optional[{param_type}]"
185
+ default = " = ..."
186
+ param_name = p.name.replace("-", "_")
187
+ if param_name == "authorization" and needs_oauth:
188
+ # Skip the Authorization Parameter, we inject this at HTTP Header Level
189
+ continue
190
+ if required:
191
+ params.append(f"{param_name}: {param_type}{default}")
192
+ else:
193
+ optional_params.append(f"{param_name}: {param_type}{default}")
194
+ if needs_oauth:
195
+ # Here, we add our own custom param instead of the earlier Authorization
196
+ # Our library will pick this up and use it later
197
+ params.append("token: Token")
198
+ optional_params.append("**kwargs: Any")
199
+ params_str = ", ".join(params + optional_params)
200
+ op_class_name = sanitize_operation_class(nm)
201
+ docstring = (op_obj.description or op_obj.summary or "").replace("\n", " ").strip()
202
+
203
+ if docstring:
204
+ f.write(f" def {nm}({params_str}) -> {op_class_name}:\n")
205
+ f.write(f" \"\"\"{docstring}\"\"\"\n")
206
+ f.write(" ...\n\n")
207
+ else:
208
+ f.write(f" def {nm}({params_str}) -> {op_class_name}: ...\n")
209
+ f.write(f"\n {sanitize_class_name(tag)}: {class_name} = {class_name}()\n\n")
210
+
211
+ self.stdout.write(self.style.SUCCESS(f"ESI stubs written to {output_path}"))
@@ -1,21 +1,22 @@
1
1
  import logging
2
2
  from datetime import timedelta
3
- from typing import Any, Union
3
+ from typing import Any
4
4
 
5
5
  import requests
6
- from django.db import models
7
- from django.utils import timezone
8
6
  from jose import jwt
9
7
  from jose.exceptions import ExpiredSignatureError, JWTError
10
8
  from requests_oauthlib import OAuth2Session
11
9
 
10
+ from django.db import models
11
+ from django.utils import timezone
12
+
12
13
  from . import app_settings
13
14
  from .errors import IncompleteResponseError, TokenError
14
15
 
15
16
  logger = logging.getLogger(__name__)
16
17
 
17
18
 
18
- def _process_scopes(scopes):
19
+ def _process_scopes(scopes) -> set[str]:
19
20
  if scopes is None:
20
21
  # support filtering by no scopes with None passed
21
22
  scopes = []
@@ -28,8 +29,8 @@ def _process_scopes(scopes):
28
29
  return {str(s) for s in scopes}
29
30
 
30
31
 
31
- class TokenQueryset(models.QuerySet):
32
- def get_expired(self) -> models.QuerySet:
32
+ class TokenQueryset(models.QuerySet["Token"]):
33
+ def get_expired(self) -> "TokenQueryset":
33
34
  """Get all tokens which have expired.
34
35
 
35
36
  Returns:
@@ -39,7 +40,7 @@ class TokenQueryset(models.QuerySet):
39
40
  timezone.now() - timedelta(seconds=app_settings.ESI_TOKEN_VALID_DURATION)
40
41
  return self.filter(created__lte=max_age)
41
42
 
42
- def bulk_refresh(self) -> models.QuerySet:
43
+ def bulk_refresh(self) -> "TokenQueryset":
43
44
  """Refresh all refreshable tokens in the queryset and delete any expired token
44
45
  that fails to refresh or can not be refreshed.
45
46
 
@@ -65,7 +66,7 @@ class TokenQueryset(models.QuerySet):
65
66
  self.filter(refresh_token__isnull=True).get_expired().delete()
66
67
  return self.exclude(pk__in=incomplete)
67
68
 
68
- def require_valid(self) -> models.QuerySet:
69
+ def require_valid(self) -> "TokenQueryset":
69
70
  """Ensure all tokens are still valid and attempt to refresh any which are expired
70
71
 
71
72
  Deletes those which fail to refresh or cannot be refreshed.
@@ -80,7 +81,7 @@ class TokenQueryset(models.QuerySet):
80
81
  qs = self.filter(pk__in=fresh_pks | refreshed_pks)
81
82
  return qs
82
83
 
83
- def require_scopes(self, scope_string: Union[str, list]) -> models.QuerySet:
84
+ def require_scopes(self, scope_string: str | list) -> "TokenQueryset":
84
85
  """Filter tokens which have at least a subset of given scopes.
85
86
 
86
87
  Args:
@@ -103,7 +104,7 @@ class TokenQueryset(models.QuerySet):
103
104
  tokens = tokens.filter(scopes__pk=pk)
104
105
  return tokens
105
106
 
106
- def require_scopes_exact(self, scope_string: Union[str, list]) -> models.QuerySet:
107
+ def require_scopes_exact(self, scope_string: str | list) -> "TokenQueryset":
107
108
  """Filter tokens which exactly have the given scopes.
108
109
 
109
110
  Args:
@@ -121,7 +122,7 @@ class TokenQueryset(models.QuerySet):
121
122
  pks = [v['pk'] for v in scopes_qs]
122
123
  return self.filter(pk__in=pks)
123
124
 
124
- def equivalent_to(self, token) -> models.QuerySet:
125
+ def equivalent_to(self, token) -> "TokenQueryset":
125
126
  """Fetch all tokens which match the character and scopes of given reference token
126
127
 
127
128
  Args:
@@ -134,8 +135,8 @@ class TokenQueryset(models.QuerySet):
134
135
  .exclude(pk=token.pk)
135
136
 
136
137
 
137
- class TokenManager(models.Manager):
138
- def get_queryset(self):
138
+ class TokenManager(models.Manager["Token"]):
139
+ def get_queryset(self) -> TokenQueryset:
139
140
  """
140
141
  Replace base queryset model with custom TokenQueryset
141
142
  :rtype: :class:`esi.managers.TokenQueryset`
@@ -143,7 +144,7 @@ class TokenManager(models.Manager):
143
144
  return TokenQueryset(self.model, using=self._db)
144
145
 
145
146
  @staticmethod
146
- def _decode_jwt(jwt_token: dict, jwk_set: dict, issuer: Any):
147
+ def _decode_jwt(jwt_token: str, jwk_set: dict, issuer: Any) -> dict[str, Any]:
147
148
  """
148
149
  Helper function to decide the JWT access token supplied by EVE SSO
149
150
  """
@@ -162,7 +163,7 @@ class TokenManager(models.Manager):
162
163
  return token_data
163
164
 
164
165
  @staticmethod
165
- def validate_access_token(token: str):
166
+ def validate_access_token(token: str) -> dict[str, Any] | None:
166
167
  """
167
168
  Validate a JWT token retrieved from the EVE SSO.
168
169
  :param token: A JWT token originating from the EVE SSO v2
@@ -200,7 +201,7 @@ class TokenManager(models.Manager):
200
201
  logger.warning("The JWT signature was invalid: %s", e)
201
202
  return None
202
203
 
203
- def create_from_code(self, code, user=None):
204
+ def create_from_code(self, code, user=None) -> "Token":
204
205
  """
205
206
  Perform OAuth code exchange to retrieve a token.
206
207
  :param code: OAuth grant code.
@@ -282,7 +283,7 @@ class TokenManager(models.Manager):
282
283
  logger.debug("Successfully created %r for user %s", model, user)
283
284
  return model
284
285
 
285
- def create_from_request(self, request):
286
+ def create_from_request(self, request) -> "Token":
286
287
  """
287
288
  Generate a token from the OAuth callback request. Must contain 'code' in GET.
288
289
  :param request: OAuth callback request.