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: support placeholder queries that only request a subset of data #39

Merged
merged 15 commits into from
Jan 24, 2024
Merged
Show file tree
Hide file tree
Changes from 12 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
148 changes: 138 additions & 10 deletions src/safeds_runner/server/messages.py
Original file line number Diff line number Diff line change
Expand Up @@ -169,6 +169,96 @@ def to_dict(self) -> dict[str, Any]:
return dataclasses.asdict(self) # pragma: no cover


@dataclass(frozen=True)
class QueryWindow:
"""
Information that is used to create a subset of the data of a placeholder.

Parameters
----------
begin : int | None
Index of the first entry that should be sent. May be present if a windowed query is required.
size : int | None
Max. amount of entries that should be sent. May be present if a windowed query is required.
"""

begin: int | None = None
size: int | None = None

@staticmethod
def from_dict(d: dict[str, Any]) -> QueryWindow:
"""
Create a new QueryWindow object from a dictionary.

Parameters
----------
d : dict[str, Any]
Dictionary which should contain all needed fields.

Returns
-------
QueryWindow
Dataclass which contains information copied from the provided dictionary.
"""
return QueryWindow(**d)

def to_dict(self) -> dict[str, Any]:
"""
Convert this dataclass to a dictionary.

Returns
-------
dict[str, Any]
Dictionary containing all the fields which are part of this dataclass.
"""
return dataclasses.asdict(self) # pragma: no cover


@dataclass(frozen=True)
class MessageQueryInformation:
"""
Information used to query a placeholder with optional window bounds. Only complex types like tables are affected by window bounds.

Parameters
----------
name : str
Placeholder name that is queried.
window : QueryWindow
Window bounds for requesting only a subset of the available data.
"""

name: str
window: QueryWindow = dataclasses.field(default_factory=QueryWindow)

@staticmethod
def from_dict(d: dict[str, Any]) -> MessageQueryInformation:
"""
Create a new MessageQueryInformation object from a dictionary.

Parameters
----------
d : dict[str, Any]
Dictionary which should contain all needed fields.

Returns
-------
MessageQueryInformation
Dataclass which contains information copied from the provided dictionary.
"""
return MessageQueryInformation(name=d["name"], window=QueryWindow.from_dict(d["window"]))

def to_dict(self) -> dict[str, Any]:
"""
Convert this dataclass to a dictionary.

Returns
-------
dict[str, Any]
Dictionary containing all the fields which are part of this dataclass.
"""
return dataclasses.asdict(self) # pragma: no cover


def create_placeholder_description(name: str, type_: str) -> dict[str, str]:
"""
Create the message data of a placeholder description message containing only name and type.
Expand All @@ -188,14 +278,17 @@ def create_placeholder_description(name: str, type_: str) -> dict[str, str]:
return {"name": name, "type": type_}


def create_placeholder_value(name: str, type_: str, value: Any) -> dict[str, Any]:
def create_placeholder_value(placeholder_query: MessageQueryInformation, 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
----------
name : str
Name of the placeholder.
placeholder_query : MessageQueryInformation
Query of the placeholder.
type_ : str
Type of the placeholder.
value : Any
Expand All @@ -206,7 +299,26 @@ def create_placeholder_value(name: str, type_: str, value: Any) -> dict[str, Any
dict[str, str]
Message data of "placeholder_value" messages.
"""
return {"name": name, "type": type_, "value": value}
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


