python-osc 1.9.3__tar.gz → 1.10.2__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.
Files changed (40) hide show
  1. {python_osc-1.9.3/python_osc.egg-info → python_osc-1.10.2}/PKG-INFO +30 -31
  2. {python_osc-1.9.3 → python_osc-1.10.2}/README.rst +2 -2
  3. {python_osc-1.9.3 → python_osc-1.10.2}/pyproject.toml +17 -3
  4. {python_osc-1.9.3 → python_osc-1.10.2}/pythonosc/dispatcher.py +113 -61
  5. {python_osc-1.9.3 → python_osc-1.10.2}/pythonosc/osc_bundle_builder.py +4 -2
  6. {python_osc-1.9.3 → python_osc-1.10.2}/pythonosc/osc_message.py +6 -2
  7. {python_osc-1.9.3 → python_osc-1.10.2}/pythonosc/osc_message_builder.py +18 -10
  8. {python_osc-1.9.3 → python_osc-1.10.2}/pythonosc/osc_packet.py +1 -2
  9. {python_osc-1.9.3 → python_osc-1.10.2}/pythonosc/osc_server.py +24 -2
  10. {python_osc-1.9.3 → python_osc-1.10.2}/pythonosc/osc_tcp_server.py +24 -4
  11. {python_osc-1.9.3 → python_osc-1.10.2}/pythonosc/parsing/osc_types.py +12 -18
  12. {python_osc-1.9.3 → python_osc-1.10.2}/pythonosc/tcp_client.py +61 -23
  13. {python_osc-1.9.3 → python_osc-1.10.2}/pythonosc/test/parsing/test_osc_types.py +5 -5
  14. {python_osc-1.9.3 → python_osc-1.10.2}/pythonosc/test/test_dispatcher.py +61 -1
  15. {python_osc-1.9.3 → python_osc-1.10.2}/pythonosc/test/test_osc_bundle.py +4 -10
  16. {python_osc-1.9.3 → python_osc-1.10.2}/pythonosc/test/test_osc_message.py +4 -12
  17. {python_osc-1.9.3 → python_osc-1.10.2}/pythonosc/test/test_osc_message_builder.py +1 -1
  18. {python_osc-1.9.3 → python_osc-1.10.2}/pythonosc/test/test_osc_packet.py +1 -1
  19. {python_osc-1.9.3 → python_osc-1.10.2}/pythonosc/test/test_osc_server.py +25 -1
  20. {python_osc-1.9.3 → python_osc-1.10.2}/pythonosc/test/test_osc_tcp_server.py +9 -9
  21. {python_osc-1.9.3 → python_osc-1.10.2}/pythonosc/test/test_udp_client.py +41 -0
  22. {python_osc-1.9.3 → python_osc-1.10.2}/pythonosc/udp_client.py +36 -10
  23. python_osc-1.9.3/MANIFEST.in +0 -3
  24. python_osc-1.9.3/PKG-INFO +0 -198
  25. python_osc-1.9.3/python_osc.egg-info/SOURCES.txt +0 -38
  26. python_osc-1.9.3/python_osc.egg-info/dependency_links.txt +0 -1
  27. python_osc-1.9.3/python_osc.egg-info/top_level.txt +0 -1
  28. python_osc-1.9.3/setup.cfg +0 -4
  29. {python_osc-1.9.3 → python_osc-1.10.2}/LICENSE.txt +0 -0
  30. {python_osc-1.9.3 → python_osc-1.10.2}/pythonosc/__init__.py +0 -0
  31. {python_osc-1.9.3 → python_osc-1.10.2}/pythonosc/osc_bundle.py +0 -0
  32. {python_osc-1.9.3 → python_osc-1.10.2}/pythonosc/parsing/__init__.py +0 -0
  33. {python_osc-1.9.3 → python_osc-1.10.2}/pythonosc/parsing/ntp.py +0 -0
  34. {python_osc-1.9.3 → python_osc-1.10.2}/pythonosc/py.typed +0 -0
  35. {python_osc-1.9.3 → python_osc-1.10.2}/pythonosc/slip.py +0 -0
  36. {python_osc-1.9.3 → python_osc-1.10.2}/pythonosc/test/__init__.py +0 -0
  37. {python_osc-1.9.3 → python_osc-1.10.2}/pythonosc/test/parsing/__init__.py +0 -0
  38. {python_osc-1.9.3 → python_osc-1.10.2}/pythonosc/test/parsing/test_ntp.py +0 -0
  39. {python_osc-1.9.3 → python_osc-1.10.2}/pythonosc/test/test_osc_bundle_builder.py +0 -0
  40. {python_osc-1.9.3 → python_osc-1.10.2}/pythonosc/test/test_tcp_client.py +0 -0
