diff options
Diffstat (limited to 'crocoite/devtools.py')
-rw-r--r-- | crocoite/devtools.py | 102 |
1 files changed, 78 insertions, 24 deletions
diff --git a/crocoite/devtools.py b/crocoite/devtools.py index b071d2e..8b5c69d 100644 --- a/crocoite/devtools.py +++ b/crocoite/devtools.py @@ -25,7 +25,12 @@ Communication with Google Chrome through its DevTools protocol. import json, asyncio, logging, os from tempfile import mkdtemp import shutil +from http.cookies import Morsel + import aiohttp, websockets +from yarl import URL + +from .util import StrJsonEncoder logger = logging.getLogger (__name__) @@ -37,18 +42,17 @@ class Browser: Destroyed upon exit. """ - __slots__ = ('session', 'url', 'tab', 'loop') + __slots__ = ('session', 'url', 'tab') - def __init__ (self, url, loop=None): - self.url = url + def __init__ (self, url): + self.url = URL (url) self.session = None self.tab = None - self.loop = loop async def __aiter__ (self): """ List all tabs """ - async with aiohttp.ClientSession (loop=self.loop) as session: - async with session.get ('{}/json/list'.format (self.url)) as r: + async with aiohttp.ClientSession () as session: + async with session.get (self.url.with_path ('/json/list')) as r: resp = await r.json () for tab in resp: if tab['type'] == 'page': @@ -58,22 +62,35 @@ class Browser: """ Create tab """ assert self.tab is None assert self.session is None - self.session = aiohttp.ClientSession (loop=self.loop) - async with self.session.get ('{}/json/new'.format (self.url)) as r: + self.session = aiohttp.ClientSession () + async with self.session.get (self.url.with_path ('/json/new')) as r: resp = await r.json () self.tab = await Tab.create (**resp) return self.tab - async def __aexit__ (self, *args): + async def __aexit__ (self, excType, excValue, traceback): assert self.tab is not None assert self.session is not None + await self.tab.close () - async with self.session.get ('{}/json/close/{}'.format (self.url, self.tab.id)) as r: - resp = await r.text () - assert resp == 'Target is closing' + + try: + async with self.session.get (self.url.with_path (f'/json/close/{self.tab.id}')) as r: + resp = await r.text () + assert resp == 'Target is closing' + except aiohttp.client_exceptions.ClientConnectorError: + # oh boy, the whole browser crashed instead + if excType is Crashed: + # exception is reraised by `return False` + pass + else: + # this one is more important + raise + self.tab = None await self.session.close () self.session = None + return False class TabFunction: @@ -101,13 +118,13 @@ class TabFunction: return hash (self.name) def __getattr__ (self, k): - return TabFunction ('{}.{}'.format (self.name, k), self.tab) + return TabFunction (f'{self.name}.{k}', self.tab) async def __call__ (self, **kwargs): return await self.tab (self.name, **kwargs) def __repr__ (self): - return '<TabFunction {}>'.format (self.name) + return f'<TabFunction {self.name}>' class TabException (Exception): pass @@ -154,8 +171,8 @@ class Tab: self.msgid += 1 message = {'method': method, 'params': kwargs, 'id': msgid} t = self.transactions[msgid] = {'event': asyncio.Event (), 'result': None} - logger.debug ('← {}'.format (message)) - await self.ws.send (json.dumps (message)) + logger.debug (f'← {message}') + await self.ws.send (json.dumps (message, cls=StrJsonEncoder)) await t['event'].wait () ret = t['result'] del self.transactions[msgid] @@ -189,7 +206,7 @@ class Tab: # right now we cannot recover from this await markCrashed (e) break - logger.debug ('→ {}'.format (msg)) + logger.debug (f'→ {msg}') if 'id' in msg: msgid = msg['id'] t = self.transactions.get (msgid, None) @@ -266,11 +283,11 @@ class Process: async def __aenter__ (self): assert self.p is None - self.userDataDir = mkdtemp () + self.userDataDir = mkdtemp (prefix=__package__ + '-chrome-userdata-') # see https://github.com/GoogleChrome/chrome-launcher/blob/master/docs/chrome-flags-for-tools.md args = [self.binary, '--window-size={},{}'.format (*self.windowSize), - '--user-data-dir={}'.format (self.userDataDir), # use temporory user dir + f'--user-data-dir={self.userDataDir}', # use temporory user dir '--no-default-browser-check', '--no-first-run', # don’t show first run screen '--disable-breakpad', # no error reports @@ -315,12 +332,26 @@ class Process: if port is None: raise Exception ('Chrome died on us.') - return 'http://localhost:{}'.format (port) + return URL.build(scheme='http', host='localhost', port=port) async def __aexit__ (self, *exc): - self.p.terminate () - await self.p.wait () - shutil.rmtree (self.userDataDir) + try: + self.p.terminate () + await self.p.wait () + except ProcessLookupError: + # ok, fine, dead already + pass + + # Try to delete the temporary directory multiple times. It looks like + # Chrome will change files in there even after it exited (i.e. .wait() + # returned). Very strange. + for i in range (5): + try: + shutil.rmtree (self.userDataDir) + break + except: + await asyncio.sleep (0.2) + self.p = None return False @@ -328,7 +359,7 @@ class Passthrough: __slots__ = ('url', ) def __init__ (self, url): - self.url = url + self.url = URL (url) async def __aenter__ (self): return self.url @@ -336,3 +367,26 @@ class Passthrough: async def __aexit__ (self, *exc): return False +def toCookieParam (m): + """ + Convert Python’s http.cookies.Morsel to Chrome’s CookieParam, see + https://chromedevtools.github.io/devtools-protocol/1-3/Network#type-CookieParam + """ + + assert isinstance (m, Morsel) + + out = {'name': m.key, 'value': m.value} + + # unsupported by chrome + for k in ('max-age', 'comment', 'version'): + if m[k]: + raise ValueError (f'Unsupported cookie attribute {k} set, cannot convert') + + for mname, cname in [('expires', None), ('path', None), ('domain', None), ('secure', None), ('httponly', 'httpOnly')]: + value = m[mname] + if value: + cname = cname or mname + out[cname] = value + + return out + |