foxglove-sdk 0.6.1__cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl → 0.15.3__cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl
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.
Potentially problematic release.
This version of foxglove-sdk might be problematic. Click here for more details.
- foxglove/__init__.py +164 -44
- foxglove/_foxglove_py/__init__.pyi +123 -49
- foxglove/_foxglove_py/channels.pyi +1916 -177
- foxglove/_foxglove_py/cloud.pyi +9 -0
- foxglove/_foxglove_py/mcap.pyi +96 -0
- foxglove/_foxglove_py/schemas.pyi +635 -292
- foxglove/_foxglove_py/schemas_wkt.pyi +17 -9
- foxglove/_foxglove_py/websocket.pyi +92 -68
- foxglove/_foxglove_py.cpython-39-aarch64-linux-gnu.so +0 -0
- foxglove/benchmarks/test_mcap_serialization.py +4 -5
- foxglove/channel.py +77 -40
- foxglove/channels/__init__.py +23 -1
- foxglove/cloud.py +61 -0
- foxglove/mcap.py +12 -0
- foxglove/notebook/__init__.py +0 -0
- foxglove/notebook/foxglove_widget.py +100 -0
- foxglove/notebook/notebook_buffer.py +114 -0
- foxglove/notebook/static/widget.js +1 -0
- foxglove/schemas/__init__.py +14 -0
- foxglove/tests/test_channel.py +116 -15
- foxglove/tests/test_context.py +10 -0
- foxglove/tests/test_logging.py +46 -0
- foxglove/tests/test_mcap.py +163 -10
- foxglove/tests/test_parameters.py +178 -0
- foxglove/tests/test_schemas.py +17 -0
- foxglove/tests/test_server.py +46 -2
- foxglove/websocket.py +46 -13
- foxglove_sdk-0.15.3.dist-info/METADATA +53 -0
- foxglove_sdk-0.15.3.dist-info/RECORD +33 -0
- {foxglove_sdk-0.6.1.dist-info → foxglove_sdk-0.15.3.dist-info}/WHEEL +1 -1
- foxglove_sdk-0.6.1.dist-info/METADATA +0 -51
- foxglove_sdk-0.6.1.dist-info/RECORD +0 -22
foxglove/__init__.py
CHANGED
|
@@ -5,68 +5,128 @@ See :py:mod:`foxglove.schemas` and :py:mod:`foxglove.channels` for working with
|
|
|
5
5
|
schemas.
|
|
6
6
|
"""
|
|
7
7
|
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
8
10
|
import atexit
|
|
9
11
|
import logging
|
|
10
|
-
from typing import
|
|
12
|
+
from typing import TYPE_CHECKING
|
|
11
13
|
|
|
12
14
|
from . import _foxglove_py as _foxglove
|
|
13
15
|
|
|
14
16
|
# Re-export these imports
|
|
15
17
|
from ._foxglove_py import (
|
|
16
|
-
|
|
18
|
+
ChannelDescriptor,
|
|
19
|
+
Context,
|
|
17
20
|
Schema,
|
|
21
|
+
SinkChannelFilter,
|
|
18
22
|
open_mcap,
|
|
19
23
|
)
|
|
20
24
|
from .channel import Channel, log
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
)
|
|
25
|
+
|
|
26
|
+
# Deprecated. Use foxglove.mcap.MCAPWriter instead.
|
|
27
|
+
from .mcap import MCAPWriter
|
|
28
|
+
|
|
29
|
+
if TYPE_CHECKING:
|
|
30
|
+
from .notebook.notebook_buffer import NotebookBuffer
|
|
28
31
|
|
|
29
32
|
atexit.register(_foxglove.shutdown)
|
|
30
33
|
|
|
31
34
|
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
"""
|
|
44
|
-
Start a websocket server for live visualization.
|
|
45
|
-
|
|
46
|
-
:param name: The name of the server.
|
|
47
|
-
:param host: The host to bind to.
|
|
48
|
-
:param port: The port to bind to.
|
|
49
|
-
:param capabilities: A list of capabilities to advertise to clients.
|
|
50
|
-
:param server_listener: A Python object that implements the :py:class:`websocket.ServerListener`
|
|
51
|
-
protocol.
|
|
52
|
-
:param supported_encodings: A list of encodings to advertise to clients.
|
|
53
|
-
:param services: A list of services to advertise to clients.
|
|
54
|
-
:param asset_handler: A callback function that returns the asset for a given URI, or None if
|
|
55
|
-
it doesn't exist.
|
|
56
|
-
"""
|
|
57
|
-
return _foxglove.start_server(
|
|
58
|
-
name=name,
|
|
59
|
-
host=host,
|
|
60
|
-
port=port,
|
|
61
|
-
capabilities=capabilities,
|
|
62
|
-
server_listener=server_listener,
|
|
63
|
-
supported_encodings=supported_encodings,
|
|
64
|
-
services=services,
|
|
65
|
-
asset_handler=asset_handler,
|
|
35
|
+
try:
|
|
36
|
+
from ._foxglove_py.cloud import CloudSink
|
|
37
|
+
|
|
38
|
+
# from ._foxglove_py.cloud import start_cloud_sink as _start_cloud_sink
|
|
39
|
+
from .cloud import CloudSinkListener
|
|
40
|
+
from .websocket import (
|
|
41
|
+
AssetHandler,
|
|
42
|
+
Capability,
|
|
43
|
+
ServerListener,
|
|
44
|
+
Service,
|
|
45
|
+
WebSocketServer,
|
|
66
46
|
)
|
|
67
47
|
|
|
48
|
+
def start_server(
|
|
49
|
+
*,
|
|
50
|
+
name: str | None = None,
|
|
51
|
+
host: str | None = "127.0.0.1",
|
|
52
|
+
port: int | None = 8765,
|
|
53
|
+
capabilities: list[Capability] | None = None,
|
|
54
|
+
server_listener: ServerListener | None = None,
|
|
55
|
+
supported_encodings: list[str] | None = None,
|
|
56
|
+
services: list[Service] | None = None,
|
|
57
|
+
asset_handler: AssetHandler | None = None,
|
|
58
|
+
context: Context | None = None,
|
|
59
|
+
session_id: str | None = None,
|
|
60
|
+
channel_filter: SinkChannelFilter | None = None,
|
|
61
|
+
) -> WebSocketServer:
|
|
62
|
+
"""
|
|
63
|
+
Start a websocket server for live visualization.
|
|
64
|
+
|
|
65
|
+
:param name: The name of the server.
|
|
66
|
+
:param host: The host to bind to.
|
|
67
|
+
:param port: The port to bind to.
|
|
68
|
+
:param capabilities: A list of capabilities to advertise to clients.
|
|
69
|
+
:param server_listener: A Python object that implements the
|
|
70
|
+
:py:class:`websocket.ServerListener` protocol.
|
|
71
|
+
:param supported_encodings: A list of encodings to advertise to clients.
|
|
72
|
+
:param services: A list of services to advertise to clients.
|
|
73
|
+
:param asset_handler: A callback function that returns the asset for a given URI, or None if
|
|
74
|
+
it doesn't exist.
|
|
75
|
+
:param context: The context to use for logging. If None, the global context is used.
|
|
76
|
+
:param session_id: An ID which allows the client to understand if the connection is a
|
|
77
|
+
re-connection or a new server instance. If None, then an ID is generated based on the
|
|
78
|
+
current time.
|
|
79
|
+
:param channel_filter: A `Callable` that determines whether a channel should be logged to.
|
|
80
|
+
Return `True` to log the channel, or `False` to skip it. By default, all channels
|
|
81
|
+
will be logged.
|
|
82
|
+
"""
|
|
83
|
+
return _foxglove.start_server(
|
|
84
|
+
name=name,
|
|
85
|
+
host=host,
|
|
86
|
+
port=port,
|
|
87
|
+
capabilities=capabilities,
|
|
88
|
+
server_listener=server_listener,
|
|
89
|
+
supported_encodings=supported_encodings,
|
|
90
|
+
services=services,
|
|
91
|
+
asset_handler=asset_handler,
|
|
92
|
+
context=context,
|
|
93
|
+
session_id=session_id,
|
|
94
|
+
channel_filter=channel_filter,
|
|
95
|
+
)
|
|
96
|
+
|
|
97
|
+
def start_cloud_sink(
|
|
98
|
+
*,
|
|
99
|
+
listener: CloudSinkListener | None = None,
|
|
100
|
+
supported_encodings: list[str] | None = None,
|
|
101
|
+
context: Context | None = None,
|
|
102
|
+
session_id: str | None = None,
|
|
103
|
+
) -> CloudSink:
|
|
104
|
+
"""
|
|
105
|
+
Connect to Foxglove Agent for live visualization and teleop.
|
|
106
|
+
|
|
107
|
+
Foxglove Agent must be running on the same host for this to work.
|
|
108
|
+
|
|
109
|
+
:param capabilities: A list of capabilities to advertise to the agent.
|
|
110
|
+
:param listener: A Python object that implements the
|
|
111
|
+
:py:class:`cloud.CloudSinkListener` protocol.
|
|
112
|
+
:param supported_encodings: A list of encodings to advertise to the agent.
|
|
113
|
+
:param context: The context to use for logging. If None, the global context is used.
|
|
114
|
+
:param session_id: An ID which allows the agent to understand if the connection is a
|
|
115
|
+
re-connection or a new connection instance. If None, then an ID is generated based on
|
|
116
|
+
the current time.
|
|
117
|
+
"""
|
|
118
|
+
return _foxglove.start_cloud_sink(
|
|
119
|
+
listener=listener,
|
|
120
|
+
supported_encodings=supported_encodings,
|
|
121
|
+
context=context,
|
|
122
|
+
session_id=session_id,
|
|
123
|
+
)
|
|
68
124
|
|
|
69
|
-
|
|
125
|
+
except ImportError:
|
|
126
|
+
pass
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
def set_log_level(level: int | str = "INFO") -> None:
|
|
70
130
|
"""
|
|
71
131
|
Enable SDK logging.
|
|
72
132
|
|
|
@@ -110,12 +170,72 @@ def _level_names() -> dict[str, int]:
|
|
|
110
170
|
}
|
|
111
171
|
|
|
112
172
|
|
|
173
|
+
def init_notebook_buffer(context: Context | None = None) -> NotebookBuffer:
|
|
174
|
+
"""
|
|
175
|
+
Create a NotebookBuffer object to manage data buffering and visualization in Jupyter
|
|
176
|
+
notebooks.
|
|
177
|
+
|
|
178
|
+
The NotebookBuffer object will buffer all data logged to the provided context. When you
|
|
179
|
+
are ready to visualize the data, you can call the :meth:`show` method to display an embedded
|
|
180
|
+
Foxglove visualization widget. The widget provides a fully-featured Foxglove interface
|
|
181
|
+
directly within your Jupyter notebook, allowing you to explore multi-modal robotics data
|
|
182
|
+
including 3D scenes, plots, images, and more.
|
|
183
|
+
|
|
184
|
+
Args:
|
|
185
|
+
context: The Context used to log the messages. If no Context is provided, the global
|
|
186
|
+
context will be used. Logged messages will be buffered.
|
|
187
|
+
|
|
188
|
+
Returns:
|
|
189
|
+
NotebookBuffer: A NotebookBuffer object that can be used to manage the data buffering
|
|
190
|
+
and visualization.
|
|
191
|
+
|
|
192
|
+
Raises:
|
|
193
|
+
Exception: If the notebook extra package is not installed. Install it
|
|
194
|
+
with `pip install foxglove-sdk[notebook]`.
|
|
195
|
+
|
|
196
|
+
Note:
|
|
197
|
+
This function is only available when the `notebook` extra package
|
|
198
|
+
is installed. Install it with `pip install foxglove-sdk[notebook]`.
|
|
199
|
+
|
|
200
|
+
Example:
|
|
201
|
+
>>> import foxglove
|
|
202
|
+
>>>
|
|
203
|
+
>>> # Create a basic viewer using the default context
|
|
204
|
+
>>> nb_buffer = foxglove.init_notebook_buffer()
|
|
205
|
+
>>>
|
|
206
|
+
>>> # Or use a specific context
|
|
207
|
+
>>> nb_buffer = foxglove.init_notebook_buffer(context=my_ctx)
|
|
208
|
+
>>>
|
|
209
|
+
>>> # ... log data as usual ...
|
|
210
|
+
>>>
|
|
211
|
+
>>> # Display the widget in the notebook
|
|
212
|
+
>>> nb_buffer.show()
|
|
213
|
+
"""
|
|
214
|
+
try:
|
|
215
|
+
from .notebook.notebook_buffer import NotebookBuffer
|
|
216
|
+
|
|
217
|
+
except ImportError:
|
|
218
|
+
raise Exception(
|
|
219
|
+
"NotebookBuffer is not installed. "
|
|
220
|
+
'Please install it with `pip install "foxglove-sdk[notebook]"`'
|
|
221
|
+
)
|
|
222
|
+
|
|
223
|
+
return NotebookBuffer(context=context)
|
|
224
|
+
|
|
225
|
+
|
|
113
226
|
__all__ = [
|
|
114
227
|
"Channel",
|
|
228
|
+
"ChannelDescriptor",
|
|
229
|
+
"Context",
|
|
115
230
|
"MCAPWriter",
|
|
116
231
|
"Schema",
|
|
232
|
+
"SinkChannelFilter",
|
|
233
|
+
"CloudSink",
|
|
234
|
+
"CloudSinkListener",
|
|
235
|
+
"start_cloud_sink",
|
|
117
236
|
"log",
|
|
118
237
|
"open_mcap",
|
|
119
238
|
"set_log_level",
|
|
120
239
|
"start_server",
|
|
240
|
+
"init_notebook_buffer",
|
|
121
241
|
]
|
|
@@ -1,46 +1,24 @@
|
|
|
1
1
|
from pathlib import Path
|
|
2
|
-
from typing import Any,
|
|
2
|
+
from typing import Any, Callable
|
|
3
3
|
|
|
4
|
-
from .websocket import AssetHandler
|
|
4
|
+
from foxglove.websocket import AssetHandler
|
|
5
5
|
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
Obtain an instance by calling :py:func:`open_mcap`.
|
|
11
|
-
|
|
12
|
-
This class may be used as a context manager, in which case the writer will
|
|
13
|
-
be closed when you exit the context.
|
|
14
|
-
|
|
15
|
-
If the writer is not closed by the time it is garbage collected, it will be
|
|
16
|
-
closed automatically, and any errors will be logged.
|
|
17
|
-
"""
|
|
18
|
-
|
|
19
|
-
def __new__(cls) -> "MCAPWriter": ...
|
|
20
|
-
def __enter__(self) -> "MCAPWriter": ...
|
|
21
|
-
def __exit__(self, exc_type: Any, exc_value: Any, traceback: Any) -> None: ...
|
|
22
|
-
def close(self) -> None:
|
|
23
|
-
"""
|
|
24
|
-
Close the writer explicitly.
|
|
25
|
-
|
|
26
|
-
You may call this to explicitly close the writer. Note that the writer
|
|
27
|
-
will be automatically closed when it is garbage-collected, or when
|
|
28
|
-
exiting the context manager.
|
|
29
|
-
"""
|
|
30
|
-
...
|
|
6
|
+
from .cloud import CloudSink
|
|
7
|
+
from .mcap import MCAPWriteOptions, MCAPWriter
|
|
8
|
+
from .websocket import Capability, Service, WebSocketServer
|
|
31
9
|
|
|
32
10
|
class BaseChannel:
|
|
33
11
|
"""
|
|
34
12
|
A channel for logging messages.
|
|
35
13
|
"""
|
|
36
14
|
|
|
37
|
-
def
|
|
38
|
-
|
|
15
|
+
def __init__(
|
|
16
|
+
self,
|
|
39
17
|
topic: str,
|
|
40
18
|
message_encoding: str,
|
|
41
|
-
schema:
|
|
42
|
-
metadata:
|
|
43
|
-
) ->
|
|
19
|
+
schema: "Schema" | None = None,
|
|
20
|
+
metadata: dict[str, str] | None = None,
|
|
21
|
+
) -> None: ...
|
|
44
22
|
def id(self) -> int:
|
|
45
23
|
"""The unique ID of the channel"""
|
|
46
24
|
...
|
|
@@ -49,20 +27,50 @@ class BaseChannel:
|
|
|
49
27
|
"""The topic name of the channel"""
|
|
50
28
|
...
|
|
51
29
|
|
|
52
|
-
|
|
30
|
+
@property
|
|
31
|
+
def message_encoding(self) -> str:
|
|
32
|
+
"""The message encoding for the channel"""
|
|
33
|
+
...
|
|
34
|
+
|
|
35
|
+
def metadata(self) -> dict[str, str]:
|
|
36
|
+
"""
|
|
37
|
+
Returns a copy of the channel's metadata.
|
|
38
|
+
|
|
39
|
+
Note that changes made to the returned dictionary will not be applied to
|
|
40
|
+
the channel's metadata.
|
|
41
|
+
"""
|
|
42
|
+
...
|
|
43
|
+
|
|
44
|
+
def schema(self) -> "Schema" | None:
|
|
45
|
+
"""
|
|
46
|
+
Returns a copy of the channel's schema.
|
|
47
|
+
|
|
48
|
+
Note that changes made to the returned object will not be applied to
|
|
49
|
+
the channel's schema.
|
|
50
|
+
"""
|
|
51
|
+
...
|
|
52
|
+
|
|
53
|
+
def schema_name(self) -> str | None:
|
|
53
54
|
"""The name of the schema for the channel"""
|
|
54
55
|
...
|
|
55
56
|
|
|
57
|
+
def has_sinks(self) -> bool:
|
|
58
|
+
"""Returns true if at least one sink is subscribed to this channel"""
|
|
59
|
+
...
|
|
60
|
+
|
|
56
61
|
def log(
|
|
57
62
|
self,
|
|
58
63
|
msg: bytes,
|
|
59
|
-
log_time:
|
|
64
|
+
log_time: int | None = None,
|
|
65
|
+
sink_id: int | None = None,
|
|
60
66
|
) -> None:
|
|
61
67
|
"""
|
|
62
68
|
Log a message to the channel.
|
|
63
69
|
|
|
64
70
|
:param msg: The message to log.
|
|
65
71
|
:param log_time: The optional time the message was logged.
|
|
72
|
+
:param sink_id: The sink ID to log the message to. If not provided, the message will be
|
|
73
|
+
sent to all sinks.
|
|
66
74
|
"""
|
|
67
75
|
...
|
|
68
76
|
|
|
@@ -77,30 +85,88 @@ class Schema:
|
|
|
77
85
|
encoding: str
|
|
78
86
|
data: bytes
|
|
79
87
|
|
|
80
|
-
def
|
|
81
|
-
|
|
88
|
+
def __init__(
|
|
89
|
+
self,
|
|
82
90
|
*,
|
|
83
91
|
name: str,
|
|
84
92
|
encoding: str,
|
|
85
93
|
data: bytes,
|
|
86
|
-
) ->
|
|
94
|
+
) -> None: ...
|
|
95
|
+
|
|
96
|
+
class Context:
|
|
97
|
+
"""
|
|
98
|
+
A context for logging messages.
|
|
99
|
+
|
|
100
|
+
A context is the binding between channels and sinks. By default, the SDK will use a single
|
|
101
|
+
global context for logging, but you can create multiple contexts in order to log to different
|
|
102
|
+
topics to different sinks or servers. To do so, associate the context by passing it to the
|
|
103
|
+
channel constructor and to :py:func:`open_mcap` or :py:func:`start_server`.
|
|
104
|
+
"""
|
|
105
|
+
|
|
106
|
+
def __init__(self) -> None: ...
|
|
107
|
+
def _create_channel(
|
|
108
|
+
self,
|
|
109
|
+
topic: str,
|
|
110
|
+
message_encoding: str,
|
|
111
|
+
schema: Schema | None = None,
|
|
112
|
+
metadata: list[tuple[str, str]] | None = None,
|
|
113
|
+
) -> "BaseChannel":
|
|
114
|
+
"""
|
|
115
|
+
Instead of calling this method, pass a context to a channel constructor.
|
|
116
|
+
"""
|
|
117
|
+
...
|
|
118
|
+
|
|
119
|
+
@staticmethod
|
|
120
|
+
def default() -> "Context":
|
|
121
|
+
"""
|
|
122
|
+
Returns the default context.
|
|
123
|
+
"""
|
|
124
|
+
...
|
|
125
|
+
|
|
126
|
+
class ChannelDescriptor:
|
|
127
|
+
"""
|
|
128
|
+
Information about a channel
|
|
129
|
+
"""
|
|
130
|
+
|
|
131
|
+
id: int
|
|
132
|
+
topic: str
|
|
133
|
+
message_encoding: str
|
|
134
|
+
metadata: dict[str, str]
|
|
135
|
+
schema: "Schema" | None
|
|
136
|
+
|
|
137
|
+
SinkChannelFilter = Callable[[ChannelDescriptor], bool]
|
|
87
138
|
|
|
88
139
|
def start_server(
|
|
89
140
|
*,
|
|
90
|
-
name:
|
|
91
|
-
host:
|
|
92
|
-
port:
|
|
93
|
-
capabilities:
|
|
141
|
+
name: str | None = None,
|
|
142
|
+
host: str | None = "127.0.0.1",
|
|
143
|
+
port: int | None = 8765,
|
|
144
|
+
capabilities: list[Capability] | None = None,
|
|
94
145
|
server_listener: Any = None,
|
|
95
|
-
supported_encodings:
|
|
96
|
-
services:
|
|
97
|
-
asset_handler:
|
|
146
|
+
supported_encodings: list[str] | None = None,
|
|
147
|
+
services: list[Service] | None = None,
|
|
148
|
+
asset_handler: AssetHandler | None = None,
|
|
149
|
+
context: Context | None = None,
|
|
150
|
+
session_id: str | None = None,
|
|
151
|
+
channel_filter: SinkChannelFilter | None = None,
|
|
98
152
|
) -> WebSocketServer:
|
|
99
153
|
"""
|
|
100
154
|
Start a websocket server for live visualization.
|
|
101
155
|
"""
|
|
102
156
|
...
|
|
103
157
|
|
|
158
|
+
def start_cloud_sink(
|
|
159
|
+
*,
|
|
160
|
+
listener: Any = None,
|
|
161
|
+
supported_encodings: list[str] | None = None,
|
|
162
|
+
context: Context | None = None,
|
|
163
|
+
session_id: str | None = None,
|
|
164
|
+
) -> CloudSink:
|
|
165
|
+
"""
|
|
166
|
+
Connect to Foxglove Agent for remote visualization and teleop.
|
|
167
|
+
"""
|
|
168
|
+
...
|
|
169
|
+
|
|
104
170
|
def enable_logging(level: int) -> None:
|
|
105
171
|
"""
|
|
106
172
|
Forward SDK logs to python's logging facility.
|
|
@@ -119,17 +185,25 @@ def shutdown() -> None:
|
|
|
119
185
|
"""
|
|
120
186
|
...
|
|
121
187
|
|
|
122
|
-
def open_mcap(
|
|
188
|
+
def open_mcap(
|
|
189
|
+
path: str | Path,
|
|
190
|
+
*,
|
|
191
|
+
allow_overwrite: bool = False,
|
|
192
|
+
context: Context | None = None,
|
|
193
|
+
channel_filter: SinkChannelFilter | None = None,
|
|
194
|
+
writer_options: MCAPWriteOptions | None = None,
|
|
195
|
+
) -> MCAPWriter:
|
|
123
196
|
"""
|
|
124
197
|
Creates a new MCAP file for recording.
|
|
125
198
|
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
199
|
+
If a context is provided, the MCAP file will be associated with that context. Otherwise, the
|
|
200
|
+
global context will be used.
|
|
201
|
+
|
|
202
|
+
You must close the writer with close() or the with statement to ensure the file is correctly finished.
|
|
129
203
|
"""
|
|
130
204
|
...
|
|
131
205
|
|
|
132
|
-
def get_channel_for_topic(topic: str) -> BaseChannel:
|
|
206
|
+
def get_channel_for_topic(topic: str) -> BaseChannel | None:
|
|
133
207
|
"""
|
|
134
208
|
Get a previously-registered channel.
|
|
135
209
|
"""
|