@@ -1,35 +1,34 @@
1
- Metadata-Version: 2.1
1
+ Metadata-Version: 2.3
2
2
  Name: python-osc
3
- Version: 1.9.3
3
+ Version: 1.10.2
4
4
  Summary: Open Sound Control server and client implementations in pure Python
5
+ Keywords: osc,sound,midi,music
6
+ Author: attwad
5
7
  Author-email: attwad <tmusoft@gmail.com>
6
8
  License: This is free and unencumbered software released into the public domain.
7
-
8
- Anyone is free to copy, modify, publish, use, compile, sell, or
9
- distribute this software, either in source code form or as a compiled
10
- binary, for any purpose, commercial or non-commercial, and by any
11
- means.
12
-
13
- In jurisdictions that recognize copyright laws, the author or authors
14
- of this software dedicate any and all copyright interest in the
15
- software to the public domain. We make this dedication for the benefit
16
- of the public at large and to the detriment of our heirs and
17
- successors. We intend this dedication to be an overt act of
18
- relinquishment in perpetuity of all present and future rights to this
19
- software under copyright law.
20
-
21
- THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
22
- EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
23
- MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT.
24
- IN NO EVENT SHALL THE AUTHORS BE LIABLE FOR ANY CLAIM, DAMAGES OR
25
- OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE,
26
- ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR
27
- OTHER DEALINGS IN THE SOFTWARE.
28
-
29
- For more information, please refer to <http://unlicense.org/>
30
-
31
- Project-URL: Repository, https://github.com/attwad/python-osc
32
- Keywords: osc,sound,midi,music
9
+
10
+ Anyone is free to copy, modify, publish, use, compile, sell, or
11
+ distribute this software, either in source code form or as a compiled
12
+ binary, for any purpose, commercial or non-commercial, and by any
13
+ means.
14
+
15
+ In jurisdictions that recognize copyright laws, the author or authors
16
+ of this software dedicate any and all copyright interest in the
17
+ software to the public domain. We make this dedication for the benefit
18
+ of the public at large and to the detriment of our heirs and
19
+ successors. We intend this dedication to be an overt act of
20
+ relinquishment in perpetuity of all present and future rights to this
21
+ software under copyright law.
22
+
23
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
24
+ EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
25
+ MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT.
26
+ IN NO EVENT SHALL THE AUTHORS BE LIABLE FOR ANY CLAIM, DAMAGES OR
27
+ OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE,
28
+ ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR
29
+ OTHER DEALINGS IN THE SOFTWARE.
30
+
31
+ For more information, please refer to <http://unlicense.org/>
33
32
  Classifier: Development Status :: 5 - Production/Stable
34
33
  Classifier: Intended Audience :: Developers
35
34
  Classifier: License :: Freely Distributable
@@ -37,8 +36,8 @@ Classifier: Programming Language :: Python :: 3
37
36
  Classifier: Topic :: Multimedia :: Sound/Audio
38
37
  Classifier: Topic :: System :: Networking
39
38
  Requires-Python: >=3.10
39
+ Project-URL: Repository, https://github.com/attwad/python-osc
40
40
  Description-Content-Type: text/x-rst
41
- License-File: LICENSE.txt
42
41
 
43
42
  ==========
44
43
  python-osc
@@ -115,7 +114,7 @@ Simple client
115
114
  help="The port the OSC server is listening on")
116
115
  args = parser.parse_args()
117
116
 
118
- client = udp_client.SimpleUDPClient(args.ip, args.port)
117
+ client = udp_client.SimpleUDPClient(args.ip, args.port, timeout=10)
119
118
 
120
119
  for x in range(10):
