django-esi 7.0.0b1__py3-none-any.whl → 8.0.0a1__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.
- {django_esi-7.0.0b1.dist-info → django_esi-8.0.0a1.dist-info}/METADATA +4 -2
- {django_esi-7.0.0b1.dist-info → django_esi-8.0.0a1.dist-info}/RECORD +27 -18
- esi/__init__.py +2 -1
- esi/admin.py +4 -3
- esi/aiopenapi3/plugins.py +78 -0
- esi/app_settings.py +14 -1
- esi/checks.py +1 -1
- esi/clients.py +13 -14
- esi/decorators.py +35 -3
- esi/exceptions.py +18 -0
- esi/helpers.py +25 -0
- esi/locale/en/LC_MESSAGES/django.po +3 -3
- esi/management/commands/generate_esi_stubs.py +211 -0
- esi/managers.py +18 -17
- esi/managers.pyi +82 -0
- esi/models.py +9 -16
- esi/openapi_clients.py +845 -0
- esi/rate_limiting.py +78 -0
- esi/stubs.py +2 -0
- esi/stubs.pyi +3913 -0
- esi/tasks.py +1 -2
- esi/templates/esi/select_token.html +2 -2
- esi/tests/jwt_factory.py +3 -4
- esi/urls.py +0 -1
- esi/views.py +4 -3
- {django_esi-7.0.0b1.dist-info → django_esi-8.0.0a1.dist-info}/WHEEL +0 -0
- {django_esi-7.0.0b1.dist-info → django_esi-8.0.0a1.dist-info}/licenses/LICENSE +0 -0
esi/openapi_clients.py
ADDED
|
@@ -0,0 +1,845 @@
|
|
|
1
|
+
import logging
|
|
2
|
+
import warnings
|
|
3
|
+
from datetime import datetime
|
|
4
|
+
from hashlib import md5
|
|
5
|
+
from typing import Any
|
|
6
|
+
|
|
7
|
+
from aiopenapi3 import OpenAPI
|
|
8
|
+
from aiopenapi3._types import ResponseDataType, ResponseHeadersType
|
|
9
|
+
from aiopenapi3.request import OperationIndex, RequestBase
|
|
10
|
+
from httpx import (
|
|
11
|
+
AsyncClient, Client, HTTPStatusError, RequestError, Response, Timeout,
|
|
12
|
+
)
|
|
13
|
+
from tenacity import (
|
|
14
|
+
AsyncRetrying, Retrying, retry_if_exception, stop_after_attempt,
|
|
15
|
+
wait_combine, wait_exponential,
|
|
16
|
+
)
|
|
17
|
+
|
|
18
|
+
from django.core.cache import cache
|
|
19
|
+
|
|
20
|
+
from esi import app_settings
|
|
21
|
+
from esi.aiopenapi3.plugins import PatchCompatibilityDatePlugin
|
|
22
|
+
from esi.exceptions import ESIErrorLimitException
|
|
23
|
+
from esi.models import Token
|
|
24
|
+
from esi.stubs import ESIClientStub
|
|
25
|
+
|
|
26
|
+
from . import __title__, __url__, __version__
|
|
27
|
+
|
|
28
|
+
logger = logging.getLogger(__name__)
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def _time_to_expiry(expires_header: str) -> int:
|
|
32
|
+
"""Calculate cache TTL from Expires header
|
|
33
|
+
Args:
|
|
34
|
+
expires_header (str): The value of the Expires header '%a, %d %b %Y %H:%M:%S %Z'
|
|
35
|
+
Returns:
|
|
36
|
+
int: The cache TTL in seconds
|
|
37
|
+
"""
|
|
38
|
+
try:
|
|
39
|
+
expires_dt = datetime.strptime(str(expires_header), '%a, %d %b %Y %H:%M:%S %Z')
|
|
40
|
+
return max(int((expires_dt - datetime.utcnow()).total_seconds()), 0)
|
|
41
|
+
except ValueError:
|
|
42
|
+
return 0
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def _httpx_exceptions(exc: BaseException) -> bool:
|
|
46
|
+
"""
|
|
47
|
+
Helper function for HTTP Retries, what various exceptions and status codes should we retry on.
|
|
48
|
+
ESI has some weird behaviours
|
|
49
|
+
"""
|
|
50
|
+
if isinstance(exc, ESIErrorLimitException):
|
|
51
|
+
return False
|
|
52
|
+
if isinstance(exc, RequestError):
|
|
53
|
+
return True
|
|
54
|
+
if isinstance(exc, HTTPStatusError) and getattr(exc.response, "status_code", None) in {502, 503, 504}:
|
|
55
|
+
return True
|
|
56
|
+
return False
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def http_retry_sync() -> Retrying:
|
|
60
|
+
return Retrying(
|
|
61
|
+
retry=retry_if_exception(_httpx_exceptions),
|
|
62
|
+
wait=wait_combine(
|
|
63
|
+
wait_exponential(multiplier=1, min=1, max=10),
|
|
64
|
+
),
|
|
65
|
+
stop=stop_after_attempt(3),
|
|
66
|
+
reraise=True,
|
|
67
|
+
)
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
async def http_retry_async() -> AsyncRetrying:
|
|
71
|
+
return AsyncRetrying(
|
|
72
|
+
retry=retry_if_exception(_httpx_exceptions),
|
|
73
|
+
wait=wait_combine(
|
|
74
|
+
wait_exponential(multiplier=1, min=1, max=10),
|
|
75
|
+
),
|
|
76
|
+
stop=stop_after_attempt(3),
|
|
77
|
+
reraise=True,
|
|
78
|
+
)
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def _load_aiopenapi_client_sync(
|
|
82
|
+
spec_url: str,
|
|
83
|
+
compatibility_date: str,
|
|
84
|
+
user_agent: str,
|
|
85
|
+
tenant: str,
|
|
86
|
+
spec_file: str | None = None) -> OpenAPI:
|
|
87
|
+
"""Create an OpenAPI3 Client from Spec
|
|
88
|
+
|
|
89
|
+
Args:
|
|
90
|
+
spec_url (str): _description_
|
|
91
|
+
compatibility_date (str): _description_
|
|
92
|
+
user_agent (str): _description_
|
|
93
|
+
tenant (str): _description_
|
|
94
|
+
spec_file (str | None, optional): _description_. Defaults to None.
|
|
95
|
+
|
|
96
|
+
Returns:
|
|
97
|
+
OpenAPI: aiopenapi3 Client Class
|
|
98
|
+
"""
|
|
99
|
+
headers = {
|
|
100
|
+
"User-Agent": user_agent,
|
|
101
|
+
"X-Tenant": tenant,
|
|
102
|
+
"X-Compatibility-Date": compatibility_date
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
def session_factory(**kwargs) -> Client:
|
|
106
|
+
kwargs.pop("headers", None)
|
|
107
|
+
return Client(
|
|
108
|
+
headers=headers,
|
|
109
|
+
timeout=Timeout(
|
|
110
|
+
connect=app_settings.ESI_REQUESTS_CONNECT_TIMEOUT,
|
|
111
|
+
read=app_settings.ESI_REQUESTS_READ_TIMEOUT,
|
|
112
|
+
write=app_settings.ESI_REQUESTS_WRITE_TIMEOUT,
|
|
113
|
+
pool=app_settings.ESI_REQUESTS_POOL_TIMEOUT
|
|
114
|
+
),
|
|
115
|
+
http2=True,
|
|
116
|
+
**kwargs
|
|
117
|
+
)
|
|
118
|
+
if spec_file:
|
|
119
|
+
return OpenAPI.load_file(
|
|
120
|
+
url=spec_url,
|
|
121
|
+
path=spec_file,
|
|
122
|
+
session_factory=session_factory,
|
|
123
|
+
use_operation_tags=True,
|
|
124
|
+
plugins=[PatchCompatibilityDatePlugin()]
|
|
125
|
+
)
|
|
126
|
+
else:
|
|
127
|
+
return OpenAPI.load_sync(
|
|
128
|
+
url=spec_url,
|
|
129
|
+
session_factory=session_factory,
|
|
130
|
+
use_operation_tags=True,
|
|
131
|
+
plugins=[PatchCompatibilityDatePlugin()]
|
|
132
|
+
)
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
async def _load_aiopenapi_client_async(
|
|
136
|
+
spec_url: str,
|
|
137
|
+
compatibility_date: str,
|
|
138
|
+
user_agent: str,
|
|
139
|
+
tenant: str,
|
|
140
|
+
spec_file: str | None = None) -> OpenAPI:
|
|
141
|
+
"""Create an OpenAPI3 Client from Spec Async
|
|
142
|
+
|
|
143
|
+
Args:
|
|
144
|
+
spec_url (str): _description_
|
|
145
|
+
compatibility_date (str): _description_
|
|
146
|
+
user_agent (str): _description_
|
|
147
|
+
tenant (str): _description_
|
|
148
|
+
spec_file (str | None, optional): _description_. Defaults to None.
|
|
149
|
+
|
|
150
|
+
Returns:
|
|
151
|
+
OpenAPI: aiopenapi3 Client Class
|
|
152
|
+
"""
|
|
153
|
+
headers = {
|
|
154
|
+
"User-Agent": user_agent,
|
|
155
|
+
"X-Tenant": tenant,
|
|
156
|
+
"X-Compatibility-Date": compatibility_date
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
def session_factory(**kwargs) -> AsyncClient:
|
|
160
|
+
kwargs.pop("headers", None)
|
|
161
|
+
return AsyncClient(
|
|
162
|
+
headers=headers,
|
|
163
|
+
timeout=Timeout(
|
|
164
|
+
connect=app_settings.ESI_REQUESTS_CONNECT_TIMEOUT,
|
|
165
|
+
read=app_settings.ESI_REQUESTS_READ_TIMEOUT,
|
|
166
|
+
write=app_settings.ESI_REQUESTS_WRITE_TIMEOUT,
|
|
167
|
+
pool=app_settings.ESI_REQUESTS_POOL_TIMEOUT
|
|
168
|
+
),
|
|
169
|
+
http2=True,
|
|
170
|
+
**kwargs
|
|
171
|
+
)
|
|
172
|
+
if spec_file:
|
|
173
|
+
# TODO find a async way to load from file?
|
|
174
|
+
return OpenAPI.load_file(
|
|
175
|
+
url=spec_url,
|
|
176
|
+
path=spec_file,
|
|
177
|
+
session_factory=session_factory,
|
|
178
|
+
use_operation_tags=True,
|
|
179
|
+
plugins=[PatchCompatibilityDatePlugin()]
|
|
180
|
+
)
|
|
181
|
+
else:
|
|
182
|
+
return await OpenAPI.load_async(
|
|
183
|
+
url=spec_url,
|
|
184
|
+
session_factory=session_factory,
|
|
185
|
+
use_operation_tags=True,
|
|
186
|
+
plugins=[PatchCompatibilityDatePlugin()]
|
|
187
|
+
)
|
|
188
|
+
|
|
189
|
+
|
|
190
|
+
def _build_user_agent(ua_appname: str, ua_version: str, ua_url: str | None) -> str:
|
|
191
|
+
"""
|
|
192
|
+
AppName/1.2.3 (foo@example.com; +https://gitlab.com/) Django-ESI/1.2.3 (+https://gitlab.com/allianceauth/django-esi)
|
|
193
|
+
Contact Email will be inserted from app_settings.
|
|
194
|
+
Args:
|
|
195
|
+
ua_appname (str): Application Name, PascalCase
|
|
196
|
+
ua_version (str): Application Version, SemVer
|
|
197
|
+
ua_url (str | None): Application URL (Optional)
|
|
198
|
+
Returns:
|
|
199
|
+
str: User-Agent string
|
|
200
|
+
"""
|
|
201
|
+
return (
|
|
202
|
+
f"{ua_appname}/{ua_version} "
|
|
203
|
+
f"({app_settings.ESI_USER_CONTACT_EMAIL}{f'; +{ua_url})' if ua_url else ')'} "
|
|
204
|
+
f"{__title__}/{__version__} (+{__url__})"
|
|
205
|
+
)
|
|
206
|
+
|
|
207
|
+
|
|
208
|
+
def _get_spec_url() -> str:
|
|
209
|
+
return f"{app_settings.ESI_API_URL}meta/openapi.json"
|
|
210
|
+
|
|
211
|
+
|
|
212
|
+
def esi_client_factory_sync(
|
|
213
|
+
compatibility_date: str,
|
|
214
|
+
ua_appname: str, ua_version: str, ua_url: str | None = None,
|
|
215
|
+
spec_file: str | None = None,
|
|
216
|
+
tenant: str = "tranquility",
|
|
217
|
+
**kwargs) -> OpenAPI:
|
|
218
|
+
"""Generate a new OpenAPI ESI client.
|
|
219
|
+
Args:
|
|
220
|
+
compatibility_date (str): "YYYY-MM-DD" The latest version of ESI your client is tested with
|
|
221
|
+
ua_appname (str): Application Name, PascalCase
|
|
222
|
+
ua_version (str): Application Version, SemVer
|
|
223
|
+
ua_url (str, optional): Application URL (Optional). Defaults to None.
|
|
224
|
+
spec_file (str | None, optional): Specification file path (Optional). Defaults to None.
|
|
225
|
+
tenant (str, optional): Tenant ID (Optional). Defaults to "tranquility".
|
|
226
|
+
Returns:
|
|
227
|
+
OpenAPI: OpenAPI ESI Client
|
|
228
|
+
"""
|
|
229
|
+
user_agent = _build_user_agent(ua_appname, ua_version, ua_url)
|
|
230
|
+
spec_url = _get_spec_url()
|
|
231
|
+
return _load_aiopenapi_client_sync(spec_url, compatibility_date, user_agent, tenant, spec_file)
|
|
232
|
+
|
|
233
|
+
|
|
234
|
+
async def esi_client_factory_async(
|
|
235
|
+
compatibility_date: str,
|
|
236
|
+
ua_appname: str, ua_version: str, ua_url: str | None = None,
|
|
237
|
+
spec_file: str | None = None,
|
|
238
|
+
tenant: str = "tranquility",
|
|
239
|
+
**kwargs) -> OpenAPI:
|
|
240
|
+
"""Generate a new OpenAPI ESI client.
|
|
241
|
+
Args:
|
|
242
|
+
compatibility_date (str): "YYYY-MM-DD" The latest version of ESI your client is tested with
|
|
243
|
+
ua_appname (str): Application Name, PascalCase
|
|
244
|
+
ua_version (str): Application Version, SemVer
|
|
245
|
+
ua_url (str | None, optional): Application URL (Optional). Defaults to None.
|
|
246
|
+
spec_file (str | None, optional): Specification file path (Optional). Defaults to None.
|
|
247
|
+
tenant (str, optional): Tenant ID (Optional). Defaults to "tranquility".
|
|
248
|
+
Returns:
|
|
249
|
+
OpenAPI: OpenAPI ESI Client
|
|
250
|
+
"""
|
|
251
|
+
user_agent = _build_user_agent(ua_appname, ua_version, ua_url)
|
|
252
|
+
spec_url = _get_spec_url()
|
|
253
|
+
return await _load_aiopenapi_client_async(spec_url, compatibility_date, user_agent, tenant, spec_file)
|
|
254
|
+
|
|
255
|
+
|
|
256
|
+
class BaseEsiOperation():
|
|
257
|
+
def __init__(self, operation, api) -> None:
|
|
258
|
+
self.method, self.url, self.operation, self.extra = operation
|
|
259
|
+
self.api = api
|
|
260
|
+
self.token: Token | None = None
|
|
261
|
+
self._args = []
|
|
262
|
+
self._kwargs = {}
|
|
263
|
+
|
|
264
|
+
def __call__(self, *args, **kwargs) -> "BaseEsiOperation":
|
|
265
|
+
self._args = args
|
|
266
|
+
self._kwargs = kwargs
|
|
267
|
+
return self
|
|
268
|
+
|
|
269
|
+
def _unnormalize_parameters(self, params: dict[str, Any]) -> dict[str, Any]:
|
|
270
|
+
"""UN-Normalize Pythonic parameter names back to OpenAPI names.
|
|
271
|
+
|
|
272
|
+
Converts pythonic keys like "Accept_Language" to "Accept-Language" when/if
|
|
273
|
+
a non pythonic (usually) hyphenated form exists in the operation's parameter list. Performs
|
|
274
|
+
case-insensitive matching and only rewrites when there's a known
|
|
275
|
+
parameter with hyphens, leaving normal snake_case params (e.g.
|
|
276
|
+
"type_id") untouched.
|
|
277
|
+
Args:
|
|
278
|
+
params: Raw parameters collected from the call
|
|
279
|
+
Returns:
|
|
280
|
+
dict: Parameters with keys aligned to the OpenAPI spec
|
|
281
|
+
"""
|
|
282
|
+
try:
|
|
283
|
+
spec_param_names = [p.name for p in getattr(self.operation, "parameters", [])]
|
|
284
|
+
except Exception:
|
|
285
|
+
spec_param_names = []
|
|
286
|
+
|
|
287
|
+
# Exact and case-insensitive lookup maps
|
|
288
|
+
spec_param_set = set(spec_param_names)
|
|
289
|
+
spec_param_map_ci = {n.lower(): n for n in spec_param_names}
|
|
290
|
+
|
|
291
|
+
normalized: dict[str, Any] = {}
|
|
292
|
+
for k, v in params.items():
|
|
293
|
+
# Fast path: exact match
|
|
294
|
+
if k in spec_param_set:
|
|
295
|
+
normalized[k] = v
|
|
296
|
+
continue
|
|
297
|
+
|
|
298
|
+
# Try hyphen variant
|
|
299
|
+
k_dash = k.replace("_", "-")
|
|
300
|
+
if k_dash in spec_param_set:
|
|
301
|
+
normalized[k_dash] = v
|
|
302
|
+
continue
|
|
303
|
+
|
|
304
|
+
# Case-insensitive fallbacks
|
|
305
|
+
kl = k.lower()
|
|
306
|
+
if kl in spec_param_map_ci:
|
|
307
|
+
normalized[spec_param_map_ci[kl]] = v
|
|
308
|
+
continue
|
|
309
|
+
|
|
310
|
+
k_dash_l = k_dash.lower()
|
|
311
|
+
if k_dash_l in spec_param_map_ci:
|
|
312
|
+
normalized[spec_param_map_ci[k_dash_l]] = v
|
|
313
|
+
continue
|
|
314
|
+
|
|
315
|
+
# Unknown to the spec; pass through as-is (aiopenapi3 will validate)
|
|
316
|
+
normalized[k] = v
|
|
317
|
+
|
|
318
|
+
return normalized
|
|
319
|
+
|
|
320
|
+
def _cache_key(self) -> str:
|
|
321
|
+
"""Generate a key name used to cache responses based on method, url, args, kwargs
|
|
322
|
+
Returns:
|
|
323
|
+
str: Key name
|
|
324
|
+
"""
|
|
325
|
+
data = (self.method + self.url + str(self._args) + str(self._kwargs)).encode('utf-8')
|
|
326
|
+
str_hash = md5(data).hexdigest() # nosec B303
|
|
327
|
+
return f'esi_{str_hash}'
|
|
328
|
+
|
|
329
|
+
def _extract_token_param(self) -> Token | None:
|
|
330
|
+
"""Pop token from parameters or use the Client wide token if set
|
|
331
|
+
Returns:
|
|
332
|
+
Token | None: The token to use for the request
|
|
333
|
+
"""
|
|
334
|
+
_token = self._kwargs.pop("token", None)
|
|
335
|
+
if _token and not getattr(self.operation, "security", False):
|
|
336
|
+
raise ValueError("Token provided on public endpoint")
|
|
337
|
+
return self.token or _token
|
|
338
|
+
|
|
339
|
+
def _has_page_param(self) -> bool:
|
|
340
|
+
"""Check if this operation supports Offset Based Pagination.
|
|
341
|
+
Returns:
|
|
342
|
+
bool: True if page parameters are present, False otherwise
|
|
343
|
+
"""
|
|
344
|
+
return any(p.name == "page" for p in self.operation.parameters)
|
|
345
|
+
|
|
346
|
+
def _has_cursor_param(self) -> bool:
|
|
347
|
+
"""Check if this operation supports Cursor Based Pagination.
|
|
348
|
+
Returns:
|
|
349
|
+
bool: True if cursor parameters are present, False otherwise
|
|
350
|
+
"""
|
|
351
|
+
return any(p.name == "before" or p.name == "after" for p in self.operation.parameters)
|
|
352
|
+
|
|
353
|
+
def _get_cache(self, cache_key: str) -> tuple[ResponseHeadersType | None, Any, Response | None]:
|
|
354
|
+
"""Retrieve cached response and validate expiry
|
|
355
|
+
Args:
|
|
356
|
+
cache_key (str): The cache key to retrieve
|
|
357
|
+
Returns:
|
|
358
|
+
tuple[ResponseHeadersType | None, Any, Response | None]: The cached response,
|
|
359
|
+
or None if not found or expired
|
|
360
|
+
"""
|
|
361
|
+
try:
|
|
362
|
+
cached_response = cache.get(cache_key)
|
|
363
|
+
except Exception as e:
|
|
364
|
+
logger.error(f"Cache retrieve failed {e}", exc_info=True)
|
|
365
|
+
return None, None, None
|
|
366
|
+
|
|
367
|
+
if cached_response:
|
|
368
|
+
logger.debug(f"Cache Hit {self.url}")
|
|
369
|
+
headers, data = self.parse_cached_request(cached_response)
|
|
370
|
+
|
|
371
|
+
expiry = _time_to_expiry(str(headers.get('Expires')))
|
|
372
|
+
if expiry < 0:
|
|
373
|
+
logger.warning("Cache expired by %d seconds, forcing expiry", expiry)
|
|
374
|
+
return None, None, None
|
|
375
|
+
return headers, data, cached_response
|
|
376
|
+
|
|
377
|
+
return None, None, None
|
|
378
|
+
|
|
379
|
+
def _store_cache(self, cache_key: str, response) -> None:
|
|
380
|
+
""" Store the response in cache with ETag and TTL.
|
|
381
|
+
Args:
|
|
382
|
+
cache_key (str): The cache key to store the response under
|
|
383
|
+
response (Response): The response object to cache
|
|
384
|
+
"""
|
|
385
|
+
if not app_settings.ESI_CACHE_RESPONSE:
|
|
386
|
+
return
|
|
387
|
+
|
|
388
|
+
if "ETag" in response.headers:
|
|
389
|
+
cache.set(f"{cache_key}_etag", response.headers["ETag"])
|
|
390
|
+
|
|
391
|
+
expires = response.headers.get("Expires")
|
|
392
|
+
ttl = _time_to_expiry(expires) if expires else 0
|
|
393
|
+
if ttl > 0:
|
|
394
|
+
try:
|
|
395
|
+
cache.set(cache_key, response, ttl)
|
|
396
|
+
except Exception as e:
|
|
397
|
+
logger.error(f"Failed to cache {e}", exc_info=True)
|
|
398
|
+
|
|
399
|
+
def _validate_token_scopes(self, token: Token) -> None:
|
|
400
|
+
"""Validate that the token provided has the required scopes for this ESI operation.
|
|
401
|
+
"""
|
|
402
|
+
token_scopes = set(token.scopes.all().values_list("name", flat=True))
|
|
403
|
+
try:
|
|
404
|
+
required_scopes = set(getattr(getattr(self.operation, "security", [])[0], "root", {}).get("OAuth2", []))
|
|
405
|
+
except KeyError:
|
|
406
|
+
required_scopes = []
|
|
407
|
+
missing_scopes = [x for x in required_scopes if x not in token_scopes]
|
|
408
|
+
if len(missing_scopes) > 0:
|
|
409
|
+
raise ValueError(f"Token Missing Scopes - {missing_scopes}")
|
|
410
|
+
|
|
411
|
+
def parse_cached_request(self, cached_response) -> tuple[ResponseHeadersType, ResponseDataType]:
|
|
412
|
+
req = self.api.createRequest(
|
|
413
|
+
f"{self.operation.tags[0]}.{self.operation.operationId}"
|
|
414
|
+
)
|
|
415
|
+
return req._process_request(cached_response)
|
|
416
|
+
|
|
417
|
+
|
|
418
|
+
class EsiOperation(BaseEsiOperation):
|
|
419
|
+
def _make_request(
|
|
420
|
+
self,
|
|
421
|
+
parameters: dict[str, Any],
|
|
422
|
+
etag: str | None = None) -> RequestBase.Response:
|
|
423
|
+
|
|
424
|
+
reset = cache.get("esi_error_limit_reset")
|
|
425
|
+
if reset is not None:
|
|
426
|
+
# Hard exception here if there is still an open Error Limit
|
|
427
|
+
# developers need to either decorators.wait_for_esi_error_limit_reset()
|
|
428
|
+
# or handle this by pushing their celery tasks back
|
|
429
|
+
raise ESIErrorLimitException(reset=reset)
|
|
430
|
+
|
|
431
|
+
retry = http_retry_sync()
|
|
432
|
+
|
|
433
|
+
def __func():
|
|
434
|
+
req = self.api.createRequest(f"{self.operation.tags[0]}.{self.operation.operationId}")
|
|
435
|
+
if self.token:
|
|
436
|
+
self.api.authenticate(OAuth2=True) # make the lib happy
|
|
437
|
+
if isinstance(self.token, str):
|
|
438
|
+
# Fallback older Django-ESI Behaviour
|
|
439
|
+
# Deprecated
|
|
440
|
+
req.req.headers["Authorization"] = f"Bearer {self.token}"
|
|
441
|
+
warnings.warn(
|
|
442
|
+
"Passing an Access Token string directly is deprecated."
|
|
443
|
+
"Doing so will Skip Validation of Scopes"
|
|
444
|
+
"Please use a Token object instead.",
|
|
445
|
+
DeprecationWarning,
|
|
446
|
+
stacklevel=2
|
|
447
|
+
)
|
|
448
|
+
else:
|
|
449
|
+
self._validate_token_scopes(self.token)
|
|
450
|
+
req.req.headers["Authorization"] = f"Bearer {self.token.valid_access_token()}"
|
|
451
|
+
if etag:
|
|
452
|
+
req.req.headers["If-None-Match"] = etag
|
|
453
|
+
return req.request(parameters=self._unnormalize_parameters(parameters))
|
|
454
|
+
return retry(__func)
|
|
455
|
+
|
|
456
|
+
def result(
|
|
457
|
+
self,
|
|
458
|
+
etag: str | None = None,
|
|
459
|
+
return_response: bool = False,
|
|
460
|
+
use_cache: bool = True,
|
|
461
|
+
**extra) -> tuple[Any, Response] | Any:
|
|
462
|
+
"""Executes the request and returns the response from ESI for the current operation.
|
|
463
|
+
Raises:
|
|
464
|
+
ESIErrorLimitException: _description_
|
|
465
|
+
Returns:
|
|
466
|
+
_type_: _description_
|
|
467
|
+
"""
|
|
468
|
+
|
|
469
|
+
self.token = self._extract_token_param()
|
|
470
|
+
parameters = self._kwargs | extra
|
|
471
|
+
cache_key = self._cache_key()
|
|
472
|
+
etag_key = f"{cache_key}_etag"
|
|
473
|
+
|
|
474
|
+
if not etag and app_settings.ESI_CACHE_RESPONSE:
|
|
475
|
+
etag = cache.get(etag_key)
|
|
476
|
+
|
|
477
|
+
headers, data, response = self._get_cache(cache_key)
|
|
478
|
+
|
|
479
|
+
if response and use_cache:
|
|
480
|
+
expiry = _time_to_expiry(str(headers.get('Expires')))
|
|
481
|
+
if expiry < 0:
|
|
482
|
+
logger.warning(
|
|
483
|
+
"cache expired by %d seconds, Forcing expiry", expiry
|
|
484
|
+
)
|
|
485
|
+
response = None
|
|
486
|
+
headers = None
|
|
487
|
+
data = None
|
|
488
|
+
|
|
489
|
+
if not response:
|
|
490
|
+
logger.debug(f"Cache Miss {self.url}")
|
|
491
|
+
headers, data, response = self._make_request(parameters, etag)
|
|
492
|
+
if response.status_code == 420:
|
|
493
|
+
reset = response.headers.get("X-RateLimit-Reset", None)
|
|
494
|
+
cache.set("esi_error_limit_reset", reset, timeout=reset)
|
|
495
|
+
raise ESIErrorLimitException(reset=reset)
|
|
496
|
+
# if response.status_code == 304 and app_settings.ESI_CACHE_RESPONSE:
|
|
497
|
+
# cached = cache.get(cache_key)
|
|
498
|
+
# if cached:
|
|
499
|
+
# return (cached, response) if return_response else cached
|
|
500
|
+
# we dont want to do this, if we do this we have to store data longer than ttl. rip ram
|
|
501
|
+
self._store_cache(cache_key, response)
|
|
502
|
+
|
|
503
|
+
return (data, response) if return_response else data
|
|
504
|
+
|
|
505
|
+
def results(
|
|
506
|
+
self,
|
|
507
|
+
etag: str | None = None,
|
|
508
|
+
return_response: bool = False,
|
|
509
|
+
use_cache: bool = True,
|
|
510
|
+
**extra) -> tuple[list[Any], Response | Any | None] | list[Any]:
|
|
511
|
+
all_results = []
|
|
512
|
+
last_response = None
|
|
513
|
+
"""Executes the request and returns the response from ESI for the current
|
|
514
|
+
operation. Response will include all pages if there are more available.
|
|
515
|
+
|
|
516
|
+
Returns:
|
|
517
|
+
_type_: _description_
|
|
518
|
+
"""
|
|
519
|
+
if self._has_page_param():
|
|
520
|
+
current_page = 1
|
|
521
|
+
total_pages = 1
|
|
522
|
+
while current_page <= total_pages:
|
|
523
|
+
self._kwargs["page"] = current_page
|
|
524
|
+
data, response = self.result(etag=etag, return_response=True, **extra)
|
|
525
|
+
last_response = response
|
|
526
|
+
all_results.extend(data if isinstance(data, list) else [data])
|
|
527
|
+
total_pages = int(response.headers.get("X-Pages", 1))
|
|
528
|
+
logger.debug(
|
|
529
|
+
f"ESI Page Fetched {self.url} - {current_page}/{total_pages}"
|
|
530
|
+
)
|
|
531
|
+
current_page += 1
|
|
532
|
+
|
|
533
|
+
elif self._has_cursor_param():
|
|
534
|
+
# Untested, there are no cursor based endpoints in ESI
|
|
535
|
+
params = self._kwargs.copy()
|
|
536
|
+
params.update(extra)
|
|
537
|
+
for cursor_param in ("after", "before"):
|
|
538
|
+
if params.get(cursor_param):
|
|
539
|
+
break
|
|
540
|
+
else:
|
|
541
|
+
cursor_param = "after"
|
|
542
|
+
while True:
|
|
543
|
+
data, response = self.result(etag=etag, return_response=True, use_cache=use_cache, **params)
|
|
544
|
+
last_response = response
|
|
545
|
+
if not data:
|
|
546
|
+
break
|
|
547
|
+
all_results.extend(data if isinstance(data, list) else [data])
|
|
548
|
+
cursor_token = {k.lower(): v for k, v in response.headers.items()}.get(cursor_param)
|
|
549
|
+
if not cursor_token:
|
|
550
|
+
break
|
|
551
|
+
params[cursor_param] = cursor_token
|
|
552
|
+
|
|
553
|
+
else:
|
|
554
|
+
data, response = self.result(etag=etag, return_response=True, use_cache=use_cache, **extra)
|
|
555
|
+
all_results.extend(data if isinstance(data, list) else [data])
|
|
556
|
+
last_response = response
|
|
557
|
+
|
|
558
|
+
return (all_results, last_response) if return_response else all_results
|
|
559
|
+
|
|
560
|
+
def results_localized(self, languages: str | list[str] = "en", **kwargs) -> list[Any]:
|
|
561
|
+
# We can either push Accept-Language up to the library level
|
|
562
|
+
# OR we insert the parameter into each request here
|
|
563
|
+
raise NotImplementedError()
|
|
564
|
+
|
|
565
|
+
def required_scopes(self) -> list[str]:
|
|
566
|
+
"""Return a simple list of scopes required for an endpoint. #Requires loading and processing a client
|
|
567
|
+
Returns:
|
|
568
|
+
list[str]: List of Scopes Required
|
|
569
|
+
"""
|
|
570
|
+
try:
|
|
571
|
+
if not getattr(self.operation, "security", False):
|
|
572
|
+
return [] # No Scopes Required
|
|
573
|
+
else:
|
|
574
|
+
return list(getattr(getattr(self.operation, "security", [])[0], "root", {}).get("OAuth2", []))
|
|
575
|
+
except (IndexError, KeyError):
|
|
576
|
+
return []
|
|
577
|
+
|
|
578
|
+
|
|
579
|
+
class EsiOperationAsync(BaseEsiOperation):
|
|
580
|
+
async def _make_request(
|
|
581
|
+
self,
|
|
582
|
+
parameters: dict[str, Any],
|
|
583
|
+
etag: str | None = None
|
|
584
|
+
) -> RequestBase.Response:
|
|
585
|
+
|
|
586
|
+
reset = cache.get("esi_error_limit_reset")
|
|
587
|
+
if reset is not None:
|
|
588
|
+
# Hard exception here if there is still an open rate limit
|
|
589
|
+
# developers need to either decorators.wait_for_esi_error_limit_reset()
|
|
590
|
+
# or handle this by pushing their celery tasks back
|
|
591
|
+
raise ESIErrorLimitException(reset=reset)
|
|
592
|
+
|
|
593
|
+
async for attempt in http_retry_async():
|
|
594
|
+
with attempt:
|
|
595
|
+
req = self.api.createRequest(f"{self.operation.tags[0]}.{self.operation.operationId}")
|
|
596
|
+
if self.token:
|
|
597
|
+
self.api.authenticate(OAuth2=True) # make the lib happy
|
|
598
|
+
self._validate_token_scopes(self.token)
|
|
599
|
+
req.req.headers["Authorization"] = f"Bearer {self.token.valid_access_token()}"
|
|
600
|
+
if etag:
|
|
601
|
+
req.req.headers["If-None-Match"] = etag
|
|
602
|
+
return await req.request(parameters=self._unnormalize_parameters(parameters))
|
|
603
|
+
# Should never be reached because AsyncRetrying always yields at least once
|
|
604
|
+
raise RuntimeError("Retry loop exited without performing a request")
|
|
605
|
+
|
|
606
|
+
async def result(
|
|
607
|
+
self,
|
|
608
|
+
etag: str | None = None,
|
|
609
|
+
return_response: bool = False,
|
|
610
|
+
use_cache: bool = True,
|
|
611
|
+
**extra
|
|
612
|
+
) -> tuple[Any, Response] | Any:
|
|
613
|
+
self.token = self._extract_token_param()
|
|
614
|
+
parameters = self._kwargs | extra
|
|
615
|
+
cache_key = self._cache_key()
|
|
616
|
+
etag_key = f"{cache_key}_etag"
|
|
617
|
+
|
|
618
|
+
if not etag and app_settings.ESI_CACHE_RESPONSE:
|
|
619
|
+
etag = cache.get(etag_key)
|
|
620
|
+
|
|
621
|
+
headers, data, response = self._get_cache(cache_key)
|
|
622
|
+
|
|
623
|
+
if response and use_cache:
|
|
624
|
+
expiry = _time_to_expiry(str(headers.get('Expires')))
|
|
625
|
+
if expiry < 0:
|
|
626
|
+
logger.warning(
|
|
627
|
+
"cache expired by %d seconds, Forcing expiry", expiry
|
|
628
|
+
)
|
|
629
|
+
response = None
|
|
630
|
+
headers = None
|
|
631
|
+
data = None
|
|
632
|
+
|
|
633
|
+
if not response:
|
|
634
|
+
logger.debug(f"Cache Miss {self.url}")
|
|
635
|
+
headers, data, response = await self._make_request(parameters, etag)
|
|
636
|
+
if response.status_code == 420:
|
|
637
|
+
reset = response.headers.get("X-RateLimit-Reset", None)
|
|
638
|
+
cache.set("esi_error_limit_reset", reset, timeout=reset)
|
|
639
|
+
raise ESIErrorLimitException(reset=reset)
|
|
640
|
+
# if response.status_code == 304 and app_settings.ESI_CACHE_RESPONSE:
|
|
641
|
+
# cached = cache.get(cache_key)
|
|
642
|
+
# if cached:
|
|
643
|
+
# return (cached, response) if return_response else cached
|
|
644
|
+
# we dont want to do this, if we do this we have to store data longer than ttl. rip ram
|
|
645
|
+
self._store_cache(cache_key, response)
|
|
646
|
+
|
|
647
|
+
return (data, response) if return_response else data
|
|
648
|
+
|
|
649
|
+
async def results(
|
|
650
|
+
self,
|
|
651
|
+
etag: str | None = None,
|
|
652
|
+
return_response: bool = False,
|
|
653
|
+
use_cache: bool = True,
|
|
654
|
+
**extra
|
|
655
|
+
) -> tuple[list[Any], Response | Any | None] | list[Any]:
|
|
656
|
+
all_results = []
|
|
657
|
+
last_response = None
|
|
658
|
+
|
|
659
|
+
if self._has_page_param():
|
|
660
|
+
current_page = 1
|
|
661
|
+
total_pages = 1
|
|
662
|
+
while current_page <= total_pages:
|
|
663
|
+
self._kwargs["page"] = current_page
|
|
664
|
+
data, response = await self.result(etag=etag, return_response=True, use_cache=use_cache, **extra)
|
|
665
|
+
last_response = response
|
|
666
|
+
all_results.extend(data if isinstance(data, list) else [data])
|
|
667
|
+
total_pages = int(response.headers.get("X-Pages", 1))
|
|
668
|
+
logger.debug(
|
|
669
|
+
f"ESI Page Fetched {self.url} - {current_page}/{total_pages}"
|
|
670
|
+
)
|
|
671
|
+
current_page += 1
|
|
672
|
+
# elif self._has_cursor_param():
|
|
673
|
+
# TODO
|
|
674
|
+
else:
|
|
675
|
+
data, response = await self.result(etag=etag, return_response=True, use_cache=use_cache, **extra)
|
|
676
|
+
all_results.extend(data if isinstance(data, list) else [data])
|
|
677
|
+
last_response = response
|
|
678
|
+
|
|
679
|
+
return (all_results, last_response) if return_response else all_results
|
|
680
|
+
|
|
681
|
+
async def results_localized(self, languages: str | list[str] = "en", **kwargs) -> list[Any]:
|
|
682
|
+
raise NotImplementedError()
|
|
683
|
+
|
|
684
|
+
def required_scopes(self) -> list[str]:
|
|
685
|
+
"""Return a simple list of scopes required for an endpoint. #Requires loading and processing a client
|
|
686
|
+
Returns:
|
|
687
|
+
list[str]: List of Scopes Required
|
|
688
|
+
"""
|
|
689
|
+
try:
|
|
690
|
+
if not getattr(self.operation, "security", False):
|
|
691
|
+
return [] # No Scopes Required
|
|
692
|
+
else:
|
|
693
|
+
return list(getattr(getattr(self.operation, "security", [])[0], "root", {}).get("OAuth2", []))
|
|
694
|
+
except (IndexError, KeyError):
|
|
695
|
+
return []
|
|
696
|
+
|
|
697
|
+
|
|
698
|
+
class ESITag:
|
|
699
|
+
"""
|
|
700
|
+
API Tag Wrapper, providing access to Operations within a tag
|
|
701
|
+
Assets, Characters, etc.
|
|
702
|
+
"""
|
|
703
|
+
|
|
704
|
+
def __init__(self, operation, api) -> None:
|
|
705
|
+
self._oi = operation._oi
|
|
706
|
+
self._operations = operation._operations
|
|
707
|
+
self.api = api
|
|
708
|
+
|
|
709
|
+
def __getattr__(self, name: str) -> EsiOperation:
|
|
710
|
+
if name not in self._operations:
|
|
711
|
+
raise AttributeError(
|
|
712
|
+
f"Operation '{name}' not found in tag '{self._oi}'. "
|
|
713
|
+
f"Available operations: {', '.join(sorted(self._operations.keys()))}"
|
|
714
|
+
)
|
|
715
|
+
return EsiOperation(self._operations[name], self.api)
|
|
716
|
+
|
|
717
|
+
|
|
718
|
+
class ESITagAsync():
|
|
719
|
+
"""
|
|
720
|
+
Async API Tag Wrapper, providing access to Operations within a tag
|
|
721
|
+
Assets, Characters, etc.
|
|
722
|
+
"""
|
|
723
|
+
|
|
724
|
+
def __init__(self, operation, api) -> None:
|
|
725
|
+
self._oi = operation._oi
|
|
726
|
+
self._operations = operation._operations
|
|
727
|
+
self.api = api
|
|
728
|
+
|
|
729
|
+
def __getattr__(self, name: str) -> EsiOperationAsync:
|
|
730
|
+
if name not in self._operations:
|
|
731
|
+
raise AttributeError(
|
|
732
|
+
f"Operation '{name}' not found in tag '{self._oi}'. "
|
|
733
|
+
f"Available operations: {', '.join(sorted(self._operations.keys()))}"
|
|
734
|
+
)
|
|
735
|
+
return EsiOperationAsync(self._operations[name], self.api)
|
|
736
|
+
|
|
737
|
+
|
|
738
|
+
class ESIClient(ESIClientStub):
|
|
739
|
+
"""
|
|
740
|
+
Base ESI Client, provides access to Tags Assets, Characters, etc.
|
|
741
|
+
or Raw aiopenapi3 via sad smiley ._.
|
|
742
|
+
"""
|
|
743
|
+
def __init__(self, api: OpenAPI) -> None:
|
|
744
|
+
self.api = api
|
|
745
|
+
self._tags = set(api._operationindex._tags.keys())
|
|
746
|
+
|
|
747
|
+
def __getattr__(self, tag: str) -> ESITag | OperationIndex:
|
|
748
|
+
# underscore returns the raw aiopenapi3 client
|
|
749
|
+
if tag == "_":
|
|
750
|
+
return self.api._operationindex
|
|
751
|
+
|
|
752
|
+
elif tag in set(self.api._operationindex._tags.keys()):
|
|
753
|
+
return ESITag(self.api._operationindex._tags[tag], self.api)
|
|
754
|
+
|
|
755
|
+
raise AttributeError(
|
|
756
|
+
f"Tag '{tag}' not found. "
|
|
757
|
+
f"Available tags: {', '.join(sorted(self._tags))}"
|
|
758
|
+
)
|
|
759
|
+
|
|
760
|
+
|
|
761
|
+
class ESIClientAsync(ESIClientStub):
|
|
762
|
+
"""
|
|
763
|
+
Async Base ESI Client, provides access to Tags Assets, Characters, etc.
|
|
764
|
+
or Raw aiopenapi3 via sad smiley ._.
|
|
765
|
+
"""
|
|
766
|
+
def __init__(self, api: OpenAPI) -> None:
|
|
767
|
+
self.api = api
|
|
768
|
+
self._tags = set(api._operationindex._tags.keys())
|
|
769
|
+
|
|
770
|
+
def __getattr__(self, tag: str) -> ESITagAsync | OperationIndex:
|
|
771
|
+
# underscore returns the raw aiopenapi3 client
|
|
772
|
+
if tag == "_":
|
|
773
|
+
return self.api._operationindex
|
|
774
|
+
|
|
775
|
+
elif tag in set(self.api._operationindex._tags.keys()):
|
|
776
|
+
return ESITagAsync(self.api._operationindex._tags[tag], self.api)
|
|
777
|
+
|
|
778
|
+
raise AttributeError(
|
|
779
|
+
f"Tag '{tag}' not found. "
|
|
780
|
+
f"Available tags: {', '.join(sorted(self._tags))}"
|
|
781
|
+
)
|
|
782
|
+
|
|
783
|
+
|
|
784
|
+
class ESIClientProvider:
|
|
785
|
+
"""Class for providing a single ESI client instance for a whole app
|
|
786
|
+
Args:
|
|
787
|
+
compatibility_date (str): The compatibility date for the ESI client.
|
|
788
|
+
ua_appname (str): Name of the App for generating a User-Agent,
|
|
789
|
+
ua_version (str): Version of the App for generating a User-Agent,
|
|
790
|
+
ua_url (str, Optional): URL To the Source Code or Documentation for generating a User-Agent,
|
|
791
|
+
spec_file (str, Optional): Absolute path to a OpenApi 3.1 spec file to load.
|
|
792
|
+
tenant (str, Optional): The ESI tenant to use (default: "tranquility").
|
|
793
|
+
Functions:
|
|
794
|
+
client(): ESIClient
|
|
795
|
+
client_async(): ESIClientAsync
|
|
796
|
+
"""
|
|
797
|
+
|
|
798
|
+
def __init__(
|
|
799
|
+
self,
|
|
800
|
+
compatibility_date: str,
|
|
801
|
+
ua_appname: str,
|
|
802
|
+
ua_version: str,
|
|
803
|
+
ua_url: str | None = None,
|
|
804
|
+
spec_file: None | str = None,
|
|
805
|
+
tenant: str = "tranquility",
|
|
806
|
+
**kwargs
|
|
807
|
+
) -> None:
|
|
808
|
+
self._compatibility_date = compatibility_date
|
|
809
|
+
self._ua_appname = ua_appname
|
|
810
|
+
self._ua_version = ua_version
|
|
811
|
+
self._ua_url = ua_url
|
|
812
|
+
self._spec_file = spec_file
|
|
813
|
+
self._tenant = tenant
|
|
814
|
+
self._kwargs = kwargs
|
|
815
|
+
|
|
816
|
+
@property
|
|
817
|
+
def client(self) -> ESIClient:
|
|
818
|
+
if self._client is None:
|
|
819
|
+
api = esi_client_factory_sync(
|
|
820
|
+
compatibility_date=self._compatibility_date,
|
|
821
|
+
ua_appname=self._ua_appname,
|
|
822
|
+
ua_version=self._ua_version,
|
|
823
|
+
ua_url=self._ua_url,
|
|
824
|
+
spec_file=self._spec_file,
|
|
825
|
+
tenant=self._tenant,
|
|
826
|
+
**self._kwargs)
|
|
827
|
+
self._client = ESIClient(api)
|
|
828
|
+
return self._client
|
|
829
|
+
|
|
830
|
+
@property
|
|
831
|
+
async def client_async(self) -> ESIClientAsync:
|
|
832
|
+
if self._client_async is None:
|
|
833
|
+
api = await esi_client_factory_async(
|
|
834
|
+
compatibility_date=self._compatibility_date,
|
|
835
|
+
ua_appname=self._ua_appname,
|
|
836
|
+
ua_version=self._ua_version,
|
|
837
|
+
ua_url=self._ua_url,
|
|
838
|
+
spec_file=self._spec_file,
|
|
839
|
+
tenant=self._tenant,
|
|
840
|
+
**self._kwargs)
|
|
841
|
+
self._client_async = ESIClientAsync(api)
|
|
842
|
+
return self._client_async
|
|
843
|
+
|
|
844
|
+
def __str__(self) -> str:
|
|
845
|
+
return "ESIClientProvider"
|