| @@ -1,6 +1,8 @@ | |||
| # -*- coding: utf-8 -*- | |||
| from urllib.parse import urlencode | |||
| from datetime import datetime | |||
| import random | |||
| from threading import Lock | |||
| import requests | |||
| @@ -8,6 +10,79 @@ from ..exceptions import EVEAPIError | |||
| __all__ = ["EVESwaggerInterface"] | |||
| class _ESICache: | |||
| """Caches ESI API responses according to their headers. | |||
| This interface is thread-safe. | |||
| """ | |||
| EXPIRATION_RATE = 0.2 | |||
| def __init__(self): | |||
| self._index = {} | |||
| self._lock = Lock() | |||
| @staticmethod | |||
| def _calculate_expiry(resp): | |||
| """Calculate the expiration date for the given response object.""" | |||
| if "Cache-Control" not in resp.headers: | |||
| return None | |||
| directives = resp.headers["Cache-Control"].lower().split(";") | |||
| directives = [d.strip() for d in directives] | |||
| if "public" not in directives: | |||
| return None | |||
| expires = resp.headers.get("Expires") | |||
| if not expires: | |||
| return None | |||
| try: | |||
| return datetime.strptime(expires, "%a, %d %b %Y %H:%M:%S GMT") | |||
| except ValueError: | |||
| return None | |||
| @staticmethod | |||
| def freeze_dict(d): | |||
| """Return a hashable string key for the given dictionary.""" | |||
| if not d: | |||
| return "{}" | |||
| items = sorted((repr(str(k)), repr(str(v))) for k, v in d.items()) | |||
| return "{" + ",".join(":".join(p) for p in items) + "}" | |||
| def _expire(self): | |||
| """Remove old entries from the cache. Assumes lock is acquired.""" | |||
| condemned = [] | |||
| now = datetime.utcnow() | |||
| for key, (expiry, _) in self._index.items(): | |||
| if expiry < now: | |||
| condemned.append(key) | |||
| for key in condemned: | |||
| del self._index[key] | |||
| def fetch(self, key): | |||
| """Try to look up a key in the cache. Return None if not found. | |||
| The key should be a string. | |||
| This will periodically clear the cache of expired entries. | |||
| """ | |||
| with self._lock: | |||
| if random.random() < self.EXPIRATION_RATE: | |||
| self._expire() | |||
| if key in self._index: | |||
| expiry, value = self._index[key] | |||
| if expiry > datetime.utcnow(): | |||
| return value | |||
| return None | |||
| def insert(self, key, value, response): | |||
| """Store a key-value pair using the response as cache control.""" | |||
| expiry = self._calculate_expiry(response) | |||
| if expiry and expiry > datetime.utcnow(): | |||
| with self._lock: | |||
| self._index[key] = (expiry, value) | |||
| class _ESIQueryBuilder: | |||
| """Stores an ESI query that is being built by the client.""" | |||
| @@ -24,9 +99,9 @@ class _ESIQueryBuilder: | |||
| self._path += str(item) + "/" | |||
| return self | |||
| def get(self): | |||
| def get(self, **kwargs): | |||
| """Do an HTTP GET request for the built query.""" | |||
| return self._esi.get(self._path, self._token) | |||
| return self._esi.get(self._path, self._token, params=kwargs) | |||
| def post(self, **kwargs): | |||
| """Do an HTTP POST request for the built query.""" | |||
| @@ -36,9 +111,9 @@ class _ESIQueryBuilder: | |||
| """Do an HTTP PUT request for the built query.""" | |||
| return self._esi.put(self._path, self._token, data=kwargs) | |||
| def delete(self): | |||
| def delete(self, **kwargs): | |||
| """Do an HTTP DELETE request for the built query.""" | |||
| return self._esi.delete(self._path, self._token) | |||
| return self._esi.delete(self._path, self._token, params=kwargs) | |||
| class EVESwaggerInterface: | |||
| @@ -51,7 +126,7 @@ class EVESwaggerInterface: | |||
| For more complex requests: | |||
| data = esi.post("/v1/universe/names/", token, {"ids": [entity_id]}) | |||
| data = esi.post("/v1/universe/names/", token, data={"ids": [entity_id]}) | |||
| data = esi(token).v1.universe.names.post(ids=[entity_id]}) | |||
| """ | |||
| @@ -59,43 +134,59 @@ class EVESwaggerInterface: | |||
| self._session = session | |||
| self._base_url = "https://esi.tech.ccp.is" | |||
| self._data_source = "tranquility" | |||
| self._cache = _ESICache() | |||
| def __call__(self, token): | |||
| return _ESIQueryBuilder(self, token) | |||
| def _do(self, query, data, token, method): | |||
| def _do(self, method, query, params, data, token, can_cache=False): | |||
| """Execute a query using a token with the given session method. | |||
| Return the JSON result, if any. Raise EVEAPIError for any errors. | |||
| """ | |||
| ... # cache requests | |||
| if can_cache: | |||
| pkey = self._cache.freeze_dict(params) | |||
| key = "|".join((method.__name__, self._data_source, query, pkey)) | |||
| cached = self._cache.fetch(key) | |||
| if cached: | |||
| return cached | |||
| params = {"datasource": self._data_source} | |||
| headers = { | |||
| "Accept": "application/json", | |||
| "Authorization": "Bearer " + token | |||
| } | |||
| url = self._base_url + query + "?" + urlencode(params) | |||
| params = params.copy() if params else {} | |||
| params["datasource"] = self._data_source | |||
| url = self._base_url + query | |||
| try: | |||
| resp = method(url, json=data or None, timeout=10, headers=headers) | |||
| resp = method(url, params=params, json=data or None, | |||
| headers=headers, timeout=10) | |||
| resp.raise_for_status() | |||
| return resp.json() if resp.content else None | |||
| result = resp.json() if resp.content else None | |||
| except (requests.RequestException, ValueError) as exc: | |||
| raise EVEAPIError(str(exc)) | |||
| def get(self, query, token): | |||
| if can_cache and result is not None: | |||
| self._cache.insert(key, result, resp) | |||
| return result | |||
| def get(self, query, token, params=None): | |||
| """Do an HTTP GET request for a query using a token.""" | |||
| return self._do(query, None, token, self._session.get) | |||
| meth = self._session.get | |||
| return self._do(meth, query, params, None, token, True) | |||
| def post(self, query, token, data=None): | |||
| def post(self, query, token, params=None, data=None): | |||
| """Do an HTTP POST request for a query using a token.""" | |||
| return self._do(query, data, token, self._session.post) | |||
| meth = self._session.post | |||
| return self._do(meth, query, params, data, token) | |||
| def put(self, query, token, data=None): | |||
| def put(self, query, token, params=None, data=None): | |||
| """Do an HTTP PUT request for a query using a token.""" | |||
| return self._do(query, data, token, self._session.put) | |||
| meth = self._session.put | |||
| return self._do(meth, query, params, data, token) | |||
| def delete(self, query, token): | |||
| def delete(self, query, token, params=None): | |||
| """Do an HTTP DELETE request for a query using a token.""" | |||
| return self._do(query, None, token, self._session.delete) | |||
| meth = self._session.delete | |||
| return self._do(meth, query, params, None, token) | |||