121
120
  client.send_message("/filter", random.random())
@@ -159,7 +158,7 @@ Simple server
159
158
  dispatcher.map("/logvolume", print_compute_handler, "Log volume", math.log)
160
159
 
161
160
  server = osc_server.ThreadingOSCUDPServer(
162
- (args.ip, args.port), dispatcher)
161
+ (args.ip, args.port), dispatcher, timeout=10)
163
162
  print("Serving on {}".format(server.server_address))
164
163
  server.serve_forever()
165
164
 
@@ -73,7 +73,7 @@ Simple client
73
73
  help="The port the OSC server is listening on")
74
74
  args = parser.parse_args()
75
75
 
76
- client = udp_client.SimpleUDPClient(args.ip, args.port)
76
+ client = udp_client.SimpleUDPClient(args.ip, args.port, timeout=10)
77
77
 
78
78
  for x in range(10):
79
79
  client.send_message("/filter", random.random())
@@ -117,7 +117,7 @@ Simple server
117
117
  dispatcher.map("/logvolume", print_compute_handler, "Log volume", math.log)
118
118
 
119
119
  server = osc_server.ThreadingOSCUDPServer(
120
- (args.ip, args.port), dispatcher)
120
+ (args.ip, args.port), dispatcher, timeout=10)
121
121
  print("Serving on {}".format(server.server_address))
122
122
  server.serve_forever()
123
123
 
@@ -1,10 +1,10 @@
1
1
  [build-system]
2
- requires = ["setuptools"]
3
- build-backend = "setuptools.build_meta"
2
+ requires = ["uv-build"]
3
+ build-backend = "uv_build"
4
4
 
5
5
  [project]
6
6
  name = "python-osc"
7
- version = "1.9.3"
7
+ version = "1.10.2"
8
8
  description = "Open Sound Control server and client implementations in pure Python"
9
9
  readme = "README.rst"
10
10
  requires-python = ">=3.10"
@@ -21,10 +21,24 @@ classifiers = [
21
21
  "Topic :: Multimedia :: Sound/Audio",
22
22
  "Topic :: System :: Networking",
23
23
  ]
24
+ dependencies = []
24
25
 
25
26
  [project.urls]
26
27
  Repository = "https://github.com/attwad/python-osc"
27
28
 
29
+ [dependency-groups]
30
+ dev = [
31
+ "pytest",
32
+ "mypy",
33
+ "ruff",
34
+ "pytest-cov",
35
+ "pre-commit",
36
+ ]
37
+
38
+ [tool.uv.build-backend]
39
+ module-name = "pythonosc"
40
+ module-root = "."
41
+
28
42
  [tool.mypy]
29
43
  # Would be great to turn this on, however there's too many cases it would break
30
44
  # right now.
@@ -1,6 +1,6 @@
1
- """Maps OSC addresses to handler functions
2
- """
1
+ """Maps OSC addresses to handler functions"""
3
2
 
3
+ import asyncio
4
4
  import collections
5
5
  import inspect
6
6
  import logging
@@ -81,6 +81,46 @@ class Handler(object):
81
81
  else:
82
82
  return self.callback(message.address, *message)
83
83
 
84
+ async def async_invoke(
85
+ self, client_address: Tuple[str, int], message: OscMessage
86
+ ) -> Union[None, AnyStr, Tuple[AnyStr, ArgValue]]:
87
+ """Invokes the associated callback function (asynchronously)
88
+
89
+ Args:
90
+ client_address: Address match that causes the invocation
91
+ message: Message causing invocation
92
+ Returns:
93
+ The result of the handler function can be None, a string OSC address, or a tuple of the OSC address
94
+ and arguments.
95
+ """
96
+ cb = self.callback
97
+ is_async = inspect.iscoroutinefunction(cb)
98
+
99
+ if self.needs_reply_address:
100
+ if self.args:
101
+ if is_async:
102
+ return await cb(
103
+ client_address, message.address, self.args, *message
104
+ )
105
+ else:
106
+ return cb(client_address, message.address, self.args, *message)
107
+ else:
108
+ if is_async:
109
+ return await cb(client_address, message.address, *message)
110
+ else:
111
+ return cb(client_address, message.address, *message)
112
+ else:
113
+ if self.args:
114
+ if is_async:
115
+ return await cb(message.address, self.args, *message)
116
+ else:
117
+ return cb(message.address, self.args, *message)
118
+ else:
119
+ if is_async:
120
+ return await cb(message.address, *message)
121
+ else:
122
+ return cb(message.address, *message)
123
+
84
124
 
