diff --git a/src/safeds_runner/server/json_encoder.py b/src/safeds_runner/server/json_encoder.py new file mode 100644 index 0000000..d3a6790 --- /dev/null +++ b/src/safeds_runner/server/json_encoder.py @@ -0,0 +1,51 @@ +"""Module containing JSON encoding utilities for Safe-DS types.""" + +import base64 +import json +import math +from typing import Any + +from safeds.data.image.containers import Image +from safeds.data.image.typing import ImageFormat +from safeds.data.tabular.containers import Table + + +class SafeDsEncoder(json.JSONEncoder): + """JSON Encoder for custom Safe-DS types.""" + + def default(self, o: Any) -> Any: + """ + Convert specific Safe-DS types to a JSON-serializable representation. + + If values are custom Safe-DS types (such as Table or Image) they are converted to a serializable representation. + If a value is not handled here, the default encoding implementation is called. + In case of Tables, note that NaN values are converted to JSON null values. + + Parameters + ---------- + o: Any + An object that needs to be encoded to JSON. + + Returns + ------- + Any + The passed object represented in a way that is serializable to JSON. + """ + if isinstance(o, Table): + dict_with_nan_infinity = o.to_dict() + # Convert NaN / Infinity to None, as the JSON encoder generates invalid JSON otherwise + return { + key: [ + value if not isinstance(value, float) or math.isfinite(value) else None + for value in dict_with_nan_infinity[key] + ] + for key in dict_with_nan_infinity + } + if isinstance(o, Image): + # Send images together with their format + match o.format: + case ImageFormat.JPEG: + return {"format": o.format.value, "bytes": str(base64.encodebytes(o._repr_jpeg_()), "utf-8")} + case ImageFormat.PNG: + return {"format": o.format.value, "bytes": str(base64.encodebytes(o._repr_png_()), "utf-8")} + return json.JSONEncoder.default(self, o) diff --git a/src/safeds_runner/server/main.py b/src/safeds_runner/server/main.py index 888c4a7..09b2fb4 100644 --- a/src/safeds_runner/server/main.py +++ b/src/safeds_runner/server/main.py @@ -12,6 +12,7 @@ from flask_sock import Sock from safeds_runner.server import messages +from safeds_runner.server.json_encoder import SafeDsEncoder from safeds_runner.server.messages import ( Message, create_placeholder_value, @@ -127,14 +128,25 @@ def ws_main(ws: simple_websocket.Server, pipeline_manager: PipelineManager) -> N ) # send back a value message if placeholder_type is not None: - send_websocket_message( - ws, - Message( - message_type_placeholder_value, - received_object.id, - create_placeholder_value(placeholder_query_data, placeholder_type, placeholder_value), - ), - ) + try: + send_websocket_message( + ws, + Message( + message_type_placeholder_value, + received_object.id, + create_placeholder_value(placeholder_query_data, placeholder_type, placeholder_value), + ), + ) + except TypeError as _encoding_error: + # if the value can't be encoded send back that the value exists but is not displayable + send_websocket_message( + ws, + Message( + message_type_placeholder_value, + received_object.id, + create_placeholder_value(placeholder_query_data, placeholder_type, ""), + ), + ) else: # Send back empty type / value, to communicate that no placeholder exists (yet) # Use name from query to allow linking a response to a request on the peer @@ -162,7 +174,7 @@ def send_websocket_message(connection: simple_websocket.Server, message: Message message : Message Object that will be sent. """ - connection.send(json.dumps(message.to_dict())) + connection.send(json.dumps(message.to_dict(), cls=SafeDsEncoder)) def start_server(port: int) -> None: # pragma: no cover diff --git a/tests/safeds_runner/server/test_json_encoder.py b/tests/safeds_runner/server/test_json_encoder.py new file mode 100644 index 0000000..ab0cf4a --- /dev/null +++ b/tests/safeds_runner/server/test_json_encoder.py @@ -0,0 +1,65 @@ +import base64 +import json +import math +from io import BytesIO +from typing import Any + +import pytest +from safeds.data.image.containers import Image +from safeds.data.image.typing import ImageFormat +from safeds.data.tabular.containers import Table +from safeds_runner.server.json_encoder import SafeDsEncoder + + +@pytest.mark.parametrize( + argnames="data,expected_string", + argvalues=[ + ( + Table.from_dict({"a": [1, 2], "b": [3.2, 4.0], "c": [math.nan, 5.6], "d": [5, -6]}), + '{"a": [1, 2], "b": [3.2, 4.0], "c": [null, 5.6], "d": [5, -6]}', + ), + ( + Image( + BytesIO( + base64.b64decode( + "iVBORw0KGgoAAAANSUhEUgAAAAQAAAAECAYAAACp8Z5" + "+AAAAD0lEQVQIW2NkQAOMpAsAAADuAAVDMQ2mAAAAAElFTkSuQmCC", + ), + ), + ImageFormat.PNG, + ), + ( + '{"format": "png", "bytes": ' + '"iVBORw0KGgoAAAANSUhEUgAAAAQAAAAECAYAAACp8Z5+AAAADElEQVR4nGNgoBwAAABEAAHX40j9\\nAAAAAElFTkSuQmCC\\n"}' + ), + ), + ( + Image( + BytesIO( + base64.b64decode( + "/9j/2wBDAAEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQH/wgALCAABAAEBAREA/8QAFAABAAAAAAAAAAAAAAAAAAAAA//aAAgBAQAAAAE//9k=", + ), + ), + ImageFormat.JPEG, + ), + ( + '{"format": "jpeg", "bytes":' + ' "/9j/4AAQSkZJRgABAQAAAQABAAD/2wBDAAgGBgcGBQgHBwcJCQgKDBQNDAsLDBkSEw8UHRofHh0a' + "\\nHBwgJC4nICIsIxwcKDcpLDAxNDQ0Hyc5PTgyPC4zNDL/wAALCAABAAEBAREA/8QAHwAAAQUBAQEB\\nAQEAAAAAAAAAAAECAwQFBgcICQoL" + "/8QAtRAAAgEDAwIEAwUFBAQAAAF9AQIDAAQRBRIhMUEGE1Fh" + "\\nByJxFDKBkaEII0KxwRVS0fAkM2JyggkKFhcYGRolJicoKSo0NTY3ODk6Q0RFRkdISUpTVFVWV1hZ" + "\\nWmNkZWZnaGlqc3R1dnd4eXqDhIWGh4iJipKTlJWWl5iZmqKjpKWmp6ipqrKztLW2t7i5usLDxMXG\\nx8jJytLT1NXW19jZ2uHi4" + '+Tl5ufo6erx8vP09fb3+Pn6/9oACAEBAAA/AEr/2Q==\\n"}' + ), + ), + ], + ids=["encode_table", "encode_image_png", "encode_image_jpeg"], +) +def test_encoding_custom_types(data: Any, expected_string: str) -> None: + assert json.dumps(data, cls=SafeDsEncoder) == expected_string + + +@pytest.mark.parametrize(argnames="data", argvalues=[(object())], ids=["encode_object"]) +def test_encoding_unsupported_types(data: Any) -> None: + with pytest.raises(TypeError): + json.dumps(data, cls=SafeDsEncoder) diff --git a/tests/safeds_runner/server/test_websocket_mock.py b/tests/safeds_runner/server/test_websocket_mock.py index 315821a..b1d9b4d 100644 --- a/tests/safeds_runner/server/test_websocket_mock.py +++ b/tests/safeds_runner/server/test_websocket_mock.py @@ -250,7 +250,8 @@ def test_should_execute_pipeline_return_exception( "gen_test_a": ( "import safeds_runner.server.pipeline_manager\n\ndef pipe():\n\tvalue1 =" " 1\n\tsafeds_runner.server.pipeline_manager.runner_save_placeholder('value1'," - " value1)\n" + " value1)\n\tsafeds_runner.server.pipeline_manager.runner_save_placeholder('obj'," + " object())\n" ), "gen_test_a_pipe": ( "from gen_test_a import pipe\n\nif __name__ == '__main__':\n\tpipe()" @@ -265,16 +266,25 @@ def test_should_execute_pipeline_return_exception( [ # Query Placeholder json.dumps({"type": "placeholder_query", "id": "abcdefg", "data": "value1"}), + # Query not displayable Placeholder + json.dumps({"type": "placeholder_query", "id": "abcdefg", "data": "obj"}), # Query invalid placeholder json.dumps({"type": "placeholder_query", "id": "abcdefg", "data": "value2"}), ], [ # Validate Placeholder Information Message(message_type_placeholder_type, "abcdefg", create_placeholder_description("value1", "Int")), + Message(message_type_placeholder_type, "abcdefg", create_placeholder_description("obj", "object")), # Validate Progress Information Message(message_type_runtime_progress, "abcdefg", create_runtime_progress_done()), # Query Result Valid Message(message_type_placeholder_value, "abcdefg", create_placeholder_value("value1", "Int", 1)), + # Query Result not displayable + Message( + message_type_placeholder_value, + "abcdefg", + create_placeholder_value("obj", "object", ""), + ), # Query Result Invalid Message(message_type_placeholder_value, "abcdefg", create_placeholder_value("value2", "", "")), ],