From 23fd2108318559e35c20434de9620b3bfcaa2c6e Mon Sep 17 00:00:00 2001 From: leo <80651389+civilwarinfrance@users.noreply.github.com> Date: Thu, 29 Oct 2026 09:01:48 -0700 Subject: [PATCH] Preserve explicit empty-string OSC arguments --- CHANGELOG.md | 2 + pythonosc/osc_message_builder.py | 11 ++- pythonosc/tcp_client.py | 10 ++- pythonosc/test/test_empty_string_messages.py | 87 ++++++++++++++++++++ 4 files changed, 102 insertions(+), 8 deletions(-) create mode 100644 pythonosc/test/test_empty_string_messages.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 0a4f292..5a434c9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,8 @@ The format is based on [Keep a Changelog](http://keepachangelog.com/) and this p ## Unreleased +- Preserve explicit empty-string arguments in message helpers and TCP clients while keeping omitted values argument-free. + ## [1.10.2] - Better asyncio handling, ability to ignore timetags for future messages diff --git a/pythonosc/osc_message_builder.py b/pythonosc/osc_message_builder.py index 0604b84..982b43e 100644 --- a/pythonosc/osc_message_builder.py +++ b/pythonosc/osc_message_builder.py @@ -200,13 +200,16 @@ def build(self) -> osc_message.OscMessage: def build_msg( - address: str, value: Union[ArgValue, Iterable[ArgValue]] = "" + address: str, value: Union[ArgValue, Iterable[ArgValue]] = () ) -> osc_message.OscMessage: + """Build a message from a scalar value or an iterable of arguments. + + Omit the value or pass an empty iterable for a message with no arguments. + An explicitly supplied empty string is retained as a string argument. + """ builder = OscMessageBuilder(address=address) values: Iterable[Any] - if value == "": - values = [] - elif not isinstance(value, Iterable) or isinstance(value, (str, bytes)): + if not isinstance(value, Iterable) or isinstance(value, (str, bytes)): values = [value] else: values = value diff --git a/pythonosc/tcp_client.py b/pythonosc/tcp_client.py index 17a2e4f..dadbe99 100644 --- a/pythonosc/tcp_client.py +++ b/pythonosc/tcp_client.py @@ -77,13 +77,14 @@ class SimpleTCPClient(TCPClient): """Simple OSC client that automatically builds :class:`OscMessage` from arguments""" def send_message( - self, address: str, value: Union[ArgValue, Iterable[ArgValue]] = "" + self, address: str, value: Union[ArgValue, Iterable[ArgValue]] = () ) -> None: """Build :class:`OscMessage` from arguments and send to server Args: address: OSC address the message shall go to - value: One or more arguments to be added to the message + value: One or more arguments to be added to the message. Omit for + no arguments; an empty string is a string argument. """ msg = build_msg(address, value) return self.send(msg) @@ -188,13 +189,14 @@ class AsyncSimpleTCPClient(AsyncTCPClient): """Simple OSC client that automatically builds :class:`OscMessage` from arguments""" async def send_message( - self, address: str, value: Union[ArgValue, Iterable[ArgValue]] = "" + self, address: str, value: Union[ArgValue, Iterable[ArgValue]] = () ) -> None: """Build :class:`OscMessage` from arguments and send to server Args: address: OSC address the message shall go to - value: One or more arguments to be added to the message + value: One or more arguments to be added to the message. Omit for + no arguments; an empty string is a string argument. """ msg = build_msg(address, value) return await self.send(msg) diff --git a/pythonosc/test/test_empty_string_messages.py b/pythonosc/test/test_empty_string_messages.py new file mode 100644 index 0000000..08ede2a --- /dev/null +++ b/pythonosc/test/test_empty_string_messages.py @@ -0,0 +1,87 @@ +"""Empty strings are arguments; omitted values and empty iterables are not.""" + +import unittest +from typing import Iterable +from unittest import mock + +from pythonosc import tcp_client, udp_client +from pythonosc.osc_message_builder import ArgValue, build_msg +from pythonosc.parsing import slip + +EMPTY_STRING_DGRAM = b"/v\x00\x00,s\x00\x00\x00\x00\x00\x00" +NO_ARGUMENTS_DGRAM = b"/v\x00\x00,\x00\x00\x00" + + +class TestEmptyStringBuilder(unittest.TestCase): + def test_explicit_empty_string(self) -> None: + message = build_msg("/v", "") + self.assertEqual(message.params, [""]) + self.assertEqual(message.dgram, EMPTY_STRING_DGRAM) + + def test_omitted_and_empty_iterable_values(self) -> None: + self.assertEqual(build_msg("/v").dgram, NO_ARGUMENTS_DGRAM) + empty_values: list[Iterable[ArgValue]] = [[], (), iter(())] + for value in empty_values: + with self.subTest(value=value): + self.assertEqual(build_msg("/v", value).dgram, NO_ARGUMENTS_DGRAM) + + def test_other_scalar_values_and_iterables(self) -> None: + values: list[ArgValue] = [0, False, None, "hello"] + for value in values: + with self.subTest(value=value): + self.assertEqual(build_msg("/v", value).params, [value]) + self.assertEqual(build_msg("/v", [""]).dgram, EMPTY_STRING_DGRAM) + nested_values: list[ArgValue] = [0, "", None] + self.assertEqual(build_msg("/v", iter(nested_values)).params, nested_values) + + +class TestEmptyStringTcpClient(unittest.TestCase): + @mock.patch("socket.socket") + def test_explicit_empty_string(self, socket_factory: mock.Mock) -> None: + with tcp_client.SimpleTCPClient("127.0.0.1", 31337) as client: + client.send_message("/v", "") + socket_factory.return_value.sendall.assert_called_once_with( + slip.encode(EMPTY_STRING_DGRAM) + ) + + @mock.patch("socket.socket") + def test_omitted_value(self, socket_factory: mock.Mock) -> None: + with tcp_client.SimpleTCPClient("127.0.0.1", 31337) as client: + client.send_message("/v") + socket_factory.return_value.sendall.assert_called_once_with( + slip.encode(NO_ARGUMENTS_DGRAM) + ) + + +class TestEmptyStringAsyncTcpClient(unittest.IsolatedAsyncioTestCase): + def configure_connection(self, connection: mock.AsyncMock) -> mock.Mock: + writer = mock.Mock() + writer.drain = mock.AsyncMock() + writer.wait_closed = mock.AsyncMock() + connection.return_value = (mock.Mock(), writer) + return writer + + @mock.patch("asyncio.open_connection") + async def test_explicit_empty_string(self, connection: mock.AsyncMock) -> None: + writer = self.configure_connection(connection) + async with tcp_client.AsyncSimpleTCPClient("127.0.0.1", 31337) as client: + await client.send_message("/v", "") + writer.write.assert_called_once_with(slip.encode(EMPTY_STRING_DGRAM)) + + @mock.patch("asyncio.open_connection") + async def test_omitted_value(self, connection: mock.AsyncMock) -> None: + writer = self.configure_connection(connection) + async with tcp_client.AsyncSimpleTCPClient("127.0.0.1", 31337) as client: + await client.send_message("/v") + writer.write.assert_called_once_with(slip.encode(NO_ARGUMENTS_DGRAM)) + + +class TestEmptyStringUdpClient(unittest.TestCase): + @mock.patch("socket.socket") + def test_existing_empty_string_behavior(self, socket_factory: mock.Mock) -> None: + client = udp_client.SimpleUDPClient("127.0.0.1", 31337) + with client: + client.send_message("/v", "") + socket_factory.return_value.sendto.assert_called_once_with( + EMPTY_STRING_DGRAM, ("127.0.0.1", 31337) + )