A corporation manager and dashboard for EVE Online
Vous ne pouvez pas sélectionner plus de 25 sujets Les noms de sujets doivent commencer par une lettre ou un nombre, peuvent contenir des tirets ('-') et peuvent comporter jusqu'à 35 caractères.
 
 
 
 
 

236 lignes
7.6 KiB

  1. # -*- coding: utf-8 -*-
  2. from datetime import datetime
  3. import random
  4. from threading import Lock
  5. import time
  6. import requests
  7. from ..exceptions import EVEAPIError, EVEAPIForbiddenError
  8. __all__ = ["EVESwaggerInterface"]
  9. class _ESICache:
  10. """Caches ESI API responses according to their headers.
  11. This interface is thread-safe.
  12. """
  13. EXPIRATION_RATE = 0.2
  14. def __init__(self):
  15. self._public = {}
  16. self._private = {}
  17. self._lock = Lock()
  18. @staticmethod
  19. def _get_cache_policy(resp):
  20. """Calculate the expiry and availability of the given response."""
  21. if "Cache-Control" not in resp.headers:
  22. return None
  23. directives = resp.headers["Cache-Control"].lower().split(";")
  24. directives = [d.strip() for d in directives]
  25. if "public" in directives:
  26. public = True
  27. elif "private" in directives:
  28. public = False
  29. else:
  30. return None
  31. expires = resp.headers.get("Expires")
  32. if not expires:
  33. return None
  34. try:
  35. expiry = datetime.strptime(expires, "%a, %d %b %Y %H:%M:%S GMT")
  36. except ValueError:
  37. return None
  38. return public, expiry
  39. @staticmethod
  40. def freeze_dict(d):
  41. """Return a hashable string key for the given dictionary."""
  42. if not d:
  43. return "{}"
  44. items = sorted((repr(str(k)), repr(str(v))) for k, v in d.items())
  45. return "{" + ",".join(":".join(p) for p in items) + "}"
  46. def _expire(self):
  47. """Remove old entries from the cache. Assumes lock is acquired."""
  48. now = datetime.utcnow()
  49. for index in (self._private, self._public):
  50. condemned = []
  51. for key, (expiry, _) in index.items():
  52. if expiry < now:
  53. condemned.append(key)
  54. for key in condemned:
  55. del index[key]
  56. def fetch(self, key, token):
  57. """Try to look up a key in the cache. Return None if not found.
  58. The key should be a string. The token is used if a route is cached
  59. privately.
  60. This will periodically clear the cache of expired entries.
  61. """
  62. with self._lock:
  63. if random.random() < self.EXPIRATION_RATE:
  64. self._expire()
  65. if (key, token) in self._private:
  66. expiry, value = self._private[key, token]
  67. if expiry > datetime.utcnow():
  68. return value
  69. if key in self._public:
  70. expiry, value = self._public[key]
  71. if expiry > datetime.utcnow():
  72. return value
  73. return None
  74. def insert(self, key, token, value, response):
  75. """Store a key-value pair using the response as cache control."""
  76. policy = self._get_cache_policy(response)
  77. if not policy:
  78. return
  79. public, expiry = policy
  80. if expiry > datetime.utcnow():
  81. with self._lock:
  82. if public:
  83. self._public[key] = (expiry, value)
  84. else:
  85. self._private[key, token] = (expiry, value)
  86. class _ESIQueryBuilder:
  87. """Stores an ESI query that is being built by the client."""
  88. def __init__(self, esi, token):
  89. self._esi = esi
  90. self._token = token
  91. self._path = "/"
  92. def __getattr__(self, item):
  93. self._path += str(item) + "/"
  94. return self
  95. def __call__(self, item):
  96. self._path += str(item) + "/"
  97. return self
  98. def get(self, **kwargs):
  99. """Do an HTTP GET request for the built query."""
  100. return self._esi.get(self._path, self._token, params=kwargs)
  101. def post(self, **kwargs):
  102. """Do an HTTP POST request for the built query."""
  103. return self._esi.post(self._path, self._token, data=kwargs)
  104. def put(self, **kwargs):
  105. """Do an HTTP PUT request for the built query."""
  106. return self._esi.put(self._path, self._token, data=kwargs)
  107. def delete(self, **kwargs):
  108. """Do an HTTP DELETE request for the built query."""
  109. return self._esi.delete(self._path, self._token, params=kwargs)
  110. class EVESwaggerInterface:
  111. """EVE API module for the EVE Swagger Interface (ESI).
  112. There are two equivalent ways to use this interface:
  113. data = esi.get("/v3/characters/{char_id}/".format(char_id=char_id), token)
  114. data = esi(token).v3.characters(char_id).get()
  115. For more complex requests:
  116. data = esi.post("/v1/universe/names/", token, data={"ids": [entity_id]})
  117. data = esi(token).v1.universe.names.post(ids=[entity_id]})
  118. """
  119. _RETRY_WAIT = 0.5
  120. def __init__(self, session, logger):
  121. self._session = session
  122. self._logger = logger
  123. self._debug = logger.debug
  124. self._base_url = "https://esi.tech.ccp.is"
  125. self._data_source = "tranquility"
  126. self._cache = _ESICache()
  127. def __call__(self, token=None):
  128. return _ESIQueryBuilder(self, token)
  129. def _do(self, method, query, params, data, token, can_cache=False):
  130. """Execute a query using a token with the given session method.
  131. Return the JSON result, if any. Raise EVEAPIError for any errors.
  132. """
  133. if can_cache:
  134. pkey = self._cache.freeze_dict(params)
  135. key = "|".join((method.__name__, self._data_source, query, pkey))
  136. cached = self._cache.fetch(key, token)
  137. else:
  138. cached = None
  139. self._debug("[%s] [%s] %s", method.__name__.upper(),
  140. "fresh" if cached is None else "cache", query)
  141. if cached is not None:
  142. return cached
  143. headers = {"Accept": "application/json"}
  144. if token is not None:
  145. headers["Authorization"] = "Bearer " + token
  146. params = params.copy() if params else {}
  147. params["datasource"] = self._data_source
  148. url = self._base_url + query
  149. call = lambda: method(url, params=params, json=data or None,
  150. headers=headers, timeout=10)
  151. try:
  152. try:
  153. resp = call()
  154. except requests.ConnectionError:
  155. self._logger.warn("ESI request failed, retrying once")
  156. time.sleep(self._RETRY_WAIT)
  157. resp = call()
  158. resp.raise_for_status()
  159. result = resp.json() if resp.content else None
  160. except (requests.RequestException, ValueError) as exc:
  161. self._logger.exception("ESI request failed")
  162. if hasattr(exc, "response") and (exc.response is not None and
  163. exc.response.status_code == 403):
  164. raise EVEAPIForbiddenError()
  165. raise EVEAPIError()
  166. if can_cache and result is not None:
  167. self._cache.insert(key, token, result, resp)
  168. return result
  169. def get(self, query, token, params=None):
  170. """Do an HTTP GET request for a query using a token."""
  171. meth = self._session.get
  172. return self._do(meth, query, params, None, token, True)
  173. def post(self, query, token, params=None, data=None):
  174. """Do an HTTP POST request for a query using a token."""
  175. meth = self._session.post
  176. return self._do(meth, query, params, data, token)
  177. def put(self, query, token, params=None, data=None):
  178. """Do an HTTP PUT request for a query using a token."""
  179. meth = self._session.put
  180. return self._do(meth, query, params, data, token)
  181. def delete(self, query, token, params=None):
  182. """Do an HTTP DELETE request for a query using a token."""
  183. meth = self._session.delete
  184. return self._do(meth, query, params, None, token)