Repository: django/channels
Pull request #1624 (2021-02-04 14:08:56)

Update ASGI docs to conform ASGI 3 specs
Note: the description and example are taken from [the ASGI docs](https://asgi.readthedocs.io/en/latest/introduction.html#how-does-asgi-work) with some modification.


Patch:
diff --git a/channels/layers.py b/channels/layers.py
index fa9a9aa..5f26037 100644
--- a/channels/layers.py
+++ b/channels/layers.py
@@ -247,11 +247,11 @@ class InMemoryChannelLayer(BaseChannelLayer):
         queue = self.channels.setdefault(channel, asyncio.Queue())
 
         # Do a plain direct receive
-        _, message = await queue.get()
-
-        # Delete if empty
-        if queue.empty():
-            del self.channels[channel]
+        try:
+            _, message = await queue.get()
+        finally:
+            if queue.empty():
+                del self.channels[channel]
 
         return message
 
@@ -274,17 +274,14 @@ class InMemoryChannelLayer(BaseChannelLayer):
         """
         # Channel cleanup
         for channel, queue in list(self.channels.items()):
-            remove = False
             # See if it's expired
             while not queue.empty() and queue._queue[0][0] < time.time():
                 queue.get_nowait()
-                remove = True
-            # Any removal prompts group discard
-            if remove:
+                # Any removal prompts group discard
                 self._remove_from_groups(channel)
-            # Is the channel now empty and needs deleting?
-            if not queue:
-                del self.channels[channel]
+                # Is the channel now empty and needs deleting?
+                if queue.empty():
+                    del self.channels[channel]
 
         # Group Expiration
         timeout = int(time.time()) - self.group_expiry
diff --git a/docs/asgi.rst b/docs/asgi.rst
index d6b026e..4af83a1 100644
--- a/docs/asgi.rst
+++ b/docs/asgi.rst
@@ -16,18 +16,15 @@ The full ASGI spec can be found at http://asgi.readthedocs.io
 Summary
 -------
 
-An ASGI application is a callable that takes a scope and returns a coroutine
-callable, that takes receive and send methods. It's usually written as a class:
+ASGI is structured as a single asynchronous callable, which takes a dict ``scope``
+and two callables ``receive`` and ``send``:
 
 .. code-block:: python
 
-    class Application:
-
-        def __init__(self, scope):
-            ...
-
-        async def __call__(self, receive, send):
-            ...
+    async def application(scope, receive, send):
+        event = await receive()
+        ...
+        await send({"type": "websocket.send", ...})
 
 The ``scope`` dict defines the properties of a connection, like its remote IP (for
 HTTP) or username (for a chat protocol), and the lifetime of a connection.
diff --git a/docs/community.rst b/docs/community.rst
index 32e56ee..35f224d 100644
--- a/docs/community.rst
+++ b/docs/community.rst
@@ -11,6 +11,14 @@ These projects from the community are developed on top of Channels:
 * Apollo_, a real-time polling application for corporate and academic environments.
 * DjangoChannelsJsonRpc_, a wrapper for the JSON-RPC protocol.
 * channels-demultiplexer_, a (de)multiplexer for ``AsyncJsonWebsocketConsumer`` consumers.
+* channels_postgres_, a Django Channels channel layer that uses PostgreSQL as its backing store.
+
+Community Tutorials
+===================
+
+Here are some Channels tutorials from around the community:
+
+* kafka-integration_, a writeup on integrating Kafka with Channels.
 
 If you'd like to add your project, please submit a PR with a link and brief description.
 
@@ -22,3 +30,5 @@ If you'd like to add your project, please submit a PR with a link and brief desc
 .. _Apollo: https://github.com/maliesa96/apollo
 .. _DjangoChannelsJsonRpc: https://github.com/millerf/django-channels2-jsonrpc
 .. _channels-demultiplexer: https://github.com/csdenboer/channels-demultiplexer
+.. _kafka-integration: https://gist.github.com/aryan340/da071d027050cfe0a03df3b500f2f44b
+.. _channels_postgres: https://github.com/danidee10/channels_postgres
diff --git a/docs/topics/routing.rst b/docs/topics/routing.rst
index f6df3b2..8a150db 100644
--- a/docs/topics/routing.rst
+++ b/docs/topics/routing.rst
@@ -45,7 +45,7 @@ Here's an example of what that ``asgi.py`` might look like:
 
     application = ProtocolTypeRouter({
         # Django's ASGI application to handle traditional HTTP requests
-        "http": get_asgi_application()
+        "http": get_asgi_application(),
 
         # WebSocket chat handler
         "websocket": AuthMiddlewareStack(
diff --git a/tox.ini b/tox.ini
index ed1a519..aa8a2f2 100644
--- a/tox.ini
+++ b/tox.ini
@@ -1,6 +1,7 @@
 [tox]
 envlist =
-    py{36,37,38,39}-dj{22,30,31,master}
+    py{36,37,38,39}-dj{22,30,31,32}
+    py{38,39}-djmain
     qa
 
 [testenv]
@@ -12,7 +13,8 @@ deps =
     dj22: Django~=2.2.8
     dj30: Django==3.0.*
     dj31: Django>=3.1,<3.2
-    djmaster: https://github.com/django/django/archive/master.tar.gz
+    dj32: Django>=3.2b1,<4.0
+    djmain: https://github.com/django/django/archive/main.tar.gz
 
 [testenv:qa]
 skip_install=true


Test patch:
diff --git a/tests/test_inmemorychannel.py b/tests/test_inmemorychannel.py
index 6544f7a..90e02b1 100644
--- a/tests/test_inmemorychannel.py
+++ b/tests/test_inmemorychannel.py
@@ -106,3 +106,65 @@ async def test_groups_channel_full(channel_layer):
     await channel_layer.group_send("test-group", {"type": "message.1"})
     await channel_layer.group_send("test-group", {"type": "message.1"})
     await channel_layer.group_send("test-group", {"type": "message.1"})
+
+
+@pytest.mark.asyncio
+async def test_expiry_single():
+    """
+    Tests that a message can expire.
+    """
+    channel_layer = InMemoryChannelLayer(expiry=0.1)
+    await channel_layer.send("test-channel-1", {"type": "message.1"})
+    assert len(channel_layer.channels) == 1
+
+    await asyncio.sleep(0.1)
+
+    # Message should have expired and been dropped.
+    with pytest.raises(asyncio.TimeoutError):
+        async with async_timeout.timeout(0.5):
+            await channel_layer.receive("test-channel-1")
+
+    # Channel should be cleaned up.
+    assert len(channel_layer.channels) == 0
+
+
+@pytest.mark.asyncio
+async def test_expiry_unread():
+    """
+    Tests that a message on a channel can expire and be cleaned up even if
+    the channel is not read from again.
+    """
+    channel_layer = InMemoryChannelLayer(expiry=0.1)
+    await channel_layer.send("test-channel-1", {"type": "message.1"})
+
+    await asyncio.sleep(0.1)
+
+    await channel_layer.send("test-channel-2", {"type": "message.2"})
+    assert len(channel_layer.channels) == 2
+    assert (await channel_layer.receive("test-channel-2"))["type"] == "message.2"
+    # Both channels should be cleaned up.
+    assert len(channel_layer.channels) == 0
+
+
+@pytest.mark.asyncio
+async def test_expiry_multi():
+    """
+    Tests that multiple messages can expire.
+    """
+    channel_layer = InMemoryChannelLayer(expiry=0.1)
+    await channel_layer.send("test-channel-1", {"type": "message.1"})
+    await channel_layer.send("test-channel-1", {"type": "message.2"})
+    await channel_layer.send("test-channel-1", {"type": "message.3"})
+    assert (await channel_layer.receive("test-channel-1"))["type"] == "message.1"
+
+    await asyncio.sleep(0.1)
+    await channel_layer.send("test-channel-1", {"type": "message.4"})
+    assert (await channel_layer.receive("test-channel-1"))["type"] == "message.4"
+
+    # The second and third message should have expired and been dropped.
+    with pytest.raises(asyncio.TimeoutError):
+        async with async_timeout.timeout(0.5):
+            await channel_layer.receive("test-channel-1")
+
+    # Channel should be cleaned up.
+    assert len(channel_layer.channels) == 0