Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: added json serializer that encodes tables and images #29

Merged
merged 5 commits into from
Dec 9, 2023
Merged
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
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:
WinPlay02 marked this conversation as resolved.
Show resolved Hide resolved
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