Merge pull request #66 from tiqi-group/fix/executing_methods_through_frontend

Fix/executing methods through frontend
This commit is contained in:
Mose Müller 2023-11-09 14:04:48 +01:00 committed by GitHub
commit 30e4ebb670
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
17 changed files with 325 additions and 92 deletions

View File

@ -35,12 +35,6 @@ type ExceptionMessage = {
*
* If the property to be updated is an object or an array, it is updated
* recursively.
*
* @param {Array<string>} path - An array where each element is a key in the object,
* forming a path to the property to be updated.
* @param {object} obj - The object to be updated.
* @param {object} value - The new value for the property specified by the path.
* @return {object} - A new object with the specified property updated.
*/
function updateNestedObject(path: Array<string>, obj: object, value: ValueType) {
// Base case: If the path is empty, return the new value.

View File

@ -1,5 +1,5 @@
import React, { useEffect, useRef } from 'react';
import { emit_update } from '../socket';
import { runMethod } from '../socket';
import { InputGroup, Form, Button } from 'react-bootstrap';
import { DocStringComponent } from './DocStringComponent';
import { getIdFromFullAccessPath } from '../utils/stringUtils';
@ -56,18 +56,18 @@ export const AsyncMethodComponent = React.memo((props: AsyncMethodProps) => {
const execute = async (event: React.FormEvent) => {
event.preventDefault();
let method_name: string;
const args = {};
const kwargs: Record<string, unknown> = {};
if (runningTask !== undefined && runningTask !== null) {
method_name = `stop_${name}`;
} else {
Object.keys(props.parameters).forEach(
(name) => (args[name] = event.target[name].value)
(name) => (kwargs[name] = event.target[name].value)
);
method_name = `start_${name}`;
}
emit_update(method_name, parentPath, { args: args });
runMethod(method_name, parentPath, kwargs);
};
const args = Object.entries(props.parameters).map(([name, type], index) => {

View File

@ -1,6 +1,6 @@
import React, { useEffect, useRef } from 'react';
import { ToggleButton } from 'react-bootstrap';
import { emit_update } from '../socket';
import { setAttribute } from '../socket';
import { DocStringComponent } from './DocStringComponent';
import { getIdFromFullAccessPath } from '../utils/stringUtils';
@ -31,7 +31,7 @@ export const ButtonComponent = React.memo((props: ButtonComponentProps) => {
}, [props.value]);
const setChecked = (checked: boolean) => {
emit_update(name, parentPath, checked);
setAttribute(name, parentPath, checked);
};
return (

View File

@ -1,6 +1,6 @@
import React, { useEffect, useRef } from 'react';
import { InputGroup, Form, Row, Col } from 'react-bootstrap';
import { emit_update } from '../socket';
import { setAttribute } from '../socket';
import { DocStringComponent } from './DocStringComponent';
import { getIdFromFullAccessPath } from '../utils/stringUtils';
@ -36,7 +36,7 @@ export const ColouredEnumComponent = React.memo((props: ColouredEnumComponentPro
}, [props.value]);
const handleValueChange = (newValue: string) => {
emit_update(name, parentPath, newValue);
setAttribute(name, parentPath, newValue);
};
return (

View File

@ -1,6 +1,6 @@
import React, { useEffect, useRef } from 'react';
import { InputGroup, Form, Row, Col } from 'react-bootstrap';
import { emit_update } from '../socket';
import { setAttribute } from '../socket';
import { DocStringComponent } from './DocStringComponent';
interface EnumComponentProps {
@ -33,7 +33,7 @@ export const EnumComponent = React.memo((props: EnumComponentProps) => {
}, [props.value]);
const handleValueChange = (newValue: string) => {
emit_update(name, parentPath, newValue);
setAttribute(name, parentPath, newValue);
};
return (

View File

@ -1,5 +1,5 @@
import React, { useState, useEffect, useRef } from 'react';
import { emit_update } from '../socket';
import { runMethod } from '../socket';
import { Button, InputGroup, Form, Collapse } from 'react-bootstrap';
import { DocStringComponent } from './DocStringComponent';
import { getIdFromFullAccessPath } from '../utils/stringUtils';
@ -46,18 +46,21 @@ export const MethodComponent = React.memo((props: MethodProps) => {
const execute = async (event: React.FormEvent) => {
event.preventDefault();
const args = {};
const kwargs = {};
Object.keys(props.parameters).forEach(
(name) => (args[name] = event.target[name].value)
(name) => (kwargs[name] = event.target[name].value)
);
emit_update(name, parentPath, { args: args }, (ack) => {
runMethod(name, parentPath, kwargs, (ack) => {
// Update the functionCalls state with the new call if we get an acknowledge msg
if (ack !== undefined) {
setFunctionCalls((prevCalls) => [...prevCalls, { name, args, result: ack }]);
setFunctionCalls((prevCalls) => [
...prevCalls,
{ name, args: kwargs, result: ack }
]);
}
});
triggerNotification(args);
triggerNotification(kwargs);
};
const args = Object.entries(props.parameters).map(([name, type], index) => {

View File

@ -1,6 +1,6 @@
import React, { useEffect, useRef, useState } from 'react';
import { Form, InputGroup } from 'react-bootstrap';
import { emit_update } from '../socket';
import { setAttribute } from '../socket';
import { DocStringComponent } from './DocStringComponent';
import '../App.css';
import { getIdFromFullAccessPath } from '../utils/stringUtils';
@ -125,7 +125,7 @@ export const NumberComponent = React.memo((props: NumberComponentProps) => {
// If emitUpdate is passed, use this instead of the emit_update from the socket
// Also used when used with a slider
const emitUpdate =
props.customEmitUpdate !== undefined ? props.customEmitUpdate : emit_update;
props.customEmitUpdate !== undefined ? props.customEmitUpdate : setAttribute;
const renderCount = useRef(0);
// Create a state for the cursor position

View File

@ -1,6 +1,6 @@
import React, { useEffect, useRef, useState } from 'react';
import { InputGroup, Form, Row, Col, Collapse, ToggleButton } from 'react-bootstrap';
import { emit_update } from '../socket';
import { setAttribute } from '../socket';
import { DocStringComponent } from './DocStringComponent';
import { Slider } from '@mui/material';
import { NumberComponent } from './NumberComponent';
@ -66,7 +66,7 @@ export const SliderComponent = React.memo((props: SliderComponentProps) => {
max: number = props.max,
stepSize: number = props.stepSize
) => {
emit_update(
setAttribute(
name,
parentPath,
{

View File

@ -1,6 +1,6 @@
import React, { useEffect, useRef, useState } from 'react';
import { Form, InputGroup } from 'react-bootstrap';
import { emit_update } from '../socket';
import { setAttribute } from '../socket';
import { DocStringComponent } from './DocStringComponent';
import '../App.css';
import { getIdFromFullAccessPath } from '../utils/stringUtils';
@ -41,19 +41,19 @@ export const StringComponent = React.memo((props: StringComponentProps) => {
const handleChange = (event) => {
setInputString(event.target.value);
if (isInstantUpdate) {
emit_update(name, parentPath, event.target.value);
setAttribute(name, parentPath, event.target.value);
}
};
const handleKeyDown = (event) => {
if (event.key === 'Enter' && !isInstantUpdate) {
emit_update(name, parentPath, inputString);
setAttribute(name, parentPath, inputString);
}
};
const handleBlur = () => {
if (!isInstantUpdate) {
emit_update(name, parentPath, inputString);
setAttribute(name, parentPath, inputString);
}
};

View File

@ -9,15 +9,28 @@ console.debug('Websocket: ', URL);
export const socket = io(URL, { path: '/ws/socket.io', transports: ['websocket'] });
export const emit_update = (
export const setAttribute = (
name: string,
parentPath: string,
value: unknown,
callback?: (ack: unknown) => void
) => {
if (callback) {
socket.emit('frontend_update', { name, parent_path: parentPath, value }, callback);
socket.emit('set_attribute', { name, parent_path: parentPath, value }, callback);
} else {
socket.emit('frontend_update', { name, parent_path: parentPath, value });
socket.emit('set_attribute', { name, parent_path: parentPath, value });
}
};
export const runMethod = (
name: string,
parentPath: string,
kwargs: Record<string, unknown>,
callback?: (ack: unknown) => void
) => {
if (callback) {
socket.emit('run_method', { name, parent_path: parentPath, kwargs }, callback);
} else {
socket.emit('run_method', { name, parent_path: parentPath, kwargs });
}
};

View File

@ -13,7 +13,7 @@ from pydase.data_service.task_manager import TaskManager
from pydase.utils.helpers import (
convert_arguments_to_hinted_types,
get_class_and_instance_attributes,
get_object_attr_from_path,
get_object_attr_from_path_list,
is_property_attribute,
parse_list_attr_and_index,
update_value_if_changed,
@ -222,10 +222,19 @@ class DataService(rpyc.Service, AbstractDataService):
attr_name: str,
value: Any,
) -> None:
warnings.warn(
"'update_DataService_attribute' is deprecated and will be removed in a "
"future version. "
"Service state management is handled by `pydase.data_service.state_manager`"
"now, instead.",
DeprecationWarning,
stacklevel=2,
)
# If attr_name corresponds to a list entry, extract the attr_name and the index
attr_name, index = parse_list_attr_and_index(attr_name)
# Traverse the object according to the path parts
target_obj = get_object_attr_from_path(self, path_list)
target_obj = get_object_attr_from_path_list(self, path_list)
# If the attribute is a property, change it using the setter without getting the
# property value (would otherwise be bad for expensive getter methods)
@ -233,7 +242,7 @@ class DataService(rpyc.Service, AbstractDataService):
setattr(target_obj, attr_name, value)
return
attr = get_object_attr_from_path(target_obj, [attr_name])
attr = get_object_attr_from_path_list(target_obj, [attr_name])
if attr is None:
return

View File

@ -1,6 +1,7 @@
from collections.abc import Callable
from typing import Any
import pydase.units as u
from pydase.utils.warnings import (
warn_if_instance_class_does_not_inherit_from_DataService,
)
@ -47,6 +48,14 @@ class DataServiceList(list):
super().__init__(*args, **kwargs) # type: ignore
def __setitem__(self, key: int, value: Any) -> None: # type: ignore
current_value = self.__getitem__(key)
# parse ints into floats if current value is a float
if isinstance(current_value, float) and isinstance(value, int):
value = float(value)
if isinstance(current_value, u.Quantity):
value = u.convert_to_quantity(value, str(current_value.u))
super().__setitem__(key, value) # type: ignore
for callback in self.callbacks:

View File

@ -6,7 +6,13 @@ from typing import TYPE_CHECKING, Any, Optional, cast
import pydase.units as u
from pydase.data_service.data_service_cache import DataServiceCache
from pydase.utils.helpers import (
get_object_attr_from_path_list,
is_property_attribute,
parse_list_attr_and_index,
)
from pydase.utils.serializer import (
dump,
generate_serialized_data_paths,
get_nested_dict_by_path,
)
@ -102,35 +108,19 @@ class StateManager:
logger.debug("Could not load the service state.")
return
serialized_class = self.cache
for path in generate_serialized_data_paths(json_dict):
nested_json_dict = get_nested_dict_by_path(json_dict, path)
value = nested_json_dict["value"]
value_type = nested_json_dict["type"]
nested_class_dict = get_nested_dict_by_path(self.cache, path)
nested_class_dict = get_nested_dict_by_path(serialized_class, path)
class_value_type = nested_class_dict.get("type", None)
if class_value_type == value_type:
class_attr_is_read_only = nested_class_dict["readonly"]
if class_attr_is_read_only:
logger.debug(
f"Attribute {path!r} is read-only. Ignoring value from JSON "
"file..."
)
continue
# Split the path into parts
parts = path.split(".")
attr_name = parts[-1]
value, value_type = nested_json_dict["value"], nested_json_dict["type"]
class_attr_value_type = nested_class_dict.get("type", None)
# Convert dictionary into Quantity
if class_value_type == "Quantity":
value = u.convert_to_quantity(value)
self.service.update_DataService_attribute(parts[:-1], attr_name, value)
if class_attr_value_type == value_type:
self.set_service_attribute_value_by_path(path, value)
else:
logger.info(
f"Attribute type of {path!r} changed from {value_type!r} to "
f"{class_value_type!r}. Ignoring value from JSON file..."
f"{class_attr_value_type!r}. Ignoring value from JSON file..."
)
def _get_state_dict_from_JSON_file(self) -> dict[str, Any]:
@ -142,3 +132,85 @@ class StateManager:
# values
return cast(dict[str, Any], json.load(f))
return {}
def set_service_attribute_value_by_path(
self,
path: str,
value: Any,
) -> None:
"""
Sets the value of an attribute in the service managed by the `StateManager`
given its path as a dot-separated string.
This method updates the attribute specified by 'path' with 'value' only if the
attribute is not read-only and the new value differs from the current one.
It also handles type-specific conversions for the new value before setting it.
Args:
path: A dot-separated string indicating the hierarchical path to the
attribute.
value: The new value to set for the attribute.
"""
current_value_dict = get_nested_dict_by_path(self.cache, path)
# This will also filter out methods as they are 'read-only'
if current_value_dict["readonly"]:
logger.debug(f"Attribute {path!r} is read-only. Ignoring new value...")
return
converted_value = self.__convert_value_if_needed(value, current_value_dict)
# only set value when it has changed
if self.__attr_value_has_changed(converted_value, current_value_dict["value"]):
self.__update_attribute_by_path(path, converted_value)
else:
logger.debug(f"Value of attribute {path!r} has not changed...")
def __attr_value_has_changed(self, value_object: Any, current_value: Any) -> bool:
"""Check if the serialized value of `value_object` differs from `current_value`.
The method serializes `value_object` to compare it, which is mainly
necessary for handling Quantity objects.
"""
return dump(value_object)["value"] != current_value
def __convert_value_if_needed(
self, value: Any, current_value_dict: dict[str, Any]
) -> Any:
if current_value_dict["type"] == "Quantity":
return u.convert_to_quantity(value, current_value_dict["value"]["unit"])
return value
def __update_attribute_by_path(self, path: str, value: Any) -> None:
parent_path_list, attr_name = path.split(".")[:-1], path.split(".")[-1]
# If attr_name corresponds to a list entry, extract the attr_name and the
# index
attr_name, index = parse_list_attr_and_index(attr_name)
# Update path to reflect the attribute without list indices
path = ".".join([*parent_path_list, attr_name])
attr_cache_type = get_nested_dict_by_path(self.cache, path)["type"]
# Traverse the object according to the path parts
target_obj = get_object_attr_from_path_list(self.service, parent_path_list)
if self.__attr_value_should_change(target_obj, attr_name):
if attr_cache_type in ("ColouredEnum", "Enum"):
enum_attr = get_object_attr_from_path_list(target_obj, [attr_name])
setattr(target_obj, attr_name, enum_attr.__class__[value])
elif attr_cache_type == "list":
list_obj = get_object_attr_from_path_list(target_obj, [attr_name])
list_obj[index] = value
else:
setattr(target_obj, attr_name, value)
def __attr_value_should_change(self, parent_object: Any, attr_name: str) -> bool:
# If the attribute is a property, change it using the setter without getting
# the property value (would otherwise be bad for expensive getter methods)
if is_property_attribute(parent_object, attr_name):
return True
return True

View File

@ -9,7 +9,9 @@ from fastapi.responses import FileResponse
from fastapi.staticfiles import StaticFiles
from pydase import DataService
from pydase.data_service.data_service import process_callable_attribute
from pydase.data_service.state_manager import StateManager
from pydase.utils.helpers import get_object_attr_from_path_list
from pydase.version import __version__
logger = logging.getLogger(__name__)
@ -44,6 +46,25 @@ class UpdateDict(TypedDict):
value: Any
class RunMethodDict(TypedDict):
"""
A TypedDict subclass representing a dictionary used for running methods from the
exposed DataService.
Attributes:
name (str): The name of the method to be run.
parent_path (str): The access path for the parent object of the method to be
run. This is used to construct the full access path for the method. For
example, for an method with access path 'attr1.list_attr[0].method_name',
'attr1.list_attr[0]' would be the parent_path.
kwargs (dict[str, Any]): The arguments passed to the method.
"""
name: str
parent_path: str
kwargs: dict[str, Any]
class WebAPI:
__sio_app: socketio.ASGIApp
__fastapi_app: FastAPI
@ -79,14 +100,23 @@ class WebAPI:
sio = socketio.AsyncServer(async_mode="asgi")
@sio.event # type: ignore
def frontend_update(sid: str, data: UpdateDict) -> Any:
def set_attribute(sid: str, data: UpdateDict) -> Any:
logger.debug(f"Received frontend update: {data}")
path_list, attr_name = data["parent_path"].split("."), data["name"]
path_list = [*data["parent_path"].split("."), data["name"]]
path_list.remove("DataService") # always at the start, does not do anything
return self.service.update_DataService_attribute(
path_list=path_list, attr_name=attr_name, value=data["value"]
path = ".".join(path_list)
return self.state_manager.set_service_attribute_value_by_path(
path=path, value=data["value"]
)
@sio.event # type: ignore
def run_method(sid: str, data: RunMethodDict) -> Any:
logger.debug(f"Running method: {data}")
path_list = [*data["parent_path"].split("."), data["name"]]
path_list.remove("DataService") # always at the start, does not do anything
method = get_object_attr_from_path_list(self.service, path_list)
return process_callable_attribute(method, data["kwargs"])
self.__sio = sio
self.__sio_app = socketio.ASGIApp(self.__sio)

View File

@ -31,7 +31,7 @@ def get_class_and_instance_attributes(obj: object) -> dict[str, Any]:
return attrs
def get_object_attr_from_path(target_obj: Any, path: list[str]) -> Any:
def get_object_attr_from_path_list(target_obj: Any, path: list[str]) -> Any:
"""
Traverse the object tree according to the given path.

View File

@ -2,6 +2,7 @@ from typing import Any
from pytest import LogCaptureFixture
import pydase.units as u
from pydase import DataService
@ -84,8 +85,8 @@ def test_nested_reused_instance_list_attribute(caplog: LogCaptureFixture) -> Non
def test_protected_list_attribute(caplog: LogCaptureFixture) -> None:
"""Changing protected lists should not emit notifications for the lists themselves, but
still for all properties depending on them.
"""Changing protected lists should not emit notifications for the lists themselves,
but still for all properties depending on them.
"""
class ServiceClass(DataService):
@ -99,3 +100,30 @@ def test_protected_list_attribute(caplog: LogCaptureFixture) -> None:
service_instance._attr[0] = 1337
assert "ServiceClass.list_dependend_property changed to 1337" in caplog.text
def test_converting_int_to_float_entries(caplog: LogCaptureFixture) -> None:
class ServiceClass(DataService):
float_list = [0.0]
service_instance = ServiceClass()
service_instance.float_list[0] = 1
assert isinstance(service_instance.float_list[0], float)
assert "ServiceClass.float_list[0] changed to 1.0" in caplog.text
def test_converting_number_to_quantity_entries(caplog: LogCaptureFixture) -> None:
class ServiceClass(DataService):
quantity_list: list[u.Quantity] = [1 * u.units.A]
service_instance = ServiceClass()
service_instance.quantity_list[0] = 4 # type: ignore
assert isinstance(service_instance.quantity_list[0], u.Quantity)
assert "ServiceClass.quantity_list[0] changed to 4.0 A" in caplog.text
caplog.clear()
service_instance.quantity_list[0] = 3.1 * u.units.mA
assert isinstance(service_instance.quantity_list[0], u.Quantity)
assert "ServiceClass.quantity_list[0] changed to 3.1 mA" in caplog.text

View File

@ -6,52 +6,79 @@ from pytest import LogCaptureFixture
import pydase
import pydase.units as u
from pydase.components.coloured_enum import ColouredEnum
from pydase.data_service.state_manager import StateManager
class SubService(pydase.DataService):
name = "SubService"
class State(ColouredEnum):
RUNNING = "#0000FF80"
COMPLETED = "hsl(120, 100%, 50%)"
FAILED = "hsla(0, 100%, 50%, 0.7)"
class Service(pydase.DataService):
def __init__(self, **kwargs: Any) -> None:
self.subservice = SubService()
self.some_unit: u.Quantity = 1.2 * u.units.A
self.some_float = 1.0
self.list_attr = [1.0, 2.0]
self._property_attr = 1337.0
self._name = "Service"
self._state = State.RUNNING
super().__init__(**kwargs)
@property
def name(self) -> str:
return self._name
@property
def property_attr(self) -> float:
return self._property_attr
CURRENT_STATE = {
"name": {
"type": "str",
"value": "Service",
"readonly": True,
"doc": None,
},
"some_float": {
"type": "float",
"value": 1.0,
"readonly": False,
"doc": None,
},
"some_unit": {
"type": "Quantity",
"value": {"magnitude": 1.2, "unit": "A"},
"readonly": False,
"doc": None,
},
}
@property_attr.setter
def property_attr(self, value: float) -> None:
self._property_attr = value
@property
def state(self) -> State:
return self._state
@state.setter
def state(self, value: State) -> None:
self._state = value
CURRENT_STATE = Service().serialize()
LOAD_STATE = {
"list_attr": {
"type": "list",
"value": [
{"type": "float", "value": 1.4, "readonly": False, "doc": None},
{"type": "float", "value": 2.0, "readonly": False, "doc": None},
],
"readonly": False,
"doc": None,
},
"name": {
"type": "str",
"value": "Service",
"value": "Another name",
"readonly": True,
"doc": None,
},
"some_float": {
"type": "int",
"value": 1,
"value": 10,
"readonly": False,
"doc": None,
},
"property_attr": {
"type": "float",
"value": 1337.1,
"readonly": False,
"doc": None,
},
@ -61,6 +88,36 @@ LOAD_STATE = {
"readonly": False,
"doc": None,
},
"state": {
"type": "ColouredEnum",
"value": "FAILED",
"readonly": True,
"doc": None,
"enum": {
"RUNNING": "#0000FF80",
"COMPLETED": "hsl(120, 100%, 50%)",
"FAILED": "hsla(0, 100%, 50%, 0.7)",
},
},
"subservice": {
"type": "DataService",
"value": {
"name": {
"type": "str",
"value": "SubService",
"readonly": False,
"doc": None,
}
},
"readonly": False,
"doc": None,
},
"removed_attr": {
"type": "str",
"value": "removed",
"readonly": False,
"doc": None,
},
}
@ -76,7 +133,7 @@ def test_save_state(tmp_path: Path):
assert file.read_text() == json.dumps(CURRENT_STATE, indent=4)
def test_load_state(tmp_path: Path):
def test_load_state(tmp_path: Path, caplog: LogCaptureFixture):
# Create a StateManager instance with a temporary file
file = tmp_path / "test_state.json"
@ -87,7 +144,27 @@ def test_load_state(tmp_path: Path):
service = Service()
manager = StateManager(service=service, filename=str(file))
manager.load_state()
assert service.some_unit == u.Quantity(12, "A")
assert service.some_unit == u.Quantity(12, "A") # has changed
assert service.list_attr[0] == 1.4 # has changed
assert service.list_attr[1] == 2.0 # has not changed
assert service.property_attr == 1337.1 # has changed
assert service.state == State.FAILED # has changed
assert service.name == "Service" # has not changed as readonly
assert service.some_float == 1.0 # has not changed due to different type
assert service.subservice.name == "SubService" # didn't change
assert "Service.some_unit changed to 12.0 A!" in caplog.text
assert "Attribute 'name' is read-only. Ignoring new value..." in caplog.text
assert (
"Attribute type of 'some_float' changed from 'int' to 'float'. "
"Ignoring value from JSON file..."
) in caplog.text
assert (
"Attribute type of 'removed_attr' changed from 'str' to None. "
"Ignoring value from JSON file..." in caplog.text
)
assert "Value of attribute 'subservice.name' has not changed..." in caplog.text
def test_filename_warning(tmp_path: Path, caplog: LogCaptureFixture):
@ -120,9 +197,7 @@ def test_readonly_attribute(tmp_path: Path, caplog: LogCaptureFixture):
service = Service()
manager = StateManager(service=service, filename=str(file))
manager.load_state()
assert (
"Attribute 'name' is read-only. Ignoring value from JSON file..." in caplog.text
)
assert "Attribute 'name' is read-only. Ignoring new value..." in caplog.text
def test_changed_type(tmp_path: Path, caplog: LogCaptureFixture):