mirror of
https://github.com/tiqi-group/pydase.git
synced 2025-04-21 08:40:03 +02:00
Merge pull request #66 from tiqi-group/fix/executing_methods_through_frontend
Fix/executing methods through frontend
This commit is contained in:
commit
30e4ebb670
@ -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.
|
||||
|
@ -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) => {
|
||||
|
@ -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 (
|
||||
|
@ -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 (
|
||||
|
@ -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 (
|
||||
|
@ -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) => {
|
||||
|
@ -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
|
||||
|
@ -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,
|
||||
{
|
||||
|
@ -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);
|
||||
}
|
||||
};
|
||||
|
||||
|
@ -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 });
|
||||
}
|
||||
};
|
||||
|
@ -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
|
||||
|
||||
|
@ -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:
|
||||
|
@ -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
|
||||
|
@ -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)
|
||||
|
||||
|
@ -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.
|
||||
|
||||
|
@ -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
|
@ -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):
|
||||
|
Loading…
x
Reference in New Issue
Block a user