85
125
  class Dispatcher(object):
86
126
  """Maps Handlers to OSC addresses and dispatches messages to the handler on matched addresses
@@ -88,9 +128,20 @@ class Dispatcher(object):
88
128
  Maps OSC addresses to handler functions and invokes the correct handler when a message comes in.
89
129
  """
90
130
 
91
- def __init__(self) -> None:
131
+ def __init__(self, strict_timing: bool = True) -> None:
132
+ """Initialize the dispatcher.
133
+
134
+ Args:
135
+ strict_timing: Whether to automatically schedule messages with future timetags.
136
+ If True (default), the dispatcher will wait (using sleep) until the specified
137
+ timetag before invoking handlers.
138
+ If False, messages are dispatched immediately regardless of their timetag.
139
+ Disabling this can prevent memory/thread accumulation issues when receiving
140
+ many future-dated messages.
141
+ """
92
142
  self._map: DefaultDict[str, List[Handler]] = collections.defaultdict(list)
93
143
  self._default_handler: Optional[Handler] = None
144
+ self._strict_timing = strict_timing
94
145
 
95
146
  def map(
96
147
  self,
@@ -183,32 +234,66 @@ class Dispatcher(object):
183
234
  ) -> Generator[Handler, None, None]:
184
235
  """Yields handlers matching an address
185
236
 
186
-
187
237
  Args:
188
238
  address_pattern: Address to match
189
239
 
190
240
  Returns:
191
241
  Generator yielding Handlers matching address_pattern
192
242
  """
193
- # First convert the address_pattern into a matchable regexp.
194
- # '?' in the OSC Address Pattern matches any single character.
195
- # Let's consider numbers and _ "characters" too here, it's not said
196
- # explicitly in the specification but it sounds good.
197
- escaped_address_pattern = re.escape(address_pattern)
198
- pattern = escaped_address_pattern.replace("\\?", "\\w?")
199
- # '*' in the OSC Address Pattern matches any sequence of zero or more
200
- # characters.
201
- pattern = pattern.replace("\\*", "[\\w|\\+]*")
202
- # The rest of the syntax in the specification is like the re module so
203
- # we're fine.
204
- pattern = f"{pattern}$"
205
- patterncompiled = re.compile(pattern)
206
- matched = False
243
+ # Convert OSC Address Pattern to a Python regular expression.
244
+ # Spec: https://opensoundcontrol.stanford.edu/spec-1_0.html#osc-address-patterns
245
+
246
+ pattern = "^"
247
+ i = 0
248
+ while i < len(address_pattern):
249
+ c = address_pattern[i]
250
+ if c == "*":
251
+ pattern += "[^/]*"
252
+ elif c == "?":
253
+ pattern += "[^/]"
254
+ elif c == "[":
255
+ pattern += "["
256
+ i += 1
257
+ if i < len(address_pattern) and address_pattern[i] == "!":
258
+ pattern += "^"
259
+ i += 1
260
+ while i < len(address_pattern) and address_pattern[i] != "]":
261
+ if address_pattern[i] in r"\^$.|()+*?":
262
+ pattern += "\\"
263
+ pattern += address_pattern[i]
264
+ i += 1
265
+ pattern += "]"
266
+ elif c == "{":
267
+ pattern += "("
268
+ i += 1
269
+ while i < len(address_pattern) and address_pattern[i] != "}":
270
+ char = address_pattern[i]
271
+ if char == ",":
272
+ pattern += "|"
273
+ elif char in r"\^$.|()[]+*?":
274
+ pattern += "\\" + char
275
+ else:
276
+ pattern += char
277
+ i += 1
278
+ pattern += ")"
279
+ elif c in r"\^$.|()[]+?":
280
+ pattern += "\\" + c
281
+ else:
282
+ pattern += c
283
+ i += 1
284
+ pattern += "$"
285
+
286
+ try:
287
+ patterncompiled = re.compile(pattern)
288
+ except re.error:
289
+ # If the pattern is invalid, it won't match anything.
290
+ return
207
291
 
