multilinkk 0.1.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2024 Marcus
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,231 @@
1
+ Metadata-Version: 2.4
2
+ Name: multilinkk
3
+ Version: 0.1.0
4
+ Summary: A high-level Python multiplayer library. Just works.
5
+ Author: Marcus
6
+ License: MIT
7
+ Project-URL: Homepage, https://github.com/yourusername/multilink
8
+ Project-URL: Repository, https://github.com/yourusername/multilink
9
+ Project-URL: Issues, https://github.com/yourusername/multilink/issues
10
+ Keywords: multiplayer,networking,websocket,gamedev,realtime
11
+ Classifier: Development Status :: 3 - Alpha
12
+ Classifier: Intended Audience :: Developers
13
+ Classifier: License :: OSI Approved :: MIT License
14
+ Classifier: Programming Language :: Python :: 3
15
+ Classifier: Programming Language :: Python :: 3.8
16
+ Classifier: Programming Language :: Python :: 3.9
17
+ Classifier: Programming Language :: Python :: 3.10
18
+ Classifier: Programming Language :: Python :: 3.11
19
+ Classifier: Programming Language :: Python :: 3.12
20
+ Classifier: Topic :: Games/Entertainment
21
+ Classifier: Topic :: Internet
22
+ Classifier: Topic :: Software Development :: Libraries :: Python Modules
23
+ Requires-Python: >=3.8
24
+ Description-Content-Type: text/markdown
25
+ License-File: LICENSE
26
+ Requires-Dist: websockets>=12.0
27
+ Dynamic: license-file
28
+
29
+ # multilink
30
+
31
+ **A high-level Python multiplayer library. Just works.**
32
+
33
+ No presets. No opinions. You define the events, the data, and the behavior — multilink handles the connections, state sync, and networking underneath.
34
+
35
+ ```
36
+ pip install multilink
37
+ ```
38
+
39
+ ---
40
+
41
+ ## Quickstart
42
+
43
+ **Server:**
44
+ ```python
45
+ from multilink import Server
46
+
47
+ server = Server(port=5000)
48
+
49
+ @server.on("connect")
50
+ def on_connect(player):
51
+ server.broadcast("joined", {"id": player.id})
52
+
53
+ @server.on("move")
54
+ def on_move(player, data):
55
+ player.update_state({"x": data["x"], "y": data["y"]})
56
+
57
+ @server.on("disconnect")
58
+ def on_disconnect(player):
59
+ server.broadcast("left", {"id": player.id})
60
+
61
+ @server.on_error()
62
+ def on_error(error, player):
63
+ print(f"Error from {player.id}: {error}")
64
+
65
+ server.start()
66
+ ```
67
+
68
+ **Client:**
69
+ ```python
70
+ from multilink import Client
71
+
72
+ client = Client("localhost", 5000)
73
+
74
+ @client.on("connect")
75
+ def on_connect():
76
+ client.send("move", {"x": 10, "y": 20})
77
+
78
+ @client.on("joined")
79
+ def on_joined(data):
80
+ print(f"Player {data['id']} joined!")
81
+
82
+ @client.on("__state_sync__")
83
+ def on_sync(data):
84
+ for player_id, state in data["players"].items():
85
+ print(f"{player_id}: {state}")
86
+
87
+ @client.on_error()
88
+ def on_error(error):
89
+ print(f"Error: {error}")
90
+
91
+ client.connect()
92
+ ```
93
+
94
+ ---
95
+
96
+ ## Core concepts
97
+
98
+ ### Events
99
+ Everything is event-based. Send an event from the client, handle it on the server (and vice versa) with a simple decorator.
100
+
101
+ ```python
102
+ # Client sends:
103
+ client.send("chat", {"msg": "hello!"})
104
+
105
+ # Server handles:
106
+ @server.on("chat")
107
+ def on_chat(player, data):
108
+ server.broadcast("chat", {"from": player.id, "msg": data["msg"]})
109
+
110
+ # All clients receive:
111
+ @client.on("chat")
112
+ def on_chat(data):
113
+ print(f"[{data['from']}]: {data['msg']}")
114
+ ```
115
+
116
+ ### Player state sync
117
+ Each player has a `.state` dict. Update it on the server and multilink automatically broadcasts only the changed fields to all clients at 20 ticks/sec.
118
+
119
+ ```python
120
+ @server.on("move")
121
+ def on_move(player, data):
122
+ player.update_state({"x": data["x"], "y": data["y"]})
123
+ # That's it — state is synced automatically
124
+ ```
125
+
126
+ Force an immediate sync if you can't wait for the next tick:
127
+ ```python
128
+ server.sync_state() # delta only
129
+ server.sync_state(full=True) # full state
130
+ ```
131
+
132
+ ### Sending to one player
133
+ ```python
134
+ @server.on("connect")
135
+ def on_connect(player):
136
+ # Send something only to this player
137
+ asyncio.ensure_future(player.send("welcome", {"id": player.id}))
138
+ ```
139
+
140
+ ### Error handling
141
+ Errors in event handlers are caught and routed to your error handler instead of crashing everything.
142
+
143
+ ```python
144
+ @server.on_error()
145
+ def on_error(error, player):
146
+ print(f"Error from {player.id}: {error}")
147
+
148
+ @client.on_error()
149
+ def on_error(error):
150
+ print(f"Client error: {error}")
151
+ ```
152
+
153
+ ### Send queue
154
+ Messages sent before the client is fully connected are automatically queued and delivered the moment the connection opens. No timing issues.
155
+
156
+ ```python
157
+ client.send("hello", {"msg": "this works even before connect() is called"})
158
+ client.connect() # queued message is flushed on connect
159
+ ```
160
+
161
+ ---
162
+
163
+ ## API Reference
164
+
165
+ ### `Server(host, port, tick_rate, delta_only)`
166
+
167
+ | Arg | Default | Description |
168
+ |-----|---------|-------------|
169
+ | `host` | `"0.0.0.0"` | Hostname to bind to |
170
+ | `port` | `5000` | Port to listen on |
171
+ | `tick_rate` | `20` | State syncs per second |
172
+ | `delta_only` | `True` | Only broadcast changed fields |
173
+
174
+ | Method | Description |
175
+ |--------|-------------|
176
+ | `server.on(event)` | Register an event handler |
177
+ | `server.on_error()` | Register an error handler |
178
+ | `server.broadcast(event, data)` | Send event to all players |
179
+ | `server.sync_state(full=False)` | Force immediate state sync |
180
+ | `server.get_players()` | List of all connected Players |
181
+ | `server.get_player(id)` | Look up a player by ID |
182
+ | `server.player_count` | Number of connected players |
183
+ | `server.start()` | Start the server (blocking) |
184
+
185
+ ### `Client(host, port)`
186
+
187
+ | Method | Description |
188
+ |--------|-------------|
189
+ | `client.on(event)` | Register an event handler |
190
+ | `client.on_error()` | Register an error handler |
191
+ | `client.send(event, data)` | Send event to server |
192
+ | `client.connect()` | Connect and start listening (blocking) |
193
+
194
+ ### `Player`
195
+
196
+ | Attribute/Method | Description |
197
+ |-----------------|-------------|
198
+ | `player.id` | Unique player ID |
199
+ | `player.state` | Dict of synced values |
200
+ | `player.set_state(key, value)` | Set one state value |
201
+ | `player.get_state(key, default)` | Get one state value |
202
+ | `player.update_state(dict)` | Merge dict into state |
203
+ | `player.clear_state()` | Wipe state entirely |
204
+ | `player.send(event, data)` | Send directly to this player |
205
+ | `player.connected_at` | Unix timestamp of connection time |
206
+ | `player.last_seen` | Unix timestamp of last message |
207
+
208
+ ---
209
+
210
+ ## Built-in events
211
+
212
+ | Event | Where | Handler signature |
213
+ |-------|-------|------------------|
214
+ | `"connect"` | server | `fn(player)` |
215
+ | `"disconnect"` | server | `fn(player)` |
216
+ | `"connect"` | client | `fn()` |
217
+ | `"disconnect"` | client | `fn()` |
218
+ | `"__state_sync__"` | client | `fn(data)` — `data["players"]` is a dict of `player_id -> state_delta` |
219
+
220
+ ---
221
+
222
+ ## Requirements
223
+
224
+ - Python 3.8+
225
+ - `websockets >= 12.0`
226
+
227
+ ---
228
+
229
+ ## License
230
+
231
+ MIT
@@ -0,0 +1,203 @@
1
+ # multilink
2
+
3
+ **A high-level Python multiplayer library. Just works.**
4
+
5
+ No presets. No opinions. You define the events, the data, and the behavior — multilink handles the connections, state sync, and networking underneath.
6
+
7
+ ```
8
+ pip install multilink
9
+ ```
10
+
11
+ ---
12
+
13
+ ## Quickstart
14
+
15
+ **Server:**
16
+ ```python
17
+ from multilink import Server
18
+
19
+ server = Server(port=5000)
20
+
21
+ @server.on("connect")
22
+ def on_connect(player):
23
+ server.broadcast("joined", {"id": player.id})
24
+
25
+ @server.on("move")
26
+ def on_move(player, data):
27
+ player.update_state({"x": data["x"], "y": data["y"]})
28
+
29
+ @server.on("disconnect")
30
+ def on_disconnect(player):
31
+ server.broadcast("left", {"id": player.id})
32
+
33
+ @server.on_error()
34
+ def on_error(error, player):
35
+ print(f"Error from {player.id}: {error}")
36
+
37
+ server.start()
38
+ ```
39
+
40
+ **Client:**
41
+ ```python
42
+ from multilink import Client
43
+
44
+ client = Client("localhost", 5000)
45
+
46
+ @client.on("connect")
47
+ def on_connect():
48
+ client.send("move", {"x": 10, "y": 20})
49
+
50
+ @client.on("joined")
51
+ def on_joined(data):
52
+ print(f"Player {data['id']} joined!")
53
+
54
+ @client.on("__state_sync__")
55
+ def on_sync(data):
56
+ for player_id, state in data["players"].items():
57
+ print(f"{player_id}: {state}")
58
+
59
+ @client.on_error()
60
+ def on_error(error):
61
+ print(f"Error: {error}")
62
+
63
+ client.connect()
64
+ ```
65
+
66
+ ---
67
+
68
+ ## Core concepts
69
+
70
+ ### Events
71
+ Everything is event-based. Send an event from the client, handle it on the server (and vice versa) with a simple decorator.
72
+
73
+ ```python
74
+ # Client sends:
75
+ client.send("chat", {"msg": "hello!"})
76
+
77
+ # Server handles:
78
+ @server.on("chat")
79
+ def on_chat(player, data):
80
+ server.broadcast("chat", {"from": player.id, "msg": data["msg"]})
81
+
82
+ # All clients receive:
83
+ @client.on("chat")
84
+ def on_chat(data):
85
+ print(f"[{data['from']}]: {data['msg']}")
86
+ ```
87
+
88
+ ### Player state sync
89
+ Each player has a `.state` dict. Update it on the server and multilink automatically broadcasts only the changed fields to all clients at 20 ticks/sec.
90
+
91
+ ```python
92
+ @server.on("move")
93
+ def on_move(player, data):
94
+ player.update_state({"x": data["x"], "y": data["y"]})
95
+ # That's it — state is synced automatically
96
+ ```
97
+
98
+ Force an immediate sync if you can't wait for the next tick:
99
+ ```python
100
+ server.sync_state() # delta only
101
+ server.sync_state(full=True) # full state
102
+ ```
103
+
104
+ ### Sending to one player
105
+ ```python
106
+ @server.on("connect")
107
+ def on_connect(player):
108
+ # Send something only to this player
109
+ asyncio.ensure_future(player.send("welcome", {"id": player.id}))
110
+ ```
111
+
112
+ ### Error handling
113
+ Errors in event handlers are caught and routed to your error handler instead of crashing everything.
114
+
115
+ ```python
116
+ @server.on_error()
117
+ def on_error(error, player):
118
+ print(f"Error from {player.id}: {error}")
119
+
120
+ @client.on_error()
121
+ def on_error(error):
122
+ print(f"Client error: {error}")
123
+ ```
124
+
125
+ ### Send queue
126
+ Messages sent before the client is fully connected are automatically queued and delivered the moment the connection opens. No timing issues.
127
+
128
+ ```python
129
+ client.send("hello", {"msg": "this works even before connect() is called"})
130
+ client.connect() # queued message is flushed on connect
131
+ ```
132
+
133
+ ---
134
+
135
+ ## API Reference
136
+
137
+ ### `Server(host, port, tick_rate, delta_only)`
138
+
139
+ | Arg | Default | Description |
140
+ |-----|---------|-------------|
141
+ | `host` | `"0.0.0.0"` | Hostname to bind to |
142
+ | `port` | `5000` | Port to listen on |
143
+ | `tick_rate` | `20` | State syncs per second |
144
+ | `delta_only` | `True` | Only broadcast changed fields |
145
+
146
+ | Method | Description |
147
+ |--------|-------------|
148
+ | `server.on(event)` | Register an event handler |
149
+ | `server.on_error()` | Register an error handler |
150
+ | `server.broadcast(event, data)` | Send event to all players |
151
+ | `server.sync_state(full=False)` | Force immediate state sync |
152
+ | `server.get_players()` | List of all connected Players |
153
+ | `server.get_player(id)` | Look up a player by ID |
154
+ | `server.player_count` | Number of connected players |
155
+ | `server.start()` | Start the server (blocking) |
156
+
157
+ ### `Client(host, port)`
158
+
159
+ | Method | Description |
160
+ |--------|-------------|
161
+ | `client.on(event)` | Register an event handler |
162
+ | `client.on_error()` | Register an error handler |
163
+ | `client.send(event, data)` | Send event to server |
164
+ | `client.connect()` | Connect and start listening (blocking) |
165
+
166
+ ### `Player`
167
+
168
+ | Attribute/Method | Description |
169
+ |-----------------|-------------|
170
+ | `player.id` | Unique player ID |
171
+ | `player.state` | Dict of synced values |
172
+ | `player.set_state(key, value)` | Set one state value |
173
+ | `player.get_state(key, default)` | Get one state value |
174
+ | `player.update_state(dict)` | Merge dict into state |
175
+ | `player.clear_state()` | Wipe state entirely |
176
+ | `player.send(event, data)` | Send directly to this player |
177
+ | `player.connected_at` | Unix timestamp of connection time |
178
+ | `player.last_seen` | Unix timestamp of last message |
179
+
180
+ ---
181
+
182
+ ## Built-in events
183
+
184
+ | Event | Where | Handler signature |
185
+ |-------|-------|------------------|
186
+ | `"connect"` | server | `fn(player)` |
187
+ | `"disconnect"` | server | `fn(player)` |
188
+ | `"connect"` | client | `fn()` |
189
+ | `"disconnect"` | client | `fn()` |
190
+ | `"__state_sync__"` | client | `fn(data)` — `data["players"]` is a dict of `player_id -> state_delta` |
191
+
192
+ ---
193
+
194
+ ## Requirements
195
+
196
+ - Python 3.8+
197
+ - `websockets >= 12.0`
198
+
199
+ ---
200
+
201
+ ## License
202
+
203
+ MIT
@@ -0,0 +1,38 @@
1
+ """
2
+ multilink
3
+ ~~~~~~~~~
4
+ A high-level Python multiplayer library. Just works.
5
+
6
+ Basic usage::
7
+
8
+ from multilink import Server, Client
9
+
10
+ # Server
11
+ server = Server(port=5000)
12
+
13
+ @server.on("connect")
14
+ def on_connect(player):
15
+ server.broadcast("joined", {"id": player.id})
16
+
17
+ server.start()
18
+
19
+ # Client
20
+ client = Client("localhost", 5000)
21
+
22
+ @client.on("joined")
23
+ def on_joined(data):
24
+ print(f"Player {data['id']} joined!")
25
+
26
+ client.connect()
27
+
28
+ :copyright: 2024 Marcus
29
+ :license: MIT
30
+ """
31
+
32
+ from .server import Server
33
+ from .client import Client
34
+ from .player import Player
35
+
36
+ __version__ = "0.1.0"
37
+ __author__ = "Marcus"
38
+ __all__ = ["Server", "Client", "Player"]
@@ -0,0 +1,221 @@
1
+ """
2
+ multilink.client
3
+ ~~~~~~~~~~~~~~~~
4
+ The multilink Client.
5
+ """
6
+
7
+ import asyncio
8
+ import json
9
+ import logging
10
+ from typing import Any, Callable, Dict, List, Optional
11
+
12
+ import websockets
13
+ from websockets.exceptions import ConnectionClosed
14
+
15
+ logger = logging.getLogger("multilink.client")
16
+
17
+
18
+ class Client:
19
+ """
20
+ A multilink client.
21
+
22
+ Args:
23
+ host: server hostname (default ``"localhost"``)
24
+ port: server port (default ``5000``)
25
+
26
+ Example::
27
+
28
+ from multilink import Client
29
+
30
+ client = Client("localhost", 5000)
31
+
32
+ @client.on("connect")
33
+ def on_connect():
34
+ client.send("hello", {"msg": "hi!"})
35
+
36
+ @client.on("welcome")
37
+ def on_welcome(data):
38
+ print(f"Server said: {data}")
39
+
40
+ @client.on("disconnect")
41
+ def on_disconnect():
42
+ print("Lost connection.")
43
+
44
+ @client.on_error()
45
+ def on_error(error):
46
+ print(f"Error: {error}")
47
+
48
+ client.connect()
49
+ """
50
+
51
+ def __init__(self, host: str = "localhost", port: int = 5000):
52
+ self.host = host
53
+ self.port = port
54
+ self._uri = f"ws://{host}:{port}"
55
+
56
+ self._handlers: Dict[str, List[Callable]] = {}
57
+ self._error_handlers: List[Callable] = []
58
+
59
+ self._ws = None
60
+ self._connected = False
61
+ self._loop: Optional[asyncio.AbstractEventLoop] = None
62
+ # Messages sent before the connection is ready are queued
63
+ # and flushed automatically once connected
64
+ self._send_queue: List[tuple] = []
65
+
66
+ def on(self, event: str) -> Callable:
67
+ """
68
+ Register a handler for a server event.
69
+
70
+ Built-in events: ``"connect"``, ``"disconnect"``
71
+
72
+ Handler signatures:
73
+
74
+ - connect / disconnect: ``fn()``
75
+ - all others: ``fn(data)``
76
+
77
+ Example::
78
+
79
+ @client.on("game_start")
80
+ def on_start(data):
81
+ print("Game starting!")
82
+ """
83
+ def decorator(fn: Callable) -> Callable:
84
+ self._handlers.setdefault(event, []).append(fn)
85
+ return fn
86
+ return decorator
87
+
88
+ def on_error(self) -> Callable:
89
+ """
90
+ Register an error handler.
91
+
92
+ Example::
93
+
94
+ @client.on_error()
95
+ def on_error(error):
96
+ print(f"Something went wrong: {error}")
97
+ """
98
+ def decorator(fn: Callable) -> Callable:
99
+ self._error_handlers.append(fn)
100
+ return fn
101
+ return decorator
102
+
103
+ def connect(self) -> None:
104
+ """Connect to the server. Blocks until disconnected."""
105
+ try:
106
+ asyncio.run(self._run())
107
+ except KeyboardInterrupt:
108
+ logger.info("Client disconnected.")
109
+
110
+ async def _run(self) -> None:
111
+ self._loop = asyncio.get_event_loop()
112
+ did_connect = False
113
+ logger.info(f"Connecting to {self._uri}")
114
+ try:
115
+ async with websockets.connect(self._uri) as ws:
116
+ self._ws = ws
117
+ self._connected = True
118
+ did_connect = True
119
+ # Flush any messages queued before connection was ready
120
+ for queued_event, queued_data in self._send_queue:
121
+ await self._send_raw(queued_event, queued_data)
122
+ self._send_queue.clear()
123
+ await self._fire_single("connect")
124
+
125
+ async for raw in ws:
126
+ try:
127
+ message = json.loads(raw)
128
+ await self._handle_message(message)
129
+ except json.JSONDecodeError:
130
+ logger.warning(f"Malformed message: {raw!r}")
131
+ except Exception as e:
132
+ logger.error(f"Message error: {e}", exc_info=True)
133
+ await self._fire_error(e)
134
+
135
+ except ConnectionClosed:
136
+ logger.info("Disconnected from server")
137
+ except OSError as e:
138
+ err = ConnectionError(f"Could not connect to {self._uri}: {e}")
139
+ logger.error(str(err))
140
+ await self._fire_error(err)
141
+ finally:
142
+ self._connected = False
143
+ self._ws = None
144
+ self._loop = None
145
+ if did_connect:
146
+ await self._fire_single("disconnect")
147
+
148
+ async def _handle_message(self, message: dict) -> None:
149
+ event = message.get("event")
150
+ data = message.get("data", {})
151
+ if event:
152
+ await self._fire(event, data)
153
+
154
+ def send(self, event: str, data: Any = None) -> None:
155
+ """
156
+ Send an event + data to the server.
157
+
158
+ Safe to call from any thread. Messages sent before the connection
159
+ is established are queued and delivered automatically once connected.
160
+
161
+ Example::
162
+
163
+ client.send("move", {"x": 10, "y": 20})
164
+ client.send("chat", {"msg": "hello!"})
165
+ """
166
+ if not self._connected or not self._ws:
167
+ self._send_queue.append((event, data))
168
+ return
169
+
170
+ coro = self._send_raw(event, data)
171
+ if self._loop and self._loop.is_running():
172
+ try:
173
+ asyncio.ensure_future(coro, loop=self._loop)
174
+ except RuntimeError:
175
+ asyncio.run_coroutine_threadsafe(coro, self._loop)
176
+ elif self._loop:
177
+ asyncio.run_coroutine_threadsafe(coro, self._loop)
178
+
179
+ async def _send_raw(self, event: str, data: Any = None) -> None:
180
+ if self._ws and self._connected:
181
+ try:
182
+ await self._ws.send(json.dumps({"event": event, "data": data}))
183
+ except ConnectionClosed:
184
+ self._connected = False
185
+ except Exception as e:
186
+ logger.error(f"Send error: {e}")
187
+ await self._fire_error(e)
188
+
189
+ async def _fire_single(self, event: str) -> None:
190
+ for handler in self._handlers.get(event, []):
191
+ try:
192
+ result = handler()
193
+ if asyncio.iscoroutine(result):
194
+ await result
195
+ except Exception as e:
196
+ logger.error(f"Error in '{event}' handler: {e}", exc_info=True)
197
+ await self._fire_error(e)
198
+
199
+ async def _fire(self, event: str, data: Any) -> None:
200
+ for handler in self._handlers.get(event, []):
201
+ try:
202
+ result = handler(data)
203
+ if asyncio.iscoroutine(result):
204
+ await result
205
+ except Exception as e:
206
+ logger.error(f"Error in '{event}' handler: {e}", exc_info=True)
207
+ await self._fire_error(e)
208
+
209
+ async def _fire_error(self, error: Exception) -> None:
210
+ if self._error_handlers:
211
+ for handler in self._error_handlers:
212
+ try:
213
+ result = handler(error)
214
+ if asyncio.iscoroutine(result):
215
+ await result
216
+ except Exception as e:
217
+ logger.error(f"Error in error handler: {e}", exc_info=True)
218
+ else:
219
+ logger.error(
220
+ f"Unhandled error (register @client.on_error() to catch): {error}"
221
+ )