From e0d02968cb4565a8cd2c4a39aef35857008bd6ea Mon Sep 17 00:00:00 2001 From: Lars Reimann Date: Sat, 4 May 2024 20:42:55 +0200 Subject: [PATCH] feat: convert values --- src/safeds_runner/interface/_reporters.py | 131 +++--------------- src/safeds_runner/server/_json_encoder.py | 54 -------- .../server/messages/_from_server.py | 7 +- .../utils/_make_value_json_serializable.py | 96 +++++++++++++ 4 files changed, 123 insertions(+), 165 deletions(-) delete mode 100644 src/safeds_runner/server/_json_encoder.py create mode 100644 src/safeds_runner/utils/_make_value_json_serializable.py diff --git a/src/safeds_runner/interface/_reporters.py b/src/safeds_runner/interface/_reporters.py index d43c334..9133b77 100644 --- a/src/safeds_runner/interface/_reporters.py +++ b/src/safeds_runner/interface/_reporters.py @@ -1,7 +1,9 @@ from typing import Any from safeds_runner.server._pipeline_manager import get_current_pipeline_process -from safeds_runner.server.messages._from_server import create_progress_message +from safeds_runner.server.messages._from_server import create_placeholder_value_message, create_progress_message +from safeds_runner.utils._get_type_name import get_type_name +from safeds_runner.utils._make_value_json_serializable import make_value_json_serializable def report_placeholder_computed(placeholder_name: str) -> None: @@ -41,112 +43,25 @@ def report_placeholder_value(placeholder_name: str, value: Any) -> None: if current_pipeline is None: return # pragma: no cover - # # TODO - # from safeds.data.image.containers import Image - # - # if isinstance(value, Image): - # import torch - # - # value = Image(value._image_tensor, torch.device("cpu")) - # placeholder_type = _get_placeholder_type(value) - # if _is_deterministically_hashable(value) and _has_explicit_identity_memory(value): - # value = ExplicitIdentityWrapperLazy.existing(value) - # elif ( - # not _is_deterministically_hashable(value) - # and _is_not_primitive(value) - # and _has_explicit_identity_memory(value) - # ): - # value = ExplicitIdentityWrapper.existing(value) - # TODO - # self._placeholder_map[placeholder_name] = value - # self._send_message( - # message_type_placeholder_type, - # create_placeholder_description(placeholder_name, placeholder_type), - # ) - - - # @sio.event - # async def placeholder_query(_sid: str, payload: Any) -> None: - # try: - # placeholder_query_message = QueryMessage(**payload) - # except (TypeError, ValidationError): - # logging.exception("Invalid message data specified in: %s", payload) - # return - # - # placeholder_type, placeholder_value = self._pipeline_manager.get_placeholder( - # placeholder_query_message.id, - # placeholder_query_message.data.name, - # ) - # - # if placeholder_type is None: - # # 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 - # data = json.dumps(create_placeholder_value(placeholder_query_message.data, "", "")) - # await sio.emit(message_type_placeholder_value, data, to=placeholder_query_message.id) - # return - # - # try: - # data = json.dumps( - # create_placeholder_value( - # placeholder_query_message.data, - # placeholder_type, - # placeholder_value, - # ), - # cls=SafeDsEncoder, - # ) - # except TypeError: - # # if the value can't be encoded send back that the value exists but is not displayable - # data = json.dumps( - # create_placeholder_value( - # placeholder_query_message.data, - # placeholder_type, - # "", - # ), - # ) - # - # await sio.emit(message_type_placeholder_value, data, to=placeholder_query_message.id) - + # Also send a progress message + current_pipeline.send_message( + create_progress_message( + run_id=current_pipeline._payload.run_id, + placeholder_name=placeholder_name, + percentage=100, + ), + ) + # Send the actual value + requested_table_window = current_pipeline._payload.table_window + serialized_value, chosen_window = make_value_json_serializable(value, requested_table_window) - # TODO: move into process that creates placeholder value messages -# def create_placeholder_value(placeholder_query: QueryMessageData, type_: str, value: Any) -> dict[str, Any]: -# """ -# Create the message data of a placeholder value message containing name, type and the actual value. -# -# If the query only requests a subset of the data and the placeholder type supports this, -# the response will contain only a subset and the information about the subset. -# -# Parameters -# ---------- -# placeholder_query: -# Query of the placeholder. -# type_: -# Type of the placeholder. -# value: -# Value of the placeholder. -# -# Returns -# ------- -# message_data: -# Message data of "placeholder_value" messages. -# """ -# import safeds.data.tabular.containers -# -# message: dict[str, Any] = {"name": placeholder_query.name, "type": type_} -# # Start Index >= 0 -# start_index = max(placeholder_query.window.begin if placeholder_query.window.begin is not None else 0, 0) -# # End Index >= Start Index -# end_index = ( -# (start_index + max(placeholder_query.window.size, 0)) if placeholder_query.window.size is not None else None -# ) -# if isinstance(value, safeds.data.tabular.containers.Table) and ( -# placeholder_query.window.begin is not None or placeholder_query.window.size is not None -# ): -# max_index = value.number_of_rows -# # End Index <= Number Of Rows -# end_index = min(end_index, value.number_of_rows) if end_index is not None else None -# value = value.slice_rows(start=start_index, end=end_index) -# window_information: dict[str, int] = {"begin": start_index, "size": value.number_of_rows, "max": max_index} -# message["window"] = window_information -# message["value"] = value -# return message + current_pipeline.send_message( + create_placeholder_value_message( + run_id=current_pipeline._payload.run_id, + placeholder_name=placeholder_name, + value=serialized_value, + type_=get_type_name(value), + window=chosen_window, + ), + ) diff --git a/src/safeds_runner/server/_json_encoder.py b/src/safeds_runner/server/_json_encoder.py deleted file mode 100644 index 3205537..0000000 --- a/src/safeds_runner/server/_json_encoder.py +++ /dev/null @@ -1,54 +0,0 @@ -"""Module containing JSON encoding utilities for Safe-DS types.""" - -from __future__ import annotations - -import base64 -import json -import math -from typing import Any - - -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: - An object that needs to be encoded to JSON. - - Returns - ------- - json_serializable: - The passed object represented in a way that is serializable to JSON. - """ - # Moving these imports to the top drastically increases startup time - from safeds.data.image.containers import Image - from safeds.data.labeled.containers import TabularDataset - from safeds.data.tabular.containers import Table - - if isinstance(o, TabularDataset): - o = o.to_table() - - 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 - } - elif isinstance(o, Image): - # Send images together with their format, by default images are encoded only as PNG - return {"format": "png", "bytes": str(base64.encodebytes(o._repr_png_()), "utf-8")} - else: - return json.JSONEncoder.default(self, o) diff --git a/src/safeds_runner/server/messages/_from_server.py b/src/safeds_runner/server/messages/_from_server.py index fe7aa9a..9403881 100644 --- a/src/safeds_runner/server/messages/_from_server.py +++ b/src/safeds_runner/server/messages/_from_server.py @@ -1,6 +1,7 @@ from __future__ import annotations from abc import ABC +from typing import Any from pydantic import BaseModel, ConfigDict @@ -49,7 +50,7 @@ class PlaceholderValueMessagePayload(MessageFromServerPayload): type: Python type of the placeholder at runtime. value: - Value of the placeholder. + Value of the placeholder. Must be JSON-serializable. window: Window of the full value included as value in the message. """ @@ -57,7 +58,7 @@ class PlaceholderValueMessagePayload(MessageFromServerPayload): run_id: str placeholder_name: str type: str - value: str + value: Any window: Window | None = None model_config = ConfigDict(extra="forbid") @@ -187,7 +188,7 @@ def create_placeholder_value_message( run_id: str, placeholder_name: str, type_: str, - value: str, + value: Any, window: Window | None = None, ) -> MessageFromServer: """Create a 'placeholder_value' message.""" diff --git a/src/safeds_runner/utils/_make_value_json_serializable.py b/src/safeds_runner/utils/_make_value_json_serializable.py new file mode 100644 index 0000000..f71178b --- /dev/null +++ b/src/safeds_runner/utils/_make_value_json_serializable.py @@ -0,0 +1,96 @@ +import base64 +import json +import math +from typing import Any + +from safeds.data.image.containers import Image +from safeds.data.labeled.containers import TabularDataset +from safeds.data.tabular.containers import Table + +from safeds_runner.server.messages._from_server import Window as ChosenWindow +from safeds_runner.server.messages._to_server import Window as RequestedWindow + + +def make_value_json_serializable(value: Any, requested_table_window: RequestedWindow) -> tuple[Any, ChosenWindow | None]: + """ + Convert a value to a JSON-serializable format. + + Parameters + ---------- + value: + The value to serialize. + requested_table_window: + Window to get for placeholders of type 'Table'. + + Returns + ------- + serialized_value: + The serialized value. + chosen_window: + The window of the value that was serialized. + """ + if isinstance(value, Table): + return make_table_json_serializable(value, requested_table_window) + elif isinstance(value, TabularDataset): + return make_table_json_serializable(value.to_table(), requested_table_window) + elif isinstance(value, Image): + return make_image_json_serializable(value) + else: + return make_other_json_serializable(value) + + +def make_table_json_serializable( + table: Table, + requested_window: RequestedWindow, +) -> tuple[Any, ChosenWindow | None]: + # Compute sizes + full_size = table.number_of_rows + + requested_size = requested_window.size if requested_window.size is not None else full_size + requested_size = max(requested_size, 0) + + # Compute indices + start_index = requested_window.start if requested_window.start is not None else 0 + start_index = max(start_index, 0) + + end_index = start_index + requested_size + end_index = min(end_index, full_size) + + # Compute value + slice_ = table.slice_rows(start=start_index, end=end_index) + value = _replace_nan_and_infinity(slice_.to_dict()) + + # Compute window + if requested_window.start is not None or requested_window.size is not None: + chosen_window = ChosenWindow(start=start_index, size=end_index - start_index, full_size=full_size) + else: + chosen_window = None + + return value, chosen_window + + +def _replace_nan_and_infinity(dict_: dict) -> dict: + return { + key: [ + value if not isinstance(value, float) or math.isfinite(value) else None + for value in dict_[key] + ] + for key in dict_ + } + + +def make_image_json_serializable(image: Image) -> tuple[Any, ChosenWindow | None]: + dict_ = { + "format": "png", + "bytes": str(base64.encodebytes(image._repr_png_()), "utf-8"), + } + return dict_, None + + +def make_other_json_serializable(value: Any) -> tuple[Any, ChosenWindow | None]: + try: + json.dumps(value) + except TypeError: + return "", None + else: + return value, None