292
+ matched = False
208
293
  for addr, handlers in self._map.items():
209
294
  if patterncompiled.match(addr) or (
210
- ("*" in addr)
211
- and re.match(addr.replace("*", "[^/]*?/*"), address_pattern)
295
+ "*" in addr
296
+ and re.match(addr.replace("*", ".*?") + "$", address_pattern)
212
297
  ):
213
298
  yield from handlers
214
299
  matched = True
@@ -239,7 +324,7 @@ class Dispatcher(object):
239
324
  if not handlers:
240
325
  continue
241
326
  # If the message is to be handled later, then so be it.
242
- if timed_msg.time > now:
327
+ if self._strict_timing and timed_msg.time > now:
243
328
  time.sleep(timed_msg.time - now)
244
329
  for handler in handlers:
245
330
  result = handler.invoke(client_address, timed_msg.message)
@@ -276,48 +361,15 @@ class Dispatcher(object):
276
361
  if not handlers:
277
362
  continue
278
363
  # If the message is to be handled later, then so be it.
279
- if timed_msg.time > now:
280
- time.sleep(timed_msg.time - now)
364
+ if self._strict_timing and timed_msg.time > now:
365
+ await asyncio.sleep(timed_msg.time - now)
281
366
  for handler in handlers:
282
- if inspect.iscoroutinefunction(handler.callback):
283
- if handler.needs_reply_address:
284
- result = await handler.callback(
285
- client_address,
286
- timed_msg.message.address,
287
- handler.args,
288
- *timed_msg.message,
289
- )
290
- elif handler.args:
291
- result = await handler.callback(
292
- timed_msg.message.address,
293
- handler.args,
294
- *timed_msg.message,
295
- )
296
- else:
297
- result = await handler.callback(
298
- timed_msg.message.address, *timed_msg.message
299
- )
300
- else:
301
- if handler.needs_reply_address:
302
- result = handler.callback(
303
- client_address,
304
- timed_msg.message.address,
305
- handler.args,
306
- *timed_msg.message,
307
- )
308
- elif handler.args:
309
- result = handler.callback(
310
- timed_msg.message.address,
311
- handler.args,
312
- *timed_msg.message,
313
- )
314
- else:
315
- result = handler.callback(
316
- timed_msg.message.address, *timed_msg.message
317
- )
318
- if result:
367
+ result = await handler.async_invoke(
368
+ client_address, timed_msg.message
369
+ )
370
+ if result is not None:
319
371
  results.append(result)
320
- except osc_packet.ParseError as e:
372
+ except osc_packet.ParseError:
321
373
  pass
322
374
  return results
323
375
 
@@ -25,9 +25,11 @@ class OscBundleBuilder(object):
25
25
  seconds since the epoch in UTC or IMMEDIATELY.
26
26
  """
27
27
  self._timestamp = timestamp
28
- self._contents: List[osc_bundle.OscBundle] = []
28
+ self._contents: List[osc_bundle.OscBundle | osc_message.OscMessage] = []
29
29
 
30
- def add_content(self, content: osc_bundle.OscBundle) -> None:
30
+ def add_content(
31
+ self, content: osc_bundle.OscBundle | osc_message.OscMessage
32
+ ) -> None:
31
33
  """Add a new content to this bundle.
32
34
 
33
35
  Args:
@@ -34,8 +34,12 @@ class OscMessage(object):
34
34
 
35
35
  # Get the parameters types.
36
36
  type_tag, index = osc_types.get_string(self._dgram, index)
37
- if type_tag.startswith(","):
38
- type_tag = type_tag[1:]
37
+ if not type_tag.startswith(","):
38
+ raise ParseError(
39
+ f"OSC Type Tag String must start with a comma, got: {type_tag}"
40
+ )
41
+
42
+ type_tag = type_tag[1:]
39
43
 
40
44
  params = [] # type: List[Any]
41
45
  param_stack = [params]
