Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
11 changes: 7 additions & 4 deletions pythonosc/osc_message_builder.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
10 changes: 6 additions & 4 deletions pythonosc/tcp_client.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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)
Expand Down
87 changes: 87 additions & 0 deletions pythonosc/test/test_empty_string_messages.py
Original file line number Diff line number Diff line change
@@ -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)
)