File: chat_tutorial.rst

package info (click to toggle)
quart 0.20.0-2
  • links: PTS, VCS
  • area: main
  • in suites: forky
  • size: 1,888 kB
  • sloc: python: 8,644; makefile: 42; sh: 17; sql: 6
file content (271 lines) | stat: -rw-r--r-- 7,568 bytes parent folder | download | duplicates (3)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
.. _chat_tutorial:

Tutorial: Building a basic chat server
======================================

In this tutorial we will build a very basic chat server. It will allow
anyone to send messages to everyone else currently connected to the
server.

This tutorial is meant to serve as an introduction to WebSockets in
Quart. If you want to skip to the end the code is on `Github
<https://github.com/pallets/quart/tree/main/examples/chat>`_.

1: Creating the project
-----------------------

We need to create a project for our chat server, I like to use `Poetry
<https://python-poetry.org>`_ to do this. Poetry is installed via pip
(or via `Brew <https://brew.sh/>`_):

.. code-block:: console

    pip install poetry

We can then use Poetry to create a new chat project:

.. code-block:: console

    poetry new --src chat

Our project can now be developed in the *chat* directory, and all
subsequent commands should be in run the *chat* directory.

2: Adding the dependencies
--------------------------

We only need Quart to build this simple chat server, which we can
install as a dependency of the project by running the following:

.. code-block:: console

    poetry add quart

Poetry will ensure that this dependency is present and the paths are
correct by running:

.. code-block:: console

    poetry install

3: Creating the app
-------------------

We need a Quart app to be our web server, which is created by the
following addition to *src/chat/__init__.py*:

.. code-block:: python
    :caption: src/chat/__init__.py

    from quart import Quart

    app = Quart(__name__)

    def run() -> None:
        app.run()

To make the app easy to run we can call the run method from a poetry
script, by adding the following to *pyproject.toml*:

.. code-block:: toml
    :caption: pyproject.toml

    [tool.poetry.scripts]
    start = "chat:run"

Which allows the following command to start the app:

.. code-block:: console

    poetry run start

4: Serving the UI
-----------------

When users visit our chat website we will need to show them a UI which
they can use to enter and receive messages. The following HTML
template should be added to *src/chat/templates/index.html*:

.. code-block:: html
    :caption: src/chat/templates/index.html

    <script type="text/javascript">
      const ws = new WebSocket(`ws://${location.host}/ws`);

      ws.addEventListener('message', function (event) {
        const li = document.createElement("li");
        li.appendChild(document.createTextNode(event.data));
        document.getElementById("messages").appendChild(li);
      });

      function send(event) {
        const message = (new FormData(event.target)).get("message");
        if (message) {
          ws.send(message);
        }
        event.target.reset();
        return false;
      }
    </script>

    <div style="display: flex; height: 100%; flex-direction: column">
      <ul id="messages" style="flex-grow: 1; list-style-type: none"></ul>

      <form onsubmit="return send(event)">
        <input type="text" name="message" minlength="1" />
        <button type="submit">Send</button>
      </form>
    </div>

This is a very basic UI both in terms of the styling, but also as
there is no error handling for the WebSocket.

We can now serve this template for the root path i.e. ``/`` by adding
the following to *src/chat/__init__.py*:

.. code-block:: python

    from quart import render_template

    @app.get("/")
    async def index():
        return await render_template("index.html")

5: Building a broker
--------------------

Before we can add the websocket route we need to be able to pass
messages from one connected client to another. For this we will need a
message-broker. To start we'll build our own in memory broker by
adding the following to *src/chat/broker.py*:

.. code-block:: python
    :caption: src/chat/broker.py

    import asyncio
    from typing import AsyncGenerator

    from quart import Quart

    class Broker:
        def __init__(self) -> None:
            self.connections = set()

        async def publish(self, message: str) -> None:
            for connection in self.connections:
                await connection.put(message)

        async def subscribe(self) -> AsyncGenerator[str, None]:
            connection = asyncio.Queue()
            self.connections.add(connection)
            try:
                while True:
                    yield await connection.get()
            finally:
                self.connections.remove(connection)

This ``Broker`` has a publish-subscibe pattern based interface, with
clients expected to publish messages to other clients whilst
subscribing to any messages sent.

6: Implementing the websocket
-----------------------------

We can now implement the websocket route, by adding the following to
*src/chat/__init__.py*:

.. code-block:: python
    :caption: src/chat/__init__.py

    import asyncio

    from quart import websocket

    from chat.broker import Broker

    broker = Broker()

    async def _receive() -> None:
        while True:
            message = await websocket.receive()
            await broker.publish(message)

    @app.websocket("/ws")
    async def ws() -> None:
        try:
            task = asyncio.ensure_future(_receive())
            async for message in broker.subscribe():
                await websocket.send(message)
        finally:
            task.cancel()
            await task

The ``_receive`` coroutine must run as a separate task to ensure that
sending and receiving run concurrently. In addition this task must be
properly cancelled and cleaned up.

When the user disconnects a CancelledError will be raised breaking the
while loops and triggering the finally blocks.

7: Testing
----------

To test our app we need to check that messages sent via the websocket
route are echoed back. This is done by adding the following to
*tests/test_chat.py*:

.. code-block:: python
    :caption: tests/test_chat.py

    import asyncio

    from quart.testing.connections import TestWebsocketConnection as _TestWebsocketConnection

    from chat import app

    async def _receive(test_websocket: _TestWebsocketConnection) -> str:
        return await test_websocket.receive()

    async def test_websocket() -> None:
        test_client = app.test_client()
        async with test_client.websocket("/ws") as test_websocket:
            task = asyncio.ensure_future(_receive(test_websocket))
            await test_websocket.send("message")
            result = await task
            assert result == "message"

As the test is an async function we need to install `pytest-asyncio
<https://github.com/pytest-dev/pytest-asyncio>`_ by running the
following:

.. code-block:: console

    poetry add --dev pytest-asyncio

Once installed it needs to be configured by adding the following to
*pyproject.toml*:

.. code-block:: toml

    [tool.pytest.ini_options]
    asyncio_mode = "auto"

Finally we can run the tests via this command:

.. code-block:: console

    poetry run pytest tests/

If you are running this in the Quart example folder you'll need to add
a ``-c pyproject.toml`` option to prevent pytest from using the Quart
pytest configuration.

8: Summary
----------

The message-broker we've built so far only works in memory, which
means that messages are only shared with users connected to the same
server instance. To share messages across server instances we need to
use a third party broker, such as redis via the `aioredis
<https://aioredis.readthedocs.io>`_ library which supports a pub/sub
interface.