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/clients.py
ADDED
|
@@ -0,0 +1,657 @@
|
|
|
1
|
+
import json
|
|
2
|
+
import logging
|
|
3
|
+
from timeit import default_timer
|
|
4
|
+
import warnings
|
|
5
|
+
import datetime as dt
|
|
6
|
+
from hashlib import md5
|
|
7
|
+
from time import sleep
|
|
8
|
+
from typing import Any
|
|
9
|
+
from urllib import parse as urlparse
|
|
10
|
+
|
|
11
|
+
from bravado import requests_client
|
|
12
|
+
from bravado.client import SwaggerClient
|
|
13
|
+
from bravado.exception import (
|
|
14
|
+
HTTPBadGateway, HTTPGatewayTimeout, HTTPServiceUnavailable, HTTPError,
|
|
15
|
+
)
|
|
16
|
+
from bravado.http_future import HttpFuture
|
|
17
|
+
from bravado.swagger_model import Loader
|
|
18
|
+
from bravado_core.response import IncomingResponse
|
|
19
|
+
from bravado_core.spec import CONFIG_DEFAULTS, Spec
|
|
20
|
+
from requests.adapters import HTTPAdapter
|
|
21
|
+
|
|
22
|
+
from django.core.cache import cache
|
|
23
|
+
|
|
24
|
+
from . import __title__, __url__, __version__, app_settings
|
|
25
|
+
from .errors import TokenExpiredError
|
|
26
|
+
from .signals import esi_request_statistics
|
|
27
|
+
|
|
28
|
+
logger = logging.getLogger(__name__)
|
|
29
|
+
|
|
30
|
+
_LIBRARIES_LOG_LEVEL = logging.getLevelName(app_settings.ESI_LOG_LEVEL_LIBRARIES)
|
|
31
|
+
logging.getLogger('swagger_spec_validator').setLevel(_LIBRARIES_LOG_LEVEL)
|
|
32
|
+
logging.getLogger('bravado_core').setLevel(_LIBRARIES_LOG_LEVEL)
|
|
33
|
+
logging.getLogger('urllib3').setLevel(_LIBRARIES_LOG_LEVEL)
|
|
34
|
+
logging.getLogger('bravado').setLevel(_LIBRARIES_LOG_LEVEL)
|
|
35
|
+
|
|
36
|
+
SPEC_CONFIG = {'use_models': False}
|
|
37
|
+
RETRY_SLEEP_SECS = 1
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
class CachingHttpFuture(HttpFuture):
|
|
41
|
+
"""Extended wrapper for a FutureAdapter that returns a HTTP response
|
|
42
|
+
and also supports caching.
|
|
43
|
+
|
|
44
|
+
This class contains the response for an ESI request with an ESI client.
|
|
45
|
+
"""
|
|
46
|
+
def _cache_key(self) -> str:
|
|
47
|
+
"""Generate the key name used to cache responses."""
|
|
48
|
+
request = self.future.request
|
|
49
|
+
data = (
|
|
50
|
+
request.method
|
|
51
|
+
+ request.url
|
|
52
|
+
+ str(request.params)
|
|
53
|
+
+ str(request.data)
|
|
54
|
+
+ str(request.json)
|
|
55
|
+
).encode('utf-8')
|
|
56
|
+
# The following hash is not used in any security context. It is only used
|
|
57
|
+
# to generate unique values, collisions are acceptable and "data" is not
|
|
58
|
+
# coming from user-generated input
|
|
59
|
+
str_hash = md5(data).hexdigest() # nosec B303, B303-1
|
|
60
|
+
return f'esi_{str_hash}'
|
|
61
|
+
|
|
62
|
+
@staticmethod
|
|
63
|
+
def _time_to_expiry(expires):
|
|
64
|
+
"""Determine the seconds until a HTTP header "Expires" timestamp.
|
|
65
|
+
|
|
66
|
+
Args:
|
|
67
|
+
expires: HTTP response "Expires" header
|
|
68
|
+
|
|
69
|
+
Returns:
|
|
70
|
+
seconds until "Expires" time
|
|
71
|
+
"""
|
|
72
|
+
try:
|
|
73
|
+
expires_dt = dt.datetime.strptime(str(expires), '%a, %d %b %Y %H:%M:%S %Z')
|
|
74
|
+
if expires_dt.tzinfo is None:
|
|
75
|
+
expires_dt = expires_dt.replace(tzinfo=dt.timezone.utc)
|
|
76
|
+
delta = expires_dt - dt.datetime.now(dt.timezone.utc)
|
|
77
|
+
return delta.total_seconds()
|
|
78
|
+
except ValueError:
|
|
79
|
+
return 0
|
|
80
|
+
|
|
81
|
+
def results(self, **kwargs) -> Any | tuple[Any, IncomingResponse]:
|
|
82
|
+
"""Executes the request and returns the response from ESI for the current
|
|
83
|
+
route. Response will include all pages if there are more available.
|
|
84
|
+
|
|
85
|
+
Accepts same parameters in ``kwargs`` as :meth:`result`
|
|
86
|
+
|
|
87
|
+
Returns:
|
|
88
|
+
same as :meth:`result`, but for multiple pages
|
|
89
|
+
"""
|
|
90
|
+
results = list()
|
|
91
|
+
headers = None
|
|
92
|
+
# preserve original value
|
|
93
|
+
_also_return_response = self.request_config.also_return_response
|
|
94
|
+
# override to always get the raw response for expiry header
|
|
95
|
+
self.request_config.also_return_response = True
|
|
96
|
+
|
|
97
|
+
if "page" in self.operation.params:
|
|
98
|
+
current_page = 1
|
|
99
|
+
total_pages = 1
|
|
100
|
+
|
|
101
|
+
# loop all pages and add data to output array
|
|
102
|
+
while current_page <= total_pages:
|
|
103
|
+
self.future.request.params["page"] = current_page
|
|
104
|
+
# will use cache if applicable
|
|
105
|
+
result, headers = self.result(**kwargs)
|
|
106
|
+
total_pages = int(headers.headers['X-Pages'])
|
|
107
|
+
# append to results list to be seamless to the client
|
|
108
|
+
results += result
|
|
109
|
+
current_page += 1
|
|
110
|
+
else: # it doesn't so just return
|
|
111
|
+
results, headers = self.result(**kwargs)
|
|
112
|
+
|
|
113
|
+
# restore original value
|
|
114
|
+
self.request_config.also_return_response = _also_return_response
|
|
115
|
+
|
|
116
|
+
# obey the output
|
|
117
|
+
if self.request_config.also_return_response:
|
|
118
|
+
return results, headers
|
|
119
|
+
else:
|
|
120
|
+
return results
|
|
121
|
+
|
|
122
|
+
def results_localized(self, languages: list = None, **kwargs) -> dict:
|
|
123
|
+
"""Executes the request and returns the response from ESI for all default
|
|
124
|
+
languages and pages (if any).
|
|
125
|
+
|
|
126
|
+
Accepts same parameters in ``kwargs`` as :meth:`result` plus ``languages``
|
|
127
|
+
|
|
128
|
+
Args:
|
|
129
|
+
languages: (optional) list of languages to return \
|
|
130
|
+
instead of default languages
|
|
131
|
+
|
|
132
|
+
Returns:
|
|
133
|
+
Dict of all responses with the language code as keys.
|
|
134
|
+
"""
|
|
135
|
+
if not languages:
|
|
136
|
+
my_languages = list(app_settings.ESI_LANGUAGES)
|
|
137
|
+
else:
|
|
138
|
+
my_languages = []
|
|
139
|
+
for lang in dict.fromkeys(languages):
|
|
140
|
+
if lang not in app_settings.ESI_LANGUAGES:
|
|
141
|
+
raise ValueError('Invalid language code: %s' % lang)
|
|
142
|
+
my_languages.append(lang)
|
|
143
|
+
|
|
144
|
+
return {
|
|
145
|
+
language: self.results(language=language, **kwargs)
|
|
146
|
+
for language in my_languages
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
def _send_signal(self, status_code: int, headers: dict = {}, latency: float = 0) -> None:
|
|
150
|
+
"""
|
|
151
|
+
Dispatch the esi request statistics signal
|
|
152
|
+
"""
|
|
153
|
+
esi_request_statistics.send(
|
|
154
|
+
sender=self.__class__,
|
|
155
|
+
operation=self.operation.path_name,
|
|
156
|
+
status_code=status_code,
|
|
157
|
+
headers=headers,
|
|
158
|
+
latency=latency,
|
|
159
|
+
bucket=""
|
|
160
|
+
)
|
|
161
|
+
|
|
162
|
+
def result(self, **kwargs) -> Any | tuple[Any, IncomingResponse]:
|
|
163
|
+
"""Executes the request and returns the response from ESI. Response will
|
|
164
|
+
include the requested / first page only if there are more pages available.
|
|
165
|
+
|
|
166
|
+
Args:
|
|
167
|
+
timeout: (optional) timeout for ESI request in seconds, overwrites default
|
|
168
|
+
retries: (optional) max number of retries, overwrites default
|
|
169
|
+
language: (optional) retrieve result for specific language
|
|
170
|
+
ignore_cache: (optional) set to ``True`` to ignore response caching
|
|
171
|
+
|
|
172
|
+
Returns:
|
|
173
|
+
Response from endpoint or a tuple with response from endpoint \
|
|
174
|
+
and an incoming response object containing additional meta data \
|
|
175
|
+
including the HTTP response headers
|
|
176
|
+
"""
|
|
177
|
+
if 'language' in kwargs.keys():
|
|
178
|
+
# this parameter is not supported by bravado, so we can't pass it on
|
|
179
|
+
self.future.request.params['language'] = str(kwargs.pop('language'))
|
|
180
|
+
|
|
181
|
+
if 'timeout' not in kwargs:
|
|
182
|
+
kwargs['timeout'] = (
|
|
183
|
+
app_settings.ESI_REQUESTS_CONNECT_TIMEOUT,
|
|
184
|
+
app_settings.ESI_REQUESTS_READ_TIMEOUT
|
|
185
|
+
)
|
|
186
|
+
|
|
187
|
+
ignore_cache = (
|
|
188
|
+
kwargs.pop('ignore_cache') if 'ignore_cache' in kwargs.keys() else False
|
|
189
|
+
)
|
|
190
|
+
|
|
191
|
+
if (
|
|
192
|
+
app_settings.ESI_CACHE_RESPONSE
|
|
193
|
+
and not ignore_cache
|
|
194
|
+
and self.future.request.method == 'GET'
|
|
195
|
+
and self.operation is not None
|
|
196
|
+
):
|
|
197
|
+
result = None
|
|
198
|
+
response = None
|
|
199
|
+
cache_key = self._cache_key()
|
|
200
|
+
try:
|
|
201
|
+
cached = cache.get(cache_key)
|
|
202
|
+
except Exception:
|
|
203
|
+
cached = None
|
|
204
|
+
logger.warning(
|
|
205
|
+
"Attempt to read ESI results from cache failed", exc_info=True
|
|
206
|
+
)
|
|
207
|
+
|
|
208
|
+
if cached:
|
|
209
|
+
self._send_signal(
|
|
210
|
+
status_code=0
|
|
211
|
+
)
|
|
212
|
+
result, response = cached
|
|
213
|
+
expiry = self._time_to_expiry(str(response.headers.get('Expires')))
|
|
214
|
+
if expiry < 0:
|
|
215
|
+
logger.warning(
|
|
216
|
+
"cache expired by %d seconds, Forcing expiry", expiry
|
|
217
|
+
)
|
|
218
|
+
cached = False
|
|
219
|
+
|
|
220
|
+
if not cached:
|
|
221
|
+
result, response = self._result_with_retries(**kwargs)
|
|
222
|
+
if response and 'Expires' in response.headers:
|
|
223
|
+
expires = self._time_to_expiry(response.headers['Expires'])
|
|
224
|
+
if expires > 0:
|
|
225
|
+
try:
|
|
226
|
+
cache.set(cache_key, (result, response), expires)
|
|
227
|
+
except Exception:
|
|
228
|
+
logger.warning(
|
|
229
|
+
"Failed to write ESI result to cache", exc_info=True
|
|
230
|
+
)
|
|
231
|
+
|
|
232
|
+
if self.request_config.also_return_response:
|
|
233
|
+
return result, response
|
|
234
|
+
return result
|
|
235
|
+
|
|
236
|
+
elif self.operation is not None:
|
|
237
|
+
result, response = self._result_with_retries(**kwargs)
|
|
238
|
+
if self.request_config.also_return_response:
|
|
239
|
+
return result, response
|
|
240
|
+
return result
|
|
241
|
+
|
|
242
|
+
return super().result(**kwargs)
|
|
243
|
+
|
|
244
|
+
def _result_with_retries(self, **kwargs) -> tuple[Any, IncomingResponse]:
|
|
245
|
+
"""Execute request and retry on certain HTTP errors.
|
|
246
|
+
|
|
247
|
+
``kwargs`` are passed through to super().result()
|
|
248
|
+
|
|
249
|
+
Returns:
|
|
250
|
+
Tuple with response from endpoint and an incoming response object \
|
|
251
|
+
containing additional meta data including the HTTP response headers
|
|
252
|
+
"""
|
|
253
|
+
# preserve original value
|
|
254
|
+
_also_return_response = self.request_config.also_return_response
|
|
255
|
+
# override to always get the raw response for expiry header
|
|
256
|
+
self.request_config.also_return_response = True
|
|
257
|
+
|
|
258
|
+
if 'retries' in kwargs.keys():
|
|
259
|
+
max_retries = int(kwargs.pop('retries'))
|
|
260
|
+
else:
|
|
261
|
+
max_retries = int(app_settings.ESI_SERVER_ERROR_MAX_RETRIES)
|
|
262
|
+
max_retries = max(0, max_retries)
|
|
263
|
+
|
|
264
|
+
retries = 0
|
|
265
|
+
while retries <= max_retries:
|
|
266
|
+
_t = default_timer()
|
|
267
|
+
try:
|
|
268
|
+
if app_settings.ESI_INFO_LOGGING_ENABLED:
|
|
269
|
+
params = self.future.request.params
|
|
270
|
+
logger.info(
|
|
271
|
+
'Fetching from ESI: %s%s%s',
|
|
272
|
+
self.future.request.url,
|
|
273
|
+
f' - language {params["language"]}'
|
|
274
|
+
if 'language' in params else '',
|
|
275
|
+
f' - page {params["page"]}'
|
|
276
|
+
if 'page' in params else ''
|
|
277
|
+
)
|
|
278
|
+
logger.debug(
|
|
279
|
+
'ESI request: %s - %s',
|
|
280
|
+
self.future.request.url,
|
|
281
|
+
self.future.request.params
|
|
282
|
+
)
|
|
283
|
+
logger.debug('ESI request headers: %s', self.future.request.headers)
|
|
284
|
+
result, response = super().result(**kwargs)
|
|
285
|
+
logger.debug('ESI response status code: %s', response.status_code)
|
|
286
|
+
logger.debug('ESI response headers: %s', response.headers)
|
|
287
|
+
if app_settings.ESI_DEBUG_RESPONSE_CONTENT_LOGGING:
|
|
288
|
+
logger.debug('ESI response content: %s', response.text)
|
|
289
|
+
break
|
|
290
|
+
except (HTTPBadGateway, HTTPGatewayTimeout, HTTPServiceUnavailable) as ex:
|
|
291
|
+
self._send_signal(
|
|
292
|
+
status_code=ex.status_code,
|
|
293
|
+
headers=ex.response.headers,
|
|
294
|
+
latency=default_timer() - _t
|
|
295
|
+
)
|
|
296
|
+
if retries < max_retries:
|
|
297
|
+
retries += 1
|
|
298
|
+
logger.warning(
|
|
299
|
+
"ESI error - %s %s - Retry: %d/%d",
|
|
300
|
+
self.future.request.url,
|
|
301
|
+
ex.status_code,
|
|
302
|
+
retries,
|
|
303
|
+
max_retries
|
|
304
|
+
)
|
|
305
|
+
wait_secs = (
|
|
306
|
+
app_settings.ESI_SERVER_ERROR_BACKOFF_FACTOR
|
|
307
|
+
* (2 ** (retries - 1))
|
|
308
|
+
)
|
|
309
|
+
sleep(wait_secs)
|
|
310
|
+
else:
|
|
311
|
+
raise ex
|
|
312
|
+
except HTTPError as ex:
|
|
313
|
+
"""
|
|
314
|
+
Throw any other error into the signal
|
|
315
|
+
then just re-raise
|
|
316
|
+
"""
|
|
317
|
+
self._send_signal(
|
|
318
|
+
status_code=ex.status_code,
|
|
319
|
+
headers=ex.response.headers,
|
|
320
|
+
latency=default_timer() - _t
|
|
321
|
+
)
|
|
322
|
+
raise ex
|
|
323
|
+
|
|
324
|
+
self._send_signal(
|
|
325
|
+
status_code=response.status_code,
|
|
326
|
+
headers=response.headers,
|
|
327
|
+
latency=default_timer() - _t
|
|
328
|
+
)
|
|
329
|
+
# restore original value
|
|
330
|
+
self.request_config.also_return_response = _also_return_response
|
|
331
|
+
return result, response
|
|
332
|
+
|
|
333
|
+
|
|
334
|
+
requests_client.HttpFuture = CachingHttpFuture
|
|
335
|
+
|
|
336
|
+
|
|
337
|
+
class TokenAuthenticator(requests_client.Authenticator):
|
|
338
|
+
"""
|
|
339
|
+
Adds the authorization header containing access token, if specified.
|
|
340
|
+
Sets ESI datasource to tranquility or singularity.
|
|
341
|
+
"""
|
|
342
|
+
|
|
343
|
+
def __init__(self, token=None, datasource=None):
|
|
344
|
+
host = urlparse.urlsplit(app_settings.ESI_API_URL).hostname
|
|
345
|
+
super().__init__(host)
|
|
346
|
+
self.token = token
|
|
347
|
+
self.datasource = datasource
|
|
348
|
+
|
|
349
|
+
def apply(self, request):
|
|
350
|
+
if self.token and self.token.expired:
|
|
351
|
+
if self.token.can_refresh:
|
|
352
|
+
self.token.refresh()
|
|
353
|
+
else:
|
|
354
|
+
raise TokenExpiredError()
|
|
355
|
+
request.headers['Authorization'] = \
|
|
356
|
+
'Bearer ' + self.token.access_token if self.token else None
|
|
357
|
+
request.params['datasource'] = \
|
|
358
|
+
self.datasource or app_settings.ESI_API_DATASOURCE
|
|
359
|
+
return request
|
|
360
|
+
|
|
361
|
+
|
|
362
|
+
class RequestsClientPlus(requests_client.RequestsClient):
|
|
363
|
+
"""RequestsClient with ability to set the user agent header for all requests"""
|
|
364
|
+
|
|
365
|
+
def __init__(
|
|
366
|
+
self,
|
|
367
|
+
ssl_verify=True,
|
|
368
|
+
ssl_cert=None,
|
|
369
|
+
future_adapter_class=requests_client.RequestsFutureAdapter,
|
|
370
|
+
response_adapter_class=requests_client.RequestsResponseAdapter,
|
|
371
|
+
):
|
|
372
|
+
super().__init__(
|
|
373
|
+
ssl_verify, ssl_cert, future_adapter_class, response_adapter_class
|
|
374
|
+
)
|
|
375
|
+
self.user_agent = None
|
|
376
|
+
|
|
377
|
+
def request(
|
|
378
|
+
self, request_params, operation=None, request_config=None
|
|
379
|
+
) -> HttpFuture:
|
|
380
|
+
if self.user_agent:
|
|
381
|
+
current_headers = request_params.get("headers", dict())
|
|
382
|
+
new_header = {"User-Agent": str(self.user_agent)}
|
|
383
|
+
request_params["headers"] = {**current_headers, **new_header}
|
|
384
|
+
|
|
385
|
+
return super().request(request_params, operation, request_config)
|
|
386
|
+
|
|
387
|
+
|
|
388
|
+
def build_cache_name(name):
|
|
389
|
+
"""
|
|
390
|
+
Cache key name formatter
|
|
391
|
+
:param name: Name of the spec dict to cache, usually version
|
|
392
|
+
:return: String name for cache key
|
|
393
|
+
:rtype: str
|
|
394
|
+
"""
|
|
395
|
+
return 'esi_swaggerspec_%s' % name
|
|
396
|
+
|
|
397
|
+
|
|
398
|
+
def cache_spec(name, spec):
|
|
399
|
+
"""
|
|
400
|
+
Cache the spec dict
|
|
401
|
+
:param name: Version name
|
|
402
|
+
:param spec: Spec dict
|
|
403
|
+
:return: True if cached
|
|
404
|
+
"""
|
|
405
|
+
return cache.set(
|
|
406
|
+
build_cache_name(name), spec, app_settings.ESI_SPEC_CACHE_DURATION
|
|
407
|
+
)
|
|
408
|
+
|
|
409
|
+
|
|
410
|
+
def build_spec_url(spec_version):
|
|
411
|
+
"""
|
|
412
|
+
Generates the URL to swagger.json for the ESI version
|
|
413
|
+
:param spec_version: Name of the swagger spec version, like latest or v4
|
|
414
|
+
:return: URL to swagger.json for the requested spec version
|
|
415
|
+
"""
|
|
416
|
+
return urlparse.urljoin(app_settings.ESI_API_URL, spec_version + '/swagger.json')
|
|
417
|
+
|
|
418
|
+
|
|
419
|
+
def get_spec(name, http_client=None, config=None):
|
|
420
|
+
"""
|
|
421
|
+
:param name: Name of the revision of spec, eg latest or v4
|
|
422
|
+
:param http_client: Requests client used for retrieving specs
|
|
423
|
+
:param config: Spec configuration - see Spec.CONFIG_DEFAULTS
|
|
424
|
+
:return: :class:`bravado_core.spec.Spec`
|
|
425
|
+
"""
|
|
426
|
+
http_client = http_client or requests_client.RequestsClient()
|
|
427
|
+
|
|
428
|
+
def load_spec():
|
|
429
|
+
loader = Loader(http_client)
|
|
430
|
+
return loader.load_spec(build_spec_url(name))
|
|
431
|
+
|
|
432
|
+
spec_dict = cache.get_or_set(
|
|
433
|
+
build_cache_name(name), load_spec, app_settings.ESI_SPEC_CACHE_DURATION
|
|
434
|
+
)
|
|
435
|
+
config = dict(CONFIG_DEFAULTS, **(config or {}))
|
|
436
|
+
return Spec.from_dict(spec_dict, build_spec_url(name), http_client, config)
|
|
437
|
+
|
|
438
|
+
|
|
439
|
+
def build_spec(base_version, http_client=None, **kwargs):
|
|
440
|
+
"""
|
|
441
|
+
Generates the Spec used to initialize a SwaggerClient,
|
|
442
|
+
supporting mixed resource versions
|
|
443
|
+
:param http_client: :class:`bravado.requests_client.RequestsClient`
|
|
444
|
+
:param base_version: Version to base the spec on.
|
|
445
|
+
Any resource without an explicit version will be this.
|
|
446
|
+
:param kwargs: Explicit resource versions, by name (eg Character='v4')
|
|
447
|
+
:return: :class:`bravado_core.spec.Spec`
|
|
448
|
+
"""
|
|
449
|
+
base_spec = get_spec(base_version, http_client=http_client, config=SPEC_CONFIG)
|
|
450
|
+
if kwargs:
|
|
451
|
+
for resource, resource_version in kwargs.items():
|
|
452
|
+
versioned_spec = get_spec(
|
|
453
|
+
resource_version, http_client=http_client, config=SPEC_CONFIG
|
|
454
|
+
)
|
|
455
|
+
try:
|
|
456
|
+
spec_resource = versioned_spec.resources[resource.capitalize()]
|
|
457
|
+
except KeyError:
|
|
458
|
+
raise AttributeError(
|
|
459
|
+
'Resource {} not found on API revision {}'.format(
|
|
460
|
+
resource, resource_version
|
|
461
|
+
)
|
|
462
|
+
)
|
|
463
|
+
base_spec.resources[resource.capitalize()] = spec_resource
|
|
464
|
+
return base_spec
|
|
465
|
+
|
|
466
|
+
|
|
467
|
+
def read_spec(path, http_client=None):
|
|
468
|
+
"""
|
|
469
|
+
Reads in a swagger spec file used to initialize a SwaggerClient
|
|
470
|
+
:param path: String path to local swagger spec file.
|
|
471
|
+
:param http_client: :class:`bravado.requests_client.RequestsClient`
|
|
472
|
+
:return: :class:`bravado_core.spec.Spec`
|
|
473
|
+
"""
|
|
474
|
+
with open(path, encoding='utf-8') as f:
|
|
475
|
+
spec_dict = json.loads(f.read())
|
|
476
|
+
|
|
477
|
+
return SwaggerClient.from_spec(
|
|
478
|
+
spec_dict, http_client=http_client, config=SPEC_CONFIG
|
|
479
|
+
)
|
|
480
|
+
|
|
481
|
+
|
|
482
|
+
def esi_client_factory(
|
|
483
|
+
token=None, datasource: str = None, spec_file: str = None, version: str = None,
|
|
484
|
+
app_info_text: str = None, # Deprecate in favour of the following variables
|
|
485
|
+
ua_appname: str = None, ua_version: str = None, ua_url: str = None, **kwargs) -> SwaggerClient:
|
|
486
|
+
"""Generate a new ESI client.
|
|
487
|
+
|
|
488
|
+
Args:
|
|
489
|
+
token(esi.models.Token): used to access authenticated endpoints.
|
|
490
|
+
datasource: Name of the ESI datasource to access.
|
|
491
|
+
spec_file: Absolute path to a swagger spec file to load.
|
|
492
|
+
version: Base ESI API version. Accepted values are 'legacy', 'latest',
|
|
493
|
+
ua_appname: Name of the App for generating a User-Agent,
|
|
494
|
+
ua_version: Version of the App for generating a User-Agent,
|
|
495
|
+
ua_url: (optional) URL To the Source Code or Documentation for generating a User-Agent,
|
|
496
|
+
kwargs: Explicit resource versions to build, in the form Character='v4'. \
|
|
497
|
+
Same values accepted as version.
|
|
498
|
+
|
|
499
|
+
If a spec_file is specified, specific versioning is not available.
|
|
500
|
+
Meaning the version and resource version kwargs are ignored in favour of the
|
|
501
|
+
versions available in the spec_file.
|
|
502
|
+
|
|
503
|
+
Returns:
|
|
504
|
+
New ESI client
|
|
505
|
+
"""
|
|
506
|
+
|
|
507
|
+
if app_info_text is not None:
|
|
508
|
+
warnings.warn(
|
|
509
|
+
"The 'app_info_text' parameter is deprecated and will be removed in a future release. "
|
|
510
|
+
"Use 'ua_appname', 'ua_version', and `ua_url` to dynamically build a User-Agent instead",
|
|
511
|
+
DeprecationWarning,
|
|
512
|
+
stacklevel=2
|
|
513
|
+
)
|
|
514
|
+
|
|
515
|
+
if ua_appname is None or ua_version is None:
|
|
516
|
+
warnings.warn(
|
|
517
|
+
"Applications must define their own 'ua_appname' and 'ua_version' to generate a User-Agent",
|
|
518
|
+
DeprecationWarning,
|
|
519
|
+
stacklevel=2
|
|
520
|
+
)
|
|
521
|
+
|
|
522
|
+
if app_settings.ESI_INFO_LOGGING_ENABLED:
|
|
523
|
+
logger.info('Generating an ESI client...')
|
|
524
|
+
|
|
525
|
+
client = RequestsClientPlus()
|
|
526
|
+
|
|
527
|
+
from esi.helpers import pascal_case_string
|
|
528
|
+
sanitized_appname = pascal_case_string(__title__)
|
|
529
|
+
|
|
530
|
+
if app_info_text:
|
|
531
|
+
# app_info_text (email@example) Django-ESI/1.2.3 (+https://gitlab.com/allianceauth/django-esi)
|
|
532
|
+
# Deprecated
|
|
533
|
+
user_agent = f"{app_info_text} ({app_settings.ESI_USER_CONTACT_EMAIL}) {sanitized_appname}/{__version__} (+{__url__})"
|
|
534
|
+
elif ua_appname is None or ua_version is None:
|
|
535
|
+
# Django-ESI/1.2.3 () (email@example; +https://gitlab.com/allianceauth/django-esi)
|
|
536
|
+
# Deprecated
|
|
537
|
+
user_agent = f"{sanitized_appname}/{__version__} ({app_settings.ESI_USER_CONTACT_EMAIL}; +{__url__})"
|
|
538
|
+
else:
|
|
539
|
+
# AppName/1.2.3 (email@example.com) Django-ESI/1.2.3 (+https://gitlab.com/allianceauth/django-esi)
|
|
540
|
+
# or AppName/1.2.3 (email@example.com; +https://gitlab.com/) Django-ESI/1.2.3 (+https://gitlab.com/allianceauth/django-esi) (+https://gitlab.com/allianceauth/django-esi)
|
|
541
|
+
# Preferred
|
|
542
|
+
|
|
543
|
+
# Enforce PascalCase for `ua_appname` and strip whitespace
|
|
544
|
+
sanitized_ua_appname = pascal_case_string(ua_appname)
|
|
545
|
+
|
|
546
|
+
user_agent = f"{sanitized_ua_appname}/{ua_version} ({app_settings.ESI_USER_CONTACT_EMAIL}{f'; +{ua_url})' if ua_url else ')'} {sanitized_appname}/{__version__} (+{__url__})"
|
|
547
|
+
|
|
548
|
+
client.user_agent = user_agent
|
|
549
|
+
|
|
550
|
+
my_http_adapter = HTTPAdapter(
|
|
551
|
+
pool_maxsize=app_settings.ESI_CONNECTION_POOL_MAXSIZE,
|
|
552
|
+
max_retries=app_settings.ESI_CONNECTION_ERROR_MAX_RETRIES
|
|
553
|
+
)
|
|
554
|
+
client.session.mount('https://', my_http_adapter)
|
|
555
|
+
|
|
556
|
+
if token or datasource:
|
|
557
|
+
client.authenticator = TokenAuthenticator(token=token, datasource=datasource)
|
|
558
|
+
|
|
559
|
+
api_version = version or app_settings.ESI_API_VERSION
|
|
560
|
+
|
|
561
|
+
if spec_file:
|
|
562
|
+
return read_spec(spec_file, http_client=client)
|
|
563
|
+
else:
|
|
564
|
+
spec = build_spec(api_version, http_client=client, **kwargs)
|
|
565
|
+
return SwaggerClient(spec)
|
|
566
|
+
|
|
567
|
+
|
|
568
|
+
def minimize_spec(spec_dict, operations=None, resources=None):
|
|
569
|
+
"""
|
|
570
|
+
Trims down a source spec dict to only the operations or resources indicated.
|
|
571
|
+
:param spec_dict: The source spec dict to minimize.
|
|
572
|
+
:type spec_dict: dict
|
|
573
|
+
:param operations: A list of operation IDs to retain.
|
|
574
|
+
:type operations: list of str
|
|
575
|
+
:param resources: A list of resource names to retain.
|
|
576
|
+
:type resources: list of str
|
|
577
|
+
:return: Minimized swagger spec dict
|
|
578
|
+
:rtype: dict
|
|
579
|
+
"""
|
|
580
|
+
operations = operations or []
|
|
581
|
+
resources = resources or []
|
|
582
|
+
|
|
583
|
+
# keep the ugly overhead for now but only add paths we need
|
|
584
|
+
minimized = {key: value for key, value in spec_dict.items() if key != 'paths'}
|
|
585
|
+
minimized['paths'] = {}
|
|
586
|
+
|
|
587
|
+
for path_name, path in spec_dict['paths'].items():
|
|
588
|
+
for method, data in path.items():
|
|
589
|
+
if (
|
|
590
|
+
data['operationId'] in operations
|
|
591
|
+
or any(tag in resources for tag in data['tags'])
|
|
592
|
+
):
|
|
593
|
+
if path_name not in minimized['paths']:
|
|
594
|
+
minimized['paths'][path_name] = {}
|
|
595
|
+
minimized['paths'][path_name][method] = data
|
|
596
|
+
|
|
597
|
+
return minimized
|
|
598
|
+
|
|
599
|
+
|
|
600
|
+
class EsiClientProvider:
|
|
601
|
+
"""Class for providing a single ESI client instance for the whole app
|
|
602
|
+
|
|
603
|
+
Args:
|
|
604
|
+
datasource: Name of the ESI datasource to access.
|
|
605
|
+
spec_file: Absolute path to a swagger spec file to load.
|
|
606
|
+
version: Base ESI API version. Accepted values are 'legacy', 'latest',
|
|
607
|
+
ua_appname: Name of the App for generating a User-Agent,
|
|
608
|
+
ua_version: Version of the App for generating a User-Agent,
|
|
609
|
+
ua_url: (optional) URL To the Source Code or Documentation for generating a User-Agent,
|
|
610
|
+
kwargs: Explicit resource versions to build, in the form Character='v4'. \
|
|
611
|
+
Same values accepted as version.
|
|
612
|
+
|
|
613
|
+
If a spec_file is specified, specific versioning is not available.
|
|
614
|
+
Meaning the version and resource version kwargs are ignored in favour of the
|
|
615
|
+
versions available in the spec_file.
|
|
616
|
+
"""
|
|
617
|
+
|
|
618
|
+
_client = None
|
|
619
|
+
|
|
620
|
+
def __init__(
|
|
621
|
+
self, datasource=None, spec_file=None, version=None,
|
|
622
|
+
app_info_text=None, # Deprecate in favour of the following variables
|
|
623
|
+
ua_appname: str = None, ua_version: str = None, ua_url: str = None, **kwargs) -> None:
|
|
624
|
+
self._datasource = datasource
|
|
625
|
+
self._spec_file = spec_file
|
|
626
|
+
self._version = version
|
|
627
|
+
self._app_text = app_info_text # Deprecate in favour of the following variables
|
|
628
|
+
self._ua_appname = ua_appname
|
|
629
|
+
self._ua_version = ua_version
|
|
630
|
+
self._ua_url = ua_url
|
|
631
|
+
self._kwargs = kwargs
|
|
632
|
+
|
|
633
|
+
if app_info_text is not None:
|
|
634
|
+
warnings.warn(
|
|
635
|
+
"The 'app_info_text' parameter is deprecated and will be removed in a future release. "
|
|
636
|
+
"Use 'ua_appname', 'ua_version', and `ua_url` to dynamically build a User-Agent instead",
|
|
637
|
+
DeprecationWarning,
|
|
638
|
+
stacklevel=2
|
|
639
|
+
)
|
|
640
|
+
|
|
641
|
+
@property
|
|
642
|
+
def client(self) -> SwaggerClient:
|
|
643
|
+
if self._client is None:
|
|
644
|
+
self._client = esi_client_factory(
|
|
645
|
+
datasource=self._datasource,
|
|
646
|
+
spec_file=self._spec_file,
|
|
647
|
+
version=self._version,
|
|
648
|
+
app_info_text=self._app_text, # Deprecate in favour of the following variables
|
|
649
|
+
ua_appname=self._ua_appname,
|
|
650
|
+
ua_version=self._ua_version,
|
|
651
|
+
ua_url=self._ua_url,
|
|
652
|
+
**self._kwargs,
|
|
653
|
+
)
|
|
654
|
+
return self._client
|
|
655
|
+
|
|
656
|
+
def __str__(self) -> str:
|
|
657
|
+
return 'EsiClientProvider'
|