11import asyncio
2+ from dataclasses import dataclass
3+ from datetime import datetime
4+ from email .utils import parsedate
25import logging
36import aiohttp
4- from typing import Optional , Union , List
7+ from typing import Coroutine , Optional , Union , List
58
69from .token import ESITokens
710from .metadata import ESIMetadata , ESIRequest
1417logger = logging .getLogger (__name__ )
1518
1619
20+ @dataclass
21+ class ESIResponse :
22+ """Response returned by ESI.request() family.
23+
24+ User should never create ESIResponse but gets it from ESI.request() calls.
25+
26+ Attributes: (referencing aiohttp doc)
27+ status: int
28+ HTTP status code of response.
29+ method: str
30+ Request's method.
31+ headers: dict
32+ A case insensitive dictionary with HTTP headers of response.
33+ data: dict | List | int
34+ A json serialized response body, a dictionary or a list or an int.
35+ expires: str | None
36+ A RFC7231 formatted datetime string, if any.
37+ """
38+
39+ status : int
40+ method : str
41+ headers : dict
42+ data : Optional [Union [dict , List , int ]]
43+ expires : Optional [str ] = None
44+
45+
1746class ESI (object ):
1847 """ESI request client for API requests.
1948
@@ -35,6 +64,10 @@ def __init__(self, **kwd):
3564 ### Exit flag
3665 self ._app_changed = False
3766
67+ ### API session
68+ self ._api_session = False
69+ self ._api_session_record = None
70+
3871 def get (
3972 self ,
4073 key : str ,
@@ -130,7 +163,8 @@ def recursive_looper(async_loop: List, kwd: dict):
130163
131164 ret = []
132165 for task in tasks :
133- ret .extend (task .result ()) # well, let's forget about memory
166+ # each task.result() is a ESIResponse instance
167+ ret .append (task .result ()) # well, let's forget about memory
134168
135169 return ret
136170
@@ -193,7 +227,7 @@ async def request(
193227 kwd: Keywords necessary for sending the request, such as headers, params, and other ESI required inputs.
194228
195229 Returns:
196- A dictionary containing json serialized data from ESI .
230+ An instance of ESIResponse containing response of the request .
197231
198232 Raises:
199233 NotImplementedError: Request type POST/DELETE/PUT is not supported.
@@ -237,6 +271,83 @@ async def request(
237271
238272 return res
239273
274+ def _api_session_recorder (_coro : Coroutine ):
275+ """Records useful info from ESIResponse that can be fed to other objects."""
276+
277+ async def _api_session_recorder_wrapped (_self , * args , ** kwd ):
278+ resp : ESIResponse = await _coro (
279+ _self , * args , ** kwd
280+ ) # this _self should be an instance of ESI
281+
282+ if not _self ._api_session :
283+ return resp
284+
285+ expires = resp .expires
286+
287+ if not _self ._api_session_record :
288+ _self ._api_session_record = expires
289+
290+ # Use the earliest expire
291+ if expires :
292+ expires_dt = datetime (* parsedate (expires )[:6 ])
293+ record_dt = datetime (* parsedate (_self ._api_session_record )[:6 ])
294+ if expires_dt < record_dt :
295+ _self ._api_session_record = expires
296+ return resp
297+
298+ return _api_session_recorder_wrapped
299+
300+ @ESIRequestError (attempts = 3 )
301+ @_api_session_recorder
302+ async def async_request (self , api_request : ESIRequest , method : str ) -> dict :
303+ """Asynchronous requests to ESI API.
304+
305+ Uses aiohttp to asynchronously request GET to ESI API.
306+ ClientSession is created once for each instance and shared by multiple async_request call of the instance.
307+ Default having maximum 100 open connections (100 async_request pending).
308+
309+ Args:
310+ api_request: ESIRequest
311+ A fully initialized ESIRequest with url, params, headers field filled in, given to aiohttp.ClientSession.get.
312+ method: str
313+ A str for HTTP request method.
314+
315+ Returns:
316+ An instance of ESIResponse containing response of the request. Memory allocation assumed not to be a problem.
317+ """
318+ if not self ._async_session :
319+ self ._async_session = aiohttp .ClientSession (
320+ connector = aiohttp .TCPConnector (ssl = False ), raise_for_status = True
321+ ) # default maximum 100 connections
322+
323+ # no encoding: "4-HWF" stays what it is
324+ if method == "get" :
325+ async with self ._async_session .get (
326+ api_request .url , params = api_request .params , headers = api_request .headers
327+ ) as req :
328+ data = await req .json ()
329+ resp = ESIResponse (
330+ req .status ,
331+ req .method ,
332+ dict (req .headers ),
333+ data ,
334+ req .headers .get ("Expires" ),
335+ )
336+
337+ elif method == "head" :
338+ async with self ._async_session .head (
339+ api_request .url , params = api_request .params , headers = api_request .headers
340+ ) as req :
341+ resp = ESIResponse (
342+ req .status ,
343+ req .method ,
344+ dict (req .headers ),
345+ None ,
346+ req .headers .get ("Expires" ),
347+ )
348+
349+ return resp
350+
240351 def add_app_generate_token (
241352 self , clientId : str , scope : str , callbackURL : Optional [str ] = None
242353 ) -> None :
@@ -280,42 +391,6 @@ def add_app_generate_token(
280391 with ESITokens (new_app ) as token :
281392 token .generate ()
282393
283- @ESIRequestError (attempts = 3 )
284- async def async_request (self , api_request : ESIRequest , method : str ) -> dict :
285- """Asynchronous requests to ESI API.
286-
287- Uses aiohttp to asynchronously request GET to ESI API.
288- ClientSession is created once for each instance and shared by multiple async_request call of the instance.
289- Default having maximum 100 open connections (100 async_request pending).
290-
291- Args:
292- api_request: ESIRequest
293- A fully initialized ESIRequest with url, params, headers field filled in, given to aiohttp.ClientSession.get.
294- method: str
295- A str for HTTP request method.
296-
297- Returns:
298- A dictionary containing the response body or response header. Memory allocation assumed not to be a problem.
299- """
300- if not self ._async_session :
301- self ._async_session = aiohttp .ClientSession (
302- connector = aiohttp .TCPConnector (ssl = False ), raise_for_status = True
303- ) # default maximum 100 connections
304-
305- # no encoding: "4-HWF" stays what it is
306- if method == "get" :
307- async with self ._async_session .get (
308- api_request .url , params = api_request .params , headers = api_request .headers
309- ) as req :
310- return (
311- await req .json ()
312- ) # read entire response to memory, which shouldn't be a problem now.
313- elif method == "head" :
314- async with self ._async_session .head (
315- api_request .url , params = api_request .params , headers = api_request .headers
316- ) as req :
317- return dict (req .headers )
318-
319394 def _get_auth_headers (
320395 self , tokens : ESITokens , cname : Optional [str ] = "any"
321396 ) -> dict :
@@ -460,3 +535,17 @@ def __del__(self):
460535 if self ._async_session ._connector_owner :
461536 self ._async_session ._connector .close ()
462537 self ._async_session ._connector = None
538+
539+ def _start_api_session (self ):
540+ """Starts recording useful ESIResponse from API calls."""
541+ self ._api_session = True
542+ self ._api_session_record = None
543+
544+ def _end_api_session (self ):
545+ """Ends recording ESIResponse from API calls."""
546+ self ._api_session = False
547+
548+ def _clear_api_record (self ):
549+ """Clears _api_session_record entry of the instance."""
550+ self ._api_session_record = None
551+ self ._api_session = False
0 commit comments