Repository: miguelgrinberg/python-engineio

Issue:
ASGI3
Hi!

About a month ago, the ASGI protocol got an overhaul with [ASGI 3.0](https://www.aeracode.org/2019/03/20/asgi-30/).

All major ASGI servers (uvicorn, daphne, hypercorn) now support ASGI3, and the whole ecosystem seems to be shifting in that direction too.

Shouldn't  this package start moving to ASGI3 too? :+1:

(I think the relevant part of the docs is [Uvicorn, Daphne, and other ASGI servers](https://python-engineio.readthedocs.io/en/latest/server.html#uvicorn-daphne-and-other-asgi-servers)).

I'd be happy to provide a PR for this!

Discussion:

Patch:
diff --git a/engineio/async_drivers/asgi.py b/engineio/async_drivers/asgi.py
index 64ac4d4..9f8d9ce 100644
--- a/engineio/async_drivers/asgi.py
+++ b/engineio/async_drivers/asgi.py
@@ -40,54 +40,50 @@ class ASGIApp:
         self.engineio_path = engineio_path.strip('/')
         self.static_files = static_files or {}
 
-    def __call__(self, scope):
+    async def __call__(self, scope, receive, send):
         if scope['type'] in ['http', 'websocket'] and \
                 scope['path'].startswith('/{0}/'.format(self.engineio_path)):
-            return self.engineio_asgi_app(scope)
+            await self.engineio_asgi_app(scope, receive, send)
         elif scope['type'] == 'http' and scope['path'] in self.static_files:
-            return self.serve_static_file(scope)
+            await self.serve_static_file(scope, receive, send)
         elif self.other_asgi_app is not None:
-            return self.other_asgi_app(scope)
+            await self.other_asgi_app(scope, receive, send)
         elif scope['type'] == 'lifespan':
-            return self.lifespan
+            await self.lifespan(scope, receive, send)
         else:
-            return self.not_found
+            await self.not_found(scope, receive, send)
 
-    def engineio_asgi_app(self, scope):
-        async def _app(receive, send):
-            await self.engineio_server.handle_request(scope, receive, send)
-        return _app
+    async def engineio_asgi_app(self, scope, receive, send):
+        await self.engineio_server.handle_request(scope, receive, send)
 
-    def serve_static_file(self, scope):
-        async def _send_static_file(receive, send):  # pragma: no cover
-            event = await receive()
-            if event['type'] == 'http.request':
-                if scope['path'] in self.static_files:
-                    content_type = self.static_files[scope['path']][
-                        'content_type'].encode('utf-8')
-                    filename = self.static_files[scope['path']]['filename']
-                    status_code = 200
-                    with open(filename, 'rb') as f:
-                        payload = f.read()
-                else:
-                    content_type = b'text/plain'
-                    status_code = 404
-                    payload = b'not found'
-                await send({'type': 'http.response.start',
-                            'status': status_code,
-                            'headers': [(b'Content-Type', content_type)]})
-                await send({'type': 'http.response.body',
-                            'body': payload})
-        return _send_static_file
-
-    async def lifespan(self, receive, send):
+    async def serve_static_file(self, scope, receive, send):
+        event = await receive()
+        if event['type'] == 'http.request':
+            if scope['path'] in self.static_files:
+                content_type = self.static_files[scope['path']][
+                    'content_type'].encode('utf-8')
+                filename = self.static_files[scope['path']]['filename']
+                status_code = 200
+                with open(filename, 'rb') as f:
+                    payload = f.read()
+            else:
+                content_type = b'text/plain'
+                status_code = 404
+                payload = b'not found'
+            await send({'type': 'http.response.start',
+                        'status': status_code,
+                        'headers': [(b'Content-Type', content_type)]})
+            await send({'type': 'http.response.body',
+                        'body': payload})
+
+    async def lifespan(self, scope, receive, send):
         event = await receive()
         if event['type'] == 'lifespan.startup':
             await send({'type': 'lifespan.startup.complete'})
         elif event['type'] == 'lifespan.shutdown':
             await send({'type': 'lifespan.shutdown.complete'})
 
-    async def not_found(self, receive, send):
+    async def not_found(self, scope, receive, send):
         """Return a 404 Not Found error to the client."""
         await send({'type': 'http.response.start',
                     'status': 404,


Test patch:
diff --git a/tests/asyncio/test_async_asgi.py b/tests/asyncio/test_async_asgi.py
index 035bf65..a5e7b9e 100644
--- a/tests/asyncio/test_async_asgi.py
+++ b/tests/asyncio/test_async_asgi.py
@@ -45,17 +45,16 @@ class AsgiTests(unittest.TestCase):
         mock_server.handle_request = AsyncMock()
         app = async_asgi.ASGIApp(mock_server)
         scope = {'type': 'http', 'path': '/engine.io/'}
-        handler = app(scope)
-        _run(handler('receive', 'send'))
+        _run(app(scope, 'receive', 'send'))
         mock_server.handle_request.mock.assert_called_once_with(
             scope, 'receive', 'send')
 
     def test_other_app_routing(self):
-        other_app = mock.MagicMock()
+        other_app = AsyncMock()
         app = async_asgi.ASGIApp('eio', other_app)
         scope = {'type': 'http', 'path': '/foo'}
-        app(scope)
-        other_app.assert_called_once_with(scope)
+        _run(app(scope, 'receive', 'send'))
+        other_app.mock.assert_called_once_with(scope, 'receive', 'send')
 
     def test_static_file_routing(self):
         root_dir = os.path.dirname(__file__)
@@ -63,45 +62,45 @@ class AsgiTests(unittest.TestCase):
             '/foo': {'content_type': 'text/html',
                      'filename': root_dir + '/index.html'}
         })
-        handler = app({'type': 'http', 'path': '/foo'})
+        scope = {'type': 'http', 'path': '/foo'}
         receive = AsyncMock(return_value={'type': 'http.request'})
         send = AsyncMock()
-        _run(handler(receive, send))
+        _run(app(scope, receive, send))
         send.mock.assert_called_with({'type': 'http.response.body',
                                       'body': b'<html></html>\n'})
 
     def test_lifespan_startup(self):
         app = async_asgi.ASGIApp('eio')
-        handler = app({'type': 'lifespan'})
+        scope = {'type': 'lifespan'}
         receive = AsyncMock(return_value={'type': 'lifespan.startup'})
         send = AsyncMock()
-        _run(handler(receive, send))
+        _run(app(scope, receive, send))
         send.mock.assert_called_once_with(
             {'type': 'lifespan.startup.complete'})
 
     def test_lifespan_shutdown(self):
         app = async_asgi.ASGIApp('eio')
-        handler = app({'type': 'lifespan'})
+        scope = {'type': 'lifespan'}
         receive = AsyncMock(return_value={'type': 'lifespan.shutdown'})
         send = AsyncMock()
-        _run(handler(receive, send))
+        _run(app(scope, receive, send))
         send.mock.assert_called_once_with(
             {'type': 'lifespan.shutdown.complete'})
 
     def test_lifespan_invalid(self):
         app = async_asgi.ASGIApp('eio')
-        handler = app({'type': 'lifespan'})
+        scope = {'type': 'lifespan'}
         receive = AsyncMock(return_value={'type': 'lifespan.foo'})
         send = AsyncMock()
-        _run(handler(receive, send))
+        _run(app(scope, receive, send))
         send.mock.assert_not_called()
 
     def test_not_found(self):
         app = async_asgi.ASGIApp('eio')
-        handler = app({'type': 'http', 'path': '/foo'})
+        scope = {'type': 'http', 'path': '/foo'}
         receive = AsyncMock(return_value={'type': 'http.request'})
         send = AsyncMock()
-        _run(handler(receive, send))
+        _run(app(scope, receive, send))
         send.mock.assert_any_call(
             {'type': 'http.response.start', 'status': 404,
              'headers': [(b'Content-Type', b'text/plain')]})