def create_runtime_error_description(message: str, backtrace: list[dict[str, Any]]) -> dict[str, Any]:
Expand Down Expand Up @@ -313,7 +425,9 @@ def validate_program_message_data(message_data: dict[str, Any] | str) -> tuple[M
return MessageDataProgram.from_dict(message_data), None


def validate_placeholder_query_message_data(message_data: dict[str, Any] | str) -> tuple[str | None, str | None]:
def validate_placeholder_query_message_data(
message_data: dict[str, Any] | str,
) -> tuple[MessageQueryInformation | None, str | None]:
"""
Validate the message data of a placeholder query message.

Expand All @@ -324,9 +438,23 @@ def validate_placeholder_query_message_data(message_data: dict[str, Any] | str)

Returns
-------
tuple[str | None, str | None]
A tuple containing either a validated message data as a string or an error message.
tuple[MessageQueryInformation | None, str | None]
A tuple containing either the validated message data or an error message.
"""
if not isinstance(message_data, str):
return None, "Message data is not a string"
return message_data, None
if not isinstance(message_data, dict):
return None, "Message data is not a JSON object"
if "name" not in message_data:
return None, "No 'name' parameter given"
if (
"window" in message_data
and "begin" in message_data["window"]
and not isinstance(message_data["window"]["begin"], int)
):
return None, "Invalid 'window'.'begin' parameter given"
if (
"window" in message_data
and "size" in message_data["window"]
and not isinstance(message_data["window"]["size"], int)
):
return None, "Invalid 'window'.'size' parameter given"
return MessageQueryInformation.from_dict(message_data), None
WinPlay02 marked this conversation as resolved.
Show resolved Hide resolved
2 changes: 1 addition & 1 deletion src/safeds_runner/server/server.py
Original file line number Diff line number Diff line change
Expand Up @@ -152,7 +152,7 @@ def _ws_main(ws: simple_websocket.Server, pipeline_manager: PipelineManager) ->
return
placeholder_type, placeholder_value = pipeline_manager.get_placeholder(
received_object.id,
placeholder_query_data,
placeholder_query_data.name,
)
# send back a value message
if placeholder_type is not None:
Expand Down
138 changes: 128 additions & 10 deletions tests/safeds_runner/server/test_websocket_mock.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,12 +5,17 @@
import sys
import threading
import time
import typing

import pytest
import safeds_runner.server.main
import simple_websocket
from safeds.data.tabular.containers import Table
from safeds_runner.server.json_encoder import SafeDsEncoder
from safeds_runner.server.messages import (
Message,
MessageQueryInformation,
QueryWindow,
create_placeholder_description,
create_placeholder_value,
create_runtime_progress_done,
Expand Down Expand Up @@ -69,7 +74,16 @@ def get_next_received_message(self) -> str:
(json.dumps({"type": {"program": "2"}, "id": "123", "data": "a"}), "Invalid Message: invalid type"),
(json.dumps({"type": "c", "id": {"": "1233"}, "data": "a"}), "Invalid Message: invalid id"),
(json.dumps({"type": "program", "id": "1234", "data": "a"}), "Message data is not a JSON object"),
(json.dumps({"type": "placeholder_query", "id": "123", "data": {"a": "v"}}), "Message data is not a string"),
(json.dumps({"type": "placeholder_query", "id": "123", "data": "abc"}), "Message data is not a JSON object"),
(json.dumps({"type": "placeholder_query", "id": "123", "data": {"a": "v"}}), "No 'name' parameter given"),
(
json.dumps({"type": "placeholder_query", "id": "123", "data": {"name": "v", "window": {"begin": "a"}}}),
"Invalid 'window'.'begin' parameter given",
),
(
json.dumps({"type": "placeholder_query", "id": "123", "data": {"name": "v", "window": {"size": "a"}}}),
"Invalid 'window'.'size' parameter given",
),
(
json.dumps({
"type": "program",
Expand Down Expand Up @@ -161,7 +175,10 @@ def get_next_received_message(self) -> str:
"any_invalid_type",
"any_invalid_id",
"program_invalid_data",
"placeholder_query_invalid_data",
"placeholder_query_invalid_data1",
"placeholder_query_invalid_data2",
"placeholder_query_invalid_data3",
"placeholder_query_invalid_data4",
"program_no_code",
"program_no_main",
"program_invalid_main1",
Expand Down Expand Up @@ -273,11 +290,11 @@ def test_should_execute_pipeline_return_exception(
2,
[
# Query Placeholder
json.dumps({"type": "placeholder_query", "id": "abcdefg", "data": "value1"}),
json.dumps({"type": "placeholder_query", "id": "abcdefg", "data": {"name": "value1", "window": {}}}),
# Query not displayable Placeholder
json.dumps({"type": "placeholder_query", "id": "abcdefg", "data": "obj"}),
json.dumps({"type": "placeholder_query", "id": "abcdefg", "data": {"name": "obj", "window": {}}}),
# Query invalid placeholder
json.dumps({"type": "placeholder_query", "id": "abcdefg", "data": "value2"}),
json.dumps({"type": "placeholder_query", "id": "abcdefg", "data": {"name": "value2", "window": {}}}),
],
[
# Validate Placeholder Information
Expand All @@ -286,15 +303,23 @@ def test_should_execute_pipeline_return_exception(
# 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)),
Message(
message_type_placeholder_value,
"abcdefg",
create_placeholder_value(MessageQueryInformation("value1"), "Int", 1),
),
# Query Result not displayable
Message(
message_type_placeholder_value,
"abcdefg",
create_placeholder_value("obj", "object", "<Not displayable>"),
create_placeholder_value(MessageQueryInformation("obj"), "object", "<Not displayable>"),
),
# Query Result Invalid
Message(message_type_placeholder_value, "abcdefg", create_placeholder_value("value2", "", "")),
Message(
message_type_placeholder_value,
"abcdefg",
create_placeholder_value(MessageQueryInformation("value2"), "", ""),
),
],
),
],
Expand Down Expand Up @@ -370,12 +395,16 @@ def test_should_execute_pipeline_return_valid_placeholder(
# Query Result Invalid (no pipeline exists)
[
json.dumps({"type": "invalid_message_type", "id": "unknown-code-id-never-generated", "data": ""}),
json.dumps({"type": "placeholder_query", "id": "unknown-code-id-never-generated", "data": "v"}),
json.dumps({
"type": "placeholder_query",
"id": "unknown-code-id-never-generated",
"data": {"name": "v", "window": {}},
}),
],
Message(
message_type_placeholder_value,
"unknown-code-id-never-generated",
create_placeholder_value("v", "", ""),
create_placeholder_value(MessageQueryInformation("v"), "", ""),
),
),
],
Expand Down Expand Up @@ -463,3 +492,92 @@ def helper_should_accept_at_least_2_parallel_connections_in_subprocess_server(
sys.stderr.write = lambda value: pipe.send(value) # type: ignore[method-assign, assignment]
sys.stdout.write = lambda value: pipe.send(value) # type: ignore[method-assign, assignment]
safeds_runner.server.main.start_server(port)


@pytest.mark.parametrize(
argnames="query,type_,value,result",
argvalues=[
(
MessageQueryInformation("name"),
"Table",
Table.from_dict({"a": [1, 2, 1, 2, 3, 2, 1], "b": [3, 4, 6, 2, 1, 2, 3]}),
'{"name": "name", "type": "Table", "value": {"a": [1, 2, 1, 2, 3, 2, 1], "b": [3, 4, 6, 2, 1, 2, 3]}}',
),
(
MessageQueryInformation("name", QueryWindow(0, 1)),
"Table",
Table.from_dict({"a": [1, 2, 1, 2, 3, 2, 1], "b": [3, 4, 6, 2, 1, 2, 3]}),
(
'{"name": "name", "type": "Table", "window": {"begin": 0, "size": 1, "max": 7}, "value": {"a": [1],'
' "b": [3]}}'
),
),
(
MessageQueryInformation("name", QueryWindow(4, 3)),
"Table",
Table.from_dict({"a": [1, 2, 1, 2, 3, 2, 1], "b": [3, 4, 6, 2, 1, 2, 3]}),
(
'{"name": "name", "type": "Table", "window": {"begin": 4, "size": 3, "max": 7}, "value": {"a": [3, 2,'
' 1], "b": [1, 2, 3]}}'
),
),
(
MessageQueryInformation("name", QueryWindow(0, 0)),
"Table",
Table.from_dict({"a": [1, 2, 1, 2, 3, 2, 1], "b": [3, 4, 6, 2, 1, 2, 3]}),
(
'{"name": "name", "type": "Table", "window": {"begin": 0, "size": 0, "max": 7}, "value": {"a": [], "b":'
" []}}"
),
),
(
MessageQueryInformation("name", QueryWindow(4, 30)),
"Table",
Table.from_dict({"a": [1, 2, 1, 2, 3, 2, 1], "b": [3, 4, 6, 2, 1, 2, 3]}),
(
'{"name": "name", "type": "Table", "window": {"begin": 4, "size": 3, "max": 7}, "value": {"a": [3, 2,'
' 1], "b": [1, 2, 3]}}'
),
),
(
MessageQueryInformation("name", QueryWindow(4, None)),
"Table",
Table.from_dict({"a": [1, 2, 1, 2, 3, 2, 1], "b": [3, 4, 6, 2, 1, 2, 3]}),
(
'{"name": "name", "type": "Table", "window": {"begin": 4, "size": 3, "max": 7}, "value": {"a": [3, 2,'
' 1], "b": [1, 2, 3]}}'
),
),
(
MessageQueryInformation("name", QueryWindow(0, -5)),
"Table",
Table.from_dict({"a": [1, 2, 1, 2, 3, 2, 1], "b": [3, 4, 6, 2, 1, 2, 3]}),
(
'{"name": "name", "type": "Table", "window": {"begin": 0, "size": 0, "max": 7}, "value": {"a": [], "b":'
" []}}"
),
),
(
MessageQueryInformation("name", QueryWindow(-5, None)),
"Table",
Table.from_dict({"a": [1, 2, 1, 2, 3, 2, 1], "b": [3, 4, 6, 2, 1, 2, 3]}),
(
'{"name": "name", "type": "Table", "window": {"begin": 0, "size": 7, "max": 7}, "value": {"a": [1, 2,'
' 1, 2, 3, 2, 1], "b": [3, 4, 6, 2, 1, 2, 3]}}'
),
),
],
ids=[
"query_nowindow",
"query_windowed_0_1",
"query_windowed_4_3",
"query_windowed_empty",
"query_windowed_size_too_large",
"query_windowed_4_max",
"query_windowed_negative_size",
"query_windowed_negative_offset",
],
)
def test_windowed_placeholder(query: MessageQueryInformation, type_: str, value: typing.Any, result: str) -> None:
message = create_placeholder_value(query, type_, value)
assert json.dumps(message, cls=SafeDsEncoder) == result