python-osc 1.10.2__tar.gz → 1.10.3__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.
- {python_osc-1.10.2 → python_osc-1.10.3}/PKG-INFO +1 -1
- python_osc-1.10.3/pyproject.toml +59 -0
- python_osc-1.10.2/pyproject.toml → python_osc-1.10.3/pyproject.toml.orig +1 -1
- {python_osc-1.10.2 → python_osc-1.10.3}/pythonosc/dispatcher.py +8 -6
- {python_osc-1.10.2 → python_osc-1.10.3}/pythonosc/osc_bundle.py +6 -0
- {python_osc-1.10.2 → python_osc-1.10.3}/pythonosc/osc_message_builder.py +7 -4
- {python_osc-1.10.2 → python_osc-1.10.3}/pythonosc/osc_server.py +3 -0
- python_osc-1.10.3/pythonosc/osc_tcp_server.py +230 -0
- python_osc-1.10.3/pythonosc/parsing/framing.py +166 -0
- {python_osc-1.10.2 → python_osc-1.10.3}/pythonosc/parsing/osc_types.py +13 -7
- {python_osc-1.10.2 → python_osc-1.10.3}/pythonosc/tcp_client.py +49 -126
- python_osc-1.10.3/pythonosc/test/parsing/test_framing.py +158 -0
- {python_osc-1.10.2 → python_osc-1.10.3}/pythonosc/test/parsing/test_osc_types.py +28 -0
- {python_osc-1.10.2 → python_osc-1.10.3}/pythonosc/test/test_dispatcher.py +21 -0
- python_osc-1.10.3/pythonosc/test/test_empty_string_messages.py +87 -0
- {python_osc-1.10.2 → python_osc-1.10.3}/pythonosc/test/test_osc_bundle.py +21 -2
- {python_osc-1.10.2 → python_osc-1.10.3}/pythonosc/test/test_osc_message.py +5 -0
- {python_osc-1.10.2 → python_osc-1.10.3}/pythonosc/test/test_osc_server.py +31 -1
- {python_osc-1.10.2 → python_osc-1.10.3}/pythonosc/test/test_osc_tcp_server.py +45 -27
- {python_osc-1.10.2 → python_osc-1.10.3}/pythonosc/test/test_tcp_client.py +2 -1
- python_osc-1.10.2/pythonosc/osc_tcp_server.py +0 -346
- {python_osc-1.10.2 → python_osc-1.10.3}/LICENSE.txt +0 -0
- {python_osc-1.10.2 → python_osc-1.10.3}/README.rst +0 -0
- {python_osc-1.10.2 → python_osc-1.10.3}/pythonosc/__init__.py +0 -0
- {python_osc-1.10.2 → python_osc-1.10.3}/pythonosc/osc_bundle_builder.py +0 -0
- {python_osc-1.10.2 → python_osc-1.10.3}/pythonosc/osc_message.py +0 -0
- {python_osc-1.10.2 → python_osc-1.10.3}/pythonosc/osc_packet.py +0 -0
- {python_osc-1.10.2 → python_osc-1.10.3}/pythonosc/parsing/__init__.py +0 -0
- {python_osc-1.10.2 → python_osc-1.10.3}/pythonosc/parsing/ntp.py +0 -0
- {python_osc-1.10.2/pythonosc → python_osc-1.10.3/pythonosc/parsing}/slip.py +0 -0
- {python_osc-1.10.2 → python_osc-1.10.3}/pythonosc/py.typed +0 -0
- {python_osc-1.10.2 → python_osc-1.10.3}/pythonosc/test/__init__.py +0 -0
- {python_osc-1.10.2 → python_osc-1.10.3}/pythonosc/test/parsing/__init__.py +0 -0
- {python_osc-1.10.2 → python_osc-1.10.3}/pythonosc/test/parsing/test_ntp.py +0 -0
- {python_osc-1.10.2 → python_osc-1.10.3}/pythonosc/test/test_osc_bundle_builder.py +0 -0
- {python_osc-1.10.2 → python_osc-1.10.3}/pythonosc/test/test_osc_message_builder.py +0 -0
- {python_osc-1.10.2 → python_osc-1.10.3}/pythonosc/test/test_osc_packet.py +0 -0
- {python_osc-1.10.2 → python_osc-1.10.3}/pythonosc/test/test_udp_client.py +0 -0
- {python_osc-1.10.2 → python_osc-1.10.3}/pythonosc/udp_client.py +0 -0
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["uv-build"]
|
|
3
|
+
build-backend = "uv_build"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "python-osc"
|
|
7
|
+
version = "1.10.3"
|
|
8
|
+
description = "Open Sound Control server and client implementations in pure Python"
|
|
9
|
+
readme = "README.rst"
|
|
10
|
+
requires-python = ">=3.10"
|
|
11
|
+
keywords = [
|
|
12
|
+
"osc",
|
|
13
|
+
"sound",
|
|
14
|
+
"midi",
|
|
15
|
+
"music",
|
|
16
|
+
]
|
|
17
|
+
classifiers = [
|
|
18
|
+
"Development Status :: 5 - Production/Stable",
|
|
19
|
+
"Intended Audience :: Developers",
|
|
20
|
+
"License :: Freely Distributable",
|
|
21
|
+
"Programming Language :: Python :: 3",
|
|
22
|
+
"Topic :: Multimedia :: Sound/Audio",
|
|
23
|
+
"Topic :: System :: Networking",
|
|
24
|
+
]
|
|
25
|
+
dependencies = []
|
|
26
|
+
|
|
27
|
+
[project.license]
|
|
28
|
+
file = "LICENSE.txt"
|
|
29
|
+
|
|
30
|
+
[[project.authors]]
|
|
31
|
+
name = "attwad"
|
|
32
|
+
email = "tmusoft@gmail.com"
|
|
33
|
+
|
|
34
|
+
[project.urls]
|
|
35
|
+
Repository = "https://github.com/attwad/python-osc"
|
|
36
|
+
|
|
37
|
+
[dependency-groups]
|
|
38
|
+
dev = [
|
|
39
|
+
"pytest",
|
|
40
|
+
"mypy",
|
|
41
|
+
"ruff",
|
|
42
|
+
"pytest-cov",
|
|
43
|
+
"pre-commit",
|
|
44
|
+
]
|
|
45
|
+
|
|
46
|
+
[tool.uv.build-backend]
|
|
47
|
+
module-name = "pythonosc"
|
|
48
|
+
module-root = "."
|
|
49
|
+
|
|
50
|
+
[tool.mypy]
|
|
51
|
+
disallow_subclassing_any = true
|
|
52
|
+
disallow_incomplete_defs = true
|
|
53
|
+
disallow_untyped_decorators = true
|
|
54
|
+
warn_redundant_casts = true
|
|
55
|
+
warn_unused_ignores = true
|
|
56
|
+
show_error_codes = true
|
|
57
|
+
scripts_are_modules = true
|
|
58
|
+
warn_unused_configs = true
|
|
59
|
+
enable_error_code = "ignore-without-code"
|
|
@@ -154,13 +154,15 @@ class Dispatcher(object):
|
|
|
154
154
|
|
|
155
155
|
The callback function must have one of the following signatures:
|
|
156
156
|
|
|
157
|
-
|
|
158
|
-
``def some_cb(address: str, fixed_args: List[Any], *osc_args: List[Any]) -> Union[None, AnyStr,
|
|
159
|
-
Tuple(str, ArgValue)]:``
|
|
157
|
+
.. code-block:: python
|
|
160
158
|
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
159
|
+
def some_cb(address: str, *osc_args: ArgValue) -> Union[None, str, Tuple[str, ArgValue]]: ...
|
|
160
|
+
def some_cb(address: str, fixed_args: List[Any], *osc_args: ArgValue) -> Union[None, str, Tuple[str, ArgValue]]: ...
|
|
161
|
+
def some_cb(client_address: Tuple[str, int], address: str, *osc_args: ArgValue) -> Union[None, str, Tuple[str, ArgValue]]: ...
|
|
162
|
+
def some_cb(client_address: Tuple[str, int], address: str, fixed_args: List[Any], *osc_args: ArgValue) -> Union[None, str, Tuple[str, ArgValue]]: ...
|
|
163
|
+
|
|
164
|
+
Each variadic OSC argument is a single ArgValue, not a list of all arguments.
|
|
165
|
+
Any fixed arguments passed to map() are collected into the separate fixed_args list.
|
|
164
166
|
|
|
165
167
|
The callback function can return None, or a string representing an OSC address to be returned to the client,
|
|
166
168
|
or a tuple that includes the address and ArgValue which will be converted to an OSC message and returned to
|
|
@@ -50,6 +50,12 @@ class OscBundle(object):
|
|
|
50
50
|
while self._dgram[index:]:
|
|
51
51
|
# Get the sub content size.
|
|
52
52
|
content_size, index = osc_types.get_int(self._dgram, index)
|
|
53
|
+
if content_size <= 0 or content_size % 4:
|
|
54
|
+
raise ParseError(
|
|
55
|
+
"Invalid bundle element size: {}".format(content_size)
|
|
56
|
+
)
|
|
57
|
+
if content_size > len(self._dgram) - index:
|
|
58
|
+
raise ParseError("Bundle element exceeds the datagram length")
|
|
53
59
|
# Get the datagram for the sub content.
|
|
54
60
|
content_dgram = self._dgram[index : index + content_size]
|
|
55
61
|
# Increment our position index up to the next possible content.
|
|
@@ -200,13 +200,16 @@ class OscMessageBuilder(object):
|
|
|
200
200
|
|
|
201
201
|
|
|
202
202
|
def build_msg(
|
|
203
|
-
address: str, value: Union[ArgValue, Iterable[ArgValue]] =
|
|
203
|
+
address: str, value: Union[ArgValue, Iterable[ArgValue]] = ()
|
|
204
204
|
) -> osc_message.OscMessage:
|
|
205
|
+
"""Build a message from a scalar value or an iterable of arguments.
|
|
206
|
+
|
|
207
|
+
Omit the value or pass an empty iterable for a message with no arguments.
|
|
208
|
+
An explicitly supplied empty string is retained as a string argument.
|
|
209
|
+
"""
|
|
205
210
|
builder = OscMessageBuilder(address=address)
|
|
206
211
|
values: Iterable[Any]
|
|
207
|
-
if value
|
|
208
|
-
values = []
|
|
209
|
-
elif not isinstance(value, Iterable) or isinstance(value, (str, bytes)):
|
|
212
|
+
if not isinstance(value, Iterable) or isinstance(value, (str, bytes)):
|
|
210
213
|
values = [value]
|
|
211
214
|
else:
|
|
212
215
|
values = value
|
|
@@ -59,6 +59,9 @@ def _is_valid_request(request: _RequestType) -> bool:
|
|
|
59
59
|
class OSCUDPServer(socketserver.UDPServer):
|
|
60
60
|
"""Superclass for different flavors of OSC UDP servers"""
|
|
61
61
|
|
|
62
|
+
# Receive complete UDP datagrams instead of socketserver's 8192-byte default.
|
|
63
|
+
max_packet_size = 65535
|
|
64
|
+
|
|
62
65
|
def __init__(
|
|
63
66
|
self,
|
|
64
67
|
server_address: Tuple[str, int],
|
|
@@ -0,0 +1,230 @@
|
|
|
1
|
+
"""OSC Servers that receive TCP packets and invoke handlers accordingly.
|
|
2
|
+
|
|
3
|
+
Use like this:
|
|
4
|
+
|
|
5
|
+
dispatcher = dispatcher.Dispatcher()
|
|
6
|
+
# This will print all parameters to stdout.
|
|
7
|
+
dispatcher.map("/bpm", print)
|
|
8
|
+
server = ForkingOSCTCPServer((ip, port), dispatcher)
|
|
9
|
+
server.serve_forever()
|
|
10
|
+
|
|
11
|
+
or run the server on its own thread:
|
|
12
|
+
server = ForkingOSCTCPServer((ip, port), dispatcher)
|
|
13
|
+
server_thread = threading.Thread(target=server.serve_forever)
|
|
14
|
+
server_thread.start()
|
|
15
|
+
...
|
|
16
|
+
server.shutdown()
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
Those servers are using the standard socketserver from the standard library:
|
|
20
|
+
http://docs.python.org/library/socketserver.html
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
Alternatively, the AsyncIOOSCTCPServer server can be integrated with an
|
|
24
|
+
asyncio event loop:
|
|
25
|
+
|
|
26
|
+
loop = asyncio.get_event_loop()
|
|
27
|
+
server = AsyncIOOSCTCPServer(server_address, dispatcher)
|
|
28
|
+
server.serve()
|
|
29
|
+
loop.run_forever()
|
|
30
|
+
|
|
31
|
+
"""
|
|
32
|
+
|
|
33
|
+
# mypy: disable-error-code="attr-defined"
|
|
34
|
+
|
|
35
|
+
import asyncio
|
|
36
|
+
import os
|
|
37
|
+
import socket
|
|
38
|
+
import socketserver
|
|
39
|
+
from typing import Tuple
|
|
40
|
+
|
|
41
|
+
from pythonosc import osc_message_builder
|
|
42
|
+
from pythonosc.dispatcher import Dispatcher
|
|
43
|
+
from pythonosc.parsing.framing import (
|
|
44
|
+
BINARY_LENGTH_FRAMING,
|
|
45
|
+
SLIP_FRAMING,
|
|
46
|
+
Framing,
|
|
47
|
+
FramingParser,
|
|
48
|
+
)
|
|
49
|
+
|
|
50
|
+
MODE_1_0 = "1.0"
|
|
51
|
+
MODE_1_1 = "1.1"
|
|
52
|
+
FRAMING_BY_MODE: dict[str, Framing] = {
|
|
53
|
+
MODE_1_0: BINARY_LENGTH_FRAMING,
|
|
54
|
+
MODE_1_1: SLIP_FRAMING,
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def get_framing(mode: str) -> Framing:
|
|
59
|
+
try:
|
|
60
|
+
return FRAMING_BY_MODE[mode]
|
|
61
|
+
except KeyError:
|
|
62
|
+
raise ValueError(f"Unsupported OSC mode: {mode}") from None
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
class TCPHandler(socketserver.BaseRequestHandler):
|
|
66
|
+
"""Handles correct OSC messages.
|
|
67
|
+
|
|
68
|
+
Whether this will be run on its own thread, the server's or a whole new
|
|
69
|
+
process depends on the server you instantiated, look at their documentation.
|
|
70
|
+
|
|
71
|
+
This method is called after a basic sanity check was done on the datagram,
|
|
72
|
+
basically whether this datagram looks like an osc message or bundle,
|
|
73
|
+
if not the server won't even bother to call it and so no new
|
|
74
|
+
threads/processes will be spawned.
|
|
75
|
+
"""
|
|
76
|
+
|
|
77
|
+
def handle(self) -> None:
|
|
78
|
+
assert isinstance(self.server, OSCTCPServer)
|
|
79
|
+
|
|
80
|
+
parser = FramingParser(self.server.framing)
|
|
81
|
+
|
|
82
|
+
while chunk := self.request.recv(16384):
|
|
83
|
+
for p in parser.feed(chunk):
|
|
84
|
+
resp = self.server.dispatcher.call_handlers_for_packet(
|
|
85
|
+
p, self.client_address
|
|
86
|
+
)
|
|
87
|
+
for r in resp:
|
|
88
|
+
if not isinstance(r, tuple):
|
|
89
|
+
r = [r]
|
|
90
|
+
msg = osc_message_builder.build_msg(r[0], r[1:])
|
|
91
|
+
self.request.sendall(self.server.framing.encode(msg.dgram))
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
class OSCTCPServer(socketserver.TCPServer):
|
|
95
|
+
"""Superclass for different flavors of OSCTCPServer"""
|
|
96
|
+
|
|
97
|
+
dispatcher: Dispatcher
|
|
98
|
+
framing: Framing
|
|
99
|
+
|
|
100
|
+
def __init__(
|
|
101
|
+
self,
|
|
102
|
+
server_address: Tuple[str | bytes | bytearray, int],
|
|
103
|
+
dispatcher: Dispatcher,
|
|
104
|
+
mode: str = MODE_1_1,
|
|
105
|
+
family: socket.AddressFamily | None = None,
|
|
106
|
+
):
|
|
107
|
+
self.request_queue_size = 300
|
|
108
|
+
self.dispatcher = dispatcher
|
|
109
|
+
self.framing = get_framing(mode)
|
|
110
|
+
|
|
111
|
+
if family is not None:
|
|
112
|
+
self.address_family = family
|
|
113
|
+
elif isinstance(server_address[0], str):
|
|
114
|
+
# Try to infer address family from server_address
|
|
115
|
+
try:
|
|
116
|
+
infos = socket.getaddrinfo(
|
|
117
|
+
server_address[0],
|
|
118
|
+
server_address[1],
|
|
119
|
+
type=socket.SOCK_STREAM,
|
|
120
|
+
family=socket.AF_UNSPEC,
|
|
121
|
+
)
|
|
122
|
+
if infos:
|
|
123
|
+
self.address_family = infos[0][0]
|
|
124
|
+
except (socket.gaierror, IndexError):
|
|
125
|
+
# Fallback to default if resolution fails
|
|
126
|
+
pass
|
|
127
|
+
|
|
128
|
+
super().__init__(server_address, TCPHandler)
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
class BlockingOSCTCPServer(OSCTCPServer):
|
|
132
|
+
"""Blocking version of the TCP server.
|
|
133
|
+
|
|
134
|
+
Each message will be handled sequentially on the same thread.
|
|
135
|
+
Use this is you don't care about latency in your message handling or don't
|
|
136
|
+
have a multiprocess/multithread environment (really?).
|
|
137
|
+
"""
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
class ThreadingOSCTCPServer(socketserver.ThreadingMixIn, OSCTCPServer):
|
|
141
|
+
"""Threading version of the OSC TCP server.
|
|
142
|
+
|
|
143
|
+
Each message will be handled in its own new thread.
|
|
144
|
+
Use this when lightweight operations are done by each message handlers.
|
|
145
|
+
"""
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
if hasattr(os, "fork"):
|
|
149
|
+
|
|
150
|
+
class ForkingOSCTCPServer(socketserver.ForkingMixIn, OSCTCPServer):
|
|
151
|
+
"""Forking version of the OSC TCP server.
|
|
152
|
+
|
|
153
|
+
Each message will be handled in its own new process.
|
|
154
|
+
Use this when heavyweight operations are done by each message handlers
|
|
155
|
+
and forking a whole new process for each of them is worth it.
|
|
156
|
+
"""
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
class AsyncOSCTCPServer:
|
|
160
|
+
"""Asyncio version of the OSC TCP Server.
|
|
161
|
+
Each TCP message is handled by _call_handlers_for_packet, the same method as in the
|
|
162
|
+
OSCTCPServer family of blocking, threading, and forking servers
|
|
163
|
+
"""
|
|
164
|
+
|
|
165
|
+
def __init__(
|
|
166
|
+
self,
|
|
167
|
+
server_address: str,
|
|
168
|
+
port: int,
|
|
169
|
+
dispatcher: Dispatcher,
|
|
170
|
+
mode: str = MODE_1_1,
|
|
171
|
+
):
|
|
172
|
+
"""
|
|
173
|
+
:param server_address: tuple of (IP address to bind to, port)
|
|
174
|
+
:param dispatcher: a pythonosc.dispatcher.Dispatcher
|
|
175
|
+
"""
|
|
176
|
+
self._port = port
|
|
177
|
+
self._server_address = server_address
|
|
178
|
+
self.dispatcher = dispatcher
|
|
179
|
+
self.framing = get_framing(mode)
|
|
180
|
+
|
|
181
|
+
async def __aenter__(self):
|
|
182
|
+
return self
|
|
183
|
+
|
|
184
|
+
async def __aexit__(self, exc_type, exc_val, exc_tb):
|
|
185
|
+
await self.stop()
|
|
186
|
+
|
|
187
|
+
async def start(self) -> None:
|
|
188
|
+
"""creates a socket endpoint and registers it with our event loop"""
|
|
189
|
+
self._server = await asyncio.start_server(
|
|
190
|
+
self.handle, self._server_address, self._port
|
|
191
|
+
)
|
|
192
|
+
|
|
193
|
+
async with self._server:
|
|
194
|
+
await self._server.serve_forever()
|
|
195
|
+
|
|
196
|
+
async def stop(self) -> None:
|
|
197
|
+
self._server.close()
|
|
198
|
+
await self._server.wait_closed()
|
|
199
|
+
|
|
200
|
+
async def handle(
|
|
201
|
+
self, reader: asyncio.StreamReader, writer: asyncio.StreamWriter
|
|
202
|
+
) -> None:
|
|
203
|
+
try:
|
|
204
|
+
await self._handle(reader, writer)
|
|
205
|
+
finally:
|
|
206
|
+
if writer.can_write_eof():
|
|
207
|
+
writer.write_eof()
|
|
208
|
+
writer.close()
|
|
209
|
+
await writer.wait_closed()
|
|
210
|
+
|
|
211
|
+
async def _handle(
|
|
212
|
+
self, reader: asyncio.StreamReader, writer: asyncio.StreamWriter
|
|
213
|
+
) -> None:
|
|
214
|
+
parser = FramingParser(self.framing)
|
|
215
|
+
client_address = ("", 0)
|
|
216
|
+
sock = writer.transport.get_extra_info("socket")
|
|
217
|
+
if sock is not None:
|
|
218
|
+
client_address = sock.getpeername()
|
|
219
|
+
|
|
220
|
+
while chunk := await reader.read(16384):
|
|
221
|
+
for p in parser.feed(chunk):
|
|
222
|
+
result = await self.dispatcher.async_call_handlers_for_packet(
|
|
223
|
+
p, client_address
|
|
224
|
+
)
|
|
225
|
+
for r in result:
|
|
226
|
+
if not isinstance(r, tuple):
|
|
227
|
+
r = [r]
|
|
228
|
+
msg = osc_message_builder.build_msg(r[0], r[1:])
|
|
229
|
+
writer.write(self.framing.encode(msg.dgram))
|
|
230
|
+
await writer.drain()
|
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
"""OSC message codecs for different framing strategies."""
|
|
2
|
+
|
|
3
|
+
from abc import ABCMeta, abstractmethod
|
|
4
|
+
from struct import pack, unpack_from
|
|
5
|
+
|
|
6
|
+
from pythonosc.parsing.slip import (
|
|
7
|
+
END,
|
|
8
|
+
decode as slip_decode,
|
|
9
|
+
encode as slip_encode,
|
|
10
|
+
)
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class Framing(metaclass=ABCMeta):
|
|
14
|
+
"""Base class for OSC stream framing strategies."""
|
|
15
|
+
|
|
16
|
+
@abstractmethod
|
|
17
|
+
def encode(self, dgram: bytes) -> bytes:
|
|
18
|
+
"""Encode a single OSC datagram for transmission on a byte stream.
|
|
19
|
+
|
|
20
|
+
Args:
|
|
21
|
+
dgram: Raw OSC datagram bytes.
|
|
22
|
+
|
|
23
|
+
Returns:
|
|
24
|
+
The framed byte sequence to write to the stream.
|
|
25
|
+
"""
|
|
26
|
+
|
|
27
|
+
@abstractmethod
|
|
28
|
+
def decode_stream(
|
|
29
|
+
self, buf: bytes | bytearray, start: int = 0
|
|
30
|
+
) -> tuple[list[bytes], int]:
|
|
31
|
+
"""Decode complete OSC datagrams from a byte buffer.
|
|
32
|
+
|
|
33
|
+
Args:
|
|
34
|
+
buf: Bytes received from a stream.
|
|
35
|
+
start: The index in ``buf`` to start decoding from.
|
|
36
|
+
|
|
37
|
+
Returns:
|
|
38
|
+
A tuple ``(messages, bytes consumed)``.
|
|
39
|
+
"""
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
class SLIPFraming(Framing):
|
|
43
|
+
"""SLIP (RFC 1055) framing for OSC 1.1.
|
|
44
|
+
|
|
45
|
+
See the "OSC Delivery Specification 1.1" section from the 2009 NIME paper for details.
|
|
46
|
+
"""
|
|
47
|
+
|
|
48
|
+
def encode(self, dgram: bytes) -> bytes:
|
|
49
|
+
return slip_encode(dgram)
|
|
50
|
+
|
|
51
|
+
def decode_stream(
|
|
52
|
+
self, buf: bytes | bytearray, start: int = 0
|
|
53
|
+
) -> tuple[list[bytes], int]:
|
|
54
|
+
pos = start
|
|
55
|
+
messages = []
|
|
56
|
+
|
|
57
|
+
scan = start
|
|
58
|
+
while (end := buf.find(END, scan)) >= 0:
|
|
59
|
+
if end > scan:
|
|
60
|
+
messages.append(slip_decode(bytes(buf[scan:end])))
|
|
61
|
+
pos = end + len(END)
|
|
62
|
+
scan = end + len(END)
|
|
63
|
+
|
|
64
|
+
# Consume trailing delimiters, but retain an incomplete packet.
|
|
65
|
+
if scan == len(buf):
|
|
66
|
+
pos = scan
|
|
67
|
+
|
|
68
|
+
return messages, pos - start
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
class BinaryLengthFraming(Framing):
|
|
72
|
+
"""Length-prefixed framing for OSC 1.0.
|
|
73
|
+
|
|
74
|
+
See the "OSC Packets" section from OSC 1.0 Specification for details.
|
|
75
|
+
"""
|
|
76
|
+
|
|
77
|
+
def encode(self, dgram: bytes) -> bytes:
|
|
78
|
+
if dgram:
|
|
79
|
+
return pack("!I", len(dgram)) + dgram
|
|
80
|
+
return b""
|
|
81
|
+
|
|
82
|
+
def decode_stream(
|
|
83
|
+
self, buf: bytes | bytearray, start: int = 0
|
|
84
|
+
) -> tuple[list[bytes], int]:
|
|
85
|
+
messages = []
|
|
86
|
+
pos = start
|
|
87
|
+
|
|
88
|
+
while (data_pos := pos + 4) < len(buf):
|
|
89
|
+
(length,) = unpack_from("!I", buf, pos)
|
|
90
|
+
end = data_pos + length
|
|
91
|
+
|
|
92
|
+
if end > len(buf):
|
|
93
|
+
break
|
|
94
|
+
|
|
95
|
+
messages.append(bytes(buf[data_pos:end]))
|
|
96
|
+
|
|
97
|
+
pos = end
|
|
98
|
+
|
|
99
|
+
return messages, pos - start
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
class FramingParser:
|
|
103
|
+
"""Stateful stream decoder for (framed) OSC byte stream.
|
|
104
|
+
|
|
105
|
+
Not safe for concurrent use.
|
|
106
|
+
|
|
107
|
+
Args:
|
|
108
|
+
framing: Strategy used to decode buffered stream data.
|
|
109
|
+
max_buffer_size: Maximum size of the internal buffer.
|
|
110
|
+
"""
|
|
111
|
+
|
|
112
|
+
__slots__ = ("buf", "start", "framing", "max_buffer_size")
|
|
113
|
+
|
|
114
|
+
def __init__(self, framing: Framing, max_buffer_size: int = 1048576) -> None:
|
|
115
|
+
self.buf = bytearray()
|
|
116
|
+
self.start = 0
|
|
117
|
+
self.framing = framing
|
|
118
|
+
self.max_buffer_size = max_buffer_size
|
|
119
|
+
|
|
120
|
+
def feed(self, chunk: bytes | bytearray | memoryview) -> list[bytes]:
|
|
121
|
+
"""Append stream data to the internal buffer and return any complete OSC datagrams.
|
|
122
|
+
|
|
123
|
+
Args:
|
|
124
|
+
chunk: Newly received bytes from the stream.
|
|
125
|
+
|
|
126
|
+
Returns:
|
|
127
|
+
A list of complete, decoded OSC datagrams.
|
|
128
|
+
"""
|
|
129
|
+
|
|
130
|
+
new_buf_len = len(self.buf) + len(chunk)
|
|
131
|
+
|
|
132
|
+
if (pending := new_buf_len - self.start) > self.max_buffer_size:
|
|
133
|
+
raise ValueError(f"Buffer is too large: {pending} > {self.max_buffer_size}")
|
|
134
|
+
|
|
135
|
+
if new_buf_len > self.max_buffer_size:
|
|
136
|
+
del self.buf[: self.start]
|
|
137
|
+
self.start = 0
|
|
138
|
+
|
|
139
|
+
self.buf.extend(chunk)
|
|
140
|
+
|
|
141
|
+
messages, consumed = self.framing.decode_stream(self.buf, self.start)
|
|
142
|
+
available = len(self.buf) - self.start
|
|
143
|
+
|
|
144
|
+
assert 0 <= consumed <= available, (
|
|
145
|
+
f"misbehaving framing decoder: {consumed=} {available=}"
|
|
146
|
+
)
|
|
147
|
+
|
|
148
|
+
self.start += consumed
|
|
149
|
+
|
|
150
|
+
return messages
|
|
151
|
+
|
|
152
|
+
@property
|
|
153
|
+
def remaining(self) -> bytes:
|
|
154
|
+
"""Return any unparsed bytes."""
|
|
155
|
+
|
|
156
|
+
return bytes(self.buf[self.start :])
|
|
157
|
+
|
|
158
|
+
def reset(self) -> None:
|
|
159
|
+
"""Clear the internal buffer (discard unparsed data)."""
|
|
160
|
+
|
|
161
|
+
del self.buf[:]
|
|
162
|
+
self.start = 0
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
SLIP_FRAMING: Framing = SLIPFraming()
|
|
166
|
+
BINARY_LENGTH_FRAMING: Framing = BinaryLengthFraming()
|
|
@@ -86,6 +86,8 @@ def get_string(dgram: bytes, start_index: int) -> Tuple[str, int]:
|
|
|
86
86
|
|
|
87
87
|
data_str = dgram[start_index : start_index + offset]
|
|
88
88
|
return data_str.decode("utf-8"), start_index + total_len
|
|
89
|
+
except UnicodeDecodeError as error:
|
|
90
|
+
raise ParseError(f"Could not decode OSC string: {error}") from error
|
|
89
91
|
except IndexError as ie:
|
|
90
92
|
raise ParseError(f"Could not parse datagram {ie}")
|
|
91
93
|
except TypeError as te:
|
|
@@ -307,7 +309,7 @@ def get_blob(dgram: bytes, start_index: int) -> Tuple[bytes, int]:
|
|
|
307
309
|
|
|
308
310
|
Args:
|
|
309
311
|
dgram: A datagram packet.
|
|
310
|
-
start_index: An index where the
|
|
312
|
+
start_index: An index where the blob starts in the datagram.
|
|
311
313
|
|
|
312
314
|
Returns:
|
|
313
315
|
A tuple containing the blob and the new end index.
|
|
@@ -315,13 +317,17 @@ def get_blob(dgram: bytes, start_index: int) -> Tuple[bytes, int]:
|
|
|
315
317
|
Raises:
|
|
316
318
|
ParseError if the datagram could not be parsed.
|
|
317
319
|
"""
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
320
|
+
if start_index < 0:
|
|
321
|
+
raise ParseError("start_index < 0")
|
|
322
|
+
size, payload_start = get_int(dgram, start_index)
|
|
323
|
+
if size < 0:
|
|
324
|
+
raise ParseError("Blob length cannot be negative")
|
|
325
|
+
|
|
326
|
+
payload_end = payload_start + size
|
|
327
|
+
next_index = payload_end + (-size % _BLOB_DGRAM_PAD)
|
|
328
|
+
if next_index > len(dgram):
|
|
323
329
|
raise ParseError("Datagram is too short.")
|
|
324
|
-
return dgram[
|
|
330
|
+
return dgram[payload_start:payload_end], next_index
|
|
325
331
|
|
|
326
332
|
|
|
327
333
|
def write_blob(val: bytes) -> bytes:
|