@@ -5,7 +5,11 @@ from typing import Any, Iterable, List, Optional, Tuple, Union
5
5
  from pythonosc import osc_message
6
6
  from pythonosc.parsing import osc_types
7
7
 
8
- ArgValue = Union[str, bytes, bool, int, float, osc_types.MidiPacket, list]
8
+ # Represents a single OSC argument value.
9
+ # Can be a primitive type, a MIDI packet, or a list/tuple for nested OSC arrays.
10
+ ArgValue = Union[
11
+ str, bytes, bool, int, float, osc_types.MidiPacket, List[Any], Tuple[Any, ...], None
12
+ ]
9
13
 
10
14
 
11
15
  class BuildError(Exception):
@@ -68,9 +72,9 @@ class OscMessageBuilder(object):
68
72
  """Returns the (type, value) arguments list of this message."""
69
73
  return self._args
70
74
 
71
- def _valid_type(self, arg_type: str) -> bool:
72
- if arg_type in self._SUPPORTED_ARG_TYPES:
73
- return True
75
+ def _valid_type(self, arg_type: Union[str, List[Any]]) -> bool:
76
+ if isinstance(arg_type, str):
77
+ return arg_type in self._SUPPORTED_ARG_TYPES
74
78
  elif isinstance(arg_type, list):
75
79
  for sub_type in arg_type:
76
80
  if not self._valid_type(sub_type):
@@ -78,7 +82,9 @@ class OscMessageBuilder(object):
78
82
  return True
79
83
  return False
80
84
 
81
- def add_arg(self, arg_value: ArgValue, arg_type: Optional[str] = None) -> None:
85
+ def add_arg(
86
+ self, arg_value: ArgValue, arg_type: Optional[Union[str, List[Any]]] = None
87
+ ) -> None:
82
88
  """Add a typed argument to this message.
83
89
 
84
90
  Args:
@@ -96,7 +102,7 @@ class OscMessageBuilder(object):
96
102
  arg_type = self._get_arg_type(arg_value)
97
103
  if isinstance(arg_type, list):
98
104
  self._args.append((self.ARG_TYPE_ARRAY_START, None))
99
- for v, t in zip(arg_value, arg_type): # type: ignore[var-annotated, arg-type]
105
+ for v, t in zip(arg_value, arg_type): # type: ignore[arg-type]
100
106
  self.add_arg(v, t)
101
107
  self._args.append((self.ARG_TYPE_ARRAY_STOP, None))
102
108
  else:
@@ -121,7 +127,7 @@ class OscMessageBuilder(object):
121
127
  elif arg_value is False:
122
128
  arg_type = self.ARG_TYPE_FALSE
123
129
  elif isinstance(arg_value, int):
124
- if arg_value.bit_length() > 32:
130
+ if arg_value.bit_length() > 31:
125
131
  arg_type = self.ARG_TYPE_INT64
126
132
  else:
127
133
  arg_type = self.ARG_TYPE_INT
@@ -134,7 +140,7 @@ class OscMessageBuilder(object):
134
140
  elif arg_value is None:
135
141
  arg_type = self.ARG_TYPE_NIL
136
142
  else:
137
- raise ValueError("Infered arg_value type is not supported")
143
+ raise ValueError("Inferred arg_value type is not supported")
138
144
  return arg_type
139
145
 
140
146
  def build(self) -> osc_message.OscMessage:
@@ -193,9 +199,11 @@ class OscMessageBuilder(object):
193
199
  raise BuildError(f"Could not build the message: {be}")
194
200
 
195
201
 
196
- def build_msg(address: str, value: ArgValue = "") -> osc_message.OscMessage:
202
+ def build_msg(
203
+ address: str, value: Union[ArgValue, Iterable[ArgValue]] = ""
204
+ ) -> osc_message.OscMessage:
197
205
  builder = OscMessageBuilder(address=address)
198
- values: ArgValue
206
+ values: Iterable[Any]
199
207
  if value == "":
200
208
  values = []
201
209
  elif not isinstance(value, Iterable) or isinstance(value, (str, bytes)):
@@ -72,8 +72,7 @@ class OscPacket(object):
72
72
  else:
73
73
  # Empty packet, should not happen as per the spec but heh, UDP...
74
74
  raise ParseError(
75
- "OSC Packet should at least contain an OscMessage or an "
76
- "OscBundle."
75
+ "OSC Packet should at least contain an OscMessage or an OscBundle."
77
76
  )
78
77
  except (osc_bundle.ParseError, osc_message.ParseError) as pe:
79
78
  raise ParseError(f"Could not parse packet {pe}")
@@ -1,8 +1,8 @@
1
- """OSC Servers that receive UDP packets and invoke handlers accordingly.
2
- """
1
+ """OSC Servers that receive UDP packets and invoke handlers accordingly."""
3
2
 
4
3
  import asyncio
5
4
  import os
5
+ import socket
6
6
  import socketserver
7
7
  from socket import socket as _socket
8
8
  from typing import Any, Coroutine, Tuple, Union, cast
@@ -64,6 +64,8 @@ class OSCUDPServer(socketserver.UDPServer):
64
64
  server_address: Tuple[str, int],
65
65
  dispatcher: Dispatcher,
66
66
  bind_and_activate: bool = True,
67
+ timeout: float | None = None,
68
+ family: socket.AddressFamily | None = None,
67
69
  ) -> None:
68
70
  """Initialize
