Skip to content

Commit

Permalink
feat: added json serializer that encodes tables and images (#29)
Browse files Browse the repository at this point in the history
Closes #20 

### Summary of Changes

- added SafeDSEncoder that is used for custom types (tables, images)
- don't crash when unknown types are attempted to be sent, instead send
a "\<Not Displayable\>" placeholder value
- added encoding tests + not displayable to existing message exchange

---------

Co-authored-by: megalinter-bot <129584137+megalinter-bot@users.noreply.github.com>
  • Loading branch information
WinPlay02 and megalinter-bot committed Dec 9, 2023
1 parent 7ed7c5c commit 054cca4
Show file tree
Hide file tree
Showing 4 changed files with 148 additions and 10 deletions.
51 changes: 51 additions & 0 deletions src/safeds_runner/server/json_encoder.py
Original file line number Diff line number Diff line change
@@ -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)
30 changes: 21 additions & 9 deletions src/safeds_runner/server/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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, "<Not displayable>"),
),
)
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
Expand Down Expand Up @@ -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
Expand Down
65 changes: 65 additions & 0 deletions tests/safeds_runner/server/test_json_encoder.py
Original file line number Diff line number Diff line change
@@ -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)
12 changes: 11 additions & 1 deletion tests/safeds_runner/server/test_websocket_mock.py
Original file line number Diff line number Diff line change
Expand Up @@ -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()"
Expand All @@ -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", "<Not displayable>"),
),
# Query Result Invalid
Message(message_type_placeholder_value, "abcdefg", create_placeholder_value("value2", "", "")),
],
Expand Down

0 comments on commit 054cca4

Please sign in to comment.