Repository: almarklein/asgineer

Issue:
Make use of ASGI 3.0

Discussion:

Patch:
diff --git a/.travis.yml b/.travis.yml
index cfdea4d..5fa533a 100644
--- a/.travis.yml
+++ b/.travis.yml
@@ -3,14 +3,6 @@
 
 language: python
 
-# use container-based infrastructure
-# sudo : false
-
-# Trick to get 3.7 working for now
-# https://github.com/travis-ci/travis-ci/issues/9815#issuecomment-405506964
-dist: xenial
-sudo: true
-
 branches:
   only:
     - master
@@ -23,6 +15,8 @@ matrix:
           env: ASGI_SERVER=mock
         - python: "3.7"
           env: ASGI_SERVER=mock
+        - python: "3.8"
+          env: ASGI_SERVER=mock
         - python: "3.7"
           env: ASGI_SERVER=uvicorn
         #- python: "3.7"  -> Hypercorn's behavior does not work with our test mechanics
diff --git a/asgineer/_app.py b/asgineer/_app.py
index 847efc9..50fbab6 100644
--- a/asgineer/_app.py
+++ b/asgineer/_app.py
@@ -23,25 +23,6 @@ _handler.setFormatter(
 logger.addHandler(_handler)
 
 
-def to_asgi(handler):
-    """ Convert a request handler (a coroutine function) to an ASGI
-    application, which can be served with an ASGI server, such as
-    Uvicorn, Hypercorn, Daphne, etc.
-    """
-
-    if not inspect.iscoroutinefunction(handler):
-        raise TypeError(
-            "asgineer.to_asgi() handler function must be a coroutine function."
-        )
-
-    class Application(BaseApplication):
-        _handler = staticmethod(handler)
-
-    Application.__module__ = handler.__module__
-    Application.__name__ = handler.__name__
-    return Application
-
-
 def normalize_response(response):
     """ Normalize the given response, by always returning a 3-element tuple
     (status, headers, body). The body is not "resolved"; it is safe
@@ -72,7 +53,7 @@ def normalize_response(response):
 
 def guess_content_type_from_body(body):
     """ Guess the content-type based of the body.
-    
+
     * "text/html" for str bodies starting with ``<!DOCTYPE html>`` or ``<html>``.
     * "text/plain" for other str bodies.
     * "application/json" for dict bodies.
@@ -89,183 +70,199 @@ def guess_content_type_from_body(body):
         return "application/octet-stream"
 
 
-class BaseApplication:
-    """ Base ASGI application class.
+def to_asgi(handler):
+    """ Convert a request handler (a coroutine function) to an ASGI
+    application, which can be served with an ASGI server, such as
+    Uvicorn, Hypercorn, Daphne, etc.
     """
 
-    _handler = None
+    if not inspect.iscoroutinefunction(handler):
+        raise TypeError(
+            "asgineer.to_asgi() handler function must be a coroutine function."
+        )
 
-    def __init__(self, scope):
-        self._scope = scope
+    async def application_wrapper(scope, receive, send):
+        return await asgineer_application(handler, scope, receive, send)
 
-    async def __call__(self, receive, send):
+    application_wrapper.__module__ = handler.__module__
+    application_wrapper.__name__ = handler.__name__
+    application_wrapper.__doc__ = handler.__doc__
+    application_wrapper.asgineer_handler = handler
+    return application_wrapper
 
-        if self._scope["type"] == "http":
-            request = HttpRequest(self._scope, receive)
-            await self._handle_http(request, receive, send)
-        elif self._scope["type"] == "websocket":
-            request = WebsocketRequest(self._scope, receive, send)
-            await self._handle_websocket(request, receive, send)
-        elif self._scope["type"] == "lifespan":
-            await self._handle_lifespan(receive, send)
-        else:
-            logger.warn(f"Unknown ASGI type {self._scope['type']}")
 
-    async def _handle_lifespan(self, receive, send):
-        while True:
-            message = await receive()
-            if message["type"] == "lifespan.startup":
+async def asgineer_application(handler, scope, receive, send):
+
+    # server_version = scope["asgi"].get("version", "2.0")
+    # spec_version = scope["asgi"].get("spec_version", "2.0")
+
+    if scope["type"] == "http":
+        request = HttpRequest(scope, receive)
+        await _handle_http(handler, request, receive, send)
+    elif scope["type"] == "websocket":
+        request = WebsocketRequest(scope, receive, send)
+        await _handle_websocket(handler, request, receive, send)
+    elif scope["type"] == "lifespan":
+        await _handle_lifespan(receive, send)
+    else:
+        logger.warning(f"Unknown ASGI type {scope['type']}")
+
+
+async def _handle_lifespan(receive, send):
+    while True:
+        message = await receive()
+        if message["type"] == "lifespan.startup":
+            try:
                 # Could do startup stuff here
-                try:
-                    logger.info("Server is starting up")
-                except Exception:  # pragma: no cover
-                    pass
+                logger.info("Server is starting up")
+            except Exception as err:  # pragma: no cover
+                await send({"type": "lifespan.startup.failed", "message": str(err)})
+            else:
                 await send({"type": "lifespan.startup.complete"})
-            elif message["type"] == "lifespan.shutdown":
+        elif message["type"] == "lifespan.shutdown":
+            try:
                 # Could do shutdown stuff here
-                try:
-                    logger.info("Server is shutting down")
-                except Exception:  # pragma: no cover
-                    pass
-                await send({"type": "lifespan.shutdown.complete"})
-                return
+                logger.info("Server is shutting down")
+            except Exception as err:  # pragma: no cover
+                await send({"type": "lifespan.shutdown.failed", "message": str(err)})
             else:
-                logger.warn(f"Unknown lifespan message {message['type']}")
+                await send({"type": "lifespan.shutdown.complete"})
+            return
+        else:
+            logger.warning(f"Unknown lifespan message {message['type']}")
 
-    async def _handle_websocket(self, request, receive, send):
 
-        # === Call websocket handler
-        try:
+async def _handle_websocket(handler, request, receive, send):
 
-            result = await self._handler(request)
+    # === Call websocket handler
+    try:
 
-        except Exception as err:
-            # Error in the handler
-            error_text = f"{type(err).__name__} in websocket handler: {str(err)}"
-            logger.error(error_text, exc_info=err)
-            return
-        finally:
-            # The ASGI spec specifies that ASGI servers should close
-            # the ws connection when the task ends. At the time of
-            # writing (04-10-2018), only Uvicorn does this.
-            # TODO: Remove this once all servers behave correctly
-            try:
-                await request.close()
-            except Exception:
-                pass
-
-        # === Process the handler output
-        if result is not None:
-            # It's likely that the user is misunderstanding how ws handlers work.
-            # Let's be strict and give directions.
-            error_text = (
-                "A websocket handler should return None; "
-                + "use request.send() and request.receive() to communicate."
-            )
-            logger.error(error_text)
-
-    async def _handle_http(self, request, receive, send):
-
-        # === Call request handler to get the result
+        result = await handler(request)
+
+    except Exception as err:
+        # Error in the handler
+        error_text = f"{type(err).__name__} in websocket handler: {str(err)}"
+        logger.error(error_text, exc_info=err)
+        return
+    finally:
+        # The ASGI spec specifies that ASGI servers should close
+        # the ws connection when the task ends. At the time of
+        # writing (04-10-2018), only Uvicorn does this.
+        # So ... just close for good measure.
         try:
+            await request.close()
+        except Exception:
+            pass
+
+    # === Process the handler output
+    if result is not None:
+        # It's likely that the user is misunderstanding how ws handlers work.
+        # Let's be strict and give directions.
+        error_text = (
+            "A websocket handler should return None; "
+            + "use request.send() and request.receive() to communicate."
+        )
+        logger.error(error_text)
 
-            result = await self._handler(request)
 
-        except Exception as err:
-            # Error in the handler
-            error_text = f"{type(err).__name__} in request handler: {str(err)}"
-            logger.error(error_text, exc_info=err)
-            await send({"type": "http.response.start", "status": 500, "headers": []})
-            await send({"type": "http.response.body", "body": error_text.encode()})
-            return
+async def _handle_http(handler, request, receive, send):
 
-        # === Process the handler output
-        try:
+    # === Call request handler to get the result
+    try:
 
-            status, headers, body = normalize_response(result)
-
-            # Make sure that there is a content type
-            if "content-type" not in headers:
-                headers["content-type"] = guess_content_type_from_body(body)
-
-            # Convert the body
-            if isinstance(body, bytes):
-                pass
-            elif isinstance(body, str):
-                body = body.encode()
-            elif isinstance(body, dict):
-                try:
-                    body = json.dumps(body).encode()
-                except Exception as err:
-                    raise ValueError(f"Could not JSON encode body: {err}")
-            elif inspect.isasyncgen(body):
-                pass
-            else:
-                if inspect.isgenerator(body):
-                    raise ValueError(
-                        f"Body cannot be a regular generator, use an async generator."
-                    )
-                elif inspect.iscoroutine(body):
-                    raise ValueError(f"Body cannot be a coroutine, forgot await?")
-                else:
-                    raise ValueError(f"Body cannot be {type(body)}.")
-
-            # Convert and further validate headers
-            if isinstance(body, bytes):
-                headers.setdefault("content-length", str(len(body)))
-            try:
-                rawheaders = [(k.encode(), v.encode()) for k, v in headers.items()]
-            except Exception:
-                raise ValueError("Header keys and values must all be strings.")
+        result = await handler(request)
 
-        except Exception as err:
-            # Error in hanlding handler output
-            error_text = f"Error in processing handler output: {str(err)}"
-            logger.error(error_text)
-            await send({"type": "http.response.start", "status": 500, "headers": []})
-            await send({"type": "http.response.body", "body": error_text.encode()})
-            return
+    except Exception as err:
+        # Error in the handler
+        error_text = f"{type(err).__name__} in request handler: {str(err)}"
+        logger.error(error_text, exc_info=err)
+        await send({"type": "http.response.start", "status": 500, "headers": []})
+        await send({"type": "http.response.body", "body": error_text.encode()})
+        return
+
+    # === Process the handler output
+    try:
+
+        status, headers, body = normalize_response(result)
 
-        # === Send response
-        start = {"type": "http.response.start", "status": status, "headers": rawheaders}
+        # Make sure that there is a content type
+        if "content-type" not in headers:
+            headers["content-type"] = guess_content_type_from_body(body)
+
+        # Convert the body
         if isinstance(body, bytes):
-            # The easy way; body as one message, not much error catching we can do here.
-            await send(start)
-            if isinstance(body, bytes):
-                await send({"type": "http.response.body", "body": body})
-        else:
-            # Chunked response, stuff can go wrong in the middle
-            start_is_sent = False
+            pass
+        elif isinstance(body, str):
+            body = body.encode()
+        elif isinstance(body, dict):
             try:
-                async for chunk in body:
-                    if isinstance(chunk, str):
-                        chunk = chunk.encode()
-                    if not isinstance(chunk, bytes):
-                        raise TypeError(
-                            f"Body chunk must be str or bytes, not {type(chunk)}"
-                        )
-                    if not start_is_sent:
-                        start_is_sent = True
-                        await send(start)
-                    await send(
-                        {"type": "http.response.body", "body": chunk, "more_body": True}
+                body = json.dumps(body).encode()
+            except Exception as err:
+                raise ValueError(f"Could not JSON encode body: {err}")
+        elif inspect.isasyncgen(body):
+            pass
+        else:
+            if inspect.isgenerator(body):
+                raise ValueError(
+                    "Body cannot be a regular generator, use an async generator."
+                )
+            elif inspect.iscoroutine(body):
+                raise ValueError("Body cannot be a coroutine, forgot await?")
+            else:
+                raise ValueError(f"Body cannot be {type(body)}.")
+
+        # Convert and further validate headers
+        if isinstance(body, bytes):
+            headers.setdefault("content-length", str(len(body)))
+        try:
+            rawheaders = [(k.encode(), v.encode()) for k, v in headers.items()]
+        except Exception:
+            raise ValueError("Header keys and values must all be strings.")
+
+    except Exception as err:
+        # Error in hanlding handler output
+        error_text = f"Error in processing handler output: {str(err)}"
+        logger.error(error_text)
+        await send({"type": "http.response.start", "status": 500, "headers": []})
+        await send({"type": "http.response.body", "body": error_text.encode()})
+        return
+
+    # === Send response
+    start = {"type": "http.response.start", "status": status, "headers": rawheaders}
+    if isinstance(body, bytes):
+        # The easy way; body as one message, not much error catching we can do here.
+        await send(start)
+        if isinstance(body, bytes):
+            await send({"type": "http.response.body", "body": body})
+    else:
+        # Chunked response, stuff can go wrong in the middle
+        start_is_sent = False
+        try:
+            async for chunk in body:
+                if isinstance(chunk, str):
+                    chunk = chunk.encode()
+                if not isinstance(chunk, bytes):
+                    raise TypeError(
+                        f"Body chunk must be str or bytes, not {type(chunk)}"
                     )
+                if not start_is_sent:
+                    start_is_sent = True
+                    await send(start)
                 await send(
-                    {"type": "http.response.body", "body": b"", "more_body": False}
+                    {"type": "http.response.body", "body": chunk, "more_body": True}
                 )
+            await send({"type": "http.response.body", "body": b"", "more_body": False})
 
-            except Exception as err:
-                error_text = f"{type(err).__name__} in chunked response: {str(err)}"
-                logger.error(error_text, exc_info=err)
-                if not start_is_sent:
-                    await send(
-                        {"type": "http.response.start", "status": 500, "headers": []}
-                    )
-                    await send(
-                        {"type": "http.response.body", "body": error_text.encode()}
-                    )
-                else:  # end-of-body has not been send
-                    await send(
-                        {"type": "http.response.body", "body": b"", "more_body": False}
-                    )
-                return
+        except Exception as err:
+            error_text = f"{type(err).__name__} in chunked response: {str(err)}"
+            logger.error(error_text, exc_info=err)
+            if not start_is_sent:
+                await send(
+                    {"type": "http.response.start", "status": 500, "headers": []}
+                )
+                await send({"type": "http.response.body", "body": error_text.encode()})
+            else:  # end-of-body has not been send
+                await send(
+                    {"type": "http.response.body", "body": b"", "more_body": False}
+                )
+            return


Test patch:
diff --git a/asgineer/testutils.py b/asgineer/testutils.py
index 77a57bc..a28914c 100644
--- a/asgineer/testutils.py
+++ b/asgineer/testutils.py
@@ -33,15 +33,15 @@ URL = f"http://127.0.0.1:{PORT}"
 class BaseTestServer:
     """ Base class for test servers. Objects of this class represent an ASGI
     server instance that can be used to test your server implementation.
-    
+
     The ``app`` object passed to the constructor can be an ASGI application
     or an async (Asgineer-style) handler.
-    
+
     The server can be started/stopped by using it as a context manager.
     The ``url`` attribute represents the url that can be used to make
     requests to the server. When the server has stopped, The ``out``
     attribute contains the server output (stdout and stderr).
-    
+
     Only one instance of this class (per process) should be used (as a
     context manager) at any given time.
     """
@@ -119,14 +119,14 @@ class BaseTestServer:
 
     def request(self, method, path, data=None, headers=None, **kwargs):
         """ Send a request to the server. Returns a named tuple ``(status, headers, body)``.
-        
+
         Arguments:
             method (str): the HTTP method (e.g. "GET")
             path (str): path or url (also see the ``url`` property).
             data: the bytes to send (optional).
             headers: headers to send (optional).
             kwargs: additional arguments to pass to ``requests.request()``.
-        
+
         """
         assert isinstance(method, str)
         assert isinstance(path, str)
@@ -141,7 +141,7 @@ class BaseTestServer:
 
     def ws_communicate(self, path, client_co_func, loop=None):
         """ Do a websocket request and communicate over the connection.
-        
+
         The ``client_co_func`` object must be an async function, it receives
         a ws object as an argument, which has methods ``send``, ``receive`` and
         ``close``, and it can be iterated over. Messages are either str or bytes.
@@ -181,15 +181,13 @@ def closer():
 
 app = APP
 
-async def stub_responder(receive, send):
-    await send({"type": "http.response.start", "status": 200, "headers": []})
-    await send({"type": "http.response.body", "body": b""})
 
-def proxy_app(scope):
+async def proxy_app(scope, receive, send):
     if scope["path"].startswith("/specialtestpath/"):
-        return stub_responder
+        await send({"type": "http.response.start", "status": 200, "headers": []})
+        await send({"type": "http.response.body", "body": b""})
     else:
-        return app(scope)
+        return await app(scope, receive, send)
 
 if __name__ == "__main__":
     threading.Thread(target=closer).start()
@@ -216,12 +214,12 @@ class ProcessTestServer(BaseTestServer):
     """ Subclass of BaseTestServer that runs an actual server in a
     subprocess. The ``server`` argument must be a server supported by
     Asgineer' ``run()`` function, like "uvicorn", "hypercorn" or "daphne".
-    
+
     This provides a very realistic approach to test server applicationes, though
     the overhead of starting and stopping the server costs about a second,
     and its hard to measure code coverage in this way. Therefore this approach
     is most suited for higher level / integration tests.
-    
+
     Requests can be done via the methods of this object, or using any other
     request library.
     """
@@ -231,16 +229,17 @@ class ProcessTestServer(BaseTestServer):
         self._app_code = self._get_app_code(app)
 
     def _get_app_code(self, app):
+        assert app.__code__.co_argcount in (1, 3)
         mod = inspect.getmodule(app)
         modname = "_main_" if mod.__name__ == "__main__" else mod.__name__
-        is_handler = inspect.iscoroutinefunction(app)
+        is_handler = app.__code__.co_argcount == 1
         name1 = app.__name__
         name2 = "handler" if is_handler else "app"
 
         if getattr(mod, name1, None) is app:
             # We can import the app - safest option since app may have deps
             code = LOAD_MODULE_CODE
-            code += f"sys.path.insert(0, '')\n" + code
+            code += "sys.path.insert(0, '')\n" + code
             if "." not in mod.__name__:
                 code += f"sys.path.insert(0, {os.path.dirname(mod.__file__)!r})\n"
             code += f"{name2} = load_module({modname!r}, {mod.__file__!r}).{name1}"
@@ -336,7 +335,7 @@ class MockTestServer(BaseTestServer):
     operates in-process. This is a less realistic approach, but faster
     and allows tracking test coverage, so it's more suited for unit
     tests.
-    
+
     Requests *must* be done via the methods of this object. The used url
     can be anything.
     """
@@ -344,10 +343,10 @@ class MockTestServer(BaseTestServer):
     def __init__(self, app, **kwargs):
         super().__init__(app, "mock", **kwargs)
 
-        if inspect.iscoroutinefunction(app):
-            self._asgi_app = asgineer.to_asgi(app)
-        else:
+        if app.__code__.co_argcount == 3:
             self._asgi_app = app
+        else:
+            self._asgi_app = asgineer.to_asgi(app)
 
         self._out_writes = []
 
@@ -383,7 +382,6 @@ class MockTestServer(BaseTestServer):
 
     def _make_lifespan_task(self):
         scope = {"type": "lifespan"}
-        app_object = self._asgi_app(scope)
 
         async def receive():
             while True:
@@ -394,7 +392,7 @@ class MockTestServer(BaseTestServer):
         async def send(m):
             self._lifespan_completes.append(m["type"])
 
-        return self._loop.create_task(app_object(receive, send))
+        return self._loop.create_task(self._asgi_app(scope, receive, send))
 
     def _wait_for_lifespan_complete(self, what, timeout=5):
         what_complete = f"lifespan.{what}.complete"
@@ -470,9 +468,7 @@ class MockTestServer(BaseTestServer):
         req = requests.Request(method, url, **kwargs)
         p = req.prepare()  # Get the "resolved" request
         p.headers.setdefault("user-agent", "asgi_mock_server")
-
         scope = self._make_scope(p)
-        app_object = self._asgi_app(scope)
 
         # ---
 
@@ -504,7 +500,7 @@ class MockTestServer(BaseTestServer):
                 pass  # ignore?
 
         response = []
-        await app_object(receive, send)
+        await self._asgi_app(scope, receive, send)
         response.append(b"".join(server_to_client))
         return tuple(response)
 
@@ -513,9 +509,7 @@ class MockTestServer(BaseTestServer):
         req = requests.Request("GET", url)
         p = req.prepare()  # Get the "resolved" request
         p.headers.setdefault("user-agent", "asgi_mock_server")
-
         scope = self._make_scope(p)
-        app_object = self._asgi_app(scope)
 
         # ---
 
@@ -572,7 +566,7 @@ class MockTestServer(BaseTestServer):
                     except IOError:
                         return
 
-        loop.create_task(app_object(receive, send))
+        loop.create_task(self._asgi_app(scope, receive, send))
         client_to_server.append({"type": "websocket.connect"})
         ws = WS()
         result = await client_co_func(ws)
diff --git a/tests/test_app.py b/tests/test_app.py
index aeadcc8..912ca7a 100644
--- a/tests/test_app.py
+++ b/tests/test_app.py
@@ -39,8 +39,7 @@ def test_invalid_scope_types():
     scope = {"type": "notaknownscope"}
     loop = asyncio.get_event_loop()
     with LogCapturer() as cap:
-        app_ob = app(scope)
-        loop.run_until_complete(app_ob(None, None))
+        loop.run_until_complete(app(scope, None, None))
 
     assert len(cap.messages) == 1
     assert "unknown" in cap.messages[0].lower() and "notaknownscope" in cap.messages[0]
@@ -67,8 +66,7 @@ def test_lifespan():
         sent.append(m["type"])
 
     with LogCapturer() as cap:
-        app_ob = app(scope)
-        loop.run_until_complete(app_ob(receive, send))
+        loop.run_until_complete(app(scope, receive, send))
 
     assert sent == ["lifespan.startup.complete", "lifespan.shutdown.complete"]