69
71
 
@@ -71,9 +73,29 @@ class OSCUDPServer(socketserver.UDPServer):
71
73
  server_address: IP and port of server
72
74
  dispatcher: Dispatcher this server will use
73
75
  (optional) bind_and_activate: default=True defines if the server has to start on call of constructor
76
+ (optional) timeout: Default timeout in seconds for socket operations
77
+ (optional) family: socket.AF_INET or socket.AF_INET6. If None, it will be inferred from server_address.
74
78
  """
79
+ if family is not None:
80
+ self.address_family = family
81
+ else:
82
+ # Try to infer address family from server_address
83
+ try:
84
+ infos = socket.getaddrinfo(
85
+ server_address[0],
86
+ server_address[1],
87
+ type=socket.SOCK_DGRAM,
88
+ family=socket.AF_UNSPEC,
89
+ )
90
+ if infos:
91
+ self.address_family = infos[0][0]
92
+ except (socket.gaierror, IndexError):
93
+ # Fallback to default if resolution fails
94
+ pass
95
+
75
96
  super().__init__(server_address, _UDPHandler, bind_and_activate)
76
97
  self._dispatcher = dispatcher
98
+ self.timeout = timeout
77
99
 
78
100
  def verify_request(
79
101
  self, request: _RequestType, client_address: _AddressType
@@ -35,6 +35,7 @@ loop.run_forever()
35
35
  import asyncio
36
36
  import logging
37
37
  import os
38
+ import socket
38
39
  import socketserver
39
40
  import struct
40
41
  from typing import List, Tuple
@@ -75,7 +76,7 @@ class _TCPHandler1_0(socketserver.BaseRequestHandler):
75
76
  )
76
77
  # resp = _call_handlers_for_packet(data, self.server.dispatcher)
77
78
  for r in resp:
78
- if not isinstance(r, list):
79
+ if not isinstance(r, tuple):
79
80
  r = [r]
80
81
  msg = osc_message_builder.build_msg(r[0], r[1:])
81
82
  b = struct.pack("!I", len(msg.dgram))
@@ -117,7 +118,7 @@ class _TCPHandler1_1(socketserver.BaseRequestHandler):
117
118
  p, self.client_address
118
119
  )
119
120
  for r in resp:
120
- if not isinstance(r, list):
121
+ if not isinstance(r, tuple):
121
122
  r = [r]
122
123
  msg = osc_message_builder.build_msg(r[0], r[1:])
123
124
  self.request.sendall(slip.encode(msg.dgram))
@@ -146,11 +147,30 @@ class OSCTCPServer(socketserver.TCPServer):
146
147
  server_address: Tuple[str | bytes | bytearray, int],
147
148
  dispatcher: Dispatcher,
148
149
  mode: str = MODE_1_1,
150
+ family: socket.AddressFamily | None = None,
149
151
  ):
150
152
  self.request_queue_size = 300
151
153
  self.mode = mode
152
154
  if mode not in [MODE_1_0, MODE_1_1]:
153
155
  raise ValueError("OSC Mode must be '1.0' or '1.1'")
156
+
157
+ if family is not None:
158
+ self.address_family = family
159
+ elif isinstance(server_address[0], str):
160
+ # Try to infer address family from server_address
161
+ try:
162
+ infos = socket.getaddrinfo(
163
+ server_address[0],
164
+ server_address[1],
165
+ type=socket.SOCK_STREAM,
166
+ family=socket.AF_UNSPEC,
167
+ )
168
+ if infos:
169
+ self.address_family = infos[0][0]
170
+ except (socket.gaierror, IndexError):
171
+ # Fallback to default if resolution fails
172
+ pass
173
+
154
174
  if self.mode == MODE_1_0:
155
175
  super().__init__(server_address, _TCPHandler1_0)
156
176
  else:
@@ -284,7 +304,7 @@ class AsyncOSCTCPServer:
284
304
  buf, client_address
285
305
  )
286
306
  for r in result:
287
- if not isinstance(r, list):
307
+ if not isinstance(r, tuple):
288
308
  r = [r]
289
309
  msg = osc_message_builder.build_msg(r[0], r[1:])
290
310
  b = struct.pack("!I", len(msg.dgram))
@@ -319,7 +339,7 @@ class AsyncOSCTCPServer:
319
339
  p, client_address
320
340
  )
321
341
  for r in result:
322
- if not isinstance(r, list):
342
+ if not isinstance(r, tuple):
323
343
  r = [r]
324
344
  msg = osc_message_builder.build_msg(r[0], r[1:])
325
345
  writer.write(slip.encode(msg.dgram))
@@ -71,24 +71,21 @@ def get_string(dgram: bytes, start_index: int) -> Tuple[str, int]:
71
71
  raise ParseError("start_index < 0")
72
72
  offset = 0
73
73
  try:
74
- if (
75
- len(dgram) > start_index + _STRING_DGRAM_PAD
76
- and dgram[start_index + _STRING_DGRAM_PAD] == _EMPTY_STR_DGRAM
77
- ):
78
- return "", start_index + _STRING_DGRAM_PAD
79
74
  while dgram[start_index + offset] != 0:
80
75
  offset += 1
81
- # Align to a byte word.
82
- if (offset) % _STRING_DGRAM_PAD == 0:
83
- offset += _STRING_DGRAM_PAD
84
- else:
85
- offset += -offset % _STRING_DGRAM_PAD
86
- # Python slices do not raise an IndexError past the last index,
87
- # do it ourselves.
88
- if offset > len(dgram[start_index:]):
76
+
77
+ # OSC spec: "followed by a null, followed by 0-3 additional null characters
78
+ # to make the total number of bits a multiple of 32"
79
+ # This means the total length (including the first null) must be a multiple of 4.
80
+ total_len = offset + 1
81
+ if total_len % 4 != 0:
82
+ total_len += 4 - (total_len % 4)
83
+
84
+ if start_index + total_len > len(dgram):
89
85
  raise ParseError("Datagram is too short")
86
+
90
87
  data_str = dgram[start_index : start_index + offset]
91
- return data_str.replace(b"\x00", b"").decode("utf-8"), start_index + offset
88
+ return data_str.decode("utf-8"), start_index + total_len
92
89
  except IndexError as ie:
93
90
  raise ParseError(f"Could not parse datagram {ie}")
94
91
  except TypeError as te:
@@ -214,11 +211,8 @@ def get_timetag(dgram: bytes, start_index: int) -> Tuple[Tuple[datetime, int], i
214
211
  timetag, _ = get_uint64(dgram, start_index)
215
212
  seconds, fraction = ntp.parse_timestamp(timetag)
216
213
 
217
- hours, seconds = seconds // 3600, seconds % 3600
218
- minutes, seconds = seconds // 60, seconds % 60
219
-
220
214
  utc = datetime.combine(ntp._NTP_EPOCH, datetime.min.time()) + timedelta(
221
- hours=hours, minutes=minutes, seconds=seconds
215
+ seconds=seconds
222
216
  )
223
217
 
224
218
  return (utc, fraction), start_index + _TIMETAG_DGRAM_LEN