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.
- django_esi-8.1.0.dist-info/METADATA +93 -0
- django_esi-8.1.0.dist-info/RECORD +100 -0
- django_esi-8.1.0.dist-info/WHEEL +4 -0
- django_esi-8.1.0.dist-info/licenses/LICENSE +674 -0
- esi/__init__.py +7 -0
- esi/admin.py +42 -0
- esi/aiopenapi3/client.py +79 -0
- esi/aiopenapi3/plugins.py +224 -0
- esi/app_settings.py +112 -0
- esi/apps.py +11 -0
- esi/checks.py +56 -0
- esi/clients.py +657 -0
- esi/decorators.py +271 -0
- esi/errors.py +22 -0
- esi/exceptions.py +51 -0
- esi/helpers.py +63 -0
- esi/locale/cs_CZ/LC_MESSAGES/django.mo +0 -0
- esi/locale/cs_CZ/LC_MESSAGES/django.po +53 -0
- esi/locale/de/LC_MESSAGES/django.mo +0 -0
- esi/locale/de/LC_MESSAGES/django.po +58 -0
- esi/locale/en/LC_MESSAGES/django.mo +0 -0
- esi/locale/en/LC_MESSAGES/django.po +54 -0
- esi/locale/es/LC_MESSAGES/django.mo +0 -0
- esi/locale/es/LC_MESSAGES/django.po +59 -0
- esi/locale/fr_FR/LC_MESSAGES/django.mo +0 -0
- esi/locale/fr_FR/LC_MESSAGES/django.po +59 -0
- esi/locale/it_IT/LC_MESSAGES/django.mo +0 -0
- esi/locale/it_IT/LC_MESSAGES/django.po +59 -0
- esi/locale/ja/LC_MESSAGES/django.mo +0 -0
- esi/locale/ja/LC_MESSAGES/django.po +58 -0
- esi/locale/ko_KR/LC_MESSAGES/django.mo +0 -0
- esi/locale/ko_KR/LC_MESSAGES/django.po +58 -0
- esi/locale/nl_NL/LC_MESSAGES/django.mo +0 -0
- esi/locale/nl_NL/LC_MESSAGES/django.po +53 -0
- esi/locale/pl_PL/LC_MESSAGES/django.mo +0 -0
- esi/locale/pl_PL/LC_MESSAGES/django.po +53 -0
- esi/locale/ru/LC_MESSAGES/django.mo +0 -0
- esi/locale/ru/LC_MESSAGES/django.po +61 -0
- esi/locale/sk/LC_MESSAGES/django.mo +0 -0
- esi/locale/sk/LC_MESSAGES/django.po +55 -0
- esi/locale/uk/LC_MESSAGES/django.mo +0 -0
- esi/locale/uk/LC_MESSAGES/django.po +57 -0
- esi/locale/zh_Hans/LC_MESSAGES/django.mo +0 -0
- esi/locale/zh_Hans/LC_MESSAGES/django.po +58 -0
- esi/management/commands/__init__.py +0 -0
- esi/management/commands/esi_clear_spec_cache.py +21 -0
- esi/management/commands/generate_esi_stubs.py +661 -0
- esi/management/commands/migrate_to_ssov2.py +188 -0
- esi/managers.py +303 -0
- esi/managers.pyi +85 -0
- esi/migrations/0001_initial.py +55 -0
- esi/migrations/0002_scopes_20161208.py +56 -0
- esi/migrations/0003_hide_tokens_from_admin_site.py +23 -0
- esi/migrations/0004_remove_unique_access_token.py +18 -0
- esi/migrations/0005_remove_token_length_limit.py +23 -0
- esi/migrations/0006_remove_url_length_limit.py +18 -0
- esi/migrations/0007_fix_mysql_8_migration.py +18 -0
- esi/migrations/0008_nullable_refresh_token.py +18 -0
- esi/migrations/0009_set_old_tokens_to_sso_v1.py +18 -0
- esi/migrations/0010_set_new_tokens_to_sso_v2.py +18 -0
- esi/migrations/0011_add_token_indices.py +28 -0
- esi/migrations/0012_fix_token_type_choices.py +18 -0
- esi/migrations/0013_squashed_0012_fix_token_type_choices.py +57 -0
- esi/migrations/__init__.py +0 -0
- esi/models.py +349 -0
- esi/openapi_clients.py +1225 -0
- esi/rate_limiting.py +107 -0
- esi/signals.py +21 -0
- esi/static/esi/img/EVE_SSO_Login_Buttons_Large_Black.png +0 -0
- esi/static/esi/img/EVE_SSO_Login_Buttons_Large_White.png +0 -0
- esi/static/esi/img/EVE_SSO_Login_Buttons_Small_Black.png +0 -0
- esi/static/esi/img/EVE_SSO_Login_Buttons_Small_White.png +0 -0
- esi/stubs.py +2 -0
- esi/stubs.pyi +6807 -0
- esi/tasks.py +78 -0
- esi/templates/esi/select_token.html +116 -0
- esi/templatetags/__init__.py +0 -0
- esi/templatetags/scope_tags.py +8 -0
- esi/tests/__init__.py +134 -0
- esi/tests/client_authed_pilot.py +63 -0
- esi/tests/client_public_pilot.py +53 -0
- esi/tests/factories.py +47 -0
- esi/tests/factories_2.py +60 -0
- esi/tests/jwt_factory.py +135 -0
- esi/tests/test_checks.py +48 -0
- esi/tests/test_clients.py +1019 -0
- esi/tests/test_decorators.py +578 -0
- esi/tests/test_management_command.py +307 -0
- esi/tests/test_managers.py +673 -0
- esi/tests/test_models.py +403 -0
- esi/tests/test_openapi.json +854 -0
- esi/tests/test_openapi.py +1017 -0
- esi/tests/test_swagger.json +489 -0
- esi/tests/test_swagger_full.json +51112 -0
- esi/tests/test_tasks.py +116 -0
- esi/tests/test_templatetags.py +22 -0
- esi/tests/test_views.py +331 -0
- esi/tests/threading_pilot.py +69 -0
- esi/urls.py +9 -0
